目录
链表与数组的内存布局
这里假设是在int为4字节的系统上,数组存储在一段连续的内存上,例如图中从地址1000开始,每个格子存放一个4字节的int,就可以通过简单的加法计算出要访问的某一项的地址了,因此数组可以做到随机访问(random-access),也就是任意访问数组某一项a[x]时,可以通过(a的地址+x*sizeof(int))计算得到a[x]地址,时间复杂度是O(1)。
相反,链表的内存是不连续的,代价就是要存储多一个next指针,指向下一个结点的地址,通过这个地址找到链表的下一个结点,因此链表支持顺序访问(sequential access),也就是当我要访问链表的第x个结点时,我要从头结点开始按顺序遍历next指针才能找到这个结点,时间复杂度是O(n)。
链表与数组的优缺点比较
数组 | 链表 | |
---|---|---|
插入 | O(n) | O(1) |
删除 | O(n) | O(1) |
访问 | O(1) | O(n) |
扩容 | O(n) | - |
这里还有一个比较重要的点:在遍历数组和链表时时间复杂度都是O(n),但是实际上数组耗时比链表小很多,原因是cpu缓存对连续内存的数组友好,对不连续内存的链表不友好。
计算机的存储器分6个层级结构:(处理速度从快到慢)
- 寄存器
- 高速缓存 (cpu缓存,一般有L1,L2,L3三层缓存)
- 主存储器(内存)
- 磁盘缓存
- 固定磁盘
- 可移动存储介质
根据局部性原理,cpu在读取内存时会读取一片连续空间的内存,将一些经常访问的数据放在cpu缓存,在访问数据的时候,先检查缓存里是否存在,如果存在就直接读取使用,如果不存在再去内存读取。CPU缓存的访问速度和内存的访问速度可能相差几十倍。
数组的插入和删除
数组进行插入操作时,要把插入的位置打后的所有元素往后移,把插入的位置腾出来,所以需要O(n)的时间复杂度,代码表示:
void insert(int index,int data){
//size表示数组中存储的元素数量,capacity表示容量即new的时候申请的空间大小
if(size==capacity)
resize();
for(int i=size-1;i>=index;i--)
array[i+1]=array[i];
array[index]=data;
++size;
}
同理,数组进行删除时,需要把删除的位置打后的所有元素往前移,同样是需要O(n)的时间复杂度。
访问在上面内存布局中已经讲述,这里不再复述。
这里还有个扩容的操作,也就是当申请的空间不够用时,需要重新申请一块新的空间,然后将旧空间的元素拷贝到新空间上去,然后释放掉旧空间的内存,也是需要O(n)的时间复杂度。
链表的插入删除
链表的插入和删除就是指针的替换,下面我用伪代码表示
//插入,newNode表示要插入的结点(上图中2的结点),在preNode(上图中1的结点)后面插入
Node* newNode=new Node(data);
newNode->next=preNode->next; //上图2指向4的那个箭头
preNode->next=newNode; //上图1指向2的那个箭头
但是这里有几个问题:
1、preNode如何得到?
答:preNode就是一开始我们在内存布局中说到的从头结点开始顺序访问next得到的,比如要在第2个元素后插入,那么就遍历到第二个真正存储数据结点,所以如果单纯只是算这几个指针替换的时间复杂度的话就是O(1),但是要完成整个删除操作需要顺序访问,时间复杂度是O(n)。
2、如果一开始链表没有任何结点为空时,上述插入逻辑就行不通了,所以要打个补丁:
if(head==nullptr)
head=newNode;
3、那如果我想在头结点前插入呢,也就是让插入的结点成为新的头结点,还要再打个补丁:
Node* oldhead=head;
head=newNode;
head->next=oldhead;
针对问题2和3,我们可以通过加入一个哨兵解决,让插入的逻辑都统一成第一段插入代码那样。
我们可以让头结点变成一个哨兵,不存储数据,真正存储数据的第一个结点是head->next,也就是初始化时head不赋值为nullptr,而是创建一个新结点,这样就能让我们的插入和删除操作逻辑统一起来,后面有实例代码。
删除也是同理,这里就不再过多描述,伪代码:
//删除,delNode表示要插入的结点(上图中2的结点),在preNode(上图中1的结点)后面删除
preNode->next=delNode->next; //上图1指向4的那个箭头
delete delNode;
至于扩容,链表天然就支持扩容,不用像数组一样预先申请一大段空间,而是需要时才申请一个结点的空间,链表的扩容已经融合在插入操作中了。
c++代码示例
在这里我实现了一个带头结点(哨兵)和尾结点(非哨兵)的双向列表,相对来说比较全面一点,如果你只需要单向列表,就把prev指针的逻辑删除掉就好,不需要尾结点就把tail的逻辑删除掉就好。
list类的定义
// list:双向链表
template<typename DataType>
class list {
private:
struct Node {
//struct默认是public,class默认是private,可以用class和friend,也可以class Node元素掌握设置为public
DataType data;
Node* prev;
Node* next;
Node() {
}
Node(const DataType& d) :data{
d }, prev{
nullptr }, next{
nullptr} {
}
//这里不需要delete prev和next因为它们不是new出来的,否则会无限循环调用~Node(),这里可以不用写这个析构函数
//~Node() { std::cout << "~Node()" << std::endl;}
};
private:
Node* head; //哨兵,不存储值
Node* tail; //尾部,非哨兵
int size;
public:
list();
list(const list<DataType>& ls);
list(std::initializer_list<DataType> init_list);
~list();
const list<DataType>& operator=(const list<DataType>& ls);
DataType& operator[](int index); //返回DataType&,那么ls[n]可以赋值,比如ls[2]=xxx
DataType operator[](int index) const; //返回DataType,那么ls[n]不可以赋值,后面带const让const list对象有一个可以调用的版本
void insert(const DataType data, int pos);
void remove(const DataType data);
void remove_at(int index);
void append(DataType data); //链表尾部追加
void printList() const;
bool empty();
void clear();
int length() const;
private:
Node* get(int index) const; //因为operator[]() const里会调用get,所以get后面也要有const
Node* search(DataType data);
void append(Node* node);
void remove(Node* node);
};
构造函数
template<typename DataType>
list<DataType>::list() {
head = new Node();
head->prev = head->next = nullptr;
tail = head;
size = 0;
}
上面说哨兵时也有说到,head在初始化时就会创建,但是不存储值,注意tail不是哨兵,但是初始化时也将head赋值给tail,为后面写插入删除等操作时方便。
template<typename DataType>
list<DataType>::list(std::initializer_list<DataType> init_list) :list() {
//委托list()初始化
for (auto e : init_list)
append(e);
}
这里使用了c++11的初始化列表std::initializer_list,那么在使用时就可以用list ls = { 1,2,3,4 }这种方式进行初始化。
顺序访问(遍历)