第 02 讲

线性表:顺序表与链表

同一个逻辑结构「线性表」,换一种内存布局就长成了两个完全不同的东西:顺序表用一整块连续内存,换来 O(1) 的随机存取, 代价是插入删除要成片搬元素;链表把元素散落在堆上,用指针串起来,插入删除只改几根指针, 代价是失去随机存取、每个结点多一个指针域。本讲把两套代码从头写全,并把指针的每一步动作逐帧拆开给你看。

预计 120 分钟 前置:C++ 指针 · 结构体 · 模板入门 关键词:随机存取 · 头结点 · 快慢指针 · Floyd 判环 · 循环链表 · 静态链表
本章导读
  • 一句话本质:线性表是「一对一」的逻辑结构;顺序表和链表是它的两种物理实现——逻辑相同,物理不同,复杂度就不同
  • 三张必背的图:顺序表的连续地址图(图 2-2)、头指针 / 头结点 / 首元结点的区别(图 2-5)、单链表插入的「先连后断」(图 2-6)。
  • 两个必背的推导:顺序表插入平均移动 n/2 次、删除平均移动 (n-1)/2 次;Floyd 判环中「头指针与相遇点同步走 a 步必在环入口相遇」。
  • 六个可交互动画:顺序表插入、单链表插入(含错误顺序对比)、单链表删除、链表反转、快慢指针判环、双向链表插入。全部支持单步 / 回退 / 自动播放。
  • 本章产出:21 段可直接编译运行的 C++ 代码,覆盖顺序表、单链表、双向链表、循环链表、静态链表五种实现,外加一段 LRU 缓存(双向链表的工业级用法)。
  • 学习建议:先看动画把指针「动」起来,再合上讲义自己写一遍 ListInsert 与反转链表——这两段写顺了,本章就过关了。

2.1 从逻辑结构说起:什么是线性表

先抛开内存和指针,只谈「数据之间谁挨着谁」——这层关系叫逻辑结构 logical structure。 如果一组数据元素排成一条「线」,每个元素最多只有一个「前面的」和一个「后面的」, 那么这组数据就构成了一个线性表 linear list

线性表 L = (a1, a2, a3, …, an)  n ≥ 0

这里的 n 称为表长 lengthn = 0 时称为空表 empty list。 括号里的每个 ai 是一个数据元素 element(也叫结点、记录), 它本身可能很简单(一个整数),也可能很复杂(一个学生的全部信息)—— 但对线性表来说,我们只关心「它在线上的哪个位置」。

2.1.1 四个必须背下来的逻辑特征

线性表的「线性」体现在四条性质上,考试里常以判断题的形式出现,请逐条记牢:

① 存在唯一的「第一个」元素
记作 a1,称为首元素。它没有直接前驱 direct predecessor。
② 存在唯一的「最后一个」元素
记作 an,称为尾元素(表尾元素)。它没有直接后继 direct successor。
③ 除首元素外,每个元素有且仅有一个直接前驱
ai(2 ≤ i ≤ n)的直接前驱是 ai-1,唯一。
④ 除尾元素外,每个元素有且仅有一个直接后继
ai(1 ≤ i ≤ n−1)的直接后继是 ai+1,唯一。

注意「唯一」两个字的分量。③④ 合起来排除了「一个元素有两个后继」的树形分支, ① ② 又排除了「环形兜圈」的可能(严格意义上,环形结构里每个元素都有前驱后继,但没有首尾)。 所以「线性」= 一条有头有尾、不分叉、不闭合的链。

线性表的逻辑结构:一条有头有尾、不分叉的「线」 a1 a2 a3 …… a(i-1) a(i) a(i+1) an 唯一直接前驱 a(i-1) 唯一直接后继 a(i+1) 位序 1 位序 2 位序 3 位序 i 位序 n 首元素:无前驱 尾元素:无后继 中间元素:一前一后,各唯一 表长 n 可变:插入使 n 加 1,删除使 n 减 1;n = 0 时称为空表。注意位序从 1 开始计数,而数组下标从 0 开始。
图 2-1 线性表的逻辑结构:首元素唯一无前驱、尾元素唯一无后继,中间元素前驱后继各唯一

2.1.2 位序 vs 下标:一个每天都在坑人的差异

数学上我们习惯把元素写成 a1, a2, …, an, 也就是位序 position / 序号1 开始;而 C/C++ 数组的下标从 0 开始。 两者之间差 1:

ai(位序 i)↔ array[i - 1](下标 i-1)

这一条看起来是废话,但它是本章所有越界 bug 的源头。教材与考试里的 ListInsert(L, i, e),参数 i位序, 合法范围是 1 ≤ i ≤ n+1i = n+1 表示插到表尾); 而 ListDelete(L, i, e) 的合法范围是 1 ≤ i ≤ n。 写代码时第一件事就是把边界想清楚:

易错:插入能到 n+1,删除只能到 n 插入是在「两个元素之间塞一个」,所以 n 个元素之间有 n+1 个空档;删除必须删掉一个已经存在的元素, 所以只有 n 个可选目标。写 if (i < 1 || i > len + 1) return false; 时, 插入用 len + 1,删除用 len,别抄错。

2.1.3 线性表的 ADT:先把接口想清楚,再谈实现

抽象数据类型 ADT(Abstract Data Type)指的是: 一个数学模型 + 定义在该模型上的一组操作,而不关心这些操作在机器里怎么实现。 换句话说,ADT 是「说明书」,数据结构是「实物」。 对线性表而言,我们关心的是下面这些操作,而不是它用数组还是链表:

