一、项目概述
1.1 核心需求解析
我最近在做 Sedgewick 那本《算法(第4版)》的 C++ 刷书计划。这本书虽然是经典教材,但有个非常现实的问题:书里的示例代码和课后练习几乎全部基于 Java 实现,用 C++ 重新实现一遍会遇到大量超出“算法本身”的坑——指针语法、内存管理、STL 容器的选择、const 正确性等等。
链表这一章的练习题尤其值得认真做。原因很简单:链表是所有动态数据结构的敲门砖,树、图、哈希表的拉链法、内存池的空闲块管理,底层全是链表那套指针操作思维。而这本书在链表练习上设计得非常巧,从基础的遍历打印到递归逆序,再到约瑟夫环问题,难度梯度很合理。把它啃下来,后面看二叉树和图的 C++ 实现会轻松不少。
我花了两周时间把第 1.3 章链表相关的习题全部过了一遍,整理出这篇详解。这篇文章适合三类人:第一类是正在用 C++ 刷《算法(第4版)》但被指针和内存管理卡住的读者;第二类是学完链表基础但想找高质量练习题巩固的人;第三类是准备面试前想系统梳理链表常见操作的人。
1.2 为什么单独拎出链表练习
这本书的练习题和普通教材最大的区别在于“抽象层级”不同。普通教材会要求你“实现单链表的增删改查”,而这本书的很多习题要求你在“迭代器”或者“抽象数据类型”的约束下完成操作。举个例子,习题 1.3.26 要求删除链表中所有值为 key 的节点,看起来很简单,但如果你用的是这本书里定义的 Queue 内部链表结构,就必须考虑头节点被删、空链表、连续重复 key 这三种边界情况。这种从“实现功能”到“覆盖边界”的思维转变,才是刷这本书的真正价值。
我用 C++ 重写时还多了一层挑战:这本书的 Exercise 通常给出的是 API 签名(比如 delete(int k)),我需要自己决定是用裸指针还是智能指针,是递归还是迭代,是传入 Node*& 还是 Node**。这些决策本身就是很好的 C++ 练习。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
二、链表练习题的整体思路拆解
2.1 这本书链表题的出题规律
把第 1.3 章的全部链表练习过完之后,我发现题目其实可以分成四类。
第一类是基础操作类,包括在链表末尾添加节点、删除指定位置节点、查找节点是否存在、遍历打印等。这类题的目的是让你熟悉指针操作的基本功,尤其是头节点和尾节点的边界处理。
第二类是递归思维类,比如递归逆序打印、递归求链表最大值、递归删除所有值为 key 的节点。这类题在 Java 版里很容易做,因为 Java 的引用传递天然适合递归;但 C++ 里你要自己处理指针的传递方式,是用 Node*(值传递指针)还是 Node*&(指针引用),结果完全不同。
第三类是工程实现类,包括实现 Iterable 接口、实现泛型、实现复制构造和析构函数。这类题 Java 版很容易,但 C++ 版会直接上升到内存管理层面。比如实现链表的复制构造函数时,Java 的引用计数机制帮你自动管理内存,C++ 就必须手动 new 每一个节点,还要保证异常安全。
第四类是经典算法类,包括约瑟夫环问题(1.3.37)、链表是否有环、寻找中间节点。这类题虽然少,但每一道都值得反复做,因为它们是后续很多高级算法的基础。
2.2 C++ 实现与 Java 实现的本质差异
做 C++ 版练习时,我最大的体会是:Java 让你关注“用什么数据结构和算法”,C++ 让你同时还要关注“内存怎么布局、资源怎么释放”。
拿最基础的 push_front 操作举例。Java 版本是:
java复制public void pushFront(String item) {
Node oldFirst = first;
first = new Node();
first.item = item;
first.next = oldFirst;
}
看起来简单,但 C++ 版本里你要写:
cpp复制void pushFront(const std::string& item) {
Node* oldFirst = first;
first = new Node(item, oldFirst);
// 如果没有析构函数,这里就会内存泄漏
}
光一个 new 就引出了三个问题:什么时候 delete?拷贝的时候怎么办?异常抛出时内存还安全吗?很多读者做 C++ 版练习做到一半就放弃,不是因为算法难,而是被这些“算法之外”的问题劝退了。
所以我在整理这篇博文时,每条练习都会刻意标注“内存管理提醒”,这是别的刷题笔记很少涉及的维度。
三、链表核心知识储备
3.1 先定义一套可复用的节点结构体
刷题前建议先做好基础设施。我定义了一套基础代码,所有练习题都复用它,避免每次重复写。
cpp复制template <typename T>
struct Node {
T item;
Node* next;
Node() : next(nullptr) {}
Node(const T& data, Node* n = nullptr) : item(data), next(n) {}
};
这里有两个要点。第一,next 一定要初始化为 nullptr,否则 new Node() 之后 next 指向的是未定义内存,后面遍历时可能访问野指针。第二,构造函数用初始化列表而不是在函数体内赋值,因为初始化列表直接构造成员,性能更好。
有的同学喜欢把节点嵌套在链表类内部,比如 class LinkedList { private: struct Node {...}; Node* head; }。这两种写法都可以,但刷题时建议把 Node 单独定义为结构体,因为很多题目会直接操作节点级别的指针,单独定义写起来更顺手。
3.2 NULL 和 nullptr 的坑
C++ 新手最容易踩的坑是 NULL 和 nullptr 混用。在 C++11 之前,NULL 是整数 0 的宏定义,它在重载场景下可能造成严重的类型推导问题。
cpp复制void func(int x);
void func(Node* p);
func(NULL); // 到底调哪个?NULL 是整数 0,所以调 func(int)
nullptr 是 C++11 引入的专用空指针类型,能被正确转换为任何指针类型,但不会转换成整数。这本书虽然是 2011 年出版的,但里面大量示例代码用的是 Java 的 null 概念,C++ 移植时请始终使用 nullptr。
3.3 头节点的设计哲学
做链表练习时,一个绕不开的问题就是“要不要用虚拟头节点(dummy header)”。
我的实践结论是:刷题时推荐使用虚拟头节点,工程实现时根据场景决定。
虚拟头节点的好处非常明确——它消除了“删除的是头节点”这个最麻烦的边界分支。比如删除首个等于 key 的节点,如果没有虚拟头节点,你要这么写:
cpp复制// 先处理头节点
if (head && head->item == key) {
Node* toDelete = head;
head = head->next;
delete toDelete;
return;
}
// 再处理中间节点
Node* prev = head;
while (prev->next) {
if (prev->next->item == key) {
Node* toDelete = prev->next;
prev->next = toDelete->next;
delete toDelete;
return;
}
prev = prev->next;
}
有了虚拟头节点就统一了:
cpp复制Node dummy(0, head);
Node* prev = &dummy;
while (prev->next && prev->next->item != key) {
prev = prev->next;
}
if (prev->next) {
Node* toDelete = prev->next;
prev->next = toDelete->next;
delete toDelete;
head = dummy.next; // 更新真正的头指针
}
两种写法在边界情况下的心智负担完全不是一个级别。虚拟头节点让所有节点的处理逻辑完全一致,这也是 LeetCode 上删除节点题的标准解法。
3.4 指针的传递方式:Node* 还是 Node*&
这是 C++ 做链表练习最核心的知识点,没有之一。
当你要修改链表头指针本身(比如在头部插入节点、删除头节点)时,函数参数必须使用指针的引用(Node*&)或者指针的指针(Node**)。否则你在函数内部修改 head 指针,不会影响到调用方。
cpp复制// 错误示范:传值传递,函数内部改了 head,调用方不知道
void pushFront(Node* head, int val) {
Node* newNode = new Node(val);
newNode->next = head;
head = newNode; // 没有实际修改调用方的 head
}
// 正确示范:传指针的引用
void pushFront(Node*& head, int val) {
Node* newNode = new Node(val);
newNode->next = head;
head = newNode;
}
// 等价写法:传指针的指针
void pushFront(Node** head, int val) {
Node* newNode = new Node(val);
newNode->next = *head;
*head = newNode;
}
如果是修改节点的 next 指针(比如删除中间节点、插入到中间位置),只需要传 Node* 就够了,因为此时你操作的是“指针指向的那个节点”。这个区别我反复在练习中体会到,写错了编译不会报错,但运行结果就是不对,而且极难排查。
四、典型练习题详解
4.1 从易到难刷题路线
我推荐按照这个顺序刷,从熟悉指针操作逐步过渡到递归和内存管理:
| 题号 | 题目(简写) | 难度 | 考察点 |
|---|---|---|---|
| 1.3.19 | 删除链表最后一个节点 | 入门 | 遍历找尾节点 |
| 1.3.20 | 删除第 k 个节点 | 入门 | 边界检查 |
| 1.3.21 | 查找 key 是否存在 | 入门 | 遍历+短路 |
| 1.3.24 | 删除给定节点的后继 | 简单 | 空指针检查 |
| 1.3.25 | 在给定节点后插入 | 简单 | 指针重连顺序 |
| 1.3.26 | 删除所有值为 key 的节点 | 中等 | 虚拟头+内存释放 |
| 1.3.27 | 递归求最大值 | 中等 | 递归+泛型 |
| 1.3.28 | 递归求最大值(返回节点) | 中等 | 递归返回值 |
| 1.3.30 | 链表逆序(迭代) | 中等 | 三指针法 |
| 1.3.30 | 链表逆序(递归) | 较难 | 递归思想 |
| 1.3.37 | 约瑟夫环问题 | 较难 | 循环链表 |
| 1.3.40 | 前移编码(自组织查找) | 中等 | 移动节点到头部 |
下面挑几道最有代表性的详细拆解。
4.2 删除最后一个节点(1.3.19)
这道题是链表操作的入门经典,核心难点在于“维护倒数第二个节点的指针”。
cpp复制template <typename T>
void removeLast(Node<T>*& head) {
if (!head) return; // 空链表
if (!head->next) { // 只有一个节点
delete head;
head = nullptr;
return;
}
Node<T>* prev = head;
while (prev->next->next) { // 停在倒数第二个节点
prev = prev->next;
}
delete prev->next;
prev->next = nullptr;
}
这里的核心思就是 prev->next->next 为 true 表示 prev->next 不是最后一个节点,继续后移;退出循环时 prev 指向倒数第二个节点。很多同学会写成 while (cur->next) 然后找尾巴,但那样你只知道尾巴在哪,不知道它的前驱是谁,没办法安全删除。
我还想强调头节点更新的问题:如果链表只有 1 个节点,删除后头指针必须置空,否则就变成悬垂指针了。这里用了 Node<T>*& 传参,就是为了让 head = nullptr 真正生效到调用方变量上。
4.3 删除所有值为 key 的节点(1.3.26)
这道题比 4.2 难一个量级,因为“所有”意味着你要在遍历过程中处理“连续重复”和“头节点被删”两个问题。
cpp复制template <typename T>
void removeAll(Node<T>*& head, const T& key) {
Node<T> dummy; // 栈上的虚拟头节点,不需要 delete
dummy.next = head;
Node<T>* prev = &dummy;
Node<T>* cur = head;
while (cur) {
if (cur->item == key) {
Node<T>* toDelete = cur;
prev->next = cur->next;
cur = cur->next; // prev 不动,因为新 cur 可能还是要删的
delete toDelete;
} else {
prev = cur;
cur = cur->next;
}
}
head = dummy.next; // 头节点可能变了,回写
}
这个写法的精妙之处在于用栈上的 dummy 对象统一了“第一个节点”和“中间节点”的逻辑。注意虚拟头节点是栈上对象,用 Node<T> dummy; 而不是 new Node<T>(),这样你不必(也不能)delete 它,它出了作用域自动销毁。
一个小细节:在删除连续重复节点时,prev 不能跟着 cur 移动,而是留在原地。因为当前 prev->next 指向的是删完后新接上来的节点,这个新节点可能仍然等于 key,需要继续判断。很多同学的 bug 就是出在这里。
4.4 链表逆序(1.3.30)——迭代版
这道题在 LeetCode 上是 206 题,在本书中也是一道经典。我先给出迭代的三个指针法,这是必须烂熟于心的解法。
cpp复制template <typename T>
void reverse(Node<T>*& head) {
Node<T>* prev = nullptr;
Node<T>* cur = head;
while (cur) {
Node<T>* next = cur->next; // 先保存后继,否则断链后找不到了
cur->next = prev; // 反转当前节点的 next
prev = cur; // 更新 prev
cur = next; // 前进到原后继
}
head = prev; // prev 最终指向原链表末尾,即新链表头
}
关键点在五个字:先存后继再反转。如果写成 cur->next = prev 后再想找原后继,链表已经断开了,根本找不回去。这也是所有指针重连操作的通用原则——改动前先备份后继。
我见过很多同学写迭代逆序时用 while (cur->next) 而不是 while (cur),结果反转后的尾节点没有置空,链表变成循环链表,调试时死循环半天找不到原因。正确写法中 prev 初始为 nullptr,所以原头节点最终会指向 nullptr,这是对的。
4.5 链表逆序(1.3.30)——递归版
递归版是这本书的进阶要求,很多读者会卡在这里。其实递归逆序的核心非常简洁:
cpp复制template <typename T>
Node<T>* reverseRecursive(Node<T>* node) {
if (!node || !node->next) return node; // 空链表或到达原链表末尾
Node<T>* newHead = reverseRecursive(node->next);
node->next->next = node; // 让后继节点反指自己
node->next = nullptr; // 自己指向空,防止环
return newHead;
}
理解这个递归,关键是信任“假设 reverseRecursive(node->next) 已经把从 node->next 开始的子链表逆序完成,并返回了新头节点”。那么对当前这个 node 来说,它需要做的就是把自己接到子链表逆序后的末尾。而子链表逆序后,原本的“末尾”(也就是 node->next 指向的那个节点)变到了“头部翻转链的头部”?不,准确说:子链表逆序前,node->next 指向子链表的第一个节点;子链表逆序后,原来子链表的最后一个节点变成了头,而原来子链表的第一个节点(即 node->next)变成了子链表的最后一个节点。所以 node->next->next = node 就是把 node 接到子链表的末尾。
每次递归调用压栈,时间复杂度 O(n),空间复杂度 O(n)。如果链表达几万甚至几十万节点,递归可能爆栈。实际工程中优先用迭代,但面试时递归版本往往是加分项,因为考察的是“分治思维”。
4.6 约瑟夫环问题(1.3.37)
这道题用循环链表最自然。约瑟夫问题的描述是:N 个人围成一圈,从第 1 个人开始报数,每次数到 M 的人出局,然后从下一个人继续报数,求最后剩余的人。
用循环链表的模拟思路:每次遍历 M-1 步,删除当前节点,继续。代码如下:
cpp复制template <typename T>
T josephus(Node<T>* head, int m) {
if (!head) throw std::invalid_argument("empty list");
if (head->next == head) return head->item; // 只剩一个人
Node<T>* cur = head;
while (cur->next != cur) { // 循环直到只剩一个节点
for (int i = 1; i < m; ++i) { // 走到要删除节点的前一个
cur = cur->next;
}
Node<T>* toDelete = cur->next;
cur->next = toDelete->next;
if (toDelete == cur) break; // 删除的是自己?
// 重点:如果删除的就是当前节点呢
if (toDelete == toDelete->next) {
delete toDelete;
// 只剩下最后一个节点
return cur->item;
}
delete toDelete;
cur = cur->next; // 从被删节点的下一个重新报数
}
return cur->item;
}
这段代码需要注意的是删除顺序:在循环链表中,如果 cur->next 就是待删除节点,删除后 cur->next 被更新为新节点,此时 cur 自身不用动。但边界情况很烦:如果 m=1,每次遍历 0 步,cur->next 就是要删除的节点,删除后 cur 要保持指向正确位置。
这道题在书里是“循环链表”主题下的练习,但实际面试中还有一个数学公式解法(O(n) 时间 O(1) 空间)。我建议做这道题时先用循环链表模拟,再额外推导递推公式 f(1)=0; f(i)=(f(i-1)+m)%i,两者对照理解会更透彻。
4.7 前移编码:自组织查找(1.3.40)
这道题考察“查询访问的局部性优化原理”,非常有意思。思路是:每次访问某个节点后,把它移动到链表头部,这样常用节点的查找效率会越来越接近 O(1)。
实现的核心是“找到节点后,将其从原位置摘除,然后插入头部”。这里又要用到 Node*& 和双指针的配合:
cpp复制template <typename T>
bool moveToFront(Node<T>*& head, const T& target) {
if (!head) return false;
if (head->item == target) return true; // 已经在头部
Node<T>* prev = head;
while (prev->next) {
if (prev->next->item == target) {
Node<T>* targetNode = prev->next;
prev->next = targetNode->next; // 从链中摘除
targetNode->next = head; // 前移到头部
head = targetNode;
return true;
}
prev = prev->next;
}
return false;
}
这个操作在缓存淘汰策略、自组织列表(self-organizing list)、LISP 的 cons 操作中都有影子。做完这道题你可以想想:为什么前移编码比“移到末尾”更高效?因为现实中 80% 的访问集中在 20% 的数据上,前移能快速让高频数据“浮”到头部。
五、实操环境与调试工具
5.1 VS Code 刷题环境配置
做这些链表练习,我强烈建议你在 VS Code 里配置好 C++ 开发环境,因为调试链表时的可视化能力能显著提高效率。
我的配置简要说一下。编译器用 MinGW-w64(Windows)或者 clang++(macOS/Linux),扩展安装 C/C++(Microsoft 官方那个)。关键步骤是设置 .vscode/tasks.json 和 launch.json,把调试器指向 gbd 或 lldb。
写链表练习时,建议你在代码里加一个 printList() 辅助函数,每次操作后打印一次。这看起来笨,但实际上比断点单步调试更高效,因为链表结构是线性的,打印输出可以直接看到全貌。
cpp复制template <typename T>
void printList(Node<T>* head) {
while (head) {
std::cout << head->item << " -> ";
head = head->next;
}
std::cout << "nullptr" << std::endl;
}
5.2 gdb 检查内存问题的三板斧
链表题的 bug 往往不是逻辑错,而是内存错。我把调试链表的常用三板斧整理一下。
第一招:watch 命令。当你怀疑某个指针被意外修改时,可以用 watch ptr 让 gdb 在指针被修改时自动暂停。
第二招:x/10gx 命令。直接查看指定地址的内存内容。比如 x/10gx head 可以看头节点附近的 10 个 8 字节值,确认 next 指针对不对。
第三招:AddressSanitizer(ASan),这个是最强的。编译时加 -fsanitize=address -g,运行时如果出现非法访问或内存泄漏,它会直接告诉你具体是哪个 malloc 的地址、在哪个函数里访问越界、释放了几次。对于链表题里的 use-after-free(释放后使用)和 double free(重复释放)问题,ASan 是最好的照妖镜。
5.3 画图辅助法
我刷链表练习时的一个心得是:不要光靠脑内模拟,遇到稍复杂的指针操作就画图。纸上画三个框,标注修改指针的先后顺序,很多看似复杂的问题瞬间就明朗了。尤其是在做递归逆序那题时,画一下递归返回后的三指针指向关系,比看十遍代码都有效。
六、常见问题与排查实录
6.1 空指针崩溃
现象:程序运行到访问 cur->next 时崩溃。
原因八成是某个节点的 next 没初始化。排查方式是:先检查构造函数是否初始化了 next 为 nullptr;再检查删除节点后是否把前驱的 next 更新了;最后检查循环终止条件是否会在空指针上多走一步。
6.2 内存泄漏
严格说 C++ 程序不会因为内存泄漏崩溃,但 LeetCode 和这本书的一些验证环境会对内存泄漏报错。特别是用了 new 却没 delete 的链表操作,跑长用例时内存不断上涨。
排查思路:把操作链表的所有函数过一遍,凡是 new 出来的节点,要么后来被 delete,要么节点还挂在链表中(链表析构时统一释放)。这条原则叫“每个 new 都有归属”。
6.3 无限循环
典型场景:逆序操作后链表成环。排查方式是打印链表长度,如果打印到一定数量后开始重复,说明有环。
另外注意:递归逆序后如果忘记把 node->next = nullptr,算法返回后整个链表可能仍然带有环或异常链接,打印时会卡死。
6.4 修改传入的指针没生效
典型场景:函数里修改了 head,但外面的 head 没变。
原因就是 3.4 节说的“指针传值”问题。函数内部修改的是 head 指针的副本,没有影响调用方的 head 变量。解决办法是函数形参改为 Node*& 或者 Node**。这是我见过最多人掉的坑,没有之一。
6.5 常见问题速查表
| 症状 | 大概率原因 | 解决方向 |
|---|---|---|
| 空指针崩溃 | next 未初始化 | 检查构造函数 |
| 内存泄漏 | new 没配 delete | 检查析构与删除逻辑 |
| 无限循环 | 链表中出现环 | 检查逆序/重连逻辑 |
| 函数改了 head 却没生效 | 指针传值 | 改为 Node*& 或 Node** |
| 删除位置不对 | 遍历位置偏了一位 | 用虚拟头节点统一逻辑 |
| 打印内容重复 | 尾节点 next 未置空 | 检查反转/追加尾节点逻辑 |
七、链表练习题做完之后
整套链表练习刷下来,我最大的收获不是“会写链表操作”本身,而是建立了几个底层思维模型。
第一个是“前驱指针思维”:几乎所有链表修改操作,核心工都是“记住前驱节点,修改其后继”。这个思维在树结构里就是“父节点指针”,在跳表里就是“前驱指针数组”。一法通万法通。
第二个是“迭代器思维”:这本书的队列和栈都要求实现 Iterable 接口,C++ 里对应的是迭代器。实现迭代器时必须让迭代器到“逻辑位置”和“实际节点”之间建立对应关系,这个抽象对后续理解 STL 容器非常有帮助。
第三个是“内存生命周期思维”:C++ 链表练习让我养成了“谁分配谁释放”“指针指向哪里,谁负责这个内存”的条件反射。后来我写二叉树、写图邻接表时,内存问题几乎没再困扰过我。
如果你正在刷这本书,我的建议是不要追求“把每道题跑通即可”,而是把每题都做两遍:第一遍实现后通过测试,第二遍不看任何参考,再写一遍,同时口头解释每一步为什么这样做。这个“第二遍”才是真正内化知识的过程。
如果你用 C++ 刷,请一定把 Node*& 和内存管理当成练习的一部分,而不是绕开的障碍。你在这上面多花的时间,后面会在写任何复杂数据结构时成倍地节省回来。
