一、项目概述
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++