操作语义参数与返回值约定
InitList构造一个空表无参;把表长置 0,准备好存储空间
Length求表长返回 n,不含头结点
LocateElem按值查找返回第一个等于给定值的元素位序;找不到返回 0
GetElem按位取值传入位序 i,用引用带回元素;越界返回 false
ListInsert插入在位序 i 处插入,原 ai 及其后元素整体后移;1 ≤ i ≤ n+1
ListDelete删除删除位序 i 的元素并带回其值;1 ≤ i ≤ n
PrintList遍历打印按位序从头到尾输出,调试必备
DestroyList销毁释放全部存储(链表必须逐个 delete

用 C++ 表达这套 ADT,最自然的做法是一个抽象基类:纯虚函数只声明接口, 具体存储细节交给派生类 SeqListSinglyList 去填。 这样做的好处是:上层算法(比如「把两个线性表合并」)只依赖 List<T>&, 换实现不用改一行调用代码——这正是「面向接口编程」在数据结构课上的第一次亮相。

/* ==========================================================================
   顺序表的抽象数据类型(ADT)—— 先想清楚「有哪些操作」,再谈怎么实现
   --------------------------------------------------------------------------
   数据结构 = 逻辑结构 + 存储结构 + 运算。
   「运算」这一层只规定**做什么**(语义),不规定**怎么做**(实现)——
   这就是抽象数据类型 ADT 的含义。

   竞赛里不需要用 class 来表达 ADT:直接开全局数组 + 写几个自由函数就行,
   又快又好调试。下面先把「顺序表的运算清单」列出来(这就是 ADT),
   具体实现见后面的 seqlist.cpp。

   约定(全课件统一):位序从 1 开始,第 i 个元素存在 a[i-1]。
     · InitList()              建空表
     · Length()                求表长 n
     · GetElem(i, e)           按位取值:取第 i 个元素,1 <= i <= n
     · LocateElem(e)           按值查找:返回位序 1..n,找不到返回 0
     · ListInsert(i, e)        在位序 i 处插入 e,1 <= i <= n+1
     · ListDelete(i, e)        删除位序 i 的元素并用 e 带回,1 <= i <= n
     · PrintList()             遍历打印
     · DestroyList()           销毁整表

   为什么找不到时返回 0 而不是 -1?
   因为位序从 1 开始,0 是天然不会被占用的「哨兵值」,
   和 C 标准库里 strchr 返回 NULL、string::find 返回 npos 是同一种思路。
   ========================================================================== */
#include <bits/stdc++.h>
using namespace std;

const int N = 100005;       // 顺序表最大长度
int a[N];                   // 数据存在 a[0..n-1],逻辑位序 = 下标 + 1
int n = 0;                  // 当前表长

void InitList() { n = 0; }
int  Length()   { return n; }

/* 下面给出各操作的「空壳」,真正实现在 seqlist.cpp;
   这里只为了让这段代码能单独编译运行,直接把实现写进来也可以。 */
void PrintList() {
    for (int i = 0; i < n; ++i) printf("%d ", a[i]);
    printf("\n");
}

int main() {
    InitList();
    a[n++] = 10; a[n++] = 20; a[n++] = 30;   // 直接往表尾放三个元素
    printf("表长 = %d\n", Length());          // 3
    PrintList();                              // 10 20 30
    return 0;
}

有了接口,本章接下来的所有实现都可以看作「同一份说明书的两种(乃至五种)实现方案」。 请特别注意:接口一样,复杂度却可能天差地别——这正是本讲要反复对比的主线。

考点 1 线性表的逻辑特征是「一对一」,与它采用顺序存储还是链式存储无关。 常考判断题:「线性表的每个元素都有前驱和后继」——错,首元素无前驱、尾元素无后继; 「线性表必须用连续空间存储」——错,那是顺序表的特点,不是线性表的定义。

2.2 顺序表:用一整块连续内存装下整张表

顺序表 sequential list 的思路极其朴素:既然元素是排成一队的, 那就把它们挨着放——在内存里申请一整块连续空间,第 1 个元素放在最前面, 第 2 个紧跟其后,依次排开。C++ 里的原生数组、std::vector、 Java 的 ArrayList、Python 的 list,底层都是这个思路。

2.2.1 内存布局与地址公式:为什么下标访问这么快

连续存放带来一个巨大的好处:只要知道第 1 个元素在哪,就能用算术算出第 i 个元素在哪, 完全不需要从头一个个找。设每个元素占 L 个字节 (L = sizeof(T),例如 int 通常是 4 字节), 首元素 a1 的起始地址为 LOC(a1),那么:

LOC(ai) = LOC(a1) + (i − 1) × L

这个公式的推导只有一句话:

推导a1 走到 ai,中间要跨过 i − 1 个元素; 每个元素占 L 字节,且它们首尾相接、中间没有空隙, 所以总共跨过 (i − 1) × L 个字节。加上起点地址,即得公式。 整个过程只用到一次减法、一次乘法、一次加法,与表长 n 无关,因此是 O(1)

这种「给一个下标就能直接算出地址并访问」的能力叫做随机存取 random access。 与之相对,链表只能从表头开始一格一格往后挪,叫做顺序存取 sequential access。 这两个词是本章最核心的对照,务必分清:顺序表能随机存取,链表只能顺序存取; 而「顺序存储」说的是物理布局,「顺序存取」说的是访问方式,一字之差、含义完全不同。

顺序表的内存布局:一段连续地址,元素等距排列 下标 21 32 45 58 66 79 [0] [1] [2] [3] [4] [5] 1000 1004 1008 1012 1016 1020 ← 字节地址 连续的一段内存,相邻元素地址差恒为 L 字节(这里 int 的 L = 4) LOC(a_i) = LOC(a_1) + (i − 1) × L 例:要求位序 4 的元素(下标 3),LOC = 1000 + (4 − 1) × 4 = 1012,一步算出,与表长 n 无关。 若用下标记(从 0 开始):LOC(a[k]) = base + k × L。位序 i 与下标 k 的关系是 k = i − 1,别混用。
图 2-2 顺序表的连续内存布局与地址公式:一次乘加即可定位任意元素,这就是 O(1) 随机存取的来源

2.2.2 动态数组实现 SeqList<T>:size 与 capacity

顺序表有两种分配方式。静态分配直接写 T data[MaxSize];, 容量在编译期定死,一旦存满就没救(除非搬家),而且开小了浪费、开大了可能栈溢出。 动态分配在堆上 new T[cap],容量不够时再申请一块更大的、把数据搬过去、 把旧的释放掉——这就是 std::vector 的做法,也是我们要实现的版本。

这里必须分清两个容易混淆的量:

size(表长 len)

当前实际存了多少个元素。它决定了哪些操作合法: 按位查找要求 1 ≤ i ≤ len,插入要求 1 ≤ i ≤ len+1。 用户看到的「线性表长度」就是这个数。

capacity(容量 cap)

底层数组最多能装多少个元素,是实现细节,用户不该关心。 恒有 len ≤ cap;当 len == cap 时再插入才需要扩容。 预留的空位正是「插入不必每次都搬家」的原因。

/* ==========================================================================
   顺序表 —— 算法竞赛写法:全局数组 + 自由函数
   --------------------------------------------------------------------------
   顺序表 = 用一段连续内存依次存放元素,逻辑上相邻的两个元素物理上也相邻。
   它的两个基本事实(考点):
     · 按下标访问是 O(1):第 i 个元素就在 a[i-1],一步算出来,不用找
     · 插入/删除是 O(n):为了保持「连续」,平均要搬动一半元素

   为方便讲解和手写,下面用静态数组(比赛里最常见)。
   如果数据量超过数组上限,就把 N 开大或换成 vector<int> a; 用 push_back。
   ========================================================================== */
#include <bits/stdc++.h>
using namespace std;

const int N = 100005;      // 顺序表的最大长度(按要求开够,比赛时按题目数据范围定)
int a[N];                  // 元素存 a[0..n-1]:逻辑位序 i 对应下标 i-1
int n = 0;                 // 表长

void InitList() { n = 0; }
int  Length()   { return n; }
bool Empty()    { return n == 0; }

/* ---------- 按位取「值」:O(1)(顺序表的看家本领) ---------- */
bool GetElem(int i, int &e) {
    if (i < 1 || i > n) return false;   // 位序合法范围是 1..n,不是 0..n-1
    e = a[i - 1];                       // 位序 → 下标:减 1,一步到位
    return true;
}

/* ---------- 按值查找:O(n),只能从头挨个比 ---------- */
int LocateElem(int e) {
    for (int k = 0; k < n; ++k)
        if (a[k] == e) return k + 1;    // 返回「位序」,所以是 k+1
    return 0;                           // 0 是哨兵:位序从 1 起,0 不会被占用
}

/* ---------- 插入:在位序 i 处插入 e,1 <= i <= n+1 → O(n) ----------
   从后往前依次后移一位,腾出 a[i-1] 这个位置,再放新元素。
   关键点:必须「从后往前」倒着搬,正着搬会把后面的元素覆盖掉。 */
bool ListInsert(int i, int e) {
    if (i < 1 || i > n + 1) return false;   // i = n+1 表示插到表尾,是合法的
    if (n == N) return false;               // 表满
    for (int k = n - 1; k >= i - 1; --k) a[k + 1] = a[k];
    a[i - 1] = e;
    ++n;
    return true;
}

/* ---------- 删除位序 i 的元素,用 e 带回,1 <= i <= n → O(n) ----------
   从前往后依次前移一位,把空洞填掉。 */
bool ListDelete(int i, int &e) {
    if (i < 1 || i > n) return false;
    e = a[i - 1];
    for (int k = i - 1; k < n - 1; ++k) a[k] = a[k + 1];
    --n;
    return true;
}

void PrintList() {
    for (int k = 0; k < n; ++k) printf("%d ", a[k]);
    printf("(表长 %d)\n", n);
}

int main() {
    InitList();
    for (int x : {12, 5, 33, 7, 20}) ListInsert(n + 1, x);   // 依次插到表尾建表
    PrintList();                                     // 12 5 33 7 20(表长 5)

    ListInsert(1, 99);                               // 插到最前面:后面 5 个元素全要后移
    PrintList();                                     // 99 12 5 33 7 20(表长 6)

    int e;
    ListDelete(3, e);
    printf("删掉的是 %d\n", e);                       // 5
    PrintList();                                     // 99 12 33 7 20(表长 5)

    GetElem(2, e);
    printf("第 2 个元素是 %d\n", e);                  // 12
    printf("值 33 的位序是 %d\n", LocateElem(33));    // 3
    printf("值 404 的位序是 %d(0 表示没找到)\n", LocateElem(404));

    /* ---------- 复杂度小结 ----------
       按位取值 O(1) | 按值查找 O(n) | 插入 O(n) | 删除 O(n)
       插入平均搬动 n/2 个元素,删除平均搬动 (n-1)/2 个:
       插入位置等概率取 1..n+1,搬动次数分别是 n, n-1, ..., 1, 0,
       平均 = (0+1+...+n)/(n+1) = n/2;删除同理得 (n-1)/2。 */
    return 0;
}

2.2.3 按位查找:O(1) 的随机存取

有了地址公式,按位查找就是「检查一下边界,然后把下标减一取出来」。 注意两个细节:一是位序转下标要减 1;二是越界必须挡在门外, 因为 data[i-1] 在 C++ 里不做任何检查,越界读是未定义行为 undefined behavior, 可能读到垃圾值,也可能直接把程序搞崩。

/* ==========================================================================
   顺序表的三种「读」操作:按位取值、按值查找、遍历打印
   --------------------------------------------------------------------------
   竞赛写法:依然是「全局数组 + 自由函数」,和上一段 seqlist.cpp 完全一致。

   这里最值得记住的一句话:
       按位取值是 O(1),因为可以「算」出地址;按值查找是 O(n),因为只能「比」。
   这就是顺序表被称为「随机存取结构」的原因。
   ========================================================================== */
#include <bits/stdc++.h>
using namespace std;

const int N = 100005;
int a[N];
int n = 0;

/* ---------- 1) 按位取值 GetElem:位序 i → 下标 i-1,一步到位,O(1) ---------- */
bool GetElem(int i, int &e) {
    if (i < 1 || i > n) return false;   // 位序范围 1..n,注意不是 0..n-1
    e = a[i - 1];                       // 位序 → 下标:减 1
    return true;
}

/* 顺便看一眼「地址」是怎么算出来的:
   设首元素地址为 LOC(a[0]),每个元素占 L 字节,则
       LOC(a[i]) = LOC(a[0]) + i * L
   所以给定下标就能直接算出地址,不需要从头走一遍 —— 这就是 O(1) 的来源。 */

/* ---------- 2) 按值查找 LocateElem:只能从头挨个比,O(n) ---------- */
int LocateElem(int e) {
    for (int k = 0; k < n; ++k)
        if (a[k] == e) return k + 1;    // 返回位序,所以是 k+1
    return 0;                           // 0 = 没找到(位序从 1 起,0 不会被占用)
}

/* 平均查找长度 ASL:等概率下每个位置被查到的概率是 1/n,
   比较次数分别是 1, 2, ..., n,所以 ASL = (1+2+...+n)/n = (n+1)/2。 */

/* ---------- 3) 遍历打印 PrintList:逐个访问,O(n) ---------- */
void PrintList() {
    for (int k = 0; k < n; ++k) printf("%d ", a[k]);
    printf("(表长 %d)\n", n);
}

int main() {
    for (int x : {12, 5, 33, 7, 20}) a[n++] = x;    // 直接往表尾放,建表 O(n)

    PrintList();                        // 12 5 33 7 20(表长 5)

    int e;
    if (GetElem(3, e)) printf("第 3 个元素 = %d\n", e);          // 33
    printf("GetElem(9) 返回 %d(false 表示越界)\n", (int)GetElem(9, e));

    printf("第一个 7 的位序 = %d\n", LocateElem(7));              // 4
    printf("404 的位序 = %d(0 表示没找到)\n", LocateElem(404));

    /* ---------- 复杂度小结 ----------
       按位取值 O(1) | 按值查找 O(n)(成功时 ASL=(n+1)/2)| 遍历 O(n) */
    return 0;
}

2.2.4 按值查找:为什么平均要比 (n+1)/2 次

按值查找没有捷径可走:地址公式只能算出「第 i 个在哪」,算不出「值等于 x 的那个在哪」。 所以只能从 a1 开始逐个比较。设查找的目标等概率地出现在任意位置, 比较次数分别为 1, 2, …, n,于是平均查找长度 ASL 为:

ASL成功 = (1/n) × Σi=1..n i = (n + 1) / 2

也就是说,平均要看一半的元素,时间复杂度 O(n)。 这也是顺序表相对哈希表(第 10 讲)最大的短板:按下标快如闪电,按值找慢如蜗牛

2.2.5 插入:为什么平均要挪一半的元素

插入的难点在于「腾位置」。要在位序 i 处插入新元素, 就必须把原来 ai 及其后面的所有元素统统往后挪一格。 挪的时候有个关键顺序问题:必须从最后一个元素开始往前挪。 如果从 ai 开始往后挪,ai 会先把 ai+1 覆盖掉,等到要挪 ai+1 时原值已经没了—— 数据被自己吃掉,这就是典型的「覆盖丢失」。

第 1 步:从后往前,把 a[5]→a[6]、a[4]→a[5]、a[3]→a[4] 依次后移,腾出下标 3 21 32 45 58 66 79 [0] [1] [2] [3] [4] [5] [6] [7] 橙色为需要移动的元素,共 3 个(下标 3、4、5) 第 2 步:把新元素 50 写进刚刚空出来的下标 3;此时元素个数仍是 6 21 32 45 50 58 66 79 新元素 写入动作只有一次赋值:data[i-1] = e 第 3 步:len 从 6 变成 7,插入完成(capacity = 8,还有 1 个空位) 21 32 45 50 58 66 79 本次插入的 i = 4、n = 6,移动次数 = n − i + 1 = 3,与图示一致
图 2-3 顺序表插入的三步(可点击「上一步 / 下一步」切换):腾位 → 写入 → 表长加一

下面用动画把这三步逐帧放慢,请特别注意「移动方向」与「移动次数计数器」:

现在把移动次数算清楚。在位序 i 处插入时,需要后移的元素是原来的 ai…an,共 n − i + 1 个:

插入位置移动的元素移动次数说明
i = 1(表头)全部 n 个n最坏情况
i = n + 1(表尾)0最好情况,直接追加
任意 iai…ann − i + 1一般情况

假设 n + 1 个可插入位置等概率(概率各为 1/(n+1)), 求平均移动次数 Einsert

Einsert = (1/(n+1)) × Σi=1..n+1 (n − i + 1) = (1/(n+1)) × (n + (n−1) + … + 1 + 0) = (1/(n+1)) × n(n+1)/2 = n / 2

所以插入平均要搬 n/2 个元素,时间复杂度 O(n)。 这个结论的直觉解释是:平均来看,新元素会插在表的正中间,那么后面一半的元素都得往后挪一格。

2.2.6 删除:平均移动 (n−1)/2 次

删除是插入的逆操作:把位序 i 的元素拿掉,为了不让中间出现空洞(一旦有洞,地址公式就失效了), 必须把 ai+1…an 整体向前挪一格。 这次的方向正好相反——从前往后挪,因为每个元素都是往已腾空的位置填,不会覆盖到还没处理的数据。

删除时要注意:表长减 1 只是让最后一个位置「逻辑上不存在」了,那块内存并没有被归还,也不需要 delete——顺序表是整块申请的,只能整块释放。

顺序表删除位序 2 的元素:后面的元素整体前移一格 删除前(len = 6): 21 32 45 58 66 79 [0] [1] [2] [3] [4] [5] [6] ← 被删元素(由 e 带回) 删除后(len = 5): 21 45 58 66 79 [0] [1] [2] [3] [4] [5] [6] ← 空位已不属于逻辑表尾 移动次数 = n − i = 6 − 2 = 4;一般式 n − i,平均 (1/n)·Σ(n−i) = (n−1)/2。
图 2-4 顺序表删除:元素自前往后前移,平均移动 (n−1)/2 个元素

删除位序 i 的元素时,需要前移的是 ai+1…an, 共 n − i 个。i1…n 等概率,于是:

Edelete = (1/n) × Σi=1..n (n − i) = (1/n) × (n−1 + n−2 + … + 1 + 0) = (1/n) × n(n−1)/2 = (n − 1) / 2
考点 2:为什么插入是 n/2,删除却是 (n−1)/2? 差别只在「等概率的分母」上:插入有 n+1 个合法位置(包括表尾那个「不移动」的位置), 而删除只有 n 个合法位置,两者的求和项也不同(插入是 n−i+1,删除是 n−i)。 记忆技巧:插入含表尾 0 次移动这一档,所以分母大 1、平均少「挪」半格。 考试若不给等概率假设,默认按等概率算。
/* ==========================================================================
   顺序表的插入与删除:移动方向、边界条件与复杂度
   --------------------------------------------------------------------------
   顺序表的插入/删除之所以慢,不是因为它「不会做」,而是因为要保持
   「逻辑相邻 = 物理相邻」这条规矩:一旦中间腾出一个空位或留下一个空洞,
   就必须把后面的元素整体搬一搬。

   两个必须记牢的方向:
      插入 —— 从后往前搬(不然会把还没搬的元素覆盖掉)
      删除 —— 从前往后搬(把空洞后面的元素依次补上来)
   ========================================================================== */
#include <bits/stdc++.h>
using namespace std;

const int N = 100005;
int a[N];
int n = 0;

void PrintList() {
    for (int k = 0; k < n; ++k) printf("%d ", a[k]);
    printf("(表长 %d)\n", n);
}

/* ---------- 插入:在位序 i 处插入 e,合法范围 1 <= i <= n+1 ----------
   i = n+1 表示插到表尾(不用搬元素);i = 1 表示插到表头(要搬 n 个)。
   搬动次数 = n - i + 1,所以最坏 O(n)。 */
bool ListInsert(int i, int e) {
    if (i < 1 || i > n + 1) return false;      // 越界:注意上界是 n+1 不是 n
    if (n == N) return false;                  // 表满(静态数组才需要判,vector 会自动扩容)
    for (int k = n - 1; k >= i - 1; --k)       // 从后往前:a[n-1] → a[n],依次后移
        a[k + 1] = a[k];
    a[i - 1] = e;
    ++n;
    return true;
}

/* ---------- 删除:删掉位序 i 的元素,用 e 带回,合法范围 1 <= i <= n ----------
   搬动次数 = n - i,所以最坏 O(n)。
   注意:删除后「表长减一」,最后那个位置虽然还留着旧值,但已经不属��表内了。 */
bool ListDelete(int i, int &e) {
    if (i < 1 || i > n) return false;
    e = a[i - 1];
    for (int k = i - 1; k < n - 1; ++k)        // 从前往后:把后面的元素依次往前补
        a[k] = a[k + 1];
    --n;
    return true;
}

int main() {
    for (int x : {12, 5, 33, 7, 20}) ListInsert(n + 1, x);   // 全都插到表尾,建表 O(n)
    PrintList();                     // 12 5 33 7 20(表长 5)

    ListInsert(1, 99);               // 插到表头:5 个元素全部后移一格
    PrintList();                     // 99 12 5 33 7 20(表长 6)

    ListInsert(n + 1, 88);           // 插到表尾:一次搬动都不需要
    PrintList();                     // 99 12 5 33 7 20 88(表长 7)

    int e;
    ListDelete(3, e);
    printf("删掉位序 3 的元素:%d\n", e);      // 5
    PrintList();                     // 99 12 33 7 20 88(表长 6)

    printf("越界删除 ListDelete(99) 返回 %d\n", (int)ListDelete(99, e));   // 0

    /* ---------- 复杂度分析(考点) ----------
       插入:位序 i 处插入要后移 n-i+1 个元素;
             等概率取 i = 1..n+1,平均搬动
             (n + (n-1) + ... + 1 + 0) / (n+1) = n/2 个 → O(n)
       删除:位序 i 处删除要前移 n-i 个元素;
             等概率取 i = 1..n,平均搬动
             ((n-1) + (n-2) + ... + 1 + 0) / n = (n-1)/2 个 → O(n)

       一句话记忆:顺序表「查得快、改得慢」——
       按位取值 O(1),插入删除平均要搬一半元素。 */
    return 0;
}

2.2.7 表尾插入的均摊 O(1):扩容账要这样算

看到 grow() 里那个 for 循环,很多人第一反应是: 「插入不是 O(n) 吗?怎么又说表尾插入是 O(1)?」 这里要区分单次操作的代价均摊代价 amortized cost

假设容量从 1 开始翻倍,连续在表尾插入 n 个元素。 触发扩容的时刻是容量为 1, 2, 4, 8, …, n/2 的时候, 每次扩容要搬的元素的个数恰好等于当时的容量,于是总搬运量为:

1 + 2 + 4 + … + n/2 = n − 1 < n

也就是说,插入 n 个元素的总搬运次数不到 n 次,分摊到每次插入上不到 1 次。 再加上每次插入本身的赋值操作,平均每次插入只做了常数次工作,所以是 O(1)。 这就是均摊分析 amortized analysis 的典型例子: 偶尔一次很贵(O(n)),但贵的次数极少,长期平均下来很便宜

为什么扩容要「翻倍」而不是「加一」? 如果每次满了只加 1 个位置,那么插入 n 个元素要扩容 n 次,总搬运量是 1 + 2 + … + n = O(n²),均摊下来每次插入仍是 O(n),动态数组就退化成了链表都不如的东西。 常见的增长因子是 2(std::vector 多数实现用 1.5~2 倍), 取 1.5 的好处是:多次扩容后旧块的总和不会超过新块,更容易复用已释放的内存。

2.2.8 顺序表的优缺点小结

优点

  • 随机存取 O(1):给定下标一步定位,这是链表永远做不到的。
  • 存储密度高:除了数据本身不额外花内存(链表每个结点要多一个指针域)。
  • 缓存友好 cache friendly:连续内存一次载入一整条缓存行,遍历速度常比链表快数倍——工程上这条往往比理论复杂度更重要。
  • 表尾插入删除快(不触发扩容时是 O(1)),实现简单、不易出指针 bug。

缺点

  • 插入删除 O(n):中间/表头操作要成片搬元素。
  • 容量固定或需扩容:静态分配会溢出,动态分配要预留空位、可能浪费内存。
  • 要求大片连续内存:内存碎片多时,可能「总量够但没有一整块」而申请失败。
  • 扩容有代价:一次 O(n) 的拷贝,实时性敏感的场景(如游戏帧循环)要提前 reserve

2.3 单链表:把结点散落在堆上,用指针串起来

顺序表的两条硬伤——「必须要一整块连续内存」和「插入删除要成片搬元素」—— 都来自同一个决定:用物理位置的相邻来表示逻辑关系的相邻。 链表把这个决定反过来做:元素爱放哪放哪,逻辑上的「下一个」用一个指针明确写出来

这样一来,插入一个元素就不再需要挪动别人了,只要改两根指针; 代价是:每个元素必须额外带一个指针(空间开销变大), 而且因为地址不再连续,下标访问彻底失效——想找第 100 个元素,只能从表头数 99 次。

2.3.1 结点结构:数据域 + 指针域

链表里存放一个元素的单元叫结点 node,它由两部分组成: 数据域 data field 存元素本身,指针域 pointer field 存「下一个结点在哪」。 在 C++ 里就是一个自引用的结构体:

struct Node { int data; Node* next; };  // 数据域 + 指针域

这一行代码有个著名的坑:Node* next; 里的 Node 此时还没定义完, 为什么能编译通过?因为指针的大小是固定的(64 位平台 8 字节), 编译器不需要知道 Node 的完整布局就能声明指向它的指针,这叫不完全类型 incomplete type。 但如果你写成 Node next;(少一个星号),编译器立刻报「不完整类型」错误—— 因为它无法算出一个「包含自己的自己」有多大。这个错误信息考试和作业里都非常常见。

2.3.2 头指针 / 头结点 / 首元结点:三个词,三样东西

这是本章最高频的易错点,没有之一。很多同学代码写不对,就是因为把这三个概念搅成了一锅粥。 先把定义摆清楚:

头指针 head pointer
一个指针变量,它指向链表的第一个结点。它是链表的「身份证」—— 只要拿到头指针,整条链表就能找到;头指针没了,整条链就泄漏了。 头指针一定不为空(哪怕表是空的,它也指向头结点)。
头结点 head node / 哑结点 dummy node
一个真实存在的结点,放在首元结点之前,它的数据域通常不使用(可以存表长等附加信息), 指针域指向首元结点。引入它是为了让「空表」和「非空表」、「第一个位置」和「其他位置」 用同一套代码处理,不用特判。
首元结点 first element node
链表中真正存放第一个数据元素 a1 的结点,也就是 head->next 指向的那个结点。空表时它不存在(head->next == nullptr)。
头指针(变量)≠ 头结点(真实结点)≠ 首元结点(存 a1 的结点) (a) 带头结点的非空链表 head 12 34 56 NULL 头指针变量 头结点(哑结点) 首元结点 = head->next ← 尾结点的 next 为 NULL (b) 带头结点的空表:head 不为空,但 head->next == NULL head NULL 这就是空表:没有首元结点,但「表」这个对象依然存在,head 依然有效。 (c) 不带头结点的链表:head 直接指向首元结点 head 12 34 NULL 空表时 head == NULL;在表头插入 / 删除首元结点必须特判, 因为「改 head 本身」与「改 head->next」是两种不同的代码, 所以本章统一采用带头结点的写法。
图 2-5 头指针、头结点、首元结点的区别;以及带头结点 / 不带头结点两种风格下空表的样子
考点 3:为什么要引入头结点?
  • 统一空表与非空表:不管表空不空,head 都指向头结点,head 永不为空,不用写 if (head == NULL)
  • 统一首位置与其他位置:在第 1 个位置插入时,「前驱」就是头结点, 于是 s->next = p->next; p->next = s; 这一套代码对所有位置都成立,不必为首元结点特判。
  • 便于统一删除:删除首元结点时同样有一个「前驱」头结点可以改指向,不需要改 head 本身。
  • 代价:多占一个结点的空间(通常 8~16 字节),且「表长」需要用单独变量记录,不能靠数结点。

2.3.3 头插法与尾插法建表:一个逆序,一个正序

建立链表时,每个新结点都要「挂」到链上。挂的位置不同,就产生了两种建表法: 头插法每次都插在头结点之后(插在表头),尾插法每次都接在尾巴后面。 它们的差别不只是顺序,还有是否需要额外的尾指针。

/* ==========================================================================
   单链表的建立 —— 头插法与尾插法(算法竞赛写法)
   --------------------------------------------------------------------------
   竞赛里写链表的标准姿势:
       · 用 struct 描述「结点长什么样」(数据域 + 指针域),这是知识点本身
       · 用全局指针存表头,操作写成自由函数(不做类封装)
       · 需要临时结点时直接 new,程序结束就回收,比赛里不用手动 delete

   两个必须区分的概念(高频易错点):
       头指针 head —— 指向链表的第一个结点,它本身不是结点,是「入口」
       首元结点    —— 链表中真正存第一个数据元素的那个结点
       头结点      —— 为了简化操作,在首元结点前面附加的一个「哑结点」,
                      它不存数据,next 指向首元结点。空表时 head->next == NULL。
   下面用「带头结点」的版本:插入删除时不用特判「在表头操作」这种边界。
   ========================================================================== */
#include <bits/stdc++.h>
using namespace std;

struct Node {               // 结点:数据域 + 指针域
    int val;
    Node *nxt;
    Node(int v = 0, Node *p = nullptr) : val(v), nxt(p) {}
};

Node *head = nullptr;       // 头指针:指向头结点(哑结点)

/* ---------- 建表:只要头结点,链表就是空表 ---------- */
void InitList() {
    head = new Node();      // 头结点不存数据
}

/* ---------- 头插法:每次插在首元结点前面,O(1) ----------
   顺序会反过来!输入 1 2 3,链表里是 3 2 1。 */
void PushFront(int x) {
    Node *p = new Node(x);              // 1) 造新结点
    p->nxt = head->nxt;                 // 2) 先连:新结点接上原来的首元结点
    head->nxt = p;                      // 3) 后断:头结点改指向新结点
    /* 这两句的顺序不能反!先写 head->nxt = p 就把原来的链表弄丢了 */
}

/* ---------- 尾插法:维护一个尾指针 tail,插到表尾也是 O(1) ----------
   顺序和输入一致,这是做题时更常用的建表方式。 */
Node *tail = nullptr;
void PushBack(int x) {
    Node *p = new Node(x);
    tail->nxt = p;                      // 接到尾巴后面
    tail = p;                           // 更新尾指针
}

void InitListWithTail() {
    head = new Node();
    tail = head;                        // 空表时尾指针也指向头结点
}

void PrintList(const char *title) {
    printf("%s:", title);
    for (Node *p = head->nxt; p != nullptr; p = p->nxt) printf("%d -> ", p->val);
    printf("NULL(不含头结点)\n");
}

int main() {
    /* ---------- 头插法 ---------- */
    InitList();
    for (int x : {1, 2, 3, 4, 5}) PushFront(x);
    PrintList("头插 1..5");               // 5 -> 4 -> 3 -> 2 -> 1 -> NULL

    /* ---------- 尾插法 ---------- */
    InitListWithTail();
    for (int x : {1, 2, 3, 4, 5}) PushBack(x);
    PrintList("尾插 1..5");               // 1 -> 2 -> 3 -> 4 -> 5 -> NULL

    /* ---------- 复杂度与对比 ----------
       头插 O(1)、尾插(带尾指针)O(1)、建立长度为 n 的表整体 O(n)。
       对比顺序表:建表也是 O(n),但顺序表插入要搬元素,链表只改指针。

       竞赛里什么时候用链表?
       很少直接用!多数题目用「数组模拟链表」(见第 02 讲静态链表一节)
       或链式前向星(第 08 讲),因为 new 慢、指针跳转还会让 cache 命中率变差。 */
    return 0;
}
尾插法为什么必须留一个尾指针? 如果不保存尾指针,每次插入都要从头走一遍找尾结点,单次插入 O(n), 建一张 n 个元素的表就是 O(n²)。留一个 r 之后,每次插入都是 O(1),总代价 O(n)。 这是「用一点额外空间换时间」的经典例子,也是考试里常问的「尾插法建表的时间复杂度」—— 带尾指针是 O(n),不带是 O(n²)

2.3.4 按位查找与按值查找:都是 O(n)

链表失去了地址公式,按位查找也不得不从头数过去。 想找第 i 个结点,就要从首元结点开始走 i − 1 步, 因此时间复杂度是 O(n),而不是顺序表的 O(1)。 按值查找同样是 O(n),但要注意它与顺序表的区别: 顺序表的按值查找最坏是「比较 n 次」,链表的按值查找最坏是「比较 n 次 + 走 n 步指针」, 常数因子更大,实际跑起来更慢(还有缓存不友好的问题)。

/* ==========================================================================
   单链表的查找:按位查找 与 按值查找
   --------------------------------------------------------------------------
   链表和顺序表最大的差别就在查找上:
       顺序表按位取值 O(1)(能直接算出地址)
       链表按位取值 O(n)(只能顺着指针一个个走,因为结点散落在内存各处)

   所以「随机存取」这个词只属于顺序存储结构,链表是「顺序存取」。
   ========================================================================== */
#include <bits/stdc++.h>
using namespace std;

struct Node {
    int val;
    Node *nxt;
    Node(int v = 0, Node *p = nullptr) : val(v), nxt(p) {}
};

Node *head;                 // 头指针(指向头结点)

void InitList() { head = new Node(); }

void PushBack(int x) {      // 尾插建表
    Node *p = head;
    while (p->nxt) p = p->nxt;          // 这里为了代码短,每次从头找尾:O(n)
    p->nxt = new Node(x);
}

/* ---------- 按位查找 GetElem:返回第 i 个结点的指针,O(n) ----------
   约定:i 从 1 开始。i 越界返回 nullptr。
   注意 p 从 head->nxt 起步 —— 跳过头结点,因为它不算「第 1 个元素」。 */
Node *GetElem(int i) {
    if (i < 1) return nullptr;
    Node *p = head->nxt;                // p 指向首元结点
    int j = 1;
    while (p && j < i) { p = p->nxt; ++j; }
    return p;                           // 走到第 i 个就返回;中途变 NULL 说明 i 越界
}

/* ---------- 按值查找 LocateElem:返回第一个值为 e 的结点指针 ---------- */
Node *LocateElem(int e) {
    for (Node *p = head->nxt; p; p = p->nxt)
        if (p->val == e) return p;
    return nullptr;
}

/* ---------- 求表长:O(n)。若经常用,可以额外维护一个 len 变量变成 O(1) ---------- */
int Length() {
    int n = 0;
    for (Node *p = head->nxt; p; p = p->nxt) ++n;
    return n;
}

void PrintList(const char *title = "") {
    printf("%s:", title);
    for (Node *p = head->nxt; p; p = p->nxt) printf("%d -> ", p->val);
    printf("NULL\n");
}

int main() {
    InitList();
    for (int x : {12, 5, 33, 7, 20}) PushBack(x);
    PrintList("链表");                    // 12 -> 5 -> 33 -> 7 -> 20 -> NULL

    printf("表长 = %d\n", Length());       // 5

    Node *p = GetElem(3);
    printf("第 3 个结点 = %s\n", p ? to_string(p->val).c_str() : "越界");   // 33

    p = GetElem(9);
    printf("第 9 个结点 = %s\n", p ? to_string(p->val).c_str() : "越界(返回 nullptr)");

    p = LocateElem(7);
    printf("值 7 的结点 = %s\n", p ? to_string(p->val).c_str() : "没找到");  // 7

    p = LocateElem(404);
    printf("值 404 的结点 = %s\n", p ? to_string(p->val).c_str() : "没找到(返回 nullptr)");

    /* ---------- 结论 ----------
       按位查找 O(n)、按值查找 O(n)、求表长 O(n)(可优化到 O(1))。
       链表唯一「快」的地方是:已知某个结点时插入删除是 O(1),因为它不用搬元素。 */
    return 0;
}

2.3.5 插入结点:为什么必须「先连后断」

链表插入的灵魂是两根指针的赋值顺序。设 p 是第 i−1 个结点 (新结点的前驱),s 是刚 new 出来的新结点,标准写法是:

s->next = p->next;  p->next = s;  // 先连后断

为什么不能反过来?关键在于 p->next 这个「唯一的路标」。 它本来记着 ai 的地址,是通往链表后半段唯一的线索。 如果先执行 p->next = s;,这个路标就被改写成新结点 s 了; 于是接下来执行 s->next = p->next; 时读到的其实是 s 自己, 结果是 s->next == s——新结点指向自己,形成一个孤立的自环, 原来的 ai 及后面整段链表全部丢失,而且再也没有指针能找到它们, 造成彻底的内存泄漏。

✔ 正确顺序:① s->next = p->next; ② p->next = s; head 12 34 p(第 i-1 个) 56 78 NULL 99 s = new Node(99) ① s->next = p->next,先让新结点接管后半段 ② p->next = s,再断开旧路标,全程不丢链 ✘ 错误顺序:先 p->next = s; 再 s->next = p->next; → 整段链表丢失 head 12 34 p(第 i-1 个) 先断:p->next 被改成 s ✘ 这条边已经不存在了 56 78 NULL 56、78 及以后全部「找不到路」→ 内存泄漏 99 s s->next = p->next 读到的其实是 s 自己 → 自环
图 2-6 单链表插入的两根指针(可切换查看):先连后断不丢链,先断后连丢半条链

光看图还不够,请一定亲手点一遍下面的动画——它会一帧一帧地演示 p 如何走到第 i−1 个结点、两根指针如何先后改写,最后还专门用一段「错误顺序」的分支告诉你断链长什么样。

易错:这两句代码不能交换 s->next = p->next;p->next = s; 的顺序不可交换。 记忆口诀:「新结点先认路,老结点再改路」。 如果新结点的 next 还没来得及赋值就去改前驱的 next, 那么「原来的后继」这个信息就永久丢失了。

2.3.6 删除结点:找前驱,以及一个 O(1) 的技巧

删除位序 i 的结点,需要做三件事: 找到它的前驱 p(第 i−1 个结点)、 p 跨过被删结点释放被删结点的内存。 代码只有三行,但每一行都有讲究:

q = p->next;  p->next = q->next;  delete q;

为什么必须先 q = p->next 保存下来?因为一旦执行 p->next = q->next, 被删结点的地址就再也拿不到了,delete 无从谈起,那块内存就永久泄漏。 同理,在「整表删除」的循环里也必须先保存 p->nextdelete p

按位删除要 O(n) 找前驱。那如果题目直接给你一个指向待删结点的指针 p, 要求 O(1) 删掉它呢?在单链表里有一个巧妙的「后继覆盖法」(偷梁换柱)

p->data = p->next->data;  q = p->next;  p->next = q->next;  delete q;

既然找不到前驱,那就干脆不删自己,而是把后继的值抄到自己身上,然后删掉后继。 从外面看效果完全一样。这个方法有两个致命限制,面试时经常被追问:

后继覆盖法的两个限制
  • 删不了尾结点:尾结点没有后继,p->nextnullptr,一用就崩。 此时只能退化成 O(n) 找前驱。
  • 删的不是「那个结点」,而是「那个位置」:如果外部还持有指向原结点的指针(比如迭代器), 它并不会失效,但指向的内容变了;如果有别的指针指向后继结点,那个结点会被误删。 这在工程上是容易出事故的隐式行为。
删除位序 i 的结点:必须先保存 q,再跨过 q,最后 delete q ① 找到前驱 p 与目标 q: head 12 34 p(前驱) 56 q = p->next(待删) 78 NULL ② p->next = q->next 跨过 q,然后 delete q: head 12 34 p->next 直接指向 q 的后继 56 delete q 后这块内存已归还 78 NULL 后继覆盖法(仅给待删结点指针 p 时,O(1) 删除): p->data = p->next->data; q = p->next; p->next = q->next; delete q; 含义:把后继的值抄到自己身上,改成删掉后继;但尾结点没有后继,此招失效。
图 2-7 单链表删除:先保存 q,再让前驱跨过 q,最后释放 q(下半部分为 O(1) 的后继覆盖法)

下面的动画把「按位删除」与「后继覆盖法」两种情形都演一遍,注意观察 pq 两个指针的落点:

/* ==========================================================================
   单链表的删除 —— 为什么删除必须知道「前驱」
   --------------------------------------------------------------------------
   要在链表中摘掉结点 p,得让 p 的前驱直接指向 p 的后继:
       pre->nxt = p->nxt;   // 绕开 p
   也就是说:**插入/删除都要动前驱的指针**,所以「找到前驱」是关键。

   竞赛里必备的一个技巧:如果只给了要删的结点 p、没给前驱,怎么办?
       —— 把后继的数据「复制」到 p 上,再删掉后继(下一段代码演示)。
       这个方法不适用于「删除尾结点」(尾结点没有后继),要特判。
   ========================================================================== */
#include <bits/stdc++.h>
using namespace std;

struct Node {
    int val;
    Node *nxt;
    Node(int v = 0, Node *p = nullptr) : val(v), nxt(p) {}
};

Node *head;

void InitList() { head = new Node(); }

void PushBack(int x) {
    Node *p = head;
    while (p->nxt) p = p->nxt;
    p->nxt = new Node(x);
}

/* ---------- 取第 i 个结点(i 从 1 起),越界返回 nullptr ---------- */
Node *GetElem(int i) {
    if (i < 1) return nullptr;
    Node *p = head->nxt;
    for (int j = 1; p && j < i; ++j) p = p->nxt;
    return p;
}

/* ---------- 删除第 i 个结点:先找第 i-1 个(前驱),再摘下第 i 个 ----------
   用 e 带回被删的值,返回是否成功。O(n)(时间花在找前驱上)。 */
bool ListDelete(int i, int &e) {
    Node *pre = head;                       // 从「头结点」开始找前驱,这样 i=1 也能统一处理
    for (int j = 1; j < i && pre->nxt; ++j) pre = pre->nxt;
    if (pre->nxt == nullptr) return false;  // i 超过表长
    Node *p = pre->nxt;
    e = p->val;
    pre->nxt = p->nxt;                      // 摘掉 p:前驱跨过 p
    delete p;                               // 释放这个结点(竞赛里也可以不写)
    return true;
}

/* ---------- 删除「值为 x」的第一个结点:同样要找前驱 ---------- */
bool DeleteValue(int x) {
    for (Node *pre = head; pre->nxt; pre = pre->nxt) {
        if (pre->nxt->val == x) {
            Node *p = pre->nxt;
            pre->nxt = p->nxt;
            delete p;
            return true;
        }
    }
    return false;
}

/* ---------- 进阶:只给结点 p(不给前驱),O(1) 删掉它 ----------
   思路:把 p 的后继「搬」到 p 身上,然后删掉后继。
   限制:p 不能是尾结点(尾结点没有后继可搬)。 */
bool DeleteNodeSelf(Node *p) {
    if (p == nullptr || p->nxt == nullptr) return false;   // 尾结点不适用
    Node *q = p->nxt;
    p->val = q->val;                        // 后继的值复制过来
    p->nxt = q->nxt;                        // 跨过后继
    delete q;
    return true;
}

void PrintList(const char *title = "") {
    printf("%s", title);
    for (Node *p = head->nxt; p; p = p->nxt) printf("%d -> ", p->val);
    printf("NULL\n");
}

int main() {
    InitList();
    for (int x : {12, 5, 33, 7, 20}) PushBack(x);
    PrintList("原链表 ");                 // 12 -> 5 -> 33 -> 7 -> 20 -> NULL

    int e;
    ListDelete(1, e);
    printf("删掉第 1 个:%d\n", e);        // 12(删表头也要走「找前驱」这条路,头结点帮了大忙)
    PrintList("现在   ");                 // 5 -> 33 -> 7 -> 20 -> NULL

    ListDelete(3, e);
    printf("删掉第 3 个:%d\n", e);        // 7
    PrintList("现在   ");                 // 5 -> 33 -> 20 -> NULL

    printf("DeleteValue(33) 返回 %d\n", (int)DeleteValue(33));
    PrintList("现在   ");                 // 5 -> 20 -> NULL

    printf("ListDelete(9) 返回 %d\n", (int)ListDelete(9, e));   // 0(越界)

    /* ---- 演示 O(1) 删除:只给结点指针,不给前驱 ---- */
    InitList();
    for (int x : {1, 2, 3, 4, 5}) PushBack(x);
    Node *p = GetElem(3);                 // 拿到第 3 个结点(值 3)
    DeleteNodeSelf(p);
    PrintList("O(1) 删掉第 3 个后 ");      // 1 -> 2 -> 4 -> 5 -> NULL

    /* ---------- 结论 ----------
       删除第 i 个结点 O(n)(找前驱),已给前驱时 O(1)。
       对比顺序表删除平均搬 (n-1)/2 个元素:链表只改两个指针,这是它唯一的优势。 */
    return 0;
}

2.3.7 单链表的完整实现 SinglyList

把前面的片段拼起来,下面是一份可以直接编译运行、覆盖全部基本操作的完整单链表实现。 它带一个尾指针 tail,因此表尾追加是 O(1);同时保留了头结点,所有位置的操作都统一。 建议把这段代码抄进 IDE 单步走一遍,尤其是 ListInsertClear

/* ==========================================================================
   单链表完整实现 —— 一份可以直接抄去比赛的模板
   --------------------------------------------------------------------------
   把前面几段的东西合起来:建表、求长、按位/按值查找、插入、删除、反转、打印。
   全部用「全局头指针 + 自由函数」的竞赛写法,不用类。

   约定(全课件统一):
       · 带头结点(哑结点),空表时 head->nxt == NULL
       · 位序 i 从 1 开始,对应链表中的第 i 个数据结点(不含头结点)
       · 函数返回 bool 表示成败,越界就是 false,不抛异常
   ========================================================================== */
#include <bits/stdc++.h>
using namespace std;

struct Node {
    int val;
    Node *nxt;
    Node(int v = 0, Node *p = nullptr) : val(v), nxt(p) {}
};

Node *head;                  // 头指针

/* ==================== 基本操作 ==================== */

void InitList() { head = new Node(); }

int Length() {
    int n = 0;
    for (Node *p = head->nxt; p; p = p->nxt) ++n;
    return n;
}

void PushFront(int x) {                  // 头插 O(1)
    head->nxt = new Node(x, head->nxt);
}

void PushBack(int x) {                   // 尾插 O(n)(没维护尾指针)
    Node *p = head;
    while (p->nxt) p = p->nxt;
    p->nxt = new Node(x);
}

Node *GetElem(int i) {                   // 按位查找 O(n),i 从 1 起
    if (i < 1) return nullptr;
    Node *p = head->nxt;
    for (int j = 1; p && j < i; ++j) p = p->nxt;
    return p;
}

Node *LocateElem(int e) {                // 按值查找 O(n)
    for (Node *p = head->nxt; p; p = p->nxt)
        if (p->val == e) return p;
    return nullptr;
}

bool ListInsert(int i, int e) {          // 在位序 i 处插入:先找前驱 O(n)
    Node *pre = head;
    for (int j = 1; j < i && pre->nxt; ++j) pre = pre->nxt;
    if (i < 1 || (i > 1 && pre->nxt == nullptr)) return false;   // i 越界
    pre->nxt = new Node(e, pre->nxt);    // 新结点接上后继,前驱再接新结点
    return true;
}

bool ListDelete(int i, int &e) {         // 删除位序 i:先找前驱 O(n)
    Node *pre = head;
    for (int j = 1; j < i && pre->nxt; ++j) pre = pre->nxt;
    if (pre->nxt == nullptr) return false;
    Node *p = pre->nxt;
    e = p->val;
    pre->nxt = p->nxt;
    delete p;
    return true;
}

/* ==================== 竞赛常考的链表操作 ==================== */

/* 1) 反转链表(迭代版):pre / cur / nxt 三个指针依次翻转,O(n)、O(1) 空间 */
void Reverse() {
    Node *pre = nullptr, *cur = head->nxt;
    while (cur) {
        Node *nxt = cur->nxt;            // 先存下后继,否则翻完就找不到了
        cur->nxt = pre;                  // 翻转指针
        pre = cur;                       // 三个指针整体后移
        cur = nxt;
    }
    head->nxt = pre;                     // 头结点指向新的首元结点
}

/* 2) 找中间结点(快慢指针):slow 走 1 步、fast 走 2 步,fast 到头时 slow 在中间 */
Node *Middle() {
    Node *slow = head->nxt, *fast = head->nxt;
    while (fast && fast->nxt) { slow = slow->nxt; fast = fast->nxt->nxt; }
    return slow;                         // 偶数个结点时返回「中间偏右」那个
}

/* 3) 判断是否有环(Floyd 判环),返回相遇点;无环返回 nullptr */
Node *HasCycle() {
    Node *slow = head->nxt, *fast = head->nxt;
    while (fast && fast->nxt) {
        slow = slow->nxt;
        fast = fast->nxt->nxt;
        if (slow == fast) return slow;
    }
    return nullptr;
}

void PrintList(const char *title = "") {
    printf("%s:", title);
    for (Node *p = head->nxt; p; p = p->nxt) printf("%d -> ", p->val);
    printf("NULL(长度 %d)\n", Length());
}

int main() {
    InitList();
    for (int x : {1, 2, 3, 4, 5, 6}) PushBack(x);
    PrintList("初始");

    ListInsert(1, 0);                 // 插到表头
    ListInsert(4, 99);                // 插到中间
    PrintList("插入 0 到表头、99 到第 4 位");

    int e;
    ListDelete(4, e);
    printf("删掉 %d\n", e);
    PrintList("删除后");

    printf("表长 = %d,中间结点 = %d\n", Length(), Middle()->val);

    Reverse();
    PrintList("反转后");

    printf("是否有环:%s\n", HasCycle() ? "有" : "无");

    /* 人为造一个环:把尾结点指回第 3 个结点 */
    Node *tail = head->nxt;
    while (tail->nxt) tail = tail->nxt;
    tail->nxt = GetElem(3);
    printf("造环后再判:%s\n", HasCycle() ? "有环(快慢指针相遇)" : "无环");

    /* ---------- 复杂度汇总(考点) ----------
       建表 O(n) | 求长 O(n) | 按位查找 O(n) | 按值查找 O(n)
       已知前驱时插入/删除 O(1) | 否则 O(n)(时间花在找前驱)
       反转 O(n) 时间、O(1) 空间 | 判环 O(n) 时间、O(1) 空间
       对比顺序表:链表牺牲了随机存取(O(1) → O(n)),换来插入删除不用搬元素。 */
    return 0;
}

2.3.8 反转单链表:三道指针小题的「母题」

反转链表是链表题的总入口,因为它强迫你同时管理三根指针。 迭代写法的核心只有一句:pre 记住已反转部分的头,用 cur 指向待处理结点, 每轮先把 cur->next 存档到 nxt,然后把 cur->next 指向 pre。 存档这一步必须最先做,道理和插入的「先连后断」完全一样——不存档就丢链。

反转进行到一半:已翻好 NULL ← 1 ← 2,正在处理 3 NULL 1 2 3 4 5 NULL 2->next = 1(已翻转) 旧边 2->3 已被改写 pre cur nxt(先存档) 已反转段:pre 指向它的头 待反转段:cur 指向它的头,nxt 是它的第二个结点 每轮四步:① nxt = cur->next ② cur->next = pre ③ pre = cur ④ cur = nxt。循环结束时 pre 就是新表头。
图 2-8 反转链表的三指针现场:绿色是已反转段,蓝色是当前结点,橙色是必须最先存档的后继

下面的动画把每一轮的四步操作完整放慢,请对照上图观察三条边的颜色变化:

// reverse_list.cpp —— 单链表反转的三种写法(均为 O(n) 时间)
#include <iostream>
using namespace std;

struct Node { int data; Node* next; Node(int d = 0, Node* n = nullptr) : data(d), next(n) {} };

/* ---------- 写法一:迭代三指针。空间 O(1),工程首选 ---------- */
Node* ReverseIter(Node* first) {          // 约定:first 是首元结点指针(不带头结点)
    Node* pre = nullptr;                  // 反转后,新链的尾结点的 next 应为 NULL
    Node* cur = first;
    while (cur) {
        Node* nxt = cur->next;            // ① 存档:不存就等于把后半段扔了
        cur->next = pre;                  // ② 翻转当前这条边
        pre = cur;                        // ③ pre 前移
        cur = nxt;                        // ④ cur 前移
    }
    return pre;                           // 循环结束 cur == NULL,pre 指向原尾结点 = 新表头
}

/* ---------- 写法二:递归。空间 O(n)(递归栈深度等于表长) ---------- */
Node* ReverseRec(Node* first) {
    if (!first || !first->next) return first;     // 空表 / 单结点:无需反转
    Node* newHead = ReverseRec(first->next);      // 先把后面全部翻好,返回新表头
    first->next->next = first;                    // 原来的后继反过来指向自己
    first->next = nullptr;                        // 自己变成新链的尾结点
    return newHead;                               // 新表头一路上传
}

/* ---------- 写法三:就地头插(带头结点版本,考试常考) ---------- */
void ReverseInPlace(Node* head) {         // head 是头结点,不存数据
    Node* p = head->next;                 // 从首元结点开始
    head->next = nullptr;                 // 先断开:头结点变成新链的「哨兵尾」
    while (p) {
        Node* q = p->next;                // ① 存档
        p->next = head->next;             // ② 头插:接到新链最前面
        head->next = p;
        p = q;                            // ③ 取下一个
    }
}

/* ---------- 测试 ---------- */
Node* Build(const int a[], int n) {
    Node dummy; Node* r = &dummy;
    for (int i = 0; i < n; ++i) { r->next = new Node(a[i]); r = r->next; }
    return dummy.next;
}
void Show(const char* tag, Node* p) {
    cout << tag;
    for (; p; p = p->next) cout << p->data << " ";
    cout << endl;
}
int main() {
    int a[] = {1, 2, 3, 4, 5};
    Show("原链表  : ", Build(a, 5));
    Show("迭代反转: ", ReverseIter(Build(a, 5)));
    Show("递归反转: ", ReverseRec(Build(a, 5)));

    Node* h = new Node();                      // 带头结点版本
    Node* r = h;
    for (int i = 0; i < 5; ++i) { r->next = new Node(a[i]); r = r->next; }
    ReverseInPlace(h);
    Show("就地头插: ", h->next);
    return 0;
}
递归反转的两个坑 一是递归深度等于表长:10 万个结点就会把默认栈撑爆(栈溢出 stack overflow), 工程上链表反转一律用迭代。二是基准情形不能写错if (!first || !first->next) return first; 里的 !first 处理空表, !first->next 处理只剩一个结点,两个都不能少,否则 first->next->next 会解空指针。

2.3.9 快慢指针(一):求中间结点与倒数第 k 个结点

快慢指针(也叫龟兔赛跑)是链表题的第二把万能钥匙: 让 fast 每次走 2 步、slow 每次走 1 步, 那么 fast 走过的路程永远是 slow 的两倍。 当 fast 到达表尾时,slow 恰好走了一半——这就是中间结点。 整个算法只遍历一遍,时间 O(n)、空间 O(1),比「先数长度再走一半」的两趟扫描优雅得多。

「倒数第 k 个」是同一个思想的应用:先让 fast 领先 slow 恰好 k 步, 然后两者同速前进;当 fast 走到 NULL(尾结点之后)时, slow 与表尾的距离正好是 k,即 slow 指向倒数第 k 个结点。

// fast_slow.cpp —— 快慢指针:求中间结点、倒数第 k 个结点
#include <iostream>
using namespace std;

struct Node { int data; Node* next; Node(int d = 0, Node* n = nullptr) : data(d), next(n) {} };

/* 中间结点:fast 每次 2 步,slow 每次 1 步。时间 O(n),空间 O(1) */
Node* MiddleNode(Node* first) {
    Node* slow = first;
    Node* fast = first;
    while (fast && fast->next) {          // 两个条件缺一不可:
        slow = slow->next;                //   fast 判空防止 fast->next 解空指针;
        fast = fast->next->next;          //   fast->next 判空保证能安全走两步。
    }
    return slow;                          // 奇数个结点返回正中;偶数个结点返回「后中间」
}
/* 如果想在偶数个结点时返回「前中间」,把条件改成:
   while (fast->next && fast->next->next) { slow = slow->next; fast = fast->next->next; } */

/* 倒数第 k 个结点:fast 先走 k 步拉开差距,再同步前进 */
Node* KthFromEnd(Node* first, int k) {
    if (k < 1) return nullptr;
    Node* fast = first;
    for (int i = 0; i < k; ++i) {         // fast 领先 k 步
        if (!fast) return nullptr;        // k 比表长还大,不存在倒数第 k 个
        fast = fast->next;
    }
    Node* slow = first;
    while (fast) {                        // 同步走,直到 fast 出表尾
        slow = slow->next;
        fast = fast->next;
    }
    return slow;                          // fast 走了 n-k 步,slow 从 1 走到 n-k+1,即倒数第 k 个
}

Node* Build(const int a[], int n) {
    Node dummy; Node* r = &dummy;
    for (int i = 0; i < n; ++i) { r->next = new Node(a[i]); r = r->next; }
    return dummy.next;
}
int main() {
    int a[] = {1, 2, 3, 4, 5};
    int b[] = {1, 2, 3, 4, 5, 6};
    cout << "5 个结点的中间 = " << MiddleNode(Build(a, 5))->data << endl;   // 3
    cout << "6 个结点的中间 = " << MiddleNode(Build(b, 6))->data << endl;   // 4(后中间)
    cout << "倒数第 2 个 = " << KthFromEnd(Build(a, 5), 2)->data << endl;    // 4
    cout << "倒数第 1 个 = " << KthFromEnd(Build(a, 5), 1)->data << endl;    // 5
    cout << "倒数第 9 个 = " << (KthFromEnd(Build(a, 5), 9) ? "存在" : "不存在") << endl;
    return 0;
}

2.3.10 快慢指针(二):Floyd 判环与环入口的数学推导

Floyd 判环算法(龟兔赛跑算法)回答两个问题: 链表里有没有环?如果有,环的入口在哪?它的做法依然是快慢指针: 如果链表无环,fast 一定会先撞上 NULL; 如果有环,fast 会先进环并且在环里绕圈,由于 slow 每轮只前进 1 步, fast 相对 slow 的速度是每轮 1 步,所以 fast 一定会追上 slow,而绝不会跨过去

「绝不会跨过去」这一条值得单独强调,因为很多人第一反应是「快指针会不会直接跳过慢指针」:

为什么 fast 一定追得上而不是跳过? 进入环之后,设某一时刻 fast 落后 slow 的距离(沿前进方向)为 d1 ≤ d ≤ bb 为环长)。 每走一轮,fast 走 2 步、slow 走 1 步,两者距离减少 1d → d−1。 由于每轮只减 1,d 必然先经过 0(相遇)而不会从 1 直接跳到 −1。 所以只要环存在,相遇一定会发生,且最多再走 b 轮。

