NOI C++ 课程体系 / 数据结构篇 / 第1讲
CSP-J 入门级 · 线性数据结构 · m5-01

链表
单链表、双向链表、循环链表

从"连续存储"走向"链式存储"——用指针把散落的节点串成一条链,灵活地插入、删除、遍历!

6 大模块 3 种链表类型 3 个经典案例 5 大易错点 32 道分层练习
本讲 · 第 41/56 讲链表【单链表、双向链表、循环链表】
模块 01

情景导航

从一个"寻宝"故事开始,理解链表的核心思想。

故事寻宝地图的碎片

小明在爷爷的阁楼里发现了一张藏宝图,但地图被撕成了碎片!每一块碎片上写着:

"我的宝物在…… → 下一块碎片在 XX 号箱子里"

也就是说,每块碎片只告诉你"下一块在哪里",你必须从第一块开始,一块一块地找下去。

这些碎片散落在不同的箱子里(不连续存放),但通过碎片上的线索(指针)串成了一条链。

这就是链表的核心思想!

元素不必挨在一起存放,每个元素都"记住"下一个元素在哪——通过指针串联起来。

①生活类比:链表无处不在
🚂

火车车厢

每节车厢通过挂钩连接下一节。想加减车厢?只需摘挂挂钩,不用移动其他车厢。

→ O(1) 插入/删除
🔗

铁链

每个铁环扣住下一个铁环,断开一个环就能拆分链表,加入一个环就能延长。

→ 指针链接的本质
🎵

音乐播放列表

每首歌播放完自动跳到下一首。双向播放列表(上一首/下一首)就是双向链表!

→ 双向链表
🎡

旋转木马

木马围成一圈,最后一个后面又回到第一个——这就是循环链表!

→ 循环链表
②编程定义

链表(Linked List)是一种线性数据结构,由若干个节点(Node)组成。每个节点包含两部分:

① 数据域(data):存储节点的值

② 指针域(next):存储下一个节点的地址(引用)

与数组不同,链表的节点在内存中不必连续存放,而是通过指针串联起来。

📌 本讲核心知识地图

板块核心内容对应大纲
单链表节点定义、增删查、静态实现链表基础
双向链表prev+next、双向遍历、静态实现链表进阶
循环链表首尾相连、约瑟夫环链表应用
③核心口诀
🔑 口诀一:链表灵魂

数组连续快访问,链表灵活善插删
先接后断保链路,指针操作要画图

🔑 口诀二:插入两步走

① 先接:新节点.next = 后继
② 后断:前驱.next = 新节点
顺序反了,后面的链就丢了!

模块 02

知识探究

从数组与链表的对比出发,逐步掌握三种链表的实现。

1数组 vs 链表:内存存储对比 基础
📦 数组:连续存储(地址递增)
地址 0x100 0x104 0x108 0x10C 0x110
数据
3
7
1
9
5
连续存放,可通过下标 O(1) 直接访问
🔗 链表:离散存储(指针串联)
节点
3
→
→
7
→
→
1
→
→
9
→
→
5
→
→ NULL
地址分散,通过指针串联,必须从头遍历
特性数组链表
存储方式连续内存离散内存
随机访问O(1) ✅ 快O(n) ❌ 慢
头部插入O(n) ❌ 需移动O(1) ✅ 快
中间插入/删除O(n) ❌ 需移动O(1) ✅ 改指针
内存利用可能浪费(预分配)按需分配,有指针开销
大小固定 / 动态扩容天然动态,任意长度
2单链表(Singly Linked List)核心

单链表是最基本的链表形式,每个节点只有一个 next 指针,只能从前往后遍历。

C++单链表节点定义
struct Node {
    int data;        // 数据域:存储节点值
    Node* next;      // 指针域:指向下一个节点
    Node(int val) : data(val), next(nullptr) {}
};
📊 单链表结构可视化
head
↓
3
→
→
7
→
→
1
→
→
9
→
→
5
→
→ NULL
操作时间复杂度关键步骤
遍历O(n)while(p) { ... p=p->next; }
头部插入O(1)新节点→head, head=新节点
尾部插入O(n)遍历到末尾,tail→新节点
中间插入O(n)查找 + O(1)链接找到前驱,改指针
删除节点O(n)查找 + O(1)链接找到前驱,跳过目标
查找值O(n)从头逐个比较
案例经典案例1:创建单链表并遍历 核心

