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 的用法。五、链表经典应用
- 约瑟夫环(链式删除 O(k·n))。
- LRU 缓存思路(双向链表 + 哈希表,NOIP 进阶向)。
- 大整数高精度乘法的链表存储(18 章)。
- 图论中邻接表本质就是"数组模拟链表"存下一节点下标(见 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 手写)。
八、课内练习建议
- 用数组模拟单链表,完成指定位置插入与删除并输出整表。
- 用链表实现"约瑟夫环"(对比第 8 章队列写法,观察差异)。
- 手写双向链表的插入/删除伪代码。
- 阅读 STL
list文档,用splice完成一次合并。