接下来是本章最漂亮的一段数学。设:

a
从表头(首元结点)到环入口的步数,也就是「尾巴」的长度。
b
环的长度(环内结点个数)。
c
从环入口沿前进方向走到相遇点的步数,显然 0 ≤ c < b
t
从出发到相遇,slow 走过的步数。

相遇时两件事同时成立:

① slow 走的步数:t = a + c
② fast 走的步数:2t = a + c + m·b (m 为 fast 在环里多绕的整圈数,m ≥ 1)

②−① 得 t = m·b,代回 ① 得:

a = m·b − c = (m − 1)·b + (b − c)

这个式子的含义是:从表头走 a 步,与从相遇点走 a 步,落点是同一个结点。 因为从相遇点出发,先走 b − c 步就回到了环入口, 再多绕 (m−1) 整圈还是回到环入口。 于是算法第二步就出来了:让一个指针从表头出发、另一个从相遇点出发,同速前进,它们相遇的地方就是环入口

Floyd 判环:a = (m−1)·b + (b−c),所以「表头」与「相遇点」同步走必在入口会合 head 尾巴:a 步 a = 表头 → 入口的距离 入口 相遇 c 步 b − c 步 环长 b:slow 与 fast 在环内绕圈,fast 相对速度 1 步/轮,必然追上 推导:设 slow 走 t 步、fast 走 2t 步。t = a + c;2t = a + c + m·b。相减得 t = m·b。 代入得 a = m·b − c = (m−1)·b + (b−c):从相遇点走 b−c 步回到入口,再多绕 m−1 圈仍在入口。 结论:ptr1 从 head 出发、ptr2 从相遇点出发,同速前进,相遇处即环入口。全程 O(n) 时间、O(1) 空间。
图 2-9 Floyd 判环的几何含义与环入口公式推导