📋 题意:输入 n 个整数,用尾插法建立单链表,然后遍历输出。

输入:5 个整数:3 7 1 9 5

🖐 手推过程(尾插法):

1
插入 3
head = tail = [3] → NULL
2
插入 7
head → [3] → [7] → NULL(tail 移到 7)
3
插入 1
head → [3] → [7] → [1] → NULL
4
插入 9
head → [3] → [7] → [1] → [9] → NULL
5
插入 5
head → [3] → [7] → [1] → [9] → [5] → NULL
C++尾插法建表 + 遍历输出
#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
案例经典案例2:单链表的插入与删除 易错

📌 在节点 prev 之后插入新节点(值 = val)

⚠️ 关键口诀:"先接后断"

必须先让新节点的 next 指向后继,再让前驱的 next 指向新节点。
如果顺序反了,会丢失后面的链表!

✓
正确顺序(在节点 A 之后插入 X)
原始:A → B → C
第①步:X.next = A.next(即 X → B)
第②步:A.next = X
结果:A → X → B → C ✅
✗
错误顺序(先断后接)
第①步:A.next = X → A 指向 X,但 X.next 还是 NULL
第②步:X.next = A.next → X.next = X(自己指向自己!)
结果:A → X → X → X → ... 死循环!B、C 全部丢失 ❌
C++插入与删除操作
// 在节点 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;                // 释放内存
}
3静态链表(数组模拟链表)竞赛推荐

💡 为什么竞赛中推荐静态链表?

动态分配内存(new/malloc)可能较慢且容易出错。静态链表用数组模拟链表,预先分配好所有节点,用数组下标代替指针,速度更快、更安全!

C++静态单链表模板
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 的开销和内存泄漏
④ 数组访问速度快(缓存友好)

4双向链表(Doubly Linked List)进阶

🤔 单链表的痛点

单链表只能从前往后遍历。如果想找某个节点的"前驱",只能从头开始!
比如:要在某节点前面插入、要删除某节点(只知道该节点地址),单链表都很麻烦。

C++双向链表节点定义
struct DNode {
    int data;
    DNode* prev;   // 前驱指针
    DNode* next;   // 后继指针
    DNode(int v) : data(v), prev(nullptr), next(nullptr) {}
};
📊 双向链表结构可视化
NULL ←
←
3
→
⇄
←
7
→
⇄
←
1
→
⇄
←
9
→
⇄
←
5
→
→ NULL
每个节点有 prev + next 两个指针,可以双向遍历
⭐静态双向链表(竞赛首选写法)
C++静态双向链表模板
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 个值,非常简洁。
这是竞赛中处理链表问题的首选写法!

案例经典案例3:约瑟夫环问题 经典

📋 题意(洛谷 P1996):n 个人围成一圈(编号 1~n),从第 1 个人开始报数,数到 m 的人出列。然后从下一个人重新报数,直到所有人出列。输出出列顺序。

数据范围:1 ≤ m, n ≤ 100

🖐 手推过程(n=5, m=3):

1
建立循环链表
1 → 2 → 3 → 4 → 5 → 1(环形)
2
第1轮:从1开始报数
1(1号) → 2(2号) → 3(3号出列!)
链表:1→2→4→5→1,从4号继续
3
第2轮:从4开始
4(4号) → 5(5号) → 1(1号出列!)
链表:2→4→5→2,从2号继续
4
第3轮:从2开始
2(2号) → 4(4号) → 5(5号出列!)
链表:2→4→2,从2号继续
5
第4轮:从2开始
2(2号) → 4(4号) → 2(2号出列!)
只剩4号
6
最后一人
4号出列
出列顺序:3, 1, 5, 2, 4
C++约瑟夫环 — 循环链表实现
#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
5循环链表(Circular Linked List)拓展

将链表最后一个节点的 next 指向头节点,形成环状结构。

🔄 循环单链表
head
↓
3
→
→
7
→
→
1
→
→
9
→
→
5
↩
5.next → 3(尾节点指向头节点,形成环!)
⚠️

循环链表的遍历终止条件

