02-数组与链表

【算法学习 · 02】数组与链表:内存视角下的两种存储哲学,附链表高频面试模板

系列文章第 2 篇。数组与链表是所有数据结构的「原子」------哈希表、堆、树最终都落在它们身上。本篇从内存布局讲起,解释二者性能差异的物理根源,并给出链表三大量产模板(虚拟头节点、反转、快慢指针),每段代码配完整执行追踪。

一、前言

面试官问「数组和链表的区别」,90% 的候选人会背「数组连续、链表不连续;数组查询快、链表插入快」。这么答不算错,但只答对了现象,没答对原因------为什么连续就快?为什么 CPU 更喜欢数组?

本篇回答三个问题:

  1. 内存布局如何决定了二者的性能特征(含 CPU 缓存的物理原因);
  2. 动态数组扩容为什么能做到「均摊 O(1)」;
  3. 链表题的三大通用模板,以及为什么几乎每道链表题都是它们的变体。

二、内存视角:一切差异的根源

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.1 倍)会导致频繁扩容,太大(如 4 倍)会浪费内存------1.5~2 倍是工业界平衡点(Python 约 1.125 起步渐进放大,Java ArrayList 是 1.5 倍);
  2. 已知最终规模时,预先指定容量 (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,消除长度差

七、易错点清单

  1. 断链 :修改 cur.next 前先保存原值,被覆盖后后半段丢失且不可恢复;
  2. 忘记 dummy:头节点可变的题不设哨兵,特判写到崩溃;
  3. 返回值 :用了 dummy 后 return dummy.next,不是 return head(head 可能已被删);
  4. prev 前进时机:删除类题中,删除发生时 prev 不动(第 3.1 节的追踪表);
  5. 快慢指针初始化:同时指向 head,循环内移动,移动后立即比较,避免 do-while 死循环;
  6. 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),很多场景(浏览器历史记录、括号匹配、撤销操作)却只需要「一端操作」------于是有了栈与队列,以及让面试官眼前一亮的单调栈。

如果本文对你有帮助,欢迎点赞、收藏、评论区交流。系列目录见首篇文章,更新不掉队。

相关推荐
路灯上の马陈12 小时前
二叉树的定义与性质
数据结构·二叉树·树结构·1024程序员节
Bshaya_16 小时前
红黑树详解:概念、规则与插入实现
数据结构·红黑树·二叉搜索树·平衡树·插入逻辑
WBluuue16 小时前
数据结构与算法:分块(一):块状数组、块状链表
数据结构·c++·算法·链表
路灯上の马陈17 小时前
数据结构-树
数据结构·1024程序员节
benjormin18 小时前
Java数据结构 二叉搜索树
数据结构·算法
H.莓飛21 小时前
【数据结构】排序总结与规模实测
linux·服务器·开发语言·数据结构·visual studio·1024程序员节
码少女1 天前
数据结构——B+树基础
数据结构·b树
揽星河..1 天前
【初阶数据结构】常见的排序算法
数据结构·排序算法·1024程序员节
YLCHUP1 天前
【模拟&STL】题解:AT_abc203_e [ABC203E] White Pawn_基础算法_数据结构_STL模板库_AtcoderABC_C++算法竞赛
数据结构·c++·算法·leetcode·动态规划·哈希算法·近邻算法