下面的动画用一条带环的链表演示全过程:slow 每步 1 格、fast 每步 2 格, 相遇之后进入第二阶段——两个指针分别从表头和相遇点同步出发,最终在入口会合。

// floyd_cycle.cpp —— 判环、求环入口、求环长(全部 O(n) 时间 / O(1) 空间)
#include <iostream>
using namespace std;

struct Node { int data; Node* next; Node(int d = 0, Node* n = nullptr) : data(d), next(n) {} };

/* 第一问:有没有环?有则返回相遇结点,无则返回 nullptr */
Node* HasCycle(Node* head) {
    Node* slow = head;
    Node* fast = head;
    while (fast && fast->next) {
        slow = slow->next;                    // 每轮 1 步
        fast = fast->next->next;              // 每轮 2 步
        if (slow == fast) return slow;        // 相遇 ⇒ 有环
    }
    return nullptr;                           // fast 撞到 NULL ⇒ 无环
}

/* 第二问:环入口在哪?依据 a = (m-1)b + (b-c) */
Node* CycleEntry(Node* head) {
    Node* meet = HasCycle(head);
    if (!meet) return nullptr;                // 无环,谈不上入口
    Node* p = head;                           // 一个从表头出发
    while (p != meet) {                       // 另一个从相遇点出发,同速前进
        p = p->next;
        meet = meet->next;
    }
    return p;                                 // 相遇处即入口
}

/* 第三问:环长是多少?从相遇点绕一圈回到自己 */
int CycleLength(Node* meet) {
    if (!meet) return 0;
    int n = 1;
    for (Node* p = meet->next; p != meet; p = p->next) ++n;
    return n;
}
/* 附加:整条链的结点总数(尾巴 a 步 + 环长 b) */
int TotalLength(Node* head) {
    Node* entry = CycleEntry(head);
    if (!entry) { int n = 0; for (Node* p = head; p; p = p->next) ++n; return n; }
    int a = 0;
    for (Node* p = head; p != entry; p = p->next) ++a;
    return a + CycleLength(entry);
}

int main() {
    /* 构造: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> (回到 4),即 a = 3, b = 5 */
    Node* nodes[9];
    for (int i = 1; i <= 8; ++i) nodes[i] = new Node(i);
    for (int i = 1; i < 8; ++i) nodes[i]->next = nodes[i + 1];
    nodes[8]->next = nodes[4];                 // 造环:8 指回 4

    Node* meet = HasCycle(nodes[1]);
    cout << (meet ? "有环,相遇于 " + to_string(meet->data) : "无环") << endl;
    Node* entry = CycleEntry(nodes[1]);
    cout << "环入口 = " << (entry ? to_string(entry->data) : "无") << endl;   // 4
    cout << "环长 = " << CycleLength(entry) << endl;                          // 5
    cout << "总长 = " << TotalLength(nodes[1]) << endl;                       // 8
    return 0;
}
考点 4:判环的变体问法 「求环长」= 相遇后继续走一圈;「求尾巴长度 a」= 从表头走到入口的步数; 「求总长」= a + b;「求环入口」= 表头与相遇点同步走。 这四个问题都能在 O(n) / O(1) 内解决,是面试与考研真题的常客。 另外注意:用哈希表记录访问过的结点也能判环,但需要 O(n) 额外空间, 面试官通常会追问「能不能做到 O(1) 空间」——那就是 Floyd。

2.3.11 合并两个有序链表

合并两个递增有序的单链表,是「归并排序」在链表上的缩影,也是「哑结点技巧」的最佳示范。 思路是双指针 + 尾插ab 分别指向两条链的待比较结点, 谁的当前值小就把谁摘下来接到结果链的尾部,然后该指针后移。 当一条链走完,把另一条整段接上即可——这一步是 O(1) 的,不需要逐个搬运。

这里我们用一个栈上的哑结点 Node dummy; 作为结果链的临时头结点, 它的作用与头结点一模一样:让「第一个结点」和「后续结点」用同一句 r->next = ...; r = r->next; 处理,省掉一堆 if (result == NULL)。 最后返回 dummy.next 即可,注意 dummy 是局部变量,函数返回后失效, 但 dummy.next 指向的是堆上的结点,完全安全。

// merge_sorted.cpp —— 合并两个递增有序单链表(复用原结点,不额外分配内存)
#include <iostream>
using namespace std;

struct Node { int data; Node* next; Node(int d = 0, Node* n = nullptr) : data(d), next(n) {} };

/* 返回合并后的首元结点指针。时间 O(m+n),空间 O(1) */
Node* MergeSorted(Node* a, Node* b) {
    Node  dummy;                          // 栈上的哑结点:只借用它的 next,不参与结果
    Node* r = &dummy;                     // r 始终指向结果链的尾结点
    while (a && b) {
        if (a->data <= b->data) {          // 取等号可保证「稳定性」:a 中相等元素排在前面
            r->next = a;
            a = a->next;
        } else {
            r->next = b;
            b = b->next;
        }
        r = r->next;                      // 尾指针后移
    }
    r->next = a ? a : b;                  // 剩下那一段直接整条挂上,O(1)
    return dummy.next;                    // dummy 在栈上,但它指向的结点在堆上,安全
}