❌ 错误
while (p != NULL)
循环链表没有 NULL,会死循环!
✅ 正确
do { ... p = p->next; } while (p != head);
用 head 判断是否回到起点。
6三种链表对比 总结
特性单链表双向链表循环链表
指针数/节点1(next)2(prev + next)1 或 2
遍历方向只能 →← 和 →循环不停
终止条件p == NULLp == NULLp == head
找前驱O(n)O(1)O(n) / O(1)
已知节点删除需找前驱直接删看类型
内存开销小中小/中
典型应用哈希链、简单队列STL list、LRU缓存约瑟夫环、轮转调度
🎩实用技巧:虚拟头节点(Dummy Head)

🤔 为什么需要虚拟头节点?

当我们要删除头节点或在头部插入时,需要特殊处理(修改 head 指针)。
引入一个虚拟头节点 dummy 放在真正的头节点前面,让所有节点都有"前驱",就能统一操作逻辑!

C++虚拟头节点:删除所有值为 val 的节点
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;
}
模块 03

实践应用

通过详细的手推追踪,巩固链表操作的理解。

题1单链表操作追踪

题目:依次执行以下操作,追踪链表状态变化:
① 尾插法依次插入 5, 3, 8, 1
② 在值为 3 的节点后面插入 7
③ 删除值为 8 的节点
④ 在头部插入 9

查看参考答案
1
尾插法插入 5, 3, 8, 1
head→[5]→[3]→[8]→[1]→NULL
2
在3后面插入7
先接:7.next = 3.next = [8]
后断:3.next = [7]
结果:head→[5]→[3]→[7]→[8]→[1]→NULL
3
删除值为8的节点
找到8的前驱(7号节点)
7.next = 8.next = [1]
结果:head→[5]→[3]→[7]→[1]→NULL
4
在头部插入9
9.next = head = [5]
head = [9]
结果:head→[9]→[5]→[3]→[7]→[1]→NULL
题2双向链表操作追踪

题目:用静态双向链表实现:
① 初始化空链表(虚拟头 0)
② 依次在 0 号右边插入 1、2、3 号节点
③ 在 2 号左边插入 4 号节点
④ 删除 1 号节点
追踪每步的 L[] 和 R[] 数组状态。

查看参考答案
1
初始化 + 插入1号
初始:L[0]=0, R[0]=0
insertRight(0, 1):R[1]=0, L[1]=0, L[0]=1, R[0]=1
链表:0 ⇄ 1 ⇄ 0
2
插入2号(在1右边)
insertRight(1, 2):R[2]=0, L[2]=1, L[0]=2, R[1]=2
链表:0 ⇄ 1 ⇄ 2 ⇄ 0
3
插入3号(在2右边)
insertRight(2, 3):R[3]=0, L[3]=2, L[0]=3, R[2]=3
链表:0 ⇄ 1 ⇄ 2 ⇄ 3 ⇄ 0
4
在2号左边插入4号
insertLeft(2, 4) = insertRight(L[2], 4) = insertRight(1, 4)
R[4]=2, L[4]=1, L[2]=4, R[1]=4
链表:0 ⇄ 1 ⇄ 4 ⇄ 2 ⇄ 3 ⇄ 0
5
删除1号节点
remove(1):R[L[1]] = R[0] = R[1] = 4; L[R[1]] = L[4] = L[1] = 0
链表:0 ⇄ 4 ⇄ 2 ⇄ 3 ⇄ 0
遍历输出:4 2 3
题3约瑟夫环详细追踪

题目:n=6, m=4 的约瑟夫环,用循环链表追踪每轮出列过程。

查看参考答案

初始:1→2→3→4→5→6→1(环形),cur=1, prev=6

轮次起点走3步到出列剩余链表
111→2→3→441→2→3→5→6→1
255→6→1→221→3→5→6→1
333→5→6→331→5→6→1
455→6→1→551→6→1
566→1→6→116→6(自环)
66—6空

出列顺序:4, 2, 3, 5, 1, 6

模块 04

总结提升

