相爱相杀好基友——数组与链表
作为线性表的两种存储方式 —— 链表和数组,这对相爱相杀的好基友有着各自的优缺点。接下来,我们梳理一下这两种方式。
逻辑结构
从逻辑结构上来说,这两种数据结构都属于线性表。所谓线性表,就是表中所有元素排排站,除了头尾节点外,其他节点都只有一个前趋,也只有一个后继。
线性表的严格定义:
- 表中存在唯一的一个被称作“第一个”的数据元素
- 表中存在唯一的一个被称作“最后一个”的数据元素
- 除第一个元素以外,集合中的其他元素均有且只有一个前驱
- 除最后一个元素以外,集合中的其他元素均有且只有一个后继
存储方式
数组,所有元素都连续的存储于一段内存中,且每个元素占用的内存大小相同。这使得数组具备了通过下标快速访问数据的能力(通过首元素地址和偏移量可以计算出目标元素的内存地址)。
但连续存储的缺点也很明显,增加容量,增删元素的成本很高,时间复杂度均为 O(n)。
增加数组容量需要先申请一块新的内存,然后复制原有的元素。如果需要的话,可能还要删除原先的内存。
数组扩容
删除元素时需要移动被删除元素之后的所有元素以保证所有元素是连续的。增加元素时需要移动指定位置及之后的所有元素,然后将新增元素插入到指定位置,如果容量不足的话还需要先进行扩容操作。
数组删除元素
总结一下数组的优缺点:
- 优点:可以根据偏移实现快速的随机读写。
- 缺点:扩容,增删元素极慢。
链表,由若干个结点组成,每个结点包含数据域和指针域。结点结构如下图所示:
链表的一个结点
一般来讲,链表中只会有一个结点的指针域为空,该结点为 尾结点,其他结点的指针域都会存储一个结点的内存地址。链表中也只会有一个结点的内存地址没有存储在其他结点的指针域,该结点称为 头结点。
内存中的链表
链表的存储方式使得它可以高效的在指定位置插入与删除,时间复杂度均为 O(1)。
在结点 p 之后增加一个结点 q 总共分三步:
- 申请一段内存用以存储 q (可以使用内存池避免频繁申请和销毁内存)。
- 将 p 的指针域数据复制到 q 的指针域。
- 更新 p 的指针域为 q 的地址。
插入新元素
删除结点 p 之后的结点 q 总共分两步:
- 将 q 的指针域复制到 p 的指针域。
- 释放 q 结点的内存。
删除结点 总计一下链表的优缺点:
- 优点:可以快速的在指定位置增删节点,时间复杂度为O(1)。
- 缺点:因为节点的内存地址没有规律可循,所以无法像数组那样通过偏移高效的访问节点。
链表的主要代码
#include using namespace std;//定义一个结点模板template<typename T>struct Node {
T data;
Node *next;
Node() : next(nullptr) {}
Node(const T &d) : data(d), next(nullptr) {}
};//删除 p 结点后面的元素template<typename T>void Remove(Node *p) {if (p == nullptr || p->next == nullptr) {return;
}auto tmp = p->next->next;delete p->next;
p->next = tmp;
}//在 p 结点后面插入元素template<typename T>void Insert(Node *p, const T &data) {auto tmp = new Node(data);
tmp->next = p->next;
p->next = tmp;
}//遍历链表template<typename T, typename V>void Walk(Node *p, const V &vistor) {while(p != nullptr) {
vistor(p);
p = p->next;
}
}int main() {auto p = new Node<int>(1);
Insert(p, 2);int sum = 0;
Walk(p, [&sum](const Node<int> *p) -> void { sum += p->data; });cout <endl;
Remove(p);
sum = 0;
Walk(p, [&sum](const Node<int> *p) -> void { sum += p->data; });cout <endl;return 0;
}