09 链表(Linked List)

GESP 5 级CSP-J 进阶

级别标注:GESP 5 级(考级要求)/ CSP-J 进阶(竞赛进阶:数组模拟链表是高效手段)。

一、为什么需要链表

数组在中间插入/删除需要移动大量元素(O(n))。链表用"指针/下标"把元素串起来,插入删除只需改邻居(O(1)),代价是不支持随机访问(要 O(n) 查找)。

操作 数组 链表
随机访问第 k 个 O(1) O(n)
头部/中间插入删除 O(n)(移动元素) O(1)(改指针)
空间 连续,无额外指针 每个节点带指针,略大
形象理解数组像电影院连排座位:座位编号连续,随便报号一秒找到(随机访问 O(1));但有人中途插入/离场,整排人都要挪位置(O(n))。链表像接力棒传递:每个人只记住下一个人是谁,中间插入一个人只需改前后两人的“接力对象”(O(1)),但想找第 k 个人必须从第一个人开始一路问下去。
考级视角GESP 5 级笔试爱考对比题:数组适合按下标随机访问,链表适合频繁中间插入删除。记住“随机访问选数组,频繁增删选链表”这个口诀就能秒答。

二、指针版单链表(理解结构)

C++ 实例
#include <iostream>
using namespace std;

struct Node {                // 链表结点:数据 + 指向下一个的指针
    int data;                // 数据域
    Node *next;              // 指针域
    Node(int d, Node *n = nullptr) : data(d), next(n) {}   // 构造函数
};

// 在链表头部插入新结点(头插法)
Node *insertHead(Node *head, int val) {
    return new Node(val, head);   // 新结点指向原头,自己成为新头
}

// 打印链表:从头沿 next 遍历
void printList(Node *head) {
    while (head) {
        cout << head->data << " ";
        head = head->next;        // 移到下一个结点
    }
    cout << endl;
}

// 释放整条链表内存(防止内存泄漏)
void freeList(Node *head) {
    while (head) {
        Node *t = head;           // 先记住当前结点
        head = head->next;        // 再后移
        delete t;                 // 删除当前结点
    }
}

int main() {
    Node *head = nullptr;         // 空链表
    head = insertHead(head, 3);
    head = insertHead(head, 2);
    head = insertHead(head, 1);
    printList(head);   // 1 2 3(头插法,最后插入的在最前)
    freeList(head);
    return 0;
}

链表节点两大成员:数据域 + 指针域(指向下一节点)。尾节点 next = nullptr。

形象理解每个节点像一张寻宝线索卡:卡片正面写着宝藏数据(data),背面写着下一张卡的位置(next 指针)。跟着 next 一路走,就能按顺序读完所有卡片。
易错点头插法会让输出逆序:insertHead(3) → insertHead(2) → insertHead(1) 后打印是 1 2 3。想保持输入顺序要用尾插(先找到尾节点再挂)。笔试读程序题常在这里设陷阱。

三、竞赛更高效的写法:数组模拟链表

竞赛中直接 new 节点较慢且有内存管理负担,常用数组模拟:用 next[i] 表示下标 i 号节点的下一个节点下标。

C++ 实例
// 数组模拟单链表:用两个数组分别存"值"和"后继下标"
#include <iostream>
using namespace std;
const int N = 100005;
int dataArr[N], nextIdx[N];   // dataArr 存节点值,nextIdx 存下一个节点的下标
int head = -1, cnt = 0;       // head=-1 表示空链表,cnt 是节点池指针

// 头插:把值为 x 的新节点插到链表头部
void insertHead(int x) {
    dataArr[cnt] = x;         // 在节点池中登记值
    nextIdx[cnt] = head;      // 新节点指向旧头
    head = cnt;               // 新节点成为头
    cnt++;                    // 池指针后移
}

void printList() {
    for (int p = head; p != -1; p = nextIdx[p]) {  // 沿 nextIdx 链走
        cout << dataArr[p] << " ";
    }
    cout << endl;
}

int main() {
    insertHead(3);
    insertHead(2);
    insertHead(1);
    printList();   // 1 2 3
    return 0;
}

数组模拟:在指定位移后插入 / 删除(约瑟夫环形链表等)