1核心要点总结
知识板块必须记住的核心
链表 vs 数组链表:离散存储,插入删除 O(1),访问 O(n)
单链表节点struct Node { int data; Node* next; }
先接后断原则插入时先让新节点指向后继,再改前驱的 next
静态链表用数组+下标模拟指针,nxt[] 存下标,0 号保留为 NULL
双向链表L[]+R[] 两个数组,插入改 4 个值,删除改 2 个值
循环链表尾→头形成环,终止条件用 p==head
虚拟头节点在头前加 dummy,统一插入/删除逻辑
约瑟夫环循环链表经典应用,维护 cur 和 prev 指针
2拓展:STL list 速查
C++std::list 常用操作
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 << " ";
3思考与启发

💡 学完本讲,你应该掌握的6项能力

1. 理解链表与数组的区别,知道何时该用链表。

2. 能手写单链表的增删查操作,掌握"先接后断"原则。

3. 能使用静态链表(数组模拟)解决竞赛问题。

4. 能实现双向链表的插入和删除操作。

5. 能用循环链表解决约瑟夫环等经典问题。

6. 了解虚拟头节点技巧,统一操作逻辑。

模块 05

学习检测

5 个链表操作中最常犯的错误。

易错1

插入时顺序搞反——"先断后接"导致丢链

❌ 错误
prev->next = newNode;
newNode->next = prev->next;
第二行 prev->next 已经是 newNode 了!
✅ 正确
newNode->next = prev->next;
prev->next = newNode;
先接后断!
口诀:新节点先接后继,前驱再指新节点!
易错2

遍历链表忘记判空

❌ 错误
while(p->next) { ... }
如果 p 本身是 NULL 就段错误!
✅ 正确
while(p) { ... p = p->next; }
先判 p 非空。
口诀:遍历先判指针空,再访数据不会崩!
易错3

循环链表用 NULL 判断终止

❌ 错误
while(p != NULL)
循环链表没有 NULL,死循环!
✅ 正确
do { ... p=p->next; } while(p!=head);
用 head 判断。
口诀:循环链表无 NULL,回到 head 才停步!
易错4

删除头节点时没更新 head

❌ 错误
delete head;
head 指针还指向已释放的内存!野指针!
✅ 正确
Node* t = head; head = head->next; delete t;
先更新 head 再删。
口诀:删头先存旧头,更新 head 再 delete!
易错5

静态链表 cnt 从 0 开始

