把 Sedgewick《算法》第四版(Algorithms_4th)里第1章的链表练习题用 C++ 重写一遍,是我认为数据结构和算法入门阶段性价比最高的一件事。链表在 C++ 里需要亲手管理指针和内存,这跟 Java、Python 帮你把链表封装好是完全两种体验;而恰恰是这种“手动”,能让你把节点、指针、引用、边界条件这些最基础的概念彻底想通透。这篇文章不是简单贴答案,而是把我在刷这批链表练习题时踩过的坑、总结过的方法、以及最终沉淀下来的一套 C++ 实现模板,完整地分享出来。适合正在啃《算法4》的读者,也适合刚学完 C++ 语法、想用链表题巩固基本功的朋友。
1. 为什么《算法4》的链表题值得用 C++ 亲手刷一遍
1.1 链表在《算法4》里的分量:不只是栈和队列的铺垫
《算法4》第1.3节把背包、队列、栈放在一起讲,其中链表是实现这些抽象数据类型的基础。书里的示例代码用的是 Java,定义了一个内部类 Node,靠对象引用把节点串起来。很多读者把书上的思路看懂后,就直接用 STL 的 list 或者 vector 去刷题,导致“手感”一直练不起来。
实际上,链表练习的真正价值不在于“会做某道题”,而在于让你建立一种指针操作的直觉:指针本身只是一个变量,它存的是地址;p = p->next 不是“移动到下一个节点”这种抽象描述,而是“把 p 这个变量里保存的地址,替换为下一个节点的地址”。C++ 里的裸指针让这个行为完全暴露出来,你能真正看见数据在内存里是怎么被串起来的。
另一个容易被忽略的点是:链表题是所有指针类问题的基础。后面的二叉树、图、并查集、LRU 缓存,本质上都是“不同结构的链表操作”。把链表题练透,后面的递归、迭代、快慢指针、虚拟头节点这些套路,都是水到渠成的事。
1.2 C++ 刷题前的最小环境准备:VS Code + g++ 调试闭环
有人可能会说:“刷个链表题,用在线编译器不就行了?”我的建议是,如果只跑通,在线编译器勉强够;但如果想搞清楚 p->next 到底指向哪、删除节点后旧内存去哪了,最好还是用本地调试器。我目前用的是 VS Code + MinGW-w64 的 g++ 编译器,配合断点观察指针变量的地址变化,对理解链表帮助极大。
配置步骤不复杂:
- 安装 VS Code,装 C/C++ 扩展(C++ 插件和 clangd 二选一即可,我习惯用微软官方 C/C++ 扩展)。
- 安装 MinGW-w64,把
g++所在目录加入系统 PATH。 - 在项目根目录创建
.vscode/tasks.json,里面写好编译任务。下面是我常用的最小配置:
json复制{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"type": "cppbuild",
"command": "g++",
"args": [
"-g",
"-std=c++17",
"${file}",
"-o",
"${fileDirname}/${fileBasenameNoExtension}.exe"
],
"group": "build"
}
]
}
- 再建一个
.vscode/launch.json,让 F5 能够直接启动调试:
json复制{
"version": "0.2.0",
"configurations": [
{
"name": "debug",
"type": "cppdbg",
"request": "launch",
"program": "${fileDirname}/${fileBasenameNoExtension}.exe",
"args": [],
"stopAtEntry": false,
"cwd": "${fileDirname}",
"environment": [],
"externalConsole": false,
"MIMode": "gdb",
"miDebuggerPath": "gdb"
}
]
}
几个提醒:
- 编译参数里一定要带
-g,否则断点不好使。 - 调试链表时,在“监视”窗口里输入
p->next、p->val,每走一步都能看到指针的变化,比看打印日志直观得多。 externalConsole设成false的话,程序输出会显示在 VS Code 内置终端里,调试起来更顺手。
1.3 链表节点定义:结构体、构造函数与语法细节
《算法4》里的 Node 定义很简单:
java复制private class Node {
Item item;
Node next;
}
C++ 版本最常见的写法是用结构体:
cpp复制struct ListNode {
int val;
ListNode* next;
// 构造函数方便创建节点
ListNode(int x) : val(x), next(nullptr) {}
};
这里有几个初学者容易忽略的细节:
next必须初始化成nullptr。如果不初始化,它就是野指针,后面判断p->next == nullptr时会出问题。- 构造函数里用了初始化列表
: val(x), next(nullptr),而不是在函数体内赋值,这是 C++ 风格里更推荐的方式,尤其后面加上自定义内存管理时,初始化列表能避免不必要的构造开销。 - 在刷题场景里,
ListNode一般直接定义在全局或者头文件里;但如果做工程,通常还会把链表方法封装成类,节点定义放到私有区。
有同学问过一个问题:“为什么不用 class 而用 struct?”其实在 C++ 里两者几乎等价,区别只在默认访问权限:struct 默认是 public,class 默认是 private。链表节点这种需要直接访问 val 和 next 的结构,用 struct 写起来更省事。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 单链表核心操作详解:从遍历到插入删除
这部分是链表练习题的“基本功”,无论题目怎么变形,最后都绕不开这些操作。我会给出可以直接“抄作业”的完整 C++ 实现,然后重点解释每一步为什么这么写。
2.1 遍历打印:循环终止条件的正确写法
先写一个最基础的遍历函数:
cpp复制void printList(ListNode* head) {
ListNode* cur = head;
while (cur != nullptr) {
std::cout << cur->val;
if (cur->next != nullptr) {
std::cout << " -> ";
}
cur = cur->next;
}
std::cout << std::endl;
}
这里唯一要记住的规则是:不要在循环体内部提前把 cur 变成 nullptr 再去访问 cur->val。很多越界崩溃都出现在这里。
另外一个很容易搞混的点是“用 cur 还是用 head 作为工作指针”。我见过不少新手写循环时直接移动 head,等遍历完才发现原来的头节点找不到了。正确的做法是:永远不要移动头指针,除非你明确知道自己在做什么。用 cur 作为遍历游标,head 一直保留,这样后面的插入、删除、反转操作才不会丢失链表入口。
2.2 头插、尾插与指定位置插入
链表插入最常见的三种位置:头部、尾部、指定位置。先看头插:
cpp复制ListNode* insertAtHead(ListNode* head, int val) {
ListNode* newNode = new ListNode(val);
newNode->next = head;
return newNode; // 新节点成为新的头
}
注意这里的返回值:因为头节点变了,所以函数必须返回新的头指针。如果你不想要返回值,就得用二级指针 ListNode** head 来修改外部指针。刷题时我统一用返回值风格,代码更清晰,不容易出错。
尾插:
cpp复制ListNode* insertAtTail(ListNode* head, int val) {
ListNode* newNode = new ListNode(val);
if (head == nullptr) {
return newNode; // 空链表直接把新节点作为头
}
ListNode* cur = head;
while (cur->next != nullptr) {
cur = cur->next;
}
cur->next = newNode;
return head;
}
这个函数里的 while (cur->next != nullptr) 是尾插的核心技巧。为什么不是 while (cur != nullptr)?因为我们要找的是最后一个节点(它的 next 为空),而不是空节点本身。如果写成 while (cur != nullptr),循环结束后 cur 是空指针,我们没法通过 cur 把新节点接上去。
指定位置插入(假设 pos 从 0 开始,要在第 pos 个节点后面插入):
cpp复制ListNode* insertAfterPos(ListNode* head, int pos, int val) {
ListNode* dummy = new ListNode(0);
dummy->next = head;
ListNode* cur = dummy;
for (int i = 0; i < pos && cur != nullptr; ++i) {
cur = cur->next;
}
if (cur == nullptr) {
delete dummy;
return head; // 位置非法,直接返回原链表
}
ListNode* newNode = new ListNode(val);
newNode->next = cur->next;
cur->next = newNode;
ListNode* newHead = dummy->next;
delete dummy;
return newHead;
}
这里我用了虚拟头节点(dummy node),目的是统一“在头部插入”和“在中间插入”的逻辑。如果不加虚拟头节点,处理 pos == 0 的情况就要单独写一套分支,代码会丑很多。
2.3 按值删除、按位置删除与析构函数
按值删除第一个匹配的节点:
cpp复制ListNode* deleteByValue(ListNode* head, int val) {
ListNode* dummy = new ListNode(0);
dummy->next = head;
ListNode* cur = dummy;
while (cur->next != nullptr && cur->next->val != val) {
cur = cur->next;
}
if (cur->next != nullptr) {
ListNode* toDelete = cur->next;
cur->next = cur->next->next;
delete toDelete;
}
ListNode* newHead = dummy->next;
delete dummy;
return newHead;
}
这段代码的核心思想是“始终关注目标节点的前一个节点”。cur 停在目标节点的前驱上,这样删除时只需修改 cur->next 指向后一个节点,然后释放被删节点的内存。为什么不直接让 cur 指向目标节点?因为单链表只有向后指针,拿不到前驱,删除操作就会卡住。
按位置删除类似,这里不重复贴整段代码,只说关键点:先让 cur 走到目标位置的前一个节点,然后做同样的 cur->next = cur->next->next,别忘了 delete 被摘下的节点。
还有一点非常重要:手写链表必须自己负责释放内存。上面每个 new 出来的节点,在删除链表时都要逐个 delete。常见的做法是写一个析构函数:
cpp复制~ListNode() {
// 注意:不要在这里 delete next,否则会递归析构,容易爆栈
}
更好的选择是单独写一个 deleteList 函数,或者封装到 LinkedList 类里,在析构函数里统一清理。很多刷题模板不管内存泄漏,因为程序运行完会自动释放;但做工程时,内存泄漏是非常头疼的问题,建议从刷题阶段就养成手动释放的习惯。
2.4 辅助工具函数:长度、查找、反转输出
除了上述核心操作,练习题里经常要用到这些辅助函数:
- 求链表长度:用一个计数器边遍历边累加。
- 查找某个值是否存在:遍历时逐个比较。
- 反向打印:可以先反转链表再打印,或者用递归打印。
- 判断两个链表是否相等:同步遍历比较。
递归反向打印的写法很优雅,但也最容易让新手迷惑:
cpp复制void printInReverse(ListNode* head) {
if (head == nullptr) {
return;
}
printInReverse(head->next);
std::cout << head->val << " ";
}
这个函数的递归顺序是先压栈到最后一个节点,然后在回溯的过程中依次输出。链表长度为 n 时,递归深度也是 n。如果链表特别长,比如超过几万节点,递归可能爆栈;这时候更稳妥的做法是先反转链表再打印,或者用循环加栈模拟。
3. 《算法4》练习题的常见题型与 C++ 解法
《算法4》第1.3节的练习题里,有一批经典问题反复出现,比如反转链表、找中间节点、删除重复元素、判断是否有环等。下面这几类是出现频率最高的,也是我认为最有代表性的。
3.1 反转链表:迭代反转与递归反转的对比
反转链表是链表题里的“Hello World”,至少能用三种方式写出来:迭代、递归、头插法。
迭代版:
cpp复制ListNode* reverseList(ListNode* head) {
ListNode* prev = nullptr;
ListNode* cur = head;
while (cur != nullptr) {
ListNode* next = cur->next; // 先保存下一个节点,否则修改 next 后丢失
cur->next = prev;
prev = cur;
cur = next;
}
return prev;
}
这里最难理解的一行是 ListNode* next = cur->next;。为什么必须先保存?因为一旦执行 cur->next = prev,原来的 next 就找不到了。链表是单向的,没有回头路,所以任何“断链”之前都要先保存后续入口。
递归版更短,但理解成本高一点:
cpp复制ListNode* reverseListRecursive(ListNode* head) {
if (head == nullptr || head->next == nullptr) {
return head;
}
ListNode* newHead = reverseListRecursive(head->next);
head->next->next = head; // 让后面的节点指向当前节点
head->next = nullptr; // 当前节点的 next 置空
return newHead;
}
递归的关键在于:reverseListRecursive(head->next) 返回的是反转后的新头节点,同时这个操作完成后,head->next 经过递归内部的改写,已经变成了原链表的最后一个节点。此时让 head->next->next = head,就把当前节点接上去了。
平时练习我建议两种都写熟练:迭代版用来打基础,递归版用来锻炼递归反推能力。面试时两种都能快速写出来的人,通常说明对链表的理解更扎实。
3.2 快慢指针:找中间节点、倒数第 k 个、环检测
快慢指针的思路在链表题里非常好用,核心是:两个指针同时从头出发,慢指针每次走一步,快指针每次走两步;两者速度差形成“距离差”,可以定位中间节点、找倒数位置、检测环。
找中间节点:
cpp复制ListNode* findMiddle(ListNode* head) {
ListNode* slow = head;
ListNode* fast = head;
while (fast != nullptr && fast->next != nullptr) {
slow = slow->next;
fast = fast->next->next;
}
return slow;
}
当 fast 走到末尾时,slow 正好停在中间。奇数个节点时返回正中间;偶数个节点时返回后半部分的第一个节点。这个行为在不同题目里要求不一样,有的题希望返回前半部分的最后一个节点,可以调整循环条件来处理。
找倒数第 k 个节点:
cpp复制ListNode* findKthFromEnd(ListNode* head, int k) {
ListNode* fast = head;
for (int i = 0; i < k; ++i) {
if (fast == nullptr) {
return nullptr; // k 超过链表长度
}
fast = fast->next;
}
ListNode* slow = head;
while (fast != nullptr) {
slow = slow->next;
fast = fast->next;
}
return slow;
}
这里让 fast 先走 k 步,然后 slow 和 fast 同步前进。当 fast 走到末尾时,slow 所在位置恰好是倒数第 k 个。这个思路的精髓在于:slow 和 fast 之间的距离始终是 k 步,不需要知道链表长度。
环检测(Floyd 判圈算法):
cpp复制bool hasCycle(ListNode* head) {
ListNode* slow = head;
ListNode* fast = head;
while (fast != nullptr && fast->next != nullptr) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) {
return true;
}
}
return false;
}
如果链表里有环,快慢指针最终会在环内相遇;如果没有环,fast 会先走到 nullptr。这个算法的正确性证明在《算法4》里也有提到,但最终要靠手推几个带环例子才能真正信服。我建议把 hasCycle 的每一步用调试器看一遍,尤其观察 slow 和 fast 进入环之后是怎么套圈的。
3.3 合并两个有序链表:递归与迭代
合并两个有序链表在《算法4》里虽然不如反转那么经典,但它在后续归并排序、外部排序里都会遇到。这里给一个递归写法,因为它特别简洁:
cpp复制ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
if (l1 == nullptr) {
return l2;
}
if (l2 == nullptr) {
return l1;
}
if (l1->val < l2->val) {
l1->next = mergeTwoLists(l1->next, l2);
return l1;
} else {
l2->next = mergeTwoLists(l1, l2->next);
return l2;
}
}
递归写法天然地选择较小的头节点,然后递归合并剩余部分。这里的关键是 l1->next = mergeTwoLists(l1->next, l2) 这句话:当前节点已经确定是谁了,后面紧接着的节点由递归结果决定。
迭代写法也不难,思路是用一个 dummy 虚拟头节点承接结果,然后逐次把较小节点接到后面。两种写法都建议熟练掌握,递归版面试时写起来快,迭代版工程上更稳(不担心递归深度)。
3.4 删除重复节点与链表重排:练习题的经典变体
删除有序链表中的重复元素是另一个高频题,有两种要求:保留一个重复项,或者全部删除。保留一个版本很简单:
cpp复制ListNode* deleteDuplicatesKeepOne(ListNode* head) {
ListNode* cur = head;
while (cur != nullptr && cur->next != nullptr) {
if (cur->val == cur->next->val) {
ListNode* tmp = cur->next;
cur->next = cur->next->next;
delete tmp;
} else {
cur = cur->next;
}
}
return head;
}
注意这里只有在不相等时才移动 cur,因为删除一个重复节点后,cur->next 可能还是同样的值,不能直接跳过。
全部删除重复节点的版本要更挑剔一些:《算法4》练习题里也有类似“重排链表”的题目,比如将链表按照“第一个节点、最后一个节点、第二个节点、倒数第二个节点……”的顺序重排。这种题往往是快慢指针找中间 + 反转后半段 + 交替合并的组合拳,能完整做出来,基本说明单链表操作已经过关了。
4. 一道综合练习题完整拆解:约瑟夫环的循环链表实现
单纯罗列操作容易让人“会了,但不知道怎么用”。所以这次我选一道综合题完整走一遍:约瑟夫环。它既能练到循环链表的构建、遍历、删除,也能练到边界条件处理,还覆盖了多个热搜词里提到的“链表插入、链表删除、循环链表”场景。
4.1 题目背景与算法思路
约瑟夫环问题是这样描述的:有 n 个人围成一圈,按顺序编号为 1 到 n;从第 1 个人开始报数,报到 m 的人出局,然后从下一个人重新从 1 报数,求最后剩下的人的编号,或者按出局顺序输出编号。
经典的解法有很多,公式法、队列模拟法都能做。但如果要练链表,这道题最适合用循环链表,因为“围成一圈”这个语义和循环链表的结构天然对应。
算法流程:
- 创建节点 1 到 n,让最后一个节点的
next指向头节点,形成循环链表。 - 用一个
cur指针从头节点开始。 - 报数到 m 时,让
cur停在被删节点的前一个节点(因为单链表删除需要前驱)。 - 删除该节点,打印出局编号。
- 继续循环,直到链表只剩一个节点。
4.2 C++ 实现:循环链表构建、删除与计数
cpp复制#include <iostream>
struct Node {
int val;
Node* next;
Node(int x) : val(x), next(nullptr) {}
};
// n 个人,每报数到 m 出局,输出出局顺序
void josephus(int n, int m) {
if (n <= 0 || m <= 0) {
return;
}
// 1. 构建循环链表
Node* head = new Node(1);
Node* cur = head;
for (int i = 2; i <= n; ++i) {
cur->next = new Node(i);
cur = cur->next;
}
cur->next = head; // 首尾相连
// 2. 删除过程:cur 指向当前报数节点的前一个节点
cur = head;
// 先让 cur 走到 head 的前一个节点,即最后一个节点
while (cur->next != head) {
cur = cur->next;
}
while (cur->next != cur) { // 当链表不止一个节点时继续
// 报数 m-1 次,让 cur 移动到被删节点的前驱
for (int i = 1; i < m; ++i) {
cur = cur->next;
}
// 此时 cur->next 是要删除的节点
Node* toDelete = cur->next;
std::cout << toDelete->val << " ";
cur->next = toDelete->next;
delete toDelete;
}
// 最后剩下的节点
std::cout << "\n最后剩下: " << cur->val << std::endl;
// 清理最后一个节点
delete cur;
}
int main() {
josephus(7, 3);
return 0;
}
我故意把这段代码写得更“工程化”一些:构建链表、遍历计数器、删除节点、释放内存,每一步都有对应函数或注释,方便你对照理解。在这个实现里,cur 始终维护的是“当前报数节点的前驱”。为什么不用 cur 直接指向当前节点?因为删除时需要前驱,单链表无法反向找前一个节点,所以只能让 cur 停在前一个位置。
4.3 测试样例与边界验证
跑一下 josephus(7, 3),输出顺序应该是 3 6 2 7 5 1,最后剩下 4。你可以手动推一遍验证:
- 初始:1 2 3 4 5 6 7
- 数到 3,3 出局
- 4 5 6 7 1 2,数到 3,6 出局
- 7 1 2 4 5,数到 3,2 出局
- 4 5 7 1,数到 3,7 出局
- 1 4 5,数到 3,5 出局
- 1 4,数到 3,1 出局
- 剩下 4
还要测几个边界:
n = 1时,直接输出1,循环链表只有一个节点,cur->next == cur成立,不进循环,最后删除这个节点。m = 1时,每次被删的都是cur->next,也就是当前节点的下一个。此时遍历顺序是 1 2 3 4 ...,需要验证cur始终在正确的前驱位置。m大于n也没关系,因为报数是循环的,取模效果由for循环自动实现。
有些同学会把 m 对链表长度取模来优化计数,这在数据量很大时确实有帮助,但取模也要注意 m % len == 0 的情况,不然会多走一轮。我的建议是:刷题阶段先用朴素计数跑通,优化的事情后面再说。
5. 链表题最常见的 C++ 坑与经验总结
这节更像是避坑清单,都是我实际写过、跑崩过、调试过的经验。网上很多教程只讲题解不讲坑,但恰恰是这些坑,才是面试和工程里最要命的地方。
5.1 内存管理:内存泄漏、悬垂指针与递归栈溢出
C++ 手写链表的三大杀手:
- 内存泄漏:每个
new出来的节点最后都要delete。刷题时程序退出自动回收,所以很多人没感觉;但大型程序里,一次循环任务创建几十万个节点而不释放,内存很快就会被吃光。 - 悬垂指针:
delete一个节点后,如果有其他指针还指向它,那个指针就变成了悬垂指针。比如删除节点后没有及时把外部引用置空,下一次访问就会触发未定义行为,表现可能是随机崩溃,也可能是数据莫名其妙被篡改。 - 递归栈溢出:递归遍历或递归反转链表时,如果链表非常长,递归深度等于链表长度,很可能超出调用栈大小。现代编译器默认栈也就 8MB 左右,几万次的递归调用就可能顶满。
针对这三点,我的建议:
- 给链表写一个统一的清理函数,比如:
cpp复制void deleteList(ListNode* head) {
while (head != nullptr) {
ListNode* tmp = head;
head = head->next;
delete tmp;
}
}
- 删除节点时,遵循“先摘链,再释放,最后把被删节点的 next 置空”的顺序,避免误用悬垂指针。
- 大规模数据的场景下,优先用迭代而不是递归实现反转、合并等操作。
5.2 边界条件:空链表、单节点、头节点变化
链表题里一半以上的 bug 都出在边界条件上。做题前先在脑袋里过这几条:
- 传入的
head是nullptr吗? - 链表只有一个节点时,函数会正确返回吗?
- 打算删除头节点时,返回值能正确更新吗?
- 快慢指针循环里的
fast != nullptr && fast->next != nullptr是否考虑了偶数长度?
教大家一个小技巧:所有会改变头节点的操作,都用虚拟头节点(dummy node)统一处理。比如删除头节点和删除中间节点,如果加了 dummy,逻辑完全一致,不需要额外分支。这个技巧在反转链表、合并链表、删除重复节点等题目里都能用。
5.3 手写链表和 STL list 该怎么选
很多初学者会问:“既然 C++ 有 std::list,为什么还要手写链表?”答案是:为了理解,不是为了使用。刷《算法4》阶段,手写链表能帮你建立底层数据结构和内存模型的心智;但进入实际开发后,能用 STL 容器就用 STL 容器,它已经经过充分测试,性能也不差。
不过有个例外:当你需要自定结构,比如循环链表、带随机指针的链表、需要同时维护头和尾的山脊链表时,STL 容器往往不能直接满足需求,这时候手写链表的经验就派上用场了。
另外,std::list 的迭代器在插入删除后仍然有效,这是它比 std::vector 更适合频繁插入删除场景的原因。但实际工程里,如果数据结构不复杂,std::vector 反而因为缓存友好而性能更好。所以“链表比数组快”这个印象要打破,它只有在特定场景下才成立。
最后聊一点我个人使用 C++ 刷链表题的小习惯:我通常把 ListNode 结构体单独放在一个头文件里,题解代码只用 include,这样每个练习题文件只写核心逻辑,不重复贴结构体定义。调试时把“监视”窗口固定到 head、cur、prev 这几个变量上,观察它们的地址变化,比看任何文字解释都有效。链表这东西,光看不能真正掌握,亲手写、亲手调、亲手踩坑之后,才会真正变成你自己的能力。