/* 如果你希望「不破坏原链表」,就得 new 新结点,空间变成 O(m+n),这是一个常见追问 */
Node* MergeCopy(Node* a, Node* b) {
    Node dummy; Node* r = &dummy;
    while (a && b) {
        if (a->data <= b->data) { r->next = new Node(a->data); a = a->next; }
        else                    { r->next = new Node(b->data); b = b->next; }
        r = r->next;
    }
    for (Node* p = a ? a : b; p; p = p->next) { r->next = new Node(p->data); r = r->next; }
    return dummy.next;
}

Node* Build(const int arr[], int n) {
    Node dummy; Node* r = &dummy;
    for (int i = 0; i < n; ++i) { r->next = new Node(arr[i]); r = r->next; }
    return dummy.next;
}
void Show(Node* p) { for (; p; p = p->next) cout << p->data << " "; cout << endl; }

int main() {
    int x[] = {1, 3, 5, 7, 9};
    int y[] = {2, 4, 6, 8};
    Show(MergeSorted(Build(x, 5), Build(y, 4)));   // 1 2 3 4 5 6 7 8 9
    int u[] = {1, 1, 3};
    int v[] = {1, 2, 3};
    Show(MergeSorted(Build(u, 3), Build(v, 3)));   // 1 1 1 2 3 3(相等时先取 a,稳定)
    return 0;
}

2.3.12 删除重复元素、求交集与并集

这三个操作本质上是「遍历 + 比较 + 摘链」的组合。 最关键的分支判断是:什么情况下指针才能前进。 删除重复元素时,一旦发现 p->data == p->next->data, 我们删掉的是 p->next,此时 p 本身不能动—— 因为新的后继可能还是重复值(想想 1,1,1,2)。 这是一个非常经典的「删了之后不要前进」的坑。

求交集与并集都要求两条链已经有序(无序则要先排序或用哈希,复杂度另算), 然后采用与归并完全相同的双指针同步推进策略: 相等就是交集元素;不等时把小的那个推进一格。 并集则是归并 + 跳过相等元素。

// unique_and_set.cpp —— 删除重复元素、求交集、求并集
#include <iostream>
using namespace std;

struct Node { int data; Node* next; Node(int d = 0, Node* n = nullptr) : data(d), next(n) {} };

/* 有序单链表去重:时间 O(n),空间 O(1) */
void UniqueSorted(Node* first) {
    Node* p = first;
    while (p && p->next) {
        if (p->data == p->next->data) {
            Node* q = p->next;                // 删掉后面的重复者
            p->next = q->next;
            delete q;
            /* 注意:这里 p 不前进!因为新的 p->next 可能还是同一个值 */
        } else {
            p = p->next;                      // 只有不相等时才前进
        }
    }
}

/* 无序单链表去重:两重循环 O(n^2)(想更快就先排序,或者用哈希表 O(n)) */
void UniqueUnordered(Node* first) {
    for (Node* p = first; p; p = p->next) {
        Node* pre = p;
        while (pre->next) {
            if (pre->next->data == p->data) {
                Node* q = pre->next;
                pre->next = q->next;
                delete q;
            } else pre = pre->next;
        }
    }
}

/* 交集:两表均递增有序。时间 O(m+n),结果按值升序 */
Node* Intersect(Node* a, Node* b) {
    Node dummy; Node* r = &dummy;
    while (a && b) {
        if (a->data == b->data) {             // 相等 ⇒ 是交集元素
            r->next = new Node(a->data);
            r = r->next;
            a = a->next;
            b = b->next;
        } else if (a->data < b->data) {
            a = a->next;                      // a 太小,不可能再出现在交集里
        } else {
            b = b->next;
        }
    }
    r->next = nullptr;
    return dummy.next;
}

/* 并集:两表均递增有序,结果不含重复值。时间 O(m+n) */
Node* Union(Node* a, Node* b) {
    Node dummy; Node* r = &dummy;
    while (a && b) {
        int v;
        if (a->data < b->data)      { v = a->data; a = a->next; }
        else if (a->data > b->data) { v = b->data; b = b->next; }
        else                        { v = a->data; a = a->next; b = b->next; }  // 相等只取一次
        if (!r->next || r->data != v) { r->next = new Node(v); r = r->next; }
    }
    for (Node* p = a ? a : b; p; p = p->next)
        if (r->data != p->data) { r->next = new Node(p->data); r = r->next; }
    r->next = nullptr;
    return dummy.next;
}

Node* Build(const int arr[], int n) {
    Node dummy; Node* r = &dummy;
    for (int i = 0; i < n; ++i) { r->next = new Node(arr[i]); r = r->next; }
    return dummy.next;
}
void Show(const char* tag, Node* p) { cout << tag; for (; p; p = p->next) cout << p->data << " "; cout << endl; }

int main() {
    int d[] = {1, 1, 2, 3, 3, 3, 5};
    Node* L = Build(d, 7);
    UniqueSorted(L);
    Show("去重后 : ", L);                       // 1 2 3 5

    int x[] = {1, 3, 5, 7, 9};
    int y[] = {3, 4, 5, 9, 10};
    Show("交集   : ", Intersect(Build(x, 5), Build(y, 5)));  // 3 5 9
    Show("并集   : ", Union(Build(x, 5), Build(y, 5)));      // 1 3 4 5 7 9 10
    return 0;
}

2.3.13 单链表的优缺点小结

优点

  • 插入删除只改指针:已经定位到前驱时是 O(1),不需要搬任何元素。
  • 不需要连续内存:内存碎片再多也能凑出结点,天生「动态扩容」。
  • 空间按需分配:不会像静态顺序表那样预留一大片空位。
  • 是栈、队列、图的邻接表、哈希桶等结构的实现基础。

缺点

  • 不能随机存取:按位查找 O(n),想找第 i 个必须从头数。
  • 每个结点多一个指针域:存储密度 < 1,64 位平台上 int 结点的有效数据只占 4/12。
  • 缓存不友好:结点散落在堆上,遍历时频繁 cache miss,实测常比顺序表慢好几倍。
  • 指针 bug 多:断链、野指针、内存泄漏、自环,全靠细心和画图。
  • 找前驱难:按位删除要先 O(n) 找前驱,这是双向链表存在的理由。

2.4 双向链表:每个结点多存一个「回头路」

单链表最大的不便在删除:明明已经拿到了要删的结点 q, 却还得从头走一遍去找它的前驱,白白花掉 O(n)。 根因是指针是单向的,回头无路。 双向链表 doubly linked list 的解决方案简单粗暴:每个结点再存一个指向前驱的指针

2.4.1 结点结构与「四条指针」的修改顺序

prior
前驱指针,指向前一个结点;首元结点的 priorNULL(非循环双链表)。
data
数据域。
next
后继指针,指向后一个结点;尾结点的 nextNULL

p 之后插入新结点 s,一共要改四条指针。 标准顺序是「先处理新结点自己的两条,再处理邻居的两条」:

① s->prior = p;
② s->next = p->next;
③ p->next->prior = s;  (若 p 是尾结点,p->next 为 NULL,此步要判空)
④ p->next = s;

为什么 ③ 必须在 ④ 之前?因为 ③ 里要用到 p->next 找到「原来的后继」。 一旦先执行 ④,p->next 就变成 s 了,原来那个后继的 prior 就再也改不到——结果是新结点的前驱链 s->prior = p 成立, 但原后继的 prior 仍然指向 p,双向链在这一点「断了一半」, 反向遍历时会直接跳过 s。这与单链表「先连后断」是同一个道理: 凡是需要用到旧指针值的地方,都必须排在覆盖它的赋值之前

(a) 双向链表结点:prior + data + next 三个域 prior 21 next ← 每个结点 3 个域,比单链表多一个指针(64 位平台上多 8 字节) (b) 在 p(值为 34)之后插入 s(值为 99):四条指针的修改顺序 head 12 34 p 99 s(新结点) 56 next → ← prior 四步(③ 必须在 ④ 之前): ① s->prior = p;     ② s->next = p->next; ③ p->next->prior = s;  ← 用到了「旧的 p->next」,所以不能放在 ④ 后面;p 是尾结点时要判空 ④ p->next = s;     ← 一旦执行,旧的 p->next 就永久丢失 删除 q 只需两条:q->prior->next = q->next; q->next->prior = q->prior; (q 是尾结点时后者要判空)
图 2-10 双向链表的结点结构与插入时的四条指针:先安顿新结点,再改两位邻居,最后断开旧链

交互演示把每一步的四条指针拆开,请特别留意第 ③ 步与第 ④ 步的先后:

2.4.2 双向链表完整实现 DLinkList

/* ==========================================================================
   双向链表 —— 每个结点多一个「前驱指针」,插入删除就不用找前驱了
   --------------------------------------------------------------------------
   结点结构:
       struct Node { int val; Node *pre, *nxt; };
                      数据域   前驱     后继

   和单链表比,它多花的代价是「每个结点多一个指针域」;
   换来的是三件事:
       ① 删除某个结点不用先找前驱 —— 直接靠 p->pre 就能拿到,O(1)
       ② 可以反向遍历
       ③ 能在某个结点「前面」插入,也是 O(1)

   竞赛里什么时候用?主要是需要「双向删除」的场景,例如 LRU 缓存、双端队列的实现。
   日常做题用数组模拟双向链表更常见(int pre[N], nxt[N]),速度更快。
   ========================================================================== */
#include <bits/stdc++.h>
using namespace std;

struct Node {
    int val;
    Node *pre, *nxt;                     // 前驱 + 后继
    Node(int v = 0, Node *p = nullptr, Node *n = nullptr) : val(v), pre(p), nxt(n) {}
};

Node *head;                              // 头结点(哑结点)
Node *tail;                              // 尾结点(哑结点),这样两端插入都 O(1)

void InitList() {
    head = new Node();
    tail = new Node();
    head->nxt = tail;                    // 空表:head <-> tail
    tail->pre = head;
}

bool Empty() { return head->nxt == tail; }

/* ---------- 在结点 p 的「后面」插入 x:改四条指针,O(1) ----------
   顺序要点:先把新结点的两个指针接好,再改邻居的指针。
   (先改邻居也不会错,但要保证每一步引用的结点都还有效) */
void InsertAfter(Node *p, int x) {
    Node *q = new Node(x, p, p->nxt);    // 新结点:前驱=p,后继=p 原来的后继
    p->nxt->pre = q;                     // 原来的后继,前驱改成新结点
    p->nxt = q;                          // p 的后继改成新结点
}

/* ---------- 在结点 p 的「前面」插入 x:O(1)(这是双链表独有的便利) ---------- */
void InsertBefore(Node *p, int x) {
    InsertAfter(p->pre, x);              // 在「p 的前驱」后面插入 = 在 p 前面插入
}

/* ---------- 删除结点 p:两条指针,O(1),不需要找前驱 ---------- */
void Erase(Node *p) {
    p->pre->nxt = p->nxt;
    p->nxt->pre = p->pre;
    delete p;
}

void PushBack(int x) { InsertBefore(tail, x); }    // 插到尾结点前面 = 表尾
void PushFront(int x) { InsertAfter(head, x); }    // 插到头结点后面 = 表头

Node *GetElem(int i) {                   // 按位查找,O(n)
    if (i < 1) return nullptr;
    Node *p = head->nxt;
    for (int j = 1; p != tail && j < i; ++j) p = p->nxt;
    return (p == tail) ? nullptr : p;    // 走到尾结点说明越界
}

void PrintForward(const char *title = "") {
    printf("%s正向:", title);
    for (Node *p = head->nxt; p != tail; p = p->nxt) printf("%d ", p->val);
    printf("\n");
}

void PrintBackward(const char *title = "") {     // 双链表才能这么干
    printf("%s反向:", title);
    for (Node *p = tail->pre; p != head; p = p->pre) printf("%d ", p->val);
    printf("\n");
}

int main() {
    InitList();
    for (int x : {1, 2, 3, 4, 5}) PushBack(x);
    PrintForward("尾插 1..5  ");
    PrintBackward("尾插 1..5  ");        // 顺序是反的:5 4 3 2 1

    PushFront(0);
    PrintForward("头插 0 后  ");          // 0 1 2 3 4 5

    Node *p = GetElem(3);                 // 第 3 个结点(值 2)
    InsertBefore(p, 99);                  // 在它前面插入 —— 单链表做不到 O(1)
    PrintForward("在 2 前面插 99 ");       // 0 1 99 2 3 4 5

    Erase(p);                             // 只给结点指针就删掉它,O(1)
    PrintForward("删掉 2 后  ");          // 0 1 99 3 4 5

    printf("空表吗:%s\n", Empty() ? "是" : "否");

    /* ---------- 与单链表对比(考点) ----------
       维度            单链表          双向链表
       每结点指针域     1 个            2 个
       删除已知结点     O(n)(要找前驱)  O(1)
       在结点前插入     O(n)            O(1)
       反向遍历         不行            可以
       空间开销         小              每结点多 8 字节

       记忆点:双向链表是用「空间」换「删除便利」,典型应用是 LRU 缓存与双端队列。 */
    return 0;
}

2.4.3 方便在哪,代价是什么