❌ 错误
cnt=0 开始,nxt[0] 作为节点 → 与"0 表示 NULL"冲突!
✅ 正确
cnt 从 1 开始,0 保留作为 NULL。数组大小要足够大(MAXN)!
口诀:零号留给 NULL,节点编号从 1 起!
速查链表操作速查表
操作核心代码复杂度
静态单链表·头插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)
静态双向链表·删iR[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组 · 基础(4题)

A1选择题

下列关于链表的说法,正确的是()

A. 链表支持 O(1) 随机访问
B. 单链表可以从任意节点向前遍历
C. 在已知前驱节点的情况下,单链表插入操作的时间复杂度为 O(1)
D. 链表的节点必须在内存中连续存放

查看参考答案

答案:C

A 错(链表 O(n) 访问);B 错(单链表只能向后);D 错(链表离散存储)。

考点:链表靠指针把结点串起来,想取第 k 个元素必须从头沿 next 逐个走,因此随机访问是 O(n);单链表只有一个 next 指针,只能单向遍历。

易错:误以为链表也能像数组一样用下标 O(1) 直接取到任意元素。

A2填空题

在单链表中,要在节点 P 之后插入新节点 S,操作顺序为:

第一步:___________
第二步:___________
如果顺序颠倒,会导致___________。

查看参考答案
C++完整参考代码
#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 原来的后继就再也找不到了,后面一整条链表直接「丢链」丢失。

A3编程题

单链表反转:输入 n 个整数建立单链表,将其反转后输出。

例:输入 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。

A4编程题

合并两个有序链表:输入两个递增有序的单链表,合并为一个新的有序单链表。

例: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 后面,合并结果就会丢掉一大截。

🟡 B组 · 提高(4题)

B1编程题

队列安排(洛谷 P1160)

N 个同学排队,1 号先入队。2~N 号依次入列,指定在某同学的左边或右边。然后去掉 M 个同学,输出最终队列。
数据范围:N ≤ 100000

查看参考答案
C++完整参考代码
#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;③ 同一个人可能被删两次,第二次必须忽略,否则链表会被改乱。

B2编程题

约瑟夫问题(洛谷 P1996)

n 人围成一圈,从 1 开始报数,数到 m 的出列。输出出列顺序。
数据范围:1 ≤ m, n ≤ 100

查看参考答案
C++完整参考代码
#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,否则找不到下一个人;③ 若只报数不真正删结点,循环会永远绕下去。

B3编程题

机器翻译(洛谷 P1540)

内存容量为 M,依次处理 N 个单词。若单词在内存中则直接翻译,否则加入内存末尾。若内存已满,删除最早加入的单词。求查词典次数。

查看参考答案
C++完整参考代码
#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 数组要按单词编号范围开够。

B4分析题

给定单链表 head→1→2→3→4→5→NULL,分析以下代码执行后链表的状态:

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。

🔴 C组 · 挑战(4题)

C1编程题

链表排序:对无序单链表进行 O(n log n) 排序。

例:输入 4→2→1→3,输出 1→2→3→4

查看参考答案
C++完整参考代码
#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)可以省掉一堆头结点的特判。

C2编程题

检测链表是否有环:给定链表,判断是否存在环。如果存在,找到环的入口节点。

查看参考答案
C++完整参考代码
#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 会踩空指针崩溃;② 相遇点不是入口!要再走一轮才能找到入口。

C3设计题

LRU 缓存机制:设计容量为 capacity 的 LRU 缓存,支持 get(key) 和 put(key,value),均 O(1)。

查看参考答案

哈希表 + 双向链表!哈希表 O(1) 查找,双向链表 O(1) 移动/删除。经典面试题。

思路:get 要 O(1) 查找,用哈希表存 key → 结点指针;把访问过的结点移到“最近使用”位置又需要 O(1) 删除和插入,用双向链表,表头最新、表尾最旧,超容量就删表尾。

易错:用单链表会取不到前驱、删除做不到 O(1);还容易漏掉 get 命中后同样要把该结点移到链表头部。

C4编程题

K 个一组翻转链表:每 k 个节点一组进行翻转。

例:1→2→3→4→5, k=2 → 2→1→4→3→5
例:1→2→3→4→5, k=3 → 3→2→1→4→5

查看参考答案
C++完整参考代码
#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题)

以下 20 道洛谷原题涵盖链表的各种应用场景,按难度从入门到提高排列。

1P1996约瑟夫问题 —— 循环链表经典,n 人报数出列入门
2P1540[NOIP2010] 机器翻译 —— 队列模拟,内存淘汰策略入门
3P1627[CQOI2009] 中位数 —— 链表维护有序序列入门
4P1980计数问题 —— 链表/数组统计数字出现次数入门
5P1449后缀表达式 —— 栈+链表处理表达式入门
6P1059[NOIP2006] 明明的随机数 —— 去重排序,链表维护有序表入门
7P1422小玉家的电费 —— 分段计算,链表思维训练入门
8P1160队列安排 —— 双向链表经典!左右插入+删除普及
9P1012[NOIP1998] 拼数 —— 链表/数组排序+字符串比较普及
10P1090合并果子 —— 优先队列/链表贪心普及
11P1094纪念品分组 —— 双指针+链表思维普及
12P1068分数线划定 —— 排序+链表截取普及
13P1177排序 —— 链表归并排序实现普及
14P1271选举学生会 —— 链表/数组计数排序普及
15P1739表达式括号匹配 —— 栈+链表处理字符串提高
16P1044[NOIP2003] 栈 —— 卡特兰数,链表模拟出栈序列提高
17P1010[NOIP1998] 幂次方 —— 递归+链表构建表达式提高
18P1308统计单词数 —— 链表遍历+字符串处理提高
19P1595信封问题 —— 错排问题,链表模拟递推提高
20P1251餐巾计划问题 —— 链表建模,网络流前置提高

💡 刷题建议

• 先做A组确保基础概念清晰,能手写链表增删

• 再做B组的 P1160 和 P1996,这两道是链表必刷题

• 最后挑战C组的 LRU 和环检测,理解链表在工程中的应用

• 洛谷原题从入门开始刷,P1160 和 P1996 务必独立完成!

推荐顺序:P1996 → P1540 → P1160 → P1090 → P1177 → C组挑战题。