C++ 实例
#include <iostream>
using namespace std;
const int N = 100005;
int nxt[N];        // 只记录后继下标
// 约瑟夫环的链表实现:节点 i 指向 i+1,最后一个指向 1 成环
int main() {
    int n, m;
    cin >> n >> m;              // 约瑟夫环:n 人,数到 m 出列
    for (int i = 1; i < n; i++) nxt[i] = i + 1;
    nxt[n] = 1;                 // 尾指向头,形成循环链表
    int p = n;                  // 从 n 开始,使第一次报数从 1 起
    for (int k = 1; k <= n; k++) {          // 依次删除 n 个人
        for (int i = 1; i < m; i++) p = nxt[p];   // 走 m-1 步找到出列者前驱
        cout << nxt[p] << " ";              // 出列者(p 的后继)
        nxt[p] = nxt[nxt[p]];               // 链式删除:越过出列者
    }
    cout << endl;
    return 0;
}
形象理解数组模拟链表像给每个同学发一个编号牌:dataArr[i] 是 i 号同学的名字,nextIdx[i] 是 i 号同学“后面排的是几号”。用下标串起队伍,比 new/delete 快得多。
易错点数组模拟里“删除”是逻辑删除:只是让前一个节点的 next 跳过它,物理空间仍然占用。nxt[p] = nxt[nxt[p]] 这种“跳链”写法,务必先取到后继的下标再改,顺序写反会断链。

四、双向链表(Doubly Linked List)

每个节点有两个指针:prev 和 next,可双向遍历。STL 的 list/forward_list 即链表容器:

C++ 实例
#include <iostream>
#include <list>
using namespace std;
int main() {
    list<int> L;               // STL 双向链表
    L.push_back(1);            // 尾部插入
    L.push_back(3);
    L.push_front(0);           // 头部插入
    // 在中间插入:找到值 1 的节点,在其后插入 2
    for (auto it = L.begin(); it != L.end(); ++it) {
        if (*it == 1) {
            L.insert(it, 2);   // 在迭代器 it 之前插入(即 1 之后)
            break;
        }
    }
    for (int x : L) cout << x << " ";
    cout << endl;   // 0 2 1 3
    return 0;
}
形象理解双向链表像前后都能走的单行道:每个节点既有“去下一个”的 next,又有“回上一个”的 prev,正着走反着走都行。代价是每个节点多存一个指针。
易错点STL 的 list 不支持下标随机访问(没有 []),只能迭代器 ++/-- 移动。别试图 L[3],那是 vector 的用法。

五、链表经典应用

  1. 约瑟夫环(链式删除 O(k·n))。
  2. LRU 缓存思路(双向链表 + 哈希表,NOIP 进阶向)。
  3. 大整数高精度乘法的链表存储(18 章)。
  4. 图论中邻接表本质就是"数组模拟链表"存下一节点下标(见 11 章)。
形象理解约瑟夫环的链表版像小朋友手拉手围圈:报到 m 的小朋友松开手出圈,左右两边的孩子重新拉上手(改指针)。出圈操作 O(1),比数组模拟每次“补位”快。
考级视角邻接表本质就是数组模拟链表:head[u] 是 u 的第一条边编号,to[i]/next[i] 存边的终点和下一条边。学 11 章图论前,先把这里的“下标串链”思想吃透,邻接表就是同款套路。

六、易错点 / 常见坑点

  • ⚠️ 空指针访问:遍历删除时要先保存"后继"再删当前(Node *t = p; p = p->next; delete t;)。
  • ⚠️ 头插/头删时必须更新头指针 head。
  • ⚠️ 数组模拟中删除节点后物理空间仍占着,只能靠"断开引用"实现逻辑删除。
  • ⚠️ 约瑟夫环"删除后继"时 nxt[p] = nxt[nxt[p]] 的先后顺序拿错会断链。
  • ⚠️ STL 的 list 不支持随机访问 [],只能迭代器遍历;别指望 O(1) 下标。
  • ⚠️ 指针链表忘记 delete 只是内存泄漏(竞赛可容忍),但访问已释放内存是崩溃级错误。

七、考级要求 vs 竞赛进阶

  • 考级要求(GESP 5 级):理解链表结构、单链表的基本操作(插入/删除/遍历)的概念与数组模拟、约瑟夫环(数组模拟链表是主流考法)。
  • 竞赛进阶(CSP-J/NOIP):数组模拟链表的高效写法、链式存储与邻接表的关系、STL list 的应用权衡(何时弃 STL 手写)。

八、课内练习建议

  1. 用数组模拟单链表,完成指定位置插入与删除并输出整表。
  2. 用链表实现"约瑟夫环"(对比第 8 章队列写法,观察差异)。
  3. 手写双向链表的插入/删除伪代码。
  4. 阅读 STL list 文档,用 splice 完成一次合并。
本章目录
一、为什么需要链表 二、指针版单链表(理解结构) 三、竞赛更高效的写法:数组模拟链表 四、双向链表(Doubly Linked List) 五、链表经典应用 六、易错点 / 常见坑点 七、考级要求 vs 竞赛进阶 八、课内练习建议