操作单链表双向链表说明
按位查找O(n)O(n)都不能随机存取,两者一样
已知前驱 p 时插入O(1),改 2 根指针O(1),改 4 根指针双向链表常数更大(这是代价
已知结点 q 时删除O(n)(必须找前驱)O(1)(q->prior 直接就是前驱)这是双向链表存在的最大理由
反向遍历做不到O(n),靠 prior需要「从后往前」的场景(如浏览记录)必用
每个结点的空间1 个指针域2 个指针域64 位平台上 int 结点从 12 字节涨到 20 字节
实现难度 / 出错率中等高(四条指针顺序不能乱)调试时务必画图
考点 5:为什么说双向链表「用空间换时间」? 换来的是「删除给定结点 O(1)」与「双向遍历」, 付出的是「每个结点多一个指针域」以及「插入删除要维护四条指针、常数更大」。 考试常考对比题:「在单链表中删除 p 所指结点的时间复杂度」= O(n); 「在双向链表中删除 p 所指结点的时间复杂度」= O(1)。 注意前提:必须已经持有 p 本身;如果题目说的是「删除第 i 个结点」, 两者都需要 O(n) 找位置。

2.5 循环链表:把尾巴接回头上

单链表的尾结点 nextNULL,走到头就没路了。 如果让尾结点的 next 指回头结点,就得到循环链表 circular linked list。 它最大的价值是:从表中任意一个结点出发,都能遍历到整张表—— 这在「轮流调度」「环形缓冲」这类场景里是刚需。

2.5.1 遍历结束条件:为什么是 p != head 而不是 p != NULL

这是循环链表最核心的一个改动。在普通单链表里,我们靠 while (p != NULL) 判断「走完了」; 但在循环链表里根本不存在 NULL——每个结点的 next 都有指向, 循环条件若还写 p != NULL,程序会永远转下去(死循环)。 正确的结束条件是「回到起点」:

带头结点:while (p != head)  // head 是头结点,绕一圈会回到它
不带头结点:do { … } while (p != first);  // first 是首元结点,必须用 do-while

不带头结点时之所以要用 do-while,是因为 p 一开始就等于 first,如果用 while (p != first),循环体一次都不会执行。 这个小细节是考试里非常爱考的「循环链表遍历」代码填空题。

循环链表的三种常见形态 (a) 带头结点的循环单链表(非空):尾结点 next 指回头结点 head 12 34 56 尾结点 56 的 next 指回头结点 遍历结束条件:p != head (b) 空循环链表:head->next == head head 头结点指向自己 ⇒ 表空;没有 NULL,所以「判空」也要写成 head->next == head (c) 只设尾指针 rear 的循环单链表:头插、尾插都是 O(1) rear 56 rear = 尾结点 头结点 12 首元结点 34 头结点 = rear->next 首元结点 = rear->next->next 头插:s->next = rear->next->next;    rear->next->next = s; 尾插:s->next = rear->next;    rear->next = s; rear = s; 无论表多长,这两组操作都只改常数条指针 ⇒ O(1)。这就是「只设尾指针」的全部意义。
图 2-11 循环链表的三种形态与「只设尾指针」时 O(1) 头插 / 尾插的指针写法

2.5.2 只设尾指针:一个 O(1) 换两个 O(1)

普通单链表只设头指针时,表头插入是 O(1),表尾插入是 O(n)(要走到尾)。 循环链表只要把指针设在尾结点上,两个操作就都变成 O(1):

所以「在表头插入」和「在表尾插入」都不需要遍历。这个技巧在实现链式队列(第 04 讲)时是标准做法: 队列需要「队尾入队 + 队头出队」,用只设尾指针的循环链表,两个操作都是 O(1)。

/* ==========================================================================
   循环链表 —— 尾结点的 next 指回头结点,整个表连成一个环
   --------------------------------------------------------------------------
   和普通单链表的唯一区别:
       普通单链表走到尾结点时 p->nxt == NULL(这是遍历结束的条件)
       循环链表没有 NULL,遍历结束的条件是「p->nxt == head」(回到起点)

   ⚠️ 高频易错点:把循环链表当普通链表写,判断 p != NULL 会死循环!

   循环链表最实用的一招:**只设尾指针 rear(不设头指针)**
       · 表头结点 = rear->nxt->nxt(跳过哑结点)
       · 在表头插入、在表尾插入都是 O(1)
       普通单链表想在表尾插入必须从头走一遍(O(n)),这是循环链表的优势。
   ========================================================================== */
#include <bits/stdc++.h>
using namespace std;

struct Node {
    int val;
    Node *nxt;
    Node(int v = 0, Node *p = nullptr) : val(v), nxt(p) {}
};

Node *head;                  // 头结点(哑结点),head->nxt 是首元结点

void InitList() {
    head = new Node();
    head->nxt = head;        // 空表:自己指自己,形成只有哑结点的环
}

bool Empty() { return head->nxt == head; }

/* ---------- 尾插:先找到尾结点(p->nxt == head 就是尾),再接到后面 ---------- */
void PushBack(int x) {
    Node *p = head;
    while (p->nxt != head) p = p->nxt;     // 注意终止条件是回到 head,不是 NULL
    Node *q = new Node(x, head);           // 新结点的 next 指向头结点,闭环
    p->nxt = q;
}

/* ---------- 头插:插在首元结点前面,O(1) ---------- */
void PushFront(int x) {
    head->nxt = new Node(x, head->nxt);
}

/* ---------- 遍历:从头结点后面出发,走回 head 就停 ---------- */
void PrintList(const char *title = "") {
    printf("%s:", title);
    if (Empty()) { printf("(空表)\n"); return; }
    for (Node *p = head->nxt; p != head; p = p->nxt) printf("%d -> ", p->val);
    printf("回到头结点\n");
}

int main() {
    InitList();
    printf("刚建好是空表吗:%s\n", Empty() ? "是" : "否");
    PrintList("空表");

    for (int x : {1, 2, 3, 4, 5}) PushBack(x);
    PrintList("尾插 1..5");

    PushFront(0);
    PrintList("头插 0");

    /* ---------- 只设尾指针的版本:两端插入都是 O(1) ---------- */
    puts("\n---- 只设尾指针 rear 的循环链表 ----");
    Node *rear = new Node();          // 只用一个哑结点当尾指针
    rear->nxt = rear;                 // 空表
    auto pushBackFast = [&](int x) {  // 表尾插入 O(1)
        Node *q = new Node(x, rear->nxt);
        rear->nxt = q;
        rear = q;
    };
    auto pushFrontFast = [&](int x) { // 表头插入 O(1)
        Node *first = rear->nxt;      // 首元结点
        rear->nxt = new Node(x, first);
    };
    for (int x = 1; x <= 3; ++x) pushBackFast(x * 10);
    pushFrontFast(5);                 // 插到最前面
    pushBackFast(40);                 // 插到最后面
    printf("从首元结点开始打印:");
    for (Node *p = rear->nxt; ; p = p->nxt) {
        printf("%d ", p->val);
        if (p == rear) break;         // 走到尾指针就停
    }
    printf("\n");

    /* ---------- 结论(考点) ----------
       ① 遍历终止条件:p != head(或回到起点),不是 p != NULL
       ② 只设尾指针时:表头插入 O(1)、表尾插入 O(1)
       ③ 典型应用:约瑟夫环(Josephus)问题——下面 static_list 段之后还有专门一段 */
    return 0;
}

2.5.3 约瑟夫环(Josephus):循环链表的经典应用

问题描述n 个人围成一圈,编号 1…n。 从 1 号开始报数,每报到 m 的人出圈,然后从出圈者的下一位重新从 1 报数, 如此反复,求最后剩下的人的编号。

为什么它天然适合循环链表?因为「报数到末尾再从头继续」这件事, 在循环链表里就是 p = p->next 一直走下去—— 不需要任何取模运算或边界判断。用数组模拟的话,每次都要 pos = (pos + 1) % n 并跳过已出圈的人,代码更啰嗦。

约瑟夫环:n = 8,m = 3,出圈顺序 3 → 6 → 1 → 5 → 2 → 8 → 4,最后剩下 7 1 2 3 4 5 6 7 8 n = 8,m = 3 出圈:3 6 1 5 2 8 4 幸存:7 红色 = 已出圈 循环链表解法要点: ① 先建 n 个结点,首尾相接 ② pre 记住 p 的前驱,方便摘链 ③ 报数 m-1 次后,p 停在出圈者 ④ pre->next = p->next 摘链 ⑤ 终止条件 p->next == p   (只剩一个结点,指向自己) 时间 O(n·m),空间 O(n) 若用数学递推(第 13 讲): f(1)=0;f(i)=(f(i-1)+m)%i 答案 = f(n)+1,时间 O(n)、空间 O(1)
图 2-12 约瑟夫环(n=8, m=3)的出圈过程与循环链表解法的关键步骤
// josephus.cpp —— 约瑟夫环:循环链表完整解法
#include <iostream>
using namespace std;

struct Node {
    int   id;                                   // 编号 1..n
    Node* next;
    Node(int i = 0, Node* n = nullptr) : id(i), next(n) {}
};

/* n 个人围成一圈,从 1 号开始报数,报到 m 的人出圈,返回最后剩下的人的编号 */
int Josephus(int n, int m) {
    if (n <= 0 || m <= 0) return -1;

    /* 第一步:建环。先建 1 号,再尾插其余,最后让尾结点指回 1 号 */
    Node* head = new Node(1);
    Node* tail = head;
    for (int i = 2; i <= n; ++i) {
        tail->next = new Node(i);
        tail = tail->next;
    }
    tail->next = head;                          // 首尾相接,成环(这一步不能漏)

    /* 第二步:反复报数 + 摘链 */
    Node* pre = tail;                           // pre 始终是 p 的前驱
    Node* p   = head;                           // 从 1 号开始报「1」
    cout << "出圈顺序: ";
    while (p->next != p) {                      // 只剩一个结点时,p->next 指向自己
        for (int k = 1; k < m; ++k) {           // 报数到 m:走 m-1 步
            pre = p;
            p = p->next;
        }
        cout << p->id << " ";
        pre->next = p->next;                     // 摘链:前驱跨过 p
        Node* dead = p;
        p = p->next;                            // 从下一位重新从 1 开始报数
        delete dead;                            // 释放出圈者,防止内存泄漏
    }
    cout << endl;

    int survivor = p->id;
    delete p;                                   // 最后一个结点也要释放
    return survivor;
}

/* 对照:数学递推解法(O(n) 时间、O(1) 空间),第 13 讲会详细推导 */
int JosephusMath(int n, int m) {
    int f = 0;                                  // f(1) = 0(0-based)
    for (int i = 2; i <= n; ++i) f = (f + m) % i;
    return f + 1;                               // 转回 1-based
}

int main() {
    cout << "n=8, m=3 => 幸存者 " << Josephus(8, 3) << endl;         // 7
    cout << "n=41, m=3 => 幸存者 " << Josephus(41, 3) << endl;       // 31(经典约瑟夫斯问题)
    cout << "数学法校验 n=8, m=3 => " << JosephusMath(8, 3) << endl;   // 7
    cout << "数学法校验 n=41, m=3 => " << JosephusMath(41, 3) << endl; // 31
    return 0;
}

2.5.4 循环双链表:删除任意结点的终极形态

把「循环」和「双向」合起来,就得到循环双链表 circular doubly linked list: 头结点的 prior 指向尾结点,尾结点的 next 指向头结点, 整条链上一个 NULL 都没有

这个「没有 NULL」的性质带来一个非常漂亮的结论: 删除任意给定结点 p 只需要两行,而且完全不需要判空

p->prior->next = p->next;  p->next->prior = p->prior;  delete p;

对比一下:普通双链表要写 if (q->next) q->next->prior = ..., 单链表还要先 O(n) 找前驱。循环双链表把这两种特判和额外开销全部消掉了, 代价仅仅是「头结点的两个指针都要正确初始化」。这也是为什么 STL 的 std::list 底层就是一个带哨兵结点的循环双链表

/* ==========================================================================
   循环双链表 —— 环形 + 双向,插入删除最"对称"的结构
   --------------------------------------------------------------------------
   结点:struct Node { int val; Node *pre, *nxt; };
   空表状态:head->nxt = head->pre = head(自己指自己)

   它的好处是:不需要头结点/尾结点两个哑结点,一个 head 就能两端操作,
   插入删除的指针修改是对称的:
       在 p 后面插入 q: q->nxt = p->nxt; q->pre = p;
                        p->nxt->pre = q;  p->nxt = q;
       删除 p:        p->pre->nxt = p->nxt;  p->nxt->pre = p->pre;
   (只有环形的结构里,p->pre 永远存在,不需要判空指针,代码最干净。)
   ========================================================================== */
#include <bits/stdc++.h>
using namespace std;

struct Node {
    int val;
    Node *pre, *nxt;
    Node(int v = 0) : val(v), pre(this), nxt(this) {}   // 自己指自己 = 只有一个元素的环
};

Node *head;

void InitList() {
    head = new Node();               // 头结点自己成环
}

bool Empty() { return head->nxt == head; }

/* ---------- 把 q 接在 p 的后面(q 必须是一个「孤立」结点) ---------- */
void Link(Node *p, Node *q) {
    q->nxt = p->nxt;
    q->pre = p;
    p->nxt->pre = q;
    p->nxt = q;
}

/* ---------- 从环里摘掉 p ---------- */
void Unlink(Node *p) {
    p->pre->nxt = p->nxt;
    p->nxt->pre = p->pre;
    p->nxt = p->pre = p;             // 让它自己成环,避免野指针
}

void PushBack(int x)  { Link(head->pre, new Node(x)); }   // 接在尾结点后面
void PushFront(int x) { Link(head, new Node(x)); }        // 接在头结点后面

int Length() {
    int n = 0;
    for (Node *p = head->nxt; p != head; p = p->nxt) ++n;
    return n;
}

Node *GetElem(int i) {
    if (i < 1) return nullptr;
    Node *p = head->nxt;
    for (int j = 1; p != head && j < i; ++j) p = p->nxt;
    return (p == head) ? nullptr : p;
}

void PrintForward(const char *title = "") {
    printf("%s正向:", title);
    for (Node *p = head->nxt; p != head; p = p->nxt) printf("%d ", p->val);
    printf("\n");
}
void PrintBackward(const char *title = "") {
    printf("%s反向:", title);
    for (Node *p = head->pre; p != head; p = p->pre) printf("%d ", p->val);
    printf("\n");
}

int main() {
    InitList();
    printf("空表吗:%s,长度 %d\n", Empty() ? "是" : "否", Length());   // 是,0

    for (int x : {1, 2, 3}) PushBack(x);
    for (int x : {0, -1}) PushFront(x);
    PrintForward("插入后 ");        // -1 0 1 2 3
    PrintBackward("插入后 ");       // 3 2 1 0 -1
    printf("长度 = %d\n", Length()); // 5

    /* 删掉中间那个结点:只给指针,O(1) */
    Node *p = GetElem(3);           // 值 1
    Unlink(p);
    PrintForward("删掉 1 后 ");      // -1 0 2 3

    /* 在它前面插一个(利用 p->pre,不需要找前驱) */
    Link(GetElem(2)->pre, new Node(88));   // 在 0 前面插 88
    PrintForward("0 前面插 88 ");    // -1 88 0 2 3
    PrintBackward("反向看   ");      // 3 2 0 88 -1

    /* ---------- 对比小结 ----------
       循环链表:尾结点的 next 回头结点 → 遍历判据是回到起点
       双向链表:多了 pre 指针 → 删除/前插 O(1)
       循环双链表:两者结合 → 类里最"对称"、判空指针最少,但每个结点两个指针域

       竞赛实战:需要频繁在两端增删时,优先用 std::list(它就是循环双链表)
       或直接用数组模拟,比手写指针更不容易出错。 */
    return 0;
}

2.6 静态链表:用数组模拟指针

在早期语言(如 BASIC、FORTRAN)里根本没有指针, 但人们又需要链表「插入删除不搬家」的好处,于是发明了静态链表 static linked list用一整块数组存放结点,用数组下标代替指针。 这个「代替指针的下标」有一个专门的名字——游标 cursor

struct SNode { int data; int next; };  // next 不是指针,是「下一个结点在数组中的下标」

约定:next == -1 表示空指针 NULL(有些教材用 0, 但那样下标 0 就不能用了)。由于每个结点的「地址」就是它在数组里的下标, 我们永远不需要真的取地址,只需要在下标之间跳来跳去。

静态链表:数组下标即「地址」,next 存的是游标(-1 表示 NULL) 下标 data next(游标) 0 21 2 1 空闲 4 2 32 4 3 58 -1 4 45 3 5 空闲 -1 head = 0,从下标 0 开始: a[0] 存 21,游标 2 ⇒ 下一个看 a[2] a[2] 存 32,游标 4 ⇒ 下一个看 a[4] a[4] 存 45,游标 3 ⇒ 下一个看 a[3] a[3] 存 58,游标 -1 ⇒ 到尾,结束 逻辑顺序:21 → 32 → 45 → 58 物理顺序:a[0] a[2] a[4] a[3](下标跳跃,不再连续) 空闲结点(下标 1、5)串成一条「备用链表」: 插入时取一个,删除时还回去,等于自己实现 new / delete。
图 2-13 静态链表:data 存数据、next 存游标;物理上不连续,逻辑上靠游标串起来

静态链表的插入删除代码与单链表几乎一模一样,只是把 p = p->next 换成 p = a[p].next, 把 new Node(e) 换成 Malloc()(从备用链表摘一个下标), 把 delete q 换成 Free(q)(还回备用链表)。

// static_list.cpp —— 静态链表:数组 + 游标,完整实现插入 / 删除 / 遍历
#include <iostream>
using namespace std;

const int MAXN = 100;

struct SNode {
    int data;
    int next;                  // 游标:下一个结点在数组中的下标,-1 表示 NULL
};

SNode a[MAXN];
const int FREE_HEAD = 0;       // a[0] 作为「备用链表」的头结点(相当于内存分配器)
const int HEAD      = 1;       // a[1] 作为「数据链表」的头结点(哨兵)

void InitList() {
    for (int i = 0; i < MAXN - 1; ++i) a[i].next = i + 1;
    a[MAXN - 1].next = -1;     // 先把所有结点串成一条备用链表
    a[HEAD].next      = -1;    // 数据链表为空
    a[FREE_HEAD].next = 2;     // 下标 0、1 已被占用,备用链表从 2 开始
}

int Malloc() {                 // 从备用链表借一个结点,返回下标;-1 表示空间耗尽
    int i = a[FREE_HEAD].next;
    if (i != -1) a[FREE_HEAD].next = a[i].next;
    return i;                  // 这一步相当于 new 失败时返回空
}

void Free(int k) {             // 把下标 k 的结点还给备用链表(相当于 delete)
    a[k].next = a[FREE_HEAD].next;
    a[FREE_HEAD].next = k;
}

int Length() {
    int n = 0;
    for (int p = a[HEAD].next; p != -1; p = a[p].next) ++n;   // 注意:-1 才是终止条件
    return n;
}

bool ListInsert(int i, int e) {                 // 在位序 i 插入
    if (i < 1 || i > Length() + 1) return false;
    int p = HEAD;                               // p 是「下标」,不是指针
    for (int k = 1; k < i; ++k) p = a[p].next;  // 走到第 i-1 个结点
    int s = Malloc();
    if (s == -1) return false;                  // 空间满,相当于 new 失败
    a[s].data = e;
    a[s].next = a[p].next;                      // ① 先连
    a[p].next = s;                              // ② 后断——和单链表完全一样的套路
    return true;
}

bool ListDelete(int i, int& e) {                // 删除位序 i
    if (i < 1 || i > Length()) return false;
    int p = HEAD;
    for (int k = 1; k < i; ++k) p = a[p].next;  // 找前驱
    int q = a[p].next;
    e = a[q].data;
    a[p].next = a[q].next;                      // 跨过 q
    Free(q);                                    // 把下标还回备用链表
    return true;
}

void PrintList() {
    cout << "head";
    for (int p = a[HEAD].next; p != -1; p = a[p].next)
        cout << " -> [" << p << "]" << a[p].data;
    cout << " -> -1 (len=" << Length() << ")" << endl;
}

int main() {
    InitList();
    int arr[] = {21, 32, 45, 58};
    for (int k = 0; k < 4; ++k) ListInsert(Length() + 1, arr[k]);   // 依次追加
    PrintList();                       // head -> [2]21 -> [3]32 -> [4]45 -> [5]58 -> -1

    ListInsert(2, 7);                  // 在下标不连续的情况下「插队」
    PrintList();

    int del = 0;
    ListDelete(3, del);
    cout << "删除了 " << del << endl;
    PrintList();
    /* 关键观察:整张表始终在同一个数组里,插入删除只改游标,没有任何元素搬家。 */
    return 0;
}
静态链表用在哪?
  • 不支持指针的语言:早期的 BASIC / FORTRAN,以及某些嵌入式或脚本环境,只能用数组模拟。
  • 竞赛中减少 new / delete 开销new 涉及堆管理和系统调用,常数很大; 开一个大数组循环使用,速度可以快几倍。图论里的「链式前向星」就是静态链表思想。
  • 需要连续内存 + 稳定地址:所有结点都在同一个数组里,便于序列化、 也便于「一次分配、永不搬家」的场景。
  • 代价:容量固定(编写时就要定 MAXN),且游标是人为约定, 越界不会报错,调试时要自己画图确认。

2.7 五者对比:一张表看清所有取舍

学到这里,我们已经见了五种线性表的实现。它们解决的是同一个逻辑问题, 差别全部集中在「怎么在内存里表示『下一个』」这一个决定上。 下面这张表请务必自己默写一遍——它是本章的「总账」。

2.7.1 横向对比大表

对比维度 顺序表 单链表 双向链表 循环链表 静态链表
存储方式 一段连续内存,逻辑相邻 = 物理相邻 结点分散在堆上,靠指针相连 结点分散在堆上,两个指针相连 链式存储,尾结点指回头结点 数组 + 游标,物理不连续、逻辑相连
是否随机存取 是(O(1)) 否,只能顺序存取
查找第 i 个元素 O(1),算地址即可 O(n),走 i−1 步 O(n)(可从头或从尾取近的一侧,常数减半) O(n),注意别绕圈绕不停 O(n),靠游标跳
按值查找 O(n),平均比较 (n+1)/2 次 O(n),比较 + 走指针 O(n) O(n) O(n)
插入 / 删除 O(n),平均移动 n/2 或 (n−1)/2 个元素;表尾均摊 O(1) 已知前驱 O(1)(改 2 根指针);按位操作 O(n) 找前驱 已知结点 O(1)(改 4 根指针);按位操作 O(n) 找位置 只设尾指针时头插 / 尾插均 O(1) 与单链表相同(O(n) 找前驱 + O(1) 改游标)
空间开销 无额外指针,存储密度 = 1(可能预留空位) 每结点 1 个指针域,存储密度 < 1 每结点 2 个指针域,开销最大 同单链表(循环双链表则同双向链表) 每结点 1 个游标(通常 4 字节,比指针省一半)
能否双向遍历 能(下标增减即可) 不能 能,靠 prior 能绕圈正向,单向链表不能反向 不能
实现难度 低,几乎不出指针 bug 中,插入顺序 / 释放内存易错 高,四条指针顺序不能乱 中高,遍历终止条件易写错 中,游标越界不报错,调试靠画图
典型应用 查多改少的表;vector、字符串、哈希表的桶数组、图的邻接矩阵 频繁插入删除的表;栈 / 队列的链式实现、图的邻接表、哈希拉链、多项式加法 需要反向遍历或频繁删已知结点的场景;std::list、LRU 缓存、文本编辑器的行表、浏览器前进后退 轮流调度(时间片轮转)、约瑟夫环、环形缓冲区、链式队列 无指针语言;竞赛中的链式前向星、内存池

2.7.2 顺序表 vs 链表:复杂度总表

操作顺序表单链表备注
按下标 / 位序取值 GetElemO(1)O(n)顺序表的看家本领
按值查找 LocateElemO(n)O(n)顺序表只比较,链表还要走指针,实际更慢
在表头插入O(n)(全体后移)O(1)链表只需改两根指针
在表尾插入O(1) 均摊O(1)(带尾指针)/ O(n)(不带)顺序表扩容那一次是 O(n),均摊后为 O(1)
在中间第 i 个位置插入O(n),移动 n−i+1 个O(n)(找前驱)+ O(1)(改指针)都是 O(n),但链表移动的是「指针」而不是「整个元素」,元素很大时链表优势明显
删除表头O(n)O(1)
删除表尾O(1)O(n)(要找到倒数第二个)双向链表可做到 O(1)
删除已知结点 pO(n)(要前移)O(n)(要找前驱)双向链表 O(1);单链表可用「后继覆盖法」O(1)(尾结点除外)
遍历全部元素O(n),缓存友好、常数极小O(n),缓存不友好、常数较大实测顺序表常常快 2~10 倍
求表长O(1)(存了 len)O(1)(存了 len)/ O(n)(靠遍历数)不设长度变量就只能数
额外空间O(1)(不含预留空位)O(n)(n 个指针域)
「链表插入删除是 O(1)」这句话并不完整 必须加上前提:已经持有插入位置的前驱(或待删结点)的指针。 如果题目是「在第 i 个位置插入」,那么找位置本身就要 O(n),整体仍是 O(n)。 很多同学在考试里把「按位插入」也答成 O(1),这是最常见的丢分点。 正确说法是:链表在「已知位置指针」时插入删除是 O(1),这比顺序表强; 但定位到那个位置的代价是 O(n),这比顺序表弱。

2.8 选型决策:这道题到底该用顺序表还是链表?

工程中选错存储结构,往往比写错一个循环更致命——因为它决定了整个系统的性能上限。 下面这张流程图把常见判断浓缩成三个问题,从上往下走一遍,基本就能定下来。

要存一组同类型元素,怎么选? 需要按下标 O(1) 随机访问? (如「取第 k 个」「折半查找」) 顺序表 SeqList / vector<T> 连续内存、cache 友好;若表长会涨,用动态扩容版 插入 / 删除是否几乎只在表尾? (如「一直往后追加」) 仍然推荐顺序表 表尾追加均摊 O(1),没有指针开销,还能随机访问 需要频繁找前驱 / 双向遍历? (如「删除已知结点」「从后往前」) 双向链表 DLinkList 删除已知结点 O(1),可反向遍历;代价是多一个指针域 单链表 SinglyList 每个结点省一个指针域;插入删除集中在表头 / 中间时最优 补充四条经验: · 只在两端进出 ⇒ 直接用栈 / 队列(第 03、04 讲) · 需要「轮流循环」 ⇒ 循环链表 · 元素很大时,链表的「只搬指针」优势会被放大 · 拿不准就先写 vector,之后再优化
图 2-14 顺序表 / 链表的选型决策流程图(沿箭头回答三个问题即可定位)
工程上的实话 现实中 90% 的场景应该先用 std::vector(顺序表)。原因有三: 一是 CPU 缓存对连续内存极其友好,实测遍历速度快数倍,实测数据常常盖过理论复杂度; 二是 vector 的插入虽然理论是 O(n),但 memmove 搬字节的速度极快, 在几千个元素的规模下和链表的差距微乎其微; 三是它没有指针 bug、没有内存泄漏、迭代器更安全。 只有当元素数量很大、元素本身很大、且插入删除极其频繁时,链表才真正划算。

2.9 C++ 实战与七条高频易错点

链表代码写不对,往往不是算法想不明白,而是踩了 C++ 内存管理的坑。 下面这七条,每一条都能让你的程序在「本地跑得好好的,一交上去就 RE」。

易错 1:内存泄漏——new 了却忘记 delete 链表结点是在堆上 new 出来的,不会自动回收。 常见泄漏点有三处: ① 删除结点时只改了指针没 delete q; ② 整表清空时没循环释放; ③ 顺序表 grow() 时忘了 delete[] data。 更隐蔽的是「断链泄漏」:p->next = s; 先执行, 原来那段链就再也没有指针指向它,delete 无从谈起,整段内存永久丢失。 自查方法:每写一个 new,立刻在纸上标注「谁负责 delete 它」。
易错 2:野指针与重复释放(double free) delete q; 之后,q 这个变量里仍然存着那个已经失效的地址, 此时 q 就是野指针 dangling pointer。 再去 q->data 是未定义行为,再 delete q 一次就是 double free,程序直接 abort。 正确习惯是 delete q; q = nullptr;——虽然 nullptr 上的 delete 是安全的空操作,但访问 nullptr->data 会立刻段错误, 把一个「随机崩溃」变成「稳定崩溃」,这已经能省下几个小时的调试时间。 同理,顺序表的析构、拷贝构造、赋值运算符必须成套出现,否则 两个对象共用一块内存,析构时必然 double free。
易错 3:new 失败不是返回空指针,而是抛异常 标准 new 在内存不足时抛出 std::bad_alloc 异常, 不会返回 nullptr,所以写 Node* p = new Node(); if (!p) ... 这种检查是完全无效的(除非用了 new (std::nothrow) Node())。 正确做法二选一:用 try / catch 捕获 std::bad_alloc; 或者在嵌入式等不允许异常的环境用 new (std::nothrow) 再判空。 另外,OG 竞赛平台上内存超限通常直接给出 MLE,题目数据规模大的话, 该用静态链表(数组模拟)就得用。
易错 4:结构体自引用必须用指针,且 typedef 有坑 struct Node { int data; Node* next; }; 里的 Node* 在 C++ 中没问题; 但在 C 语言里写成 struct Node { int data; struct Node* next; }; 才行, 因为 C 的结构体名不会自动成为类型名。typedef struct Node { ... } Node; 之后 才可以直接写 Node* next;。 写成 Node next;(少星号)会报 incomplete type—— 编译器算不出「包含自己的自己」有多大。这条错误信息在作业里出现率极高。
易错 5:模板类不能「声明在 .h、实现在 .cpp」 如果你把 template <typename T> class SeqList 的声明放在 seqlist.h、成员函数定义放在 seqlist.cpp, 然后在 main.cpp#include "seqlist.h", 链接时一定报 undefined reference。 原因:模板本身不是代码,编译器在实例化 SeqList<int> 时才生成代码, 而编译 seqlist.cpp 时它看不到 main.cpp 用了哪个 T, 于是什么也没生成。 三种解法:① 全部写在头文件里(最常用);② 头文件末尾 #include "seqlist.cpp";③ 在 .cpp 里显式实例化 template class SeqList<int>;
易错 6:nullptr 与 NULL 不是一回事 NULL 在 C++ 里通常被定义为整数常量 0, 而 nullptr 是真正的空指针类型 std::nullptr_t。 这会导致重载决议诡异: void f(int); void f(char*); f(NULL); 会调用 f(int)—— 因为 0 是整数!而 f(nullptr) 才会正确调用 f(char*)。 链表代码里凡是判空、赋空,一律写 nullptr(C++11 起), 既能避免歧义,也能让模板推导更准确。
易错 7:头结点的数据域不是 a1,表长也不含头结点 头结点是「哑结点」,它的 data 没有意义(有些教材用来存表长)。 以下三条都算错: ① 把 head->data 当成第一个元素返回; ② 求表长时把 head 也算进去,得到 n+1; ③ 遍历时写 for (p = head; p; p = p->next), 结果多输出一个垃圾值。 标准写法永远是 for (Node* p = head->next; p; p = p->next)—— 从头结点的下一个开始

这七条是「写对」的底线。下一节(2.10)我们换个视角:把这些结构放回真实的操作系统、 数据库与缓存系统里,看工程上究竟在什么条件下选顺序表、什么条件下选链表。

2.10 工程视角:顺序表与链表在真实系统里怎么选

本章的五种结构讲完了。但你心里可能一直有个疑问: 这些课本上的结构,真的会在操作系统、数据库、网络系统里出现吗? 答案是:不但会出现,而且天天出现。这一节我们离开考卷,去看四个真实的工程现场—— CPU 缓存、动态数组扩容、内核的链表、数据库的主键索引,看看「用连续内存还是用指针串」 这个选择在真实系统里是怎样反复做出的。你会发现,工程师给出的答案和课本上的复杂度表 并不总是吻合,因为真实世界里还有一条课本不写的成本:访存

2.10.1 先看一个反常识的实验:复杂度相同的两种表,速度差 5~10 倍

取一个再普通不过的任务:求 1 + 2 + 3 + … + n 的和。 分别用顺序表和链表存下 1..n,各写一个循环把它们累加起来。 两个循环都是从头到尾扫一遍、每个元素做一次加法,时间复杂度都是 O(n), 按课本的分析应该「一样快」。可实测下来通常是这样:

实现循环体做的事理论复杂度n = 107 实测(量级)
顺序表 sum += data[i]读一块连续内存O(n)约 10 ms
链表 sum += p->data; p = p->next读一个结点,再跳到一个「不知道在哪」的地址O(n)约 60~120 ms

同样的 O(n),为什么差了 5~10 倍?因为瓶颈不在加法的次数上,而在内存跟不跟得上。

三个必须记住的硬件事实 ① cache line(缓存行):CPU 每次搬一整条 64 字节的内存进缓存, 而一个 int 只有 4 字节,所以一条 cache line 装着 16 个相邻的 int
② 预取器(prefetcher):硬件发现你在按固定步长顺序扫内存, 就猜你下一步要读后面那块,提前搬进缓存;用到时已在 L1 里,这叫「命中」。
③ 指针追逐(pointer chasing):链表下一个元素的地址写在当前结点里 (p = p->next),这个地址要等本次访存回来才知道。 这种「有数据依赖的访存」让预取器无从下手——它没法猜,只能干等。

合起来看,实验结果的解释就很直白了:

所以,理论复杂度相同 ≠ 实际性能相同。大 O 只管「操作次数随 n 的增长趋势」, 刻意忽略常数因子,而常数因子里恰好藏着 cache line 与预取器这种数量级的差异。 这也是为什么在竞赛里,理论最优的链表常被数组模拟的版本按在地上摩擦—— 第 08 讲用数组模拟链表(链式前向星)、第 11 讲把堆存进数组(堆排序), 讲的都是同一件事:能连续,就不要散着放

顺序表:一条 cache line 装下 16 个 int,顺序扫描几乎全部命中 cache line #1(64 字节) 1 2 3 4 [0] [1] [2] [3] 1000 1004 1008 1012 5 6 7 8 [4] [5] [6] [7] 1016 1020 1024 1028 (第二行重复同样的规律:下标 8~15 是下一条 cache line,地址 1032~1060) cache line #2 9 10 11 12 [8] [9] [10] [11] 13 14 15 16 [12] [13] [14] [15] 一次访存搬回 64 字节 = 16 个相邻 int 扫 16 个元素:1 次 miss + 15 命中 每个元素只摊 1/16 次访存 地址加 64 字节 就是下一条 cache line 预取器还按固定步长 把后面几条一起搬进来 链表:每个结点都要 p = p->next ,而下个地址要等本次访存回来才知道 —— 预取器无从下手 物理内存(32 位地址示意,每个结点 32 字节) 0x1000 0x2200 0x0A40 0x3F80 0x1C00 0x05C0 0x2E80 0x1180 1 2 3 4 5 6 7 8 ← 下一个结点 对比:连续数组「1 次访存换 16 个元素」,链表「1 次访存换 1 个结点」——大 O 一样,内存系统的账不一样。
图 2-15 顺序表与链表的内存布局对比:一条 64 字节 cache line 在顺序表里覆盖 16 个 int,在链表里只够装下一个结点

2.10.2 动态数组为什么敢「容量翻倍」:均摊 O(1) 的来历

第 2.2 节我们写过动态顺序表的 grow(),也提到 std::vector 是同一套做法, 但留了一个问题没答:容量不够时,为什么是「翻倍」,而不是每次只多开一个?

std::vectorpush_back 承诺「末尾插入 O(1)」, 可它明明会在某一刻做一件昂贵的事:申请一块更大的内存,把已有的 size 个元素逐个搬过去,再释放旧内存。既然有这次「搬家」,凭什么说它是 O(1)? 答案在「均摊」二字,而均摊的结论取决于增长策略:

策略 A:每次只多开 1 个(capacity = size + 1)

插第 2 个元素搬 1 个,插第 3 个搬 2 个,插第 4 个搬 3 个…… 插入 n 个元素的总搬运量是 1 + 2 + 3 + … + n = n(n+1)/2,也就是 O(n²): 单次看着「只搬一点点」,但次数太多,总量是平方级的。

策略 B:容量翻倍(capacity = size × 2,vector / ArrayList 的做法)

只有容量为 1、2、4、8、16… 时才会搬家,搬的量分别是 0、1、2、4、8…, 总和是 1 + 2 + 4 + … + n/2 < n—— 一个等比数列,总和被最后一项管住,是 O(n)。

翻倍扩容:连续 n 次 push_back 的总搬运量 = 1 + 2 + 4 + … + n/2 < n = O(n)

把 O(n) 的总代价摊到 n 次插入上,每次插入平均只花 O(1)。 这就是「均摊 O(1)(amortized O(1))」:不是每一次都快, 而是连续做任意多次,平均下来每次是常数。单看触发扩容的那一次代价确实是 O(n), 但你已经「攒」了 n/2 次便宜的插入,早就把账付清了。它与「平均 O(1)」不是一回事: 均摊分析不给概率留位置,它是对任意一串操作序列都成立的最坏保证。

代价也有,必须在工程里认账:

工程习惯:能预估就先 reserve 若事先知道要装 n 个元素,写一句 v.reserve(n);, vector 会一次把容量开足,整个过程中一次都不搬家,既省搬运也避免指针失效。

2.10.3 操作系统(一):进程就绪队列与空闲内存块链表,为什么内核偏爱链表

场景一:进程就绪队列(ready queue)。 时间片用完的进程要扔到队尾,被唤醒的进程要插到队首或按优先级插到中间, 阻塞、退出时又要从队列中间摘掉。系统里可能同时有几万个就绪任务, CPU 每秒要做几十万次插入和摘除。课本上说「就绪队列是队列」, 但在内核实现里它就是一条链表(Linux 的 CFS 调度器改用红黑树, 任务仍靠链表结点串起来)。

场景二:空闲内存块链表(free list)。 内存管理器要维护「哪些内存块空闲」的账:进程申请时从表里摘一块,释放时把块还回表里。 关键词是插入删除极其频繁、发生在任意位置,而内核从不需要按下标的随机访问。

这两条需求正好踩在链表的甜点区上。但真正让内核不能用顺序表的,是下面这条:

关键约束:这些结构不能整体搬移 顺序表插入一个元素要把它后面的元素全部后移——从「时间」角度看这只是 O(n), 但从「语义」角度看,这是所有元素的地址都变了。 而在就绪队列和空闲链表里,元素的地址早已被别处引用着: 进程控制块(PCB)的地址被调度器和各种等待队列记着, 空闲内存块的地址被页表、被 DMA 描述符记着, 你不可能对它们说「抱歉,我扩容了,请你们把指针都更新一下」。 地址一旦发出去就不能再变,所以只能用「结点原地增删、只改指针」的结构:链表。 更彻底的做法是把链表指针直接嵌进对象内部(Linux 的 list_head), 对象在哪结点就在哪,连结点都不用额外分配。

代价也要说清:链表结点逐个分配,每个结点至少多存一个指针(64 位系统上 8 字节), 内存开销按结点数线性增长,缓存友好度又差(就是上一节讲的指针追逐)。 所以现代内核在能改数组的地方尽量改数组,只有「频繁中间增删 + 地址不能动」时才用链表。

2.10.4 操作系统(二):LRU 页面置换与缓存淘汰,为什么是「哈希表 + 双向链表」

再看操作系统里的另一个经典问题:内存装不下所有页面,该把哪一页换出去? 最常用的策略是 LRU(Least Recently Used,最近最少使用): 淘汰「最久没有被访问过」的那一页。要让这个策略跑起来,数据结构必须同时支持三件事:

  1. 给定一个页号,O(1) 判断它在不在内存里(每次访存都要查,慢了整个系统都慢);
  2. 刚被访问过的页面要标记成「最近使用」,也就是移到「最新」那一端
  3. 淘汰时,O(1) 找到并摘掉「最旧」那一端的结点

三条要求一摆出来,答案已经呼之欲出:

于是工程上的标准答案是:哈希表 + 双向链表。哈希表负责「O(1) 定位结点」, 双向链表负责「O(1) 调整顺序」,一句话说清分工就是哈希表管定位,链表管顺序。 这个组合可以原样搬到应用层:Redis 的键淘汰、MySQL 的 Buffer Pool、浏览器缓存淘汰, 用的都是它。

那么这里的链表为什么必须是双向的?回想 2.4 节的结论: 「删除」这个操作本身,需要的是前驱结点。 单链表里拿到 p 之后想删掉它,只能从头再走一遍去找前驱,代价 O(n); 而双向链表的结点里存着 prior,知道 p 就能立刻写出 p->prior->next = p->next; p->next->prior = p->prior;, 两次赋值、O(1) 完成。

这正是 2.4 节双向链表存在的工程理由 如果只做「按位查找后再插入 / 删除」,单链表确实够了,多出来的 prior 域像是浪费。 但在 LRU 里,我们手里早就握着一个结点的指针了(哈希表刚给的), 要做的动作是「把这个已知结点摘下来、挂到表头」,只有双向链表能 O(1) 做到。 「给定结点指针,O(1) 删除」就是双向链表的用武之地,也是它多花一个指针域的回报。

代价也很清楚:每个结点多一个指针域,64 位系统上一个结点从 16 字节涨到 24 字节, 内存多占 50%,哈希表本身还要再占一份空间。所以 LRU 只用在「值得为它多花内存」 的地方——页表、数据库缓冲池这种容量可控、命中率收益极高的场景; 小对象缓存往往退化成近似算法(只记一个访问位、或用 Redis 的随机近似淘汰),用精度换开销。

// lru_list.cpp —— LRU 缓存的最小实现:std::list(双向链表)+ 哈希表
// 说明:std::list 就是本章 2.4 节双向链表的标准库实现,
//       它的 erase(it) 只改两个指针、O(1) 完成,splice 也只是搬动结点、不搬数据。
#include <iostream>
#include <list>
#include <unordered_map>
#include <iterator>
using namespace std;

struct LRUCache {
    struct Entry {
        int key;
        int value;
    };
    int cap;
    std::list<Entry> order;                          // 表头 = 最近使用,表尾 = 最久未用
    std::unordered_map<int, std::list<Entry>::iterator> pos;   // 键 -> 结点在链表中的位置

    LRUCache(int c) : cap(c) {}

    // 访问:命中则把结点挪到表头,并返回值;未命中返回 -1
    int get(int key) {
        auto it = pos.find(key);
        if (it == pos.end()) return -1;                       // 哈希表 O(1) 判断存在
        std::list<Entry>::iterator p = it->second;
        order.splice(order.begin(), order, p);                // O(1) 摘下来再挂到表头,不拷贝数据
        return p->value;
    }

    // 写入:已存在则改值并挪到表头;不存在则新建,必要时淘汰表尾
    void put(int key, int value) {
        auto it = pos.find(key);
        if (it != pos.end()) {
            std::list<Entry>::iterator p = it->second;
            p->value = value;
            order.splice(order.begin(), order, p);
            return;
        }
        if ((int)order.size() == cap) {                       // 满了:淘汰表尾(最久未使用)
            int oldKey = order.back().key;                    // 先记下键,再去哈希表里删
            pos.erase(oldKey);
            order.pop_back();
        }
        order.push_front(Entry{key, value});                  // 新结点挂表头
        pos[key] = order.begin();                             // 记下它的迭代器,供将来 O(1) 摘除
    }

    int size() { return (int)order.size(); }
};

int main() {
    LRUCache cache(2);                       // 容量 2
    cache.put(1, 10);
    cache.put(2, 20);
    cout << cache.get(1) << endl;            // 10,且 1 变成最近使用
    cache.put(3, 30);                        // 容量满,淘汰最久未用的 2
    cout << cache.get(2) << endl;            // -1,已被淘汰
    cout << cache.get(1) << endl;            // 10,仍在
    cout << cache.get(3) << endl;            // 30
    cout << "size = " << cache.size() << endl;   // size = 2
    return 0;
}
/* 实测输出:10 / -1 / 10 / 30 / size = 2
   复杂度:get 与 put 都是 O(1)。
     - 哈希表负责「按 key 找结点」;
     - 双向链表负责「把结点挪到表头 / 从表尾摘掉」,都是 O(1)。
   若换成单链表:删除已知结点要先找前驱,退化成 O(n),LRU 也就不成立了。 */

2.10.5 数据库:主键索引为什么是 B+ 树,而不是顺序表或链表

最后一个现场是数据库:一亿条用户记录,按主键 id 建索引, 要求支持 WHERE id = 12345678(等值查找) 和 WHERE id BETWEEN a AND b(范围查找)。 先看课本上的两种结构能不能扛:

候选结构等值查找范围查找致命问题
有序顺序表 + 二分O(log n)O(log n + k)插入 / 删除要整体搬移 O(n):一亿行数据,插一行就得挪动后面所有行
链表(哪怕是有序的)O(n)O(n)无法二分——链表的中间位置必须一步一步走过去
B+ 树O(logm n)O(logm n + k)三者兼顾

这两个结构失败的原因恰好互补:顺序表的问题是「插入要搬家」, 链表的问题是「查找要一步一步走」。而数据库要求既能快速定位, 又能原地增删,还能顺序扫描一个区间。把这三条同时满足的结构, 就是把「多路分支」和「结点内有序数组」叠起来的树——B+ 树

考点视角:一句话回答「为什么数据库索引用 B+ 树」 顺序表插入要整体搬移、链表查找只能 O(n),两者各缺一半; B+ 树用「多路 + 结点内有序」把两个短板同时补上:查找 O(logm n)、 增删只动局部结点、范围查询顺着叶子链表扫;再叠加磁盘按「块」读写的物理特性 (结点大小刻意对齐到磁盘块),它就成了数据库与文件系统索引的事实标准。 这棵树的完整定义与插入分裂、删除合并,留到第 10 讲「查找与哈希表」展开; 它的基础正是第 07 讲的树与多路平衡思想——本讲只需记住它「为什么赢」。

2.10.6 一张表:三个结构在真实工程维度上的对比

把上面四个现场收拢成一张表。注意这里的维度不是考试用的「时间复杂度」, 而是工程上真正要权衡的东西:

工程维度顺序表(动态数组 vector 单链表 forward_list双向链表 list
随机访问 O(1),一次乘加算出地址 不支持,只能 O(n) 走 不支持,只能 O(n) 走(但可以反向走)
任意位置插入 / 删除 O(n),插入点之后的元素整体搬移 已知前驱时 O(1);按位查找前驱要 O(n) 已知结点时 O(1),无需找前驱
每元素额外内存 0 字节(未计扩容预留的空间浪费) 1 个指针 = 8 字节(32 字节结点里数据只占 4 字节,开销 87%) 2 个指针 = 16 字节,内存开销最大
缓存友好度 极好:一条 64 字节 cache line 覆盖 16 个 int,预取器还能提前搬 差:结点散落在堆上,指针追逐导致每次访问都可能 miss 同样差:结点多一个域,但访存模式没有改善
O(1) 删除给定结点 做不到(删除本身要搬移) 做不到(必须先找前驱) 可以,靠 prior 两次赋值搞定
典型工程用例 vector / string、图的邻接表数组、堆、内存池、 图像像素缓冲区、数据库里的页内槽位数组 单向后继的链、内核里的单向任务链、 forward_list、哈希桶的溢出链(Java HashMap 的桶) LRU 淘汰链、Linux list_head、内核就绪队列、 std::list / std::map、编辑器里最近打开的缓冲区

和 2.7 节那张结构对比表相比,这里多出来的其实只有两行:「内存开销」「缓存友好度」。 而恰恰是这两行,解释了为什么「链表的教科书优势」在真实机器上经常缩水: 理论复杂度只算了操作次数,没算每次操作背后的访存代价,而在内存远慢于 CPU 的今天, 后者的权重往往更大。

选型口诀:四句话定结构按下标访问多、批量顺序扫、只在尾部增删 → 顺序表(vector), 缓存友好,几乎永远是最快的默认选择。
频繁在中间增删、元素地址不能变、内存紧张 → 链接结构, 而且优先用「数组模拟 + 下标」而不是 new,2.6 节的静态链表就是为此准备的。
要「给定结点指针就 O(1) 删除」双向链表,单链表在这里无解;LRU 就是活教材。
一种结构搞不定时,就把它们组合起来 → 哈希表 + 双向链表(LRU)、 哈希表 + 跳表(有序集合)、B+ 树(有序数组 + 多路指针 + 叶子链表)—— 真实系统里的答案,几乎从来不是「选一个」,而是「搭一个」。

最后回到一句话:本章讲的所有取舍,在工程里都会以同一种形式出现—— 「我需不需要按下标随机访问?需不需要频繁在中间增删?元素地址会不会被别处引用着?」 把这三个问题问清楚,选型基本就不会错。这也是第 15 讲综合复习时会再拿出来对照的一把尺子。

2.11 本章小结

本章的骨架其实只有一句话:逻辑结构相同,物理实现不同,复杂度就不同。 线性表的逻辑是「一对一」,而「一对一」可以用连续内存的位置相邻来表示, 也可以用散落结点的指针指向来表示。前者叫顺序表,后者叫链表。 后续所有的展开——双向、循环、静态——都只是在这两个极端之间做加法和折中。

2.11.1 一张图记住五种结构

结构一句话记住它核心代价核心收益
顺序表元素挨着放,地址能算出来插入删除 O(n)随机存取 O(1)、缓存友好
单链表结点散着放,用一根指针串起来失去随机存取插入删除只改指针
双向链表再存一根「回头路」每结点多一个指针域删除已知结点 O(1)、可反向遍历
循环链表尾巴接回头上,没有 NULL遍历条件变成「回到起点」从任意结点可遍历全表;只设尾指针时头尾都 O(1)
静态链表用数组下标冒充指针容量写死、游标不报错无指针也能玩链表;省 new 开销

2.11.2 必须能默写出来的六段代码

  1. 顺序表插入:边界 len+1、满了先 grow()从后往前搬元素。
  2. 顺序表删除:边界 len、先用引用带回被删值、从前往后搬元素。
  3. 单链表插入:走 i−1 步找前驱、s->next = p->next; 在前
  4. 单链表删除:先 q = p->next; 保存、再跨过、最后 delete q;
  5. 反转链表(迭代)pre/cur/nxt 三指针,四步一轮。
  6. Floyd 判环 + 找入口:快慢指针相遇;两指针分别从表头与相遇点同步走。
三句口诀顺序表:按下标快,按值慢,插删要搬家。
链表:找位置慢,改指针快,多花一个域。
指针顺序:先给新结点找出路,再让老结点改路标;要用旧值,就不能先覆盖。

2.12 考点归纳与自测

本章高频考点清单
  1. 概念判断:首元素无前驱、尾元素无后继;线性表的逻辑特征与存储方式无关; 「顺序存储」≠「顺序存取」;「随机存取」只有顺序表具备。
  2. 地址计算LOC(ai) = LOC(a1) + (i−1)×L。 常以「已知首地址与每个元素字节数,求第 i 个元素的地址」形式出现, 注意位序与下标的转换。
  3. 平均移动次数:插入 n/2,删除 (n−1)/2, 要求能写出求和推导过程。
  4. 头结点作用:统一空表 / 非空表、统一首位置 / 其他位置的操作。
  5. 指针操作顺序:单链表插入「先连后断」;双向链表「③ 在 ④ 之前」; 常以「下列代码哪一句顺序错了」的形式考。
  6. 复杂度对比:给定场景选结构,或问「在单链表中删除 p 所指结点的时间复杂度」(O(n))。
  7. 经典算法:链表反转、快慢指针求中间结点 / 倒数第 k 个、Floyd 判环与环入口推导、 合并有序链表、删除重复元素。
  8. 循环链表:遍历终止条件 p != head;空表判断 head->next == head; 只设尾指针时的头插 / 尾插代码。
  9. 静态链表:游标的概念、-1 表示 NULL、适用场景。

2.12.1 自测题(答案已折叠,请先自己做)

第 1 题(计算题) 一个顺序表中有 n = 10 个元素, 现在要在第 i = 3 个位置插入一个新元素,需要移动多少个元素? 若在第 i = 3 个位置删除一个元素,又需要移动多少个? 并说明为什么「插入平均 n/2」而「删除平均 (n−1)/2」。

查看答案与解析

插入:移动 n − i + 1 = 10 − 3 + 1 = 8 个元素 (原来的 a3…a10 全部后移一格)。

删除:移动 n − i = 10 − 3 = 7 个元素 (原来的 a4…a10 前移一格)。

为什么平均值不同:插入有 n+1 = 11 个合法位置, 移动次数分别是 10, 9, 8, …, 1, 0,总和 55, 平均 55/11 = 5 = n/2; 删除有 n = 10 个合法位置,移动次数分别是 9, 8, …, 1, 0, 总和 45,平均 45/10 = 4.5 = (n−1)/2。 差别来自「插入多了一个『插在表尾、移动 0 次』的位置」,因此分母更大、平均值更小。

第 2 题(算法设计) 带头结点的单链表中,删除所有值为 x 的结点。 要求:只遍历一遍链表,空间 O(1),并正确处理「连续多个 x」「x 在表尾」「表里全是 x」三种情况。

查看答案与解析

思路:用 p 指向「已确认保留」的最后一个结点,检查 p->next关键点:删掉一个结点后 p 不能前进, 因为新的 p->next 可能还是 x

// delete_all_x.cpp —— 删除单链表中所有值为 x 的结点(一趟扫描,O(n) 时间 / O(1) 空间)
#include <iostream>
using namespace std;

struct Node {
    int   data;
    Node* next;
    Node(int d = 0, Node* n = nullptr) : data(d), next(n) {}
};

void DeleteAllX(Node* head, int x) {   // head 是头结点
    Node* p = head;                    // p 是「最后一个确定保留的结点」
    while (p->next) {
        if (p->next->data == x) {
            Node* q = p->next;         // 保存待删结点
            p->next = q->next;         // 跨过它
            delete q;                  // 释放
            /* 注意:这里 p 不前进!新后继可能仍是 x */
        } else {
            p = p->next;               // 只有确认保留才前进
        }
    }
}
/* 三种情况都被覆盖:
   - 连续多个 x:删掉一个后 p 不动,下一轮继续检查新的 p->next;
   - x 在表尾:p->next 是尾结点,删除后 p->next 变成 nullptr,循环正常结束;
   - 全是 x:p 始终停在头结点,把所有数据结点依次删光,最后 p->next == nullptr。 */

/* 测试:1 2 2 2 3 4 2 —— 同时覆盖「连续多个 x」「x 在表尾」两种情况 */
int main() {
    Node* head = new Node();               // 头结点(哑结点)
    Node* r = head;
    int a[] = {1, 2, 2, 2, 3, 4, 2};
    for (int k = 0; k < 7; ++k) { r->next = new Node(a[k]); r = r->next; }

    DeleteAllX(head, 2);

    for (Node* p = head->next; p; p = p->next) cout << p->data << " ";
    cout << endl;                          // 1 3 4

    for (Node* p = head; p; ) { Node* q = p->next; delete p; p = q; }  // 整表释放
    return 0;
}

复杂度:每个结点最多被访问一次,时间 O(n);只用了两个指针变量,空间 O(1)。

第 3 题(证明题) 请证明 Floyd 判环算法中「从表头与相遇点同时出发、每次走一步, 一定会在环入口相遇」。并说明:如果快指针每次走 3 步而不是 2 步,还能保证相遇吗?

查看答案与解析

第一问:设表头到环入口为 a 步,环长为 b, 入口沿前进方向走 c 步到相遇点,slow 从出发到相遇共走 t 步。则:

t = a + c (slow 的路径)
2t = a + c + m·b (fast 多绕了 m 整圈,m ≥ 1)

两式相减得 t = m·b,代回第一式:

a = m·b − c = (m − 1)·b + (b − c)

从相遇点出发走 b − c 步恰好回到环入口,再绕 m − 1 整圈仍回到环入口, 所以从相遇点走 a 步会停在环入口; 而从表头走 a 步同样停在环入口。 两个指针速度都是 1 步 / 轮,因此它们一定在同一轮同时到达该结点,即相遇于环入口。证毕。

第二问不一定能保证相遇。 快指针每轮 3 步时,两者在环内的相对速度是 2 步 / 轮, 距离 d 的变化是 d → d−2, 当 d 为奇数时会出现 1 → −1,也就是直接跨过慢指针。 所以「每次 2 步」不是随便选的:相对速度为 1 才能保证每轮最多缩短 1 格、必然命中。 (这也是为什么标准写法固定是 slow 走 1、fast 走 2。)

第 4 题(对比与选型) 某系统需要维护一个「最近访问列表」: 每次访问一个页面就把它移到列表头部,列表长度上限 1000,超限时淘汰末尾。 同时需要经常「判断某个页面是否已在列表中」。 请回答:应该选顺序表还是链表?是否需要双向?请说明理由与各操作的复杂度。

查看答案与解析

推荐:哈希表 + 双向链表(这就是经典的 LRU 缓存结构)。但若只在本章范围内二选一:

  • 「移到表头」需要删除已知结点 + 头插:单链表删除已知结点要 O(n) 找前驱; 双向链表因为持有 prior,删除是 O(1)。所以必须双向
  • 「淘汰末尾」:双向链表维护一个尾指针即可 O(1); 单链表能维护尾指针,但删除尾结点仍要 O(n) 找倒数第二个。
  • 「判断是否存在」:链表只能 O(n) 线性扫描,1000 个元素尚可接受; 若要求更快,必须再加一张哈希表(unordered_map<key, 结点指针>), 把查找降到 O(1)。
  • 顺序表不适合:每次「移到表头」都要把前面所有元素后移,是 O(n) 的搬家,而且元素一多代价急剧上升。

结论:选双向链表(带尾指针)+ 哈希表。 各操作复杂度:移到表头 O(1)、淘汰末尾 O(1)、判断存在 O(1)(有哈希表时)/ O(n)(只用链表时)。 本题也说明了本章的一个重要观点:数据结构往往需要组合使用,而不是二选一。