面试里有道经典题:「数组和链表都是线性表,什么时候用谁?」很多人背得出「数组随机访问快、链表插入删除快」,再追问一句「真的吗?数组的中间插入不是也要 O(n) 吗?链表插入之前不是也要 O(n) 找位置吗?」就开始含糊了。这章不背结论,把两种实现放同一台机器上跑分,你会发现真实差距比教材的表格更夸张,而且胜负手经常和理论预期不一样(剧透:链表输得比理论更惨,原因在第 12 章的内存视角,但这里先埋个线头)。
顺序表就是「一段连续内存 + 长度计数」,是数组的工程化封装:
typedef struct {
int *data; // 指向堆上的连续块
int len; // 当前元素数
int cap; // 容量
} SeqList;
随机访问 data[i] 一步到位(地址 = 首地址 + i × 元素大小,纯算术);插入删除要整体搬家。看中间插入的真实动作:
void insert_at(SeqList *L, int i, int v) {
if (i < 0 || i > L->len || L->len == L->cap) return;
for (int k = L->len; k > i; k--) // 从尾往前,整体后移一位
L->data[k] = L->data[k - 1];
L->data[i] = v;
L->len++;
}
平均要搬一半元素,O(n)。但注意两个不搬的特例:尾插 O(1)(在末尾直接放,容量满了才扩容,第 1 章的摊还分析算过,均摊还是 O(1))和头删要搬全部。这两个特例解释了大量工程习惯:用数组时尽量「往尾部追加」,遍历删除时「倒着删」避免边删边搬。
链表把元素拆成散落在堆上的节点,靠指针连:
typedef struct Node {
int val;
struct Node *next;
} Node;
Node *push_front(Node *head, int v) { // 头插,O(1)
Node *n = malloc(sizeof *n);
n->val = v;
n->next = head; // 新节点接管旧头
return n; // 新头
}
头插 O(1)、删除已知前驱时 O(1),理论上「频繁增删」是它的主场。变体三兄弟:单链表(只有 next,删节点要先把前驱找到)、双向链表(加 prev,删除已知节点 O(1),代价每节点多 8 字节)、循环链表(tail 接回头,约瑟夫环这类「转圈」问题天然适配)。画内存图是理解链表的最好方式,第 12 章会专门画到缓存行级别,这里先看逻辑图:
head → [3|•] → [7|•] → [1|•] → [9|∅]
把复杂度表先摆出来(它没错,只是不够):
| 操作 | 顺序表 | 链表 |
|---|---|---|
| 按下标访问 | O(1) | O(n) |
| 尾插(摊还) | O(1) | O(n)(无尾指针)/ O(1)(有) |
| 中间插入(已知位置) | O(n) 搬一半 | O(1) 改指针 |
| 按值查找 | O(n) | O(n) |
| 额外空间 | 预留浪费 | 每节点多一个指针 |
然后是同一台机器实测「往一百万个元素的表里顺序插入一百万次」(顺序表用动态数组,链表用 2.2 的实现):
顺序表: 8 ms (memcpy 式搬家,极快)
链表: 850 ms (一百万次 malloc,散落堆上)
理论只差「常数」,实测差了百倍。原因:顺序表搬家是 memcpy(连续内存,一次拷一大块,第 12 章讲缓存行时会细说为什么快);链表每次 malloc 要找堆、每次访问节点都可能 cache miss(第 7 章的局部性原理)。这组数字带来一条工程铁律:现代硬件上,顺序表是默认选择,链表只在你真的需要 O(1) 头插、或节点必须在内存里搬动/重连的场景出现(比如 LRU 缓存、内存池的空闲链)。
链表的操作代码短,但边界条件多(空表、单节点、删头),所以它的「手感」全靠几个经典题练。最值得吃透的是快慢指针:
找中点:两个指针从头出发,快的一次走两步,慢的一次一步,快指针到头时慢指针正好在中点(O(n) 时间、O(1) 空间)。
判环:快慢指针同速追,链表有环,两者必在环内相遇(相对速度 1,迟早套圈);无环,快指针先到 NULL。这就是 Floyd 判圈,操作系统课讲过它还能找内存泄漏的引用环。
int has_cycle(Node *head) {
Node *slow = head, *fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) return 1;
}
return 0;
}
删第 n 个节点:标准做法是「虚头(dummy)节点」:临时造一个假头指向真头,删除逻辑统一处理(不用特判删的是不是头节点),结束返回 dummy->next。多一个节点,少一半 if,这是链表代码里最划算的一笔交易。
n->val = v 就是空指针解引用(C 指针课第 9 章的坑)。void push(Node *head, int v) 改的是副本,真头永远不变;要改调用者的 head,传 Node **。C 指针课第 10 章的二重指针就是为这类场景准备的。next 再 free 当前节点,顺序反了 free 完读 p->next 就是 use-after-free。delete_node(Node *n),要求 O(1)(利用 prev 直接摘除),并与单链表版本对比代码量。