小明在爷爷的阁楼里发现了一张藏宝图,但地图被撕成了碎片!每一块碎片上写着:
"我的宝物在…… → 下一块碎片在 XX 号箱子里"
也就是说,每块碎片只告诉你"下一块在哪里",你必须从第一块开始,一块一块地找下去。
这些碎片散落在不同的箱子里(不连续存放),但通过碎片上的线索(指针)串成了一条链。
这就是链表的核心思想!
元素不必挨在一起存放,每个元素都"记住"下一个元素在哪——通过指针串联起来。
从"连续存储"走向"链式存储"——用指针把散落的节点串成一条链,灵活地插入、删除、遍历!
从一个"寻宝"故事开始,理解链表的核心思想。
小明在爷爷的阁楼里发现了一张藏宝图,但地图被撕成了碎片!每一块碎片上写着:
"我的宝物在…… → 下一块碎片在 XX 号箱子里"
也就是说,每块碎片只告诉你"下一块在哪里",你必须从第一块开始,一块一块地找下去。
这些碎片散落在不同的箱子里(不连续存放),但通过碎片上的线索(指针)串成了一条链。
这就是链表的核心思想!
元素不必挨在一起存放,每个元素都"记住"下一个元素在哪——通过指针串联起来。
每节车厢通过挂钩连接下一节。想加减车厢?只需摘挂挂钩,不用移动其他车厢。
→ O(1) 插入/删除每个铁环扣住下一个铁环,断开一个环就能拆分链表,加入一个环就能延长。
→ 指针链接的本质每首歌播放完自动跳到下一首。双向播放列表(上一首/下一首)就是双向链表!
→ 双向链表木马围成一圈,最后一个后面又回到第一个——这就是循环链表!
→ 循环链表链表(Linked List)是一种线性数据结构,由若干个节点(Node)组成。每个节点包含两部分:
① 数据域(data):存储节点的值
② 指针域(next):存储下一个节点的地址(引用)
与数组不同,链表的节点在内存中不必连续存放,而是通过指针串联起来。
📌 本讲核心知识地图
| 板块 | 核心内容 | 对应大纲 |
|---|---|---|
| 单链表 | 节点定义、增删查、静态实现 | 链表基础 |
| 双向链表 | prev+next、双向遍历、静态实现 | 链表进阶 |
| 循环链表 | 首尾相连、约瑟夫环 | 链表应用 |
数组连续快访问,链表灵活善插删
先接后断保链路,指针操作要画图
① 先接:新节点.next = 后继
② 后断:前驱.next = 新节点
顺序反了,后面的链就丢了!
从数组与链表的对比出发,逐步掌握三种链表的实现。
| 特性 | 数组 | 链表 |
|---|---|---|
| 存储方式 | 连续内存 | 离散内存 |
| 随机访问 | O(1) ✅ 快 | O(n) ❌ 慢 |
| 头部插入 | O(n) ❌ 需移动 | O(1) ✅ 快 |
| 中间插入/删除 | O(n) ❌ 需移动 | O(1) ✅ 改指针 |
| 内存利用 | 可能浪费(预分配) | 按需分配,有指针开销 |
| 大小 | 固定 / 动态扩容 | 天然动态,任意长度 |
单链表是最基本的链表形式,每个节点只有一个 next 指针,只能从前往后遍历。
struct Node {
int data; // 数据域:存储节点值
Node* next; // 指针域:指向下一个节点
Node(int val) : data(val), next(nullptr) {}
};
| 操作 | 时间复杂度 | 关键步骤 |
|---|---|---|
| 遍历 | O(n) | while(p) { ... p=p->next; } |
| 头部插入 | O(1) | 新节点→head, head=新节点 |
| 尾部插入 | O(n) | 遍历到末尾,tail→新节点 |
| 中间插入 | O(n)查找 + O(1)链接 | 找到前驱,改指针 |
| 删除节点 | O(n)查找 + O(1)链接 | 找到前驱,跳过目标 |
| 查找值 | O(n) | 从头逐个比较 |
📋 题意:输入 n 个整数,用尾插法建立单链表,然后遍历输出。
输入:5 个整数:3 7 1 9 5
🖐 手推过程(尾插法):
#include <iostream>
using namespace std;
struct Node {
int data;
Node* next;
Node(int v) : data(v), next(nullptr) {}
};
// 尾插法建表
Node* buildList() {
int n; cin >> n;
Node *head = nullptr, *tail = nullptr;
for (int i = 0; i < n; i++) {
int x; cin >> x;
Node* p = new Node(x);
if (!head) {
head = tail = p; // 第一个节点
} else {
tail->next = p; // 接在尾部
tail = p; // 更新尾指针
}
}
return head;
}
// 遍历输出
void printList(Node* head) {
Node* p = head;
while (p) {
cout << p->data << " ";
p = p->next;
}
cout << endl;
}
int main() {
Node* head = buildList();
printList(head);
return 0;
}
输入:
5
3 7 1 9 5
输出:
3 7 1 9 5📌 在节点 prev 之后插入新节点(值 = val)
必须先让新节点的 next 指向后继,再让前驱的 next 指向新节点。
如果顺序反了,会丢失后面的链表!
// 在节点 prev 之后插入值为 val 的新节点
void insertAfter(Node* prev, int val) {
Node* newNode = new Node(val);
newNode->next = prev->next; // ① 先接:新节点指向后继
prev->next = newNode; // ② 后断:前驱指向新节点
}
// 删除节点 prev 的后继节点
void deleteAfter(Node* prev) {
if (!prev->next) return;
Node* temp = prev->next;
prev->next = temp->next; // 跳过被删节点
delete temp; // 释放内存
}
动态分配内存(new/malloc)可能较慢且容易出错。静态链表用数组模拟链表,预先分配好所有节点,用数组下标代替指针,速度更快、更安全!
const int MAXN = 100005;
int data[MAXN]; // 数据域
int nxt[MAXN]; // 指针域(存下标,0 表示 NULL)
int head = 0; // 头指针(0 表示空链表)
int cnt = 0; // 节点计数器(从 1 开始!)
// 头部插入
void pushFront(int val) {
cnt++;
data[cnt] = val;
nxt[cnt] = head; // 新节点指向旧头部
head = cnt; // 更新头指针
}
// 在节点 i 之后插入
void insertAfter(int i, int val) {
cnt++;
data[cnt] = val;
nxt[cnt] = nxt[i]; // ① 先接
nxt[i] = cnt; // ② 后断
}
// 删除节点 i 的后继
void deleteAfter(int i) {
nxt[i] = nxt[nxt[i]]; // 跳过被删节点
}
// 遍历
void print() {
for (int p = head; p != 0; p = nxt[p])
cout << data[p] << " ";
cout << endl;
}
① 数组下标从 1 开始,0 保留作为 NULL
② nxt[i] 存的是下一个节点的下标,而不是指针
③ 避免 new/delete 的开销和内存泄漏
④ 数组访问速度快(缓存友好)
单链表只能从前往后遍历。如果想找某个节点的"前驱",只能从头开始!
比如:要在某节点前面插入、要删除某节点(只知道该节点地址),单链表都很麻烦。
struct DNode {
int data;
DNode* prev; // 前驱指针
DNode* next; // 后继指针
DNode(int v) : data(v), prev(nullptr), next(nullptr) {}
};
const int MAXN = 100005;
int L[MAXN], R[MAXN]; // L[i]=i的左邻居, R[i]=i的右邻居
// 初始化:0号节点为虚拟头,自身成环
void init() {
L[0] = 0;
R[0] = 0;
}
// 在节点 pos 右边插入节点 idx
void insertRight(int pos, int idx) {
R[idx] = R[pos];
L[idx] = pos;
L[R[pos]] = idx;
R[pos] = idx;
}
// 在节点 pos 左边插入节点 idx
void insertLeft(int pos, int idx) {
insertRight(L[pos], idx);
}
// 删除节点 idx
void remove(int idx) {
R[L[idx]] = R[idx]; // 左邻居的右指针跳过自己
L[R[idx]] = L[idx]; // 右邻居的左指针跳过自己
}
// 遍历(从虚拟头0开始)
void print() {
for (int i = R[0]; i != 0; i = R[i])
cout << i << " ";
cout << endl;
}
用 L[] 和 R[] 两个数组就够了!
插入只需改 4 个值,删除只需改 2 个值,非常简洁。
这是竞赛中处理链表问题的首选写法!
📋 题意(洛谷 P1996):n 个人围成一圈(编号 1~n),从第 1 个人开始报数,数到 m 的人出列。然后从下一个人重新报数,直到所有人出列。输出出列顺序。
数据范围:1 ≤ m, n ≤ 100
🖐 手推过程(n=5, m=3):
#include <iostream>
using namespace std;
const int MAXN = 105;
int nxt[MAXN];
int main() {
int n, m;
cin >> n >> m;
// 建立循环链表:1→2→3→...→n→1
for (int i = 1; i < n; i++)
nxt[i] = i + 1;
nxt[n] = 1;
int cur = 1, prev = n;
for (int i = 0; i < n; i++) {
// 报数 m-1 次(cur本身算第1个)
for (int j = 1; j < m; j++) {
prev = cur;
cur = nxt[cur];
}
cout << cur << " "; // cur 出列
nxt[prev] = nxt[cur]; // 删除 cur
cur = nxt[cur]; // 从下一个人继续
}
cout << endl;
return 0;
}
输入:
5 3
输出:
3 1 5 2 4将链表最后一个节点的 next 指向头节点,形成环状结构。
while (p != NULL)do { ... p = p->next; } while (p != head);| 特性 | 单链表 | 双向链表 | 循环链表 |
|---|---|---|---|
| 指针数/节点 | 1(next) | 2(prev + next) | 1 或 2 |
| 遍历方向 | 只能 → | ← 和 → | 循环不停 |
| 终止条件 | p == NULL | p == NULL | p == head |
| 找前驱 | O(n) | O(1) | O(n) / O(1) |
| 已知节点删除 | 需找前驱 | 直接删 | 看类型 |
| 内存开销 | 小 | 中 | 小/中 |
| 典型应用 | 哈希链、简单队列 | STL list、LRU缓存 | 约瑟夫环、轮转调度 |
当我们要删除头节点或在头部插入时,需要特殊处理(修改 head 指针)。
引入一个虚拟头节点 dummy 放在真正的头节点前面,让所有节点都有"前驱",就能统一操作逻辑!
Node* removeElements(Node* head, int val) {
Node* dummy = new Node(0); // 虚拟头
dummy->next = head;
Node* prev = dummy;
Node* cur = head;
while (cur) {
if (cur->data == val) {
prev->next = cur->next; // 跳过 cur
delete cur;
cur = prev->next;
} else {
prev = cur;
cur = cur->next;
}
}
Node* newHead = dummy->next;
delete dummy;
return newHead;
}
通过详细的手推追踪,巩固链表操作的理解。
题目:依次执行以下操作,追踪链表状态变化:
① 尾插法依次插入 5, 3, 8, 1
② 在值为 3 的节点后面插入 7
③ 删除值为 8 的节点
④ 在头部插入 9
题目:用静态双向链表实现:
① 初始化空链表(虚拟头 0)
② 依次在 0 号右边插入 1、2、3 号节点
③ 在 2 号左边插入 4 号节点
④ 删除 1 号节点
追踪每步的 L[] 和 R[] 数组状态。
题目:n=6, m=4 的约瑟夫环,用循环链表追踪每轮出列过程。
初始:1→2→3→4→5→6→1(环形),cur=1, prev=6
| 轮次 | 起点 | 走3步到 | 出列 | 剩余链表 |
|---|---|---|---|---|
| 1 | 1 | 1→2→3→4 | 4 | 1→2→3→5→6→1 |
| 2 | 5 | 5→6→1→2 | 2 | 1→3→5→6→1 |
| 3 | 3 | 3→5→6→3 | 3 | 1→5→6→1 |
| 4 | 5 | 5→6→1→5 | 5 | 1→6→1 |
| 5 | 6 | 6→1→6→1 | 1 | 6→6(自环) |
| 6 | 6 | — | 6 | 空 |
出列顺序:4, 2, 3, 5, 1, 6
| 知识板块 | 必须记住的核心 |
|---|---|
| 链表 vs 数组 | 链表:离散存储,插入删除 O(1),访问 O(n) |
| 单链表节点 | struct Node { int data; Node* next; } |
| 先接后断原则 | 插入时先让新节点指向后继,再改前驱的 next |
| 静态链表 | 用数组+下标模拟指针,nxt[] 存下标,0 号保留为 NULL |
| 双向链表 | L[]+R[] 两个数组,插入改 4 个值,删除改 2 个值 |
| 循环链表 | 尾→头形成环,终止条件用 p==head |
| 虚拟头节点 | 在头前加 dummy,统一插入/删除逻辑 |
| 约瑟夫环 | 循环链表经典应用,维护 cur 和 prev 指针 |
list<int> lst; // 定义链表
lst.push_back(x); // 尾部插入 O(1)
lst.push_front(x); // 头部插入 O(1)
lst.pop_back(); // 尾部删除 O(1)
lst.pop_front(); // 头部删除 O(1)
lst.front(); // 获取头部元素
lst.back(); // 获取尾部元素
lst.insert(it, x); // 在迭代器 it 前插入 O(1)
lst.erase(it); // 删除迭代器 it 指向的元素 O(1)
lst.size(); // 链表长度
lst.empty(); // 是否为空
lst.sort(); // 链表排序
lst.reverse(); // 反转链表
// 遍历方式
for (auto it = lst.begin(); it != lst.end(); ++it)
cout << *it << " ";
1. 理解链表与数组的区别,知道何时该用链表。
2. 能手写单链表的增删查操作,掌握"先接后断"原则。
3. 能使用静态链表(数组模拟)解决竞赛问题。
4. 能实现双向链表的插入和删除操作。
5. 能用循环链表解决约瑟夫环等经典问题。
6. 了解虚拟头节点技巧,统一操作逻辑。
5 个链表操作中最常犯的错误。
prev->next = newNode;newNode->next = prev->next;newNode->next = prev->next;prev->next = newNode;while(p->next) { ... }while(p) { ... p = p->next; }while(p != NULL)do { ... p=p->next; } while(p!=head);delete head;Node* t = head; head = head->next; delete t;| 操作 | 核心代码 | 复杂度 |
|---|---|---|
| 静态单链表·头插 | cnt++; data[cnt]=v; nxt[cnt]=head; head=cnt; | O(1) |
| 静态单链表·i后插 | cnt++; data[cnt]=v; nxt[cnt]=nxt[i]; nxt[i]=cnt; | O(1) |
| 静态单链表·删i后 | nxt[i] = nxt[nxt[i]]; | O(1) |
| 静态双向链表·i右插 | R[x]=R[i]; L[x]=i; L[R[i]]=x; R[i]=x; | O(1) |
| 静态双向链表·删i | R[L[i]]=R[i]; L[R[i]]=L[i]; | O(1) |
| 单链表遍历 | for(int p=head; p!=0; p=nxt[p]) | O(n) |
| 双向链表遍历 | for(int i=R[0]; i!=0; i=R[i]) | O(n) |
| 循环链表遍历 | do{...p=p->next;}while(p!=head); | O(n) |
节点+指针
离散存储,指针串联
优势 O(1) 插删
①新节点.next=后继
②前驱.next=新节点
顺序不能反!
数组模拟链表
nxt[] 存下标
0 号保留为 NULL
L[]+R[] 两数组
插入改 4 值
删除改 2 值
尾→头形成环
终止用 p==head
约瑟夫环经典
头前加 dummy
让头也有前驱
统一操作逻辑
A基础 / B提高 / C挑战 + 洛谷原题20道,难度递增。
A. 链表支持 O(1) 随机访问
B. 单链表可以从任意节点向前遍历
C. 在已知前驱节点的情况下,单链表插入操作的时间复杂度为 O(1)
D. 链表的节点必须在内存中连续存放
答案:C
A 错(链表 O(n) 访问);B 错(单链表只能向后);D 错(链表离散存储)。
考点:链表靠指针把结点串起来,想取第 k 个元素必须从头沿 next 逐个走,因此随机访问是 O(n);单链表只有一个 next 指针,只能单向遍历。
易错:误以为链表也能像数组一样用下标 O(1) 直接取到任意元素。
第一步:___________
第二步:___________
如果顺序颠倒,会导致___________。
#include <iostream>
using namespace std;
struct Node {
int val;
Node* next;
Node(int v) : val(v), next(nullptr) {}
};
int main() {
// 建一条单链表:1 -> 2 -> 3
Node* head = new Node(1);
head->next = new Node(2);
head->next->next = new Node(3);
Node* P = head->next; // P 指向节点 2
Node* S = new Node(99); // 待插入的新节点
S->next = P->next; // ← 这里填 S->next = P->next(先接住后半段)
P->next = S; // ← 这里填 P->next = S(再挂上新节点)
for (Node* p = head; p != nullptr; p = p->next)
cout << p->val << " ";
cout << endl; // 输出:1 2 99 3
return 0;
}
答案:第一步 S->next = P->next;,第二步 P->next = S;。
思路:插入的本质是「改两条链」。先让新节点 S 的 next 接住 P 原来的后继,把后面那段链表牢牢记住;再让 P 的 next 指向 S。这样 S 就稳稳插在 P 后面了。
考点:单链表结点的插入,指针改链的先后顺序。
易错:两步千万不能颠倒!如果先写 P->next = S;,P 原来的后继就再也找不到了,后面一整条链表直接「丢链」丢失。
例:输入 1 2 3 4 5,输出 5 4 3 2 1
思路:维护 prev 和 cur 两个指针,遍历过程中逐个反转 next 指针。
Node* reverseList(Node* head) {
Node* prev = nullptr;
Node* cur = head;
while (cur) {
Node* next = cur->next; // 暂存后继
cur->next = prev; // 反转指针
prev = cur; // prev 前进
cur = next; // cur 前进
}
return prev; // 新的头节点
}易错:改写 cur->next 前没有先用临时变量存好后继,断链后就找不到后面的结点了;另外最后要返回 prev(原尾结点成为新头),而不是原 head。
例:1→3→5 和 2→4→6,合并为 1→2→3→4→5→6
思路:用双指针比较两个链表当前节点,取较小的接到结果链表后面。可用虚拟头节点简化代码。
Node* merge(Node* a, Node* b) {
Node dummy(0);
Node* tail = &dummy;
while (a && b) {
if (a->data <= b->data) {
tail->next = a; a = a->next;
} else {
tail->next = b; b = b->next;
}
tail = tail->next;
}
tail->next = a ? a : b;
return dummy.next;
}易错:循环结束时必有一条链表还没走完,忘记把剩下的整条接到 tail 后面,合并结果就会丢掉一大截。
N 个同学排队,1 号先入队。2~N 号依次入列,指定在某同学的左边或右边。然后去掉 M 个同学,输出最终队列。
数据范围:N ≤ 100000
#include <iostream>
using namespace std;
struct Node { // 双向链表结点
int val;
Node *pre, *nxt;
Node(int v) : val(v), pre(nullptr), nxt(nullptr) {}
};
const int N = 100005;
Node* pos[N]; // pos[i] 指向编号为 i 的同学
bool alive[N]; // 该同学是否还在队列里
int main() {
int n;
cin >> n;
Node* head = new Node(1); // 先把 1 号同学放进队列
pos[1] = head;
alive[1] = true;
for (int i = 2; i <= n; i++) {
int k, p;
cin >> k >> p;
Node* cur = new Node(i);
pos[i] = cur;
alive[i] = true;
Node* t = pos[k];
if (p == 0) { // i 站到 k 的左边
cur->pre = t->pre;
cur->nxt = t;
if (t->pre) t->pre->nxt = cur;
t->pre = cur;
if (t == head) head = cur; // k 原来是队头,队头换成 i
} else { // i 站到 k 的右边
cur->nxt = t->nxt;
cur->pre = t;
if (t->nxt) t->nxt->pre = cur;
t->nxt = cur;
}
}
int m;
cin >> m;
while (m--) {
int x;
cin >> x;
if (!alive[x]) continue; // 已经离开队列,忽略这条指令
alive[x] = false;
Node* t = pos[x];
if (t->pre) t->pre->nxt = t->nxt;
if (t->nxt) t->nxt->pre = t->pre;
if (t == head) head = t->nxt; // 删掉的正好是队头
}
for (Node* q = head; q != nullptr; q = q->nxt) // 从左到右输出
cout << q->val << " ";
cout << endl; // 样例输出:2 4 1
return 0;
}
答案:用双向链表模拟排队。样例(N=4,相继插入 2 到 1 左边、3 到 2 右边、4 到 1 左边,再删掉 3)最后从左到右输出 2 4 1。
思路:把每个同学看成一个结点,记住它的左、右邻居。插入时要改 4 根指针:新结点连左右两根;左右邻居各被拉一次。删除时让被删结点的左右邻居直接「手拉手」,再用一个 bool 数组标记谁已经离开,重复删除的指令直接忽略。
考点:双向链表的插入 / 删除、用数组存指针实现 O(1) 定位。
易错:① 插到队头左边时要更新 head;② 删的可能是队头,删完也要更新 head;③ 同一个人可能被删两次,第二次必须忽略,否则链表会被改乱。
n 人围成一圈,从 1 开始报数,数到 m 的出列。输出出列顺序。
数据范围:1 ≤ m, n ≤ 100
#include <iostream>
using namespace std;
struct Node {
int val;
Node* next;
};
int main() {
int n, m;
cin >> n >> m;
// 1. 先建一条普通单链表 1 -> 2 -> ... -> n
Node* head = new Node{1, nullptr};
Node* tail = head;
for (int i = 2; i <= n; i++) {
tail->next = new Node{i, nullptr};
tail = tail->next;
}
tail->next = head; // 尾巴指回头 → 循环链表
// 2. 报数出圈
Node* cur = head; // 当前数到谁
Node* prev = tail; // cur 的前驱,方便删除
for (int k = 0; k < n; k++) {
for (int step = 1; step < m; step++) { // 走 m-1 步
prev = cur;
cur = cur->next;
}
cout << cur->val << " "; // 报到 m 的人出圈
prev->next = cur->next; // 从链表里摘掉 cur
Node* nxt = cur->next;
delete cur;
cur = nxt;
}
cout << endl; // n=10, m=3 → 3 6 9 2 7 1 8 5 10 4
return 0;
}
答案:n=10、m=3 时出圈顺序是 3 6 9 2 7 1 8 5 10 4。
思路:把 n 个人建成循环链表——让尾结点的 next 指回头结点,这样数到最后一个人还能自动绕回开头。用 cur 表示当前报数的人、prev 记它的前驱;每轮先走 m-1 步,让 cur 报到 m,输出 cur 并从链表里删掉它,再把 cur 移到下一个人。
考点:循环链表的建立、指针的移动与删除。
易错:① 走的是 m-1 步而不是 m 步(cur 自己就算第 1 个);② 删除时要先保存 cur->next 再 delete,否则找不到下一个人;③ 若只报数不真正删结点,循环会永远绕下去。
内存容量为 M,依次处理 N 个单词。若单词在内存中则直接翻译,否则加入内存末尾。若内存已满,删除最早加入的单词。求查词典次数。
#include <iostream>
#include <queue>
using namespace std;
const int MAXW = 1005;
bool inMem[MAXW]; // 某单词当前是否在内存里
int main() {
int cap, len; // 内存容量、文章单词个数
cin >> cap >> len;
queue<int> q; // 内存中的单词,队头是最早进来的
int ans = 0; // 查词典的次数
for (int i = 0; i < len; i++) {
int w;
cin >> w;
if (!inMem[w]) { // 内存里没有,必须查词典
ans++;
if ((int)q.size() == cap) { // 内存满了,先挤掉最早进来的
inMem[q.front()] = false;
q.pop();
}
q.push(w); // 新单词进队尾
inMem[w] = true;
}
}
cout << ans << endl; // 样例 3 7 / 1 2 1 5 4 4 1 → 5
return 0;
}
答案:样例(内存容量 3,文章 1 2 1 5 4 4 1)需要查词典 5 次。
思路:内存就是一个队列,队头是最早进内存的单词。每读一个词先看它在不在内存里(用 bool 数组 O(1) 判断):不在就查词典、计数 +1;如果内存满了,就把队头(最早的)挤出去,再把新词放进队尾 —— 这就是"先进先出"。
考点:队列 FIFO 模拟、bool 标记数组做快速查找。
易错:① 只有"内存里没有"才计数,重复出现的词不重复计数;② 挤出去的是队头(最早进来),搞成队尾就变成栈了;③ bool 数组要按单词编号范围开够。
Node* prev = NULL;
Node* cur = head;
while(cur) {
Node* next = cur->next;
cur->next = prev;
prev = cur;
cur = next;
}
head = prev;
这是经典的链表反转算法!
逐轮追踪:每轮把 cur->next 反转指向 prev,然后 prev 和 cur 各前进一步。
最终结果:head→5→4→3→2→1→NULL
思路:这是就地反转单链表。用 prev 与 cur 双指针,每轮先把 cur->next 暂存,再让 cur->next 指向 prev,最后 prev 和 cur 各自前进一步,走完时 prev 就是新头结点。
易错:以为反转后 head 仍然指向结点 1;实际上结点 1 变成了尾结点,新的头是原来的尾结点 5。
例:输入 4→2→1→3,输出 1→2→3→4
#include <iostream>
using namespace std;
struct Node {
int val;
Node* next;
Node(int v) : val(v), next(nullptr) {}
};
// 合并两条已经有序的链表
Node* merge(Node* a, Node* b) {
Node dummy(0);
Node* tail = &dummy;
while (a && b) {
if (a->val <= b->val) { tail->next = a; a = a->next; }
else { tail->next = b; b = b->next; }
tail = tail->next;
}
tail->next = a ? a : b; // 剩下的一截直接接上
return dummy.next;
}
// 链表归并排序
Node* sortList(Node* head) {
if (!head || !head->next) return head; // 0 或 1 个结点天然有序
Node* slow = head; // 快慢指针找中点
Node* fast = head->next;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
}
Node* mid = slow->next;
slow->next = nullptr; // 从中间断开成两半
return merge(sortList(head), sortList(mid));
}
int main() {
int a[] = {4, 2, 1, 3, 5};
Node dummy(0);
Node* tail = &dummy;
for (int x : a) { tail->next = new Node(x); tail = tail->next; }
for (Node* p = sortList(dummy.next); p; p = p->next)
cout << p->val << " ";
cout << endl; // 输出:1 2 3 4 5
return 0;
}
答案:用归并排序,时间复杂度 O(n log n),额外空间只用递归栈 O(log n)。输入 4 2 1 3 5 排序后为 1 2 3 4 5。
思路:链表最适合归并排序:① 用快慢指针找中点(fast 每次走 2 步,slow 走 1 步);② 从中点断开成两条短链表;③ 左右分别递归排序;④ 把两条有序链表合并成一条。
考点:链表归并排序、快慢指针找中点、有序链表合并。
易错:① fast 从 head->next 出发,否则结点数为偶数时中点会偏;② 断开前要先存下 mid;③ 合并时用哑结点(dummy)可以省掉一堆头结点的特判。
#include <iostream>
using namespace std;
struct Node {
int val;
Node* next;
Node(int v) : val(v), next(nullptr) {}
};
// 有环则返回环的入口结点,无环返回 nullptr
Node* detectCycle(Node* head) {
Node* slow = head;
Node* fast = head;
while (fast && fast->next) {
slow = slow->next; // 慢指针每次走 1 步
fast = fast->next->next; // 快指针每次走 2 步
if (slow == fast) { // 相遇 → 说明有环
Node* p = head; // 一个指针从起点出发
while (p != slow) { // 另一个从相遇点出发,同速前进
p = p->next;
slow = slow->next;
}
return p; // 再次相遇处就是环的入口
}
}
return nullptr; // 快指针走到头 → 没有环
}
int main() {
Node* head = new Node(1);
head->next = new Node(2);
head->next->next = new Node(3);
head->next->next->next = new Node(4);
head->next->next->next->next = head->next; // 4 号指回 2 号,形成环
Node* entry = detectCycle(head);
if (entry) cout << "有环,入口结点是 " << entry->val << endl; // 有环,入口结点是 2
else cout << "无环" << endl;
return 0;
}
答案:用 Floyd 判圈法(快慢指针):有环就返回环的入口结点,无环返回空指针。上面例子 4 号指回 2 号,入口结点是 2。
思路:slow 每次走 1 步、fast 每次走 2 步。若链表无环,fast 一定先走到头;若有环,fast 会在环里追上 slow,两者必然相遇。相遇之后,让一个指针回到 head,另一个留在相遇点,两指针同速前进,再次相遇的位置就是环的入口。
考点:快慢指针、Floyd 判圈算法、环入口的推导。
易错:① 循环条件必须写 fast && fast->next,否则 fast->next->next 会踩空指针崩溃;② 相遇点不是入口!要再走一轮才能找到入口。
哈希表 + 双向链表!哈希表 O(1) 查找,双向链表 O(1) 移动/删除。经典面试题。
思路:get 要 O(1) 查找,用哈希表存 key → 结点指针;把访问过的结点移到“最近使用”位置又需要 O(1) 删除和插入,用双向链表,表头最新、表尾最旧,超容量就删表尾。
易错:用单链表会取不到前驱、删除做不到 O(1);还容易漏掉 get 命中后同样要把该结点移到链表头部。
例:1→2→3→4→5, k=2 → 2→1→4→3→5
例:1→2→3→4→5, k=3 → 3→2→1→4→5
#include <iostream>
using namespace std;
struct Node {
int val;
Node* next;
Node(int v) : val(v), next(nullptr) {}
};
Node* reverseKGroup(Node* head, int k) {
Node dummy(0);
dummy.next = head;
Node* groupPrev = &dummy; // 上一组的尾巴
while (true) {
// 1. 找到这一组的第 k 个结点
Node* node = groupPrev;
for (int i = 0; i < k && node; i++) node = node->next;
if (!node) break; // 不足 k 个,剩下的不动
Node* groupNext = node->next; // 下一组的开头
// 2. 组内反转(头插法反过来)
Node* prev = groupNext;
Node* cur = groupPrev->next;
while (cur != groupNext) {
Node* nxt = cur->next;
cur->next = prev;
prev = cur;
cur = nxt;
}
// 3. 重新接回主链
Node* oldHead = groupPrev->next; // 反转后它变成这一组的尾巴
groupPrev->next = prev; // 反转后的组头
groupPrev = oldHead;
}
return dummy.next;
}
int main() {
int a[] = {1, 2, 3, 4, 5};
Node dummy(0);
Node* tail = &dummy;
for (int x : a) { tail->next = new Node(x); tail = tail->next; }
for (Node* p = reverseKGroup(dummy.next, 2); p; p = p->next)
cout << p->val << " ";
cout << endl; // k=2 输出:2 1 4 3 5
return 0;
}
答案:每 k 个一组翻转。1→2→3→4→5 在 k=2 时变成 2 1 4 3 5,k=3 时变成 3 2 1 4 5(末尾不足 k 个的 4、5 保持原顺序)。
思路:整条链分成若干组处理:① 从上一组尾巴出发,先找这一组的第 k 个结点,找不到说明剩下的不足 k 个,直接结束;② 在这组里做一次标准的链表反转;③ 把反转后的组头接到上一组尾巴上,再更新 groupPrev 为反转后的组尾,进入下一组。
考点:链表分组 + 组内反转、哑结点的使用、多指针改链。
易错:① 组内反转时把 prev 初始成 groupNext,才能顺带把这一组尾接到下一组头;② 不足 k 个的那组一定不能翻;③ 每轮结束要记住 oldHead(反转后的组尾)作为下一轮的 groupPrev。
以下 20 道洛谷原题涵盖链表的各种应用场景,按难度从入门到提高排列。
• 先做A组确保基础概念清晰,能手写链表增删
• 再做B组的 P1160 和 P1996,这两道是链表必刷题
• 最后挑战C组的 LRU 和环检测,理解链表在工程中的应用
• 洛谷原题从入门开始刷,P1160 和 P1996 务必独立完成!
推荐顺序:P1996 → P1540 → P1160 → P1090 → P1177 → C组挑战题。