【算法学习 · 02】数组与链表:内存视角下的两种存储哲学,附链表高频面试模板
系列文章第 2 篇。数组与链表是所有数据结构的「原子」------哈希表、堆、树最终都落在它们身上。本篇从内存布局讲起,解释二者性能差异的物理根源,并给出链表三大量产模板(虚拟头节点、反转、快慢指针),每段代码配完整执行追踪。
一、前言
面试官问「数组和链表的区别」,90% 的候选人会背「数组连续、链表不连续;数组查询快、链表插入快」。这么答不算错,但只答对了现象,没答对原因------为什么连续就快?为什么 CPU 更喜欢数组?
本篇回答三个问题:
- 内存布局如何决定了二者的性能特征(含 CPU 缓存的物理原因);
- 动态数组扩容为什么能做到「均摊 O(1)」;
- 链表题的三大通用模板,以及为什么几乎每道链表题都是它们的变体。
二、内存视角:一切差异的根源
2.1 数组:连续存放,算术寻址
数组在内存中连续存放。因为连续,所以「第 i 个元素」的地址可以直接算出来:
地址 = 首地址 + i × 单个元素大小
一次乘法一次加法,与 i 的大小无关------这就是「按 index 随机访问 O(1)」的物理原因,没有任何查找过程,是纯算术。
连续存放还带来一个常被忽略的好处:CPU 缓存友好(Cache Locality)。CPU 读内存时按「缓存行(Cache Line,通常 64 字节)」整块加载。遍历数组时,一个缓存行里的邻居元素会顺带被加载进来,后续访问直接命中缓存(快约 100 倍)。链表节点散落在堆内存各处,每跳一次指针都可能触发一次缓存未命中(Cache Miss)。
这解释了一个工程反直觉现象:Java 的
LinkedList即使在「频繁头插」场景下也常常比ArrayList慢------指针跳转的缓存代价 + 每个节点的对象头开销,吃掉了理论上的插入优势。生产代码中链表几乎总是输给数组方案。
2.2 数组的代价:移动与扩容
| 操作 | 复杂度 | 原因 |
|---|---|---|
| 按 index 读 | O(1) | 算术寻址 |
| 尾部追加 | O(1) 均摊 | 直接写末尾;满了才扩容 |
| 中间插入 / 删除 | O(n) | 后续元素整体后移 / 前移 |
| 按值查找(无序) | O(n) | 只能线性扫 |
为什么中间插入是 O(n)?因为连续性是数组一切优势的前提,插入位置之后的每个元素都必须挪一格来维持它。
2.3 动态数组:扩容与均摊分析
Python 的 list、Java 的 ArrayList 都是「动态数组」:底层仍是定长数组,装满后申请约 1.5~2 倍的新空间整体搬家。手写一个核心逻辑(Python):
python
class MyArrayList:
def __init__(self):
self._data = [None] * 4 # 初始容量 4
self._size = 0
def append(self, x):
if self._size == len(self._data): # 装满了
new = [None] * len(self._data) * 2 # 容量翻倍
for i in range(self._size): # 搬家,本次 O(n)
new[i] = self._data[i]
self._data = new
self._data[self._size] = x
self._size += 1
单看「触发扩容的那一次」,代价是 O(n)。但均摊分析(第 1 篇讲过)告诉我们:从空表连续 append n 次,扩容发生在容量 1→2→4→8→...→n 的时刻,搬家总量为 1+2+4+...+n ≤ 2n,加上 n 次普通写入,总开销 ≤ 3n。均摊到每次操作,仍是 O(1)。
两个推论值得记住:
- 扩容倍数太小(如 1.1 倍)会导致频繁扩容,太大(如 4 倍)会浪费内存------1.5~2 倍是工业界平衡点(Python 约 1.125 起步渐进放大,Java ArrayList 是 1.5 倍);
- 已知最终规模时,预先指定容量 (
new ArrayList<>(10000))可以完全规避搬家,这是刷 LeetCode 和写业务代码都实用的优化。
2.4 链表:指针串联,寻找式访问
链表节点 = 数据 + 后继指针,节点本身散落在堆内存:
head ──→ [A | ·] ──→ [B | ·] ──→ [C | ∅]
访问第 i 个元素必须从 head 开始逐跳前进,O(n)。换来的是:
| 操作 | 复杂度 | 原因 |
|---|---|---|
| 按 index 读 | O(n) | 逐跳寻找 |
| 头部插入 / 删除 | O(1) | 改一个指针 |
| 已知前驱的插入 / 删除 | O(1) | 改两个指针(找到前驱仍 O(n)) |
| 按值查找 | O(n) | 只能顺着跳 |
变体 :双向链表每节点多一个 prev 指针,可反向走、删除时无需找前驱(Java LinkedList、LRU 缓存的底层就是它);循环链表尾节点指回头。
三、链表三大模板(面试 90% 的链表题都是它们的排列组合)
模板一:虚拟头节点(dummy head)
痛点:删除链表元素时,如果要删的恰好是头节点,需要单独处理。dummy 节点是一个「假头」,让真头节点也变成「中间节点」,消灭一切特判:
python
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def remove_elements(head, val):
dummy = ListNode(0, head) # 假头指向真头
prev, cur = dummy, head
while cur:
if cur.val == val:
prev.next = cur.next # 跳过 cur 即删除
else:
prev = cur # 只有不删时才前进 prev
cur = cur.next
return dummy.next # 返回真头(不是 dummy!)
执行追踪(链表 1→2→3,删除 2):
| 步骤 | prev | cur | 动作 |
|---|---|---|---|
| 初始 | dummy | 1 | 1 ≠ 2,prev 前进 |
| 1 | 1 | 2 | 2 = 2,1.next = 3,prev 不动 |
| 2 | 1 | 3 | 3 ≠ 2,prev 前进 |
| 结束 | 3 | None | 返回 dummy.next = 1→3 |
注意 prev 只在「不删除」时前进------刚删完一个,新的 prev.next 可能还是要删的元素,这个细节是本题唯一的坑。
适用题型 :删除类、合并类(LC 21)、构建类。经验法则:只要头节点可能被改动,先建 dummy。
模板二:原地反转(面试出现率 Top 3)
python
def reverse(head):
prev, cur = None, head
while cur:
nxt = cur.next # ① 先存后继,防止断链
cur.next = prev # ② 掉转指针
prev = cur # ③ prev 前进
cur = nxt # ④ cur 前进
return prev # prev 即新头
执行追踪(1→2→3):
| 轮次 | prev | cur | nxt | 链表状态 |
|---|---|---|---|---|
| 初始 | None | 1 | - | 1→2→3 |
| 1 | 1 | 2 | 2 | 2→3,1→None |
| 2 | 2 | 3 | 3 | 3→None,2→1→None |
| 3 | 3 | None | None | 3→2→1→None |
为什么必须先存 nxt?因为第 ② 步把 cur.next 改成 prev 后,原链表后半段的入口就丢了------不先保存,后面所有节点变成不可达内存。
延伸:只反转部分区间(LC 92)、K 个一组反转(LC 25)都是在本模板上加「记录断点、拼接子链」的变体,先把本模板默写到肌肉记忆。
模板三:快慢指针
找中点(LC 876):
python
def middle(head):
slow = fast = head
while fast and fast.next:
slow = slow.next # 慢指针一次一步
fast = fast.next.next # 快指针一次两步
return slow
判环(LC 141):
python
def has_cycle(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast:
return True
return False
原理:fast 速度是 slow 的两倍。若有环,二者进入环后相对速度为 1,fast 一定会在若干圈内「套圈」追上 slow------就像操场跑圈,快的必然追上慢的。若 fast 走到 None,说明链有终点,无环。
为什么「找中点」也成立 :fast 走完时,slow 恰好走到一半。长度为 4:fast 走两轮(1→3→None 停),slow 走两步停在第 3 个节点------即偶数长度时返回后半段的头,这个行为在「重排链表」(LC 143)等题里刚好可用。
延伸 :找环的入口(LC 142)需要一步数学推导:设头到入环点距离 a,入环点到相遇点为 b,环剩余部分为 c。相遇时 slow 走了 a+b,fast 走了 a+b+k(b+c)(k 为圈数)。由 2(a+b) = a+b+k(b+c) 得 a = c + (k-1)(b+c)。所以一个指针从 head 出发、另一个从相遇点出发,同速前进,必在入环点相遇。这题是快慢指针的天花板,建议自己推一遍。
四、完整手写:单链表的增删改查(Java 对照)
指针操作是链表题的难点,Java 版完整实现一次:
java
class MyLinkedList {
private static class Node {
int val;
Node next;
Node(int val) { this.val = val; }
}
private final Node dummy = new Node(0); // 永久哨兵
private int size = 0;
/** 获取第 index 个节点的值(index 从 0 开始)*/
public int get(int index) {
if (index < 0 || index >= size) return -1;
Node cur = dummy.next;
for (int i = 0; i < index; i++) cur = cur.next;
return cur.val;
}
/** 头部插入 */
public void addAtHead(int val) { addAtIndex(0, val); }
/** 尾部插入 */
public void addAtTail(int val) { addAtIndex(size, val); }
/** 在第 index 个位置前插入新节点 */
public void addAtIndex(int index, int val) {
if (index > size) return;
if (index < 0) index = 0;
Node prev = dummy;
for (int i = 0; i < index; i++) prev = prev.next;
Node node = new Node(val);
node.next = prev.next; // ① 新节点先牵住后半段
prev.next = node; // ② 前驱再指向新节点,顺序不能反
size++;
}
/** 删除第 index 个节点 */
public void deleteAtIndex(int index) {
if (index < 0 || index >= size) return;
Node prev = dummy;
for (int i = 0; i < index; i++) prev = prev.next;
prev.next = prev.next.next;
size--;
}
}
对应 LeetCode 707(设计链表),刷这道题等于把本章所有指针细节过一遍,强烈建议作为本章的结业作业。
指针操作只有一条铁律:改指针前,先把会被覆盖的引用保存下来。断链错误 90% 源于违反这条。
五、数组 vs 链表:决策表
| 场景 | 推荐 | 理由 |
|---|---|---|
| 随机访问多、按 index 处理 | 数组 | O(1) 算术寻址 |
| 尾部增删为主 | 数组(动态数组) | 均摊 O(1),缓存友好 |
| 头部频繁插入 | 双端队列(deque) | 即使这里,链表也常不是最优 |
| 已知节点的插入删除、需双向遍历 | 双向链表 | LRU、频次管理的底层 |
| 大规模遍历 | 数组 | 缓存行预取 |
一句话:读多选数组,改头选链表(但仍先考虑 deque)。
六、高频题型清单(按难度递进)
| 题号 | 题目 | 核心模板 |
|---|---|---|
| 707 | 设计链表 | 完整手写,结业作业 |
| 203 | 移除链表元素 | dummy |
| 206 | 反转链表 | 反转 |
| 876 | 链表的中间结点 | 快慢指针 |
| 141 / 142 | 环形链表 I / II | 快慢指针 + 数学推导 |
| 19 | 删除链表的倒数第 N 个结点 | 快慢指针变体:fast 先走 n+1 步 |
| 21 | 合并两个有序链表 | dummy + 归并拼接(第 7 篇归并排序的前置技能) |
| 234 | 回文链表 | 找中点 + 反转后半段 + 比较 |
| 143 | 重排链表 | 找中点 + 反转 + 交错合并 |
| 160 | 相交链表 | 双指针换道:走完 A 走 B,消除长度差 |
七、易错点清单
- 断链 :修改
cur.next前先保存原值,被覆盖后后半段丢失且不可恢复; - 忘记 dummy:头节点可变的题不设哨兵,特判写到崩溃;
- 返回值 :用了 dummy 后
return dummy.next,不是 return head(head 可能已被删); - prev 前进时机:删除类题中,删除发生时 prev 不动(第 3.1 节的追踪表);
- 快慢指针初始化:同时指向 head,循环内移动,移动后立即比较,避免 do-while 死循环;
- Java 遍历 :用
while (cur != null)而非 index 循环,每步判空。
八、面试高频问答
Q1:为什么生产环境很少用链表?
缓存不友好 + 每节点额外指针开销 + 现代动态数组已解决头部插入(deque)和扩容(均摊 O(1))问题。
Q2:单向链表如何一次遍历找到倒数第 k 个节点?
双指针:fast 先走 k 步,然后 slow、fast 同速前进,fast 到 None 时 slow 即倒数第 k 个。
Q3:如何判断两个链表是否相交?(不允许哈希)
双指针 p、q 分别从 headA、headB 出发,走到尾就切换到另一条链的头。若相交,两指针走过的总长度相同(a+c+b = b+c+a),必在交点相遇。
九、总结
- 性能差异的物理根源是内存连续性:数组算术寻址 + 缓存友好,链表指针跳转 + 局部性差;
- 动态数组扩容通过均摊分析证明 O(1),已知规模时预分配容量;
- 三大模板覆盖绝大多数链表题:dummy 哨兵、原地反转、快慢指针;
- 铁律:改指针前保存引用,头节点可变先建 dummy。
下一篇解决一个本章埋下的问题:数组中间插入删除是 O(n),很多场景(浏览器历史记录、括号匹配、撤销操作)却只需要「一端操作」------于是有了栈与队列,以及让面试官眼前一亮的单调栈。
如果本文对你有帮助,欢迎点赞、收藏、评论区交流。系列目录见首篇文章,更新不掉队。