很多人刷第 3 天时,已经开始有点发怵了:数组的边界条件刚有点手感,题目突然全变成了一串 next、->,指针绕来绕去,仿佛换个了语言。我 Day3 就是在这个状态下度过的。如果你也在按代码随想录的题单刷 LeetCode,大概率这一天的节奏是开始进入链表,核心题目是 203. 移除链表元素、707. 设计链表、206. 反转链表。这篇文章不是把题解抄一遍,而是我完整刷完 Day3 之后想重新整理一遍的心得:哪些代码要能默写,为什么解法要那样从头设计,哪些边界不写根本不知道会错。
先说明一下,不同版本的代码随想录训练营在划分上有细微差别,有的把数组最后两题放到 Day3,但按经典主路线走,链表第一枪基本就是这个时候。不管题单怎么排,链表这一块始终绕不开,所以这篇文章把“Day3 链表三题”讲透,对大部分按计划刷题的人是可以直接参考的。
1. Day3 为什么从数组突然切到链表,这个节奏其实是刻意的
数组偏重“同一种数据在内存里连续摆放”,链表偏重“用指针把不同对象串起来”。连续摆放的东西好想,不连续的东西难想。所以很多算法新手在数组题里还能靠暴力写对,一到链表就连怎么遍历都会写错,原因不是不会语法,而是脑子里缺少一张“谁指向谁”的图。
1.1 Day3 核心题单速览
先给出这一天的常见题单,我建议按下面的顺序刷,不要乱换。因为 203 用于建立虚拟头节点的直觉,707 是加深对增删查边界理解的好工具,206 是第一次主动反转指针方向,三题之间的递进关系很明显。
| 题号 | 题目 | 核心考点 | 建议用时 | 优先级 |
|---|---|---|---|---|
| 203 | 移除链表元素 | 虚拟头节点、节点删除逻辑 | 30-45分钟 | 必须刷 |
| 707 | 设计链表 | 增删查接口、下标边界 | 50-70分钟 | 必须刷 |
| 206 | 反转链表 | 三指针迭代、递归写法 | 40-60分钟 | 必须刷 |
我之前见过有人把 206 放到最后刷,结果卡太久。建议无论如何都在 707 之后再碰 206,因为 707 会让你对 dummyHead、前驱节点、后继节点的关系建立感觉,这种感觉正是 206 需要的东西。
1.2 链表基础不过关,后面二叉树和相关题目会加倍难受
很多人以为链表只是个“数据结构基础”,刷几题就算完。真正等到刷二叉树的时候才意识到,二叉树节点就是 ListNode 的加强版,每个节点多了 left 和 right 两个指针,遍历和递归全部建立在指针操作之上。链表里的空指针判断、递归中止条件、指针交换顺序,跟二叉树里 root->left 的处理一模一样。
Day3 这波题,本质上就是一次“指针思维”的脱敏训练。从数组的 nums[i] 到链表的 cur->next,这套转换必须尽早完成。我刷完这一天的体会是:真正难的不是某一道题,而是能不能用指针想象出每一步之后的结构长什么样。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 203. 移除链表元素:先统一“删头”和“删中间”,再谈优化
题目很直白:给一个链表头节点 head 和一个整数 val,删除链表中所有值等于 val 的节点。但刷过的人都知道,看起来越简单的题,坑越容易埋在头节点上。
2.1 不用虚拟头节点时会遇到什么烦心事
如果你不用 dummyHead,删除逻辑自然分成两段:
- 如果被删除的是头节点,需要不断执行
head = head->next,因为下一个头节点可能仍然等于val。 - 如果被删除的是中间节点,需要让前一个节点的
next跨过当前节点,指向后继。
这两段逻辑虽然单独看都不难,但放到一个循环里必须写成分支。等你写完,回头再看,代码里到处是 head 和 cur 的分别处理,很容易漏掉某种情况。比如删除连续相同值的时候,如果每次删完都执行 cur = cur->next,那么相邻的等值节点就会漏掉一个。
Carl 在代码随想录里反复强调虚拟头节点,不是因为它能提升复杂度,而是它能把所有删除逻辑统一成一种写法:无论删头还是删中间,都变成“站在被删节点的前一个位置,检查 cur->next 是否要删”。
2.2 用虚拟头节点后的最终写法
这里我用 C++ 来写,注意 LeetCode 内置的 ListNode 已经有带 val 和 next 的构造函数,所以可以直接构造一个栈上的 dummyHead。
cpp复制class Solution {
public:
ListNode* removeElements(ListNode* head, int val) {
ListNode dummyHead(0, head);
ListNode* cur = &dummyHead;
while (cur->next != nullptr) {
if (cur->next->val == val) {
cur->next = cur->next->next;
} else {
cur = cur->next;
}
}
return dummyHead.next;
}
};
这段代码里最关键的点是:当 cur->next->val == val 时,删除节点后不要立刻把 cur 往后移。 因为新的 cur->next 很可能仍然等于 val,只有继续判断下去才能把连续相同值的节点全部删完。而 cur->next->val != val 时,才可以把 cur 移到下一个节点。
这个细节我第一天写的时候踩过。那时我把两种情况都写成了 cur = cur->next,结果面对 1 -> 2 -> 2 -> 1,删除 2 只删了第一个 2,第二个 2 被跳了过去。用图走一遍就清楚了。
如果想把脱离链表的节点真正释放,可以这样写:
cpp复制ListNode* toDelete = cur->next;
cur->next = cur->next->next;
delete toDelete;
但 LeetCode 这类 OJ 通常不判内存泄漏,刷题时很多人不写 delete 也能过。我的个人建议是:刷题阶段以算法为主,但面试时要主动提到“释放被删节点”这件事,因为面试官会关心内存管理意识。
2.3 dummyHead 本身就是一种“边界统一法”
虚拟头节点的思想不止用在链表删除里,很多边界问题都可以套用同一个思路:造一个不参与业务逻辑的辅助节点,把特殊情况转化为普通情况。
拿字符串处理举例,有些题目在开头加一个哨兵字符,就是为了避免每次判断 index == 0;在动态规划里加一圈 0 边界,也是为了省去下标越界判断。所以 Day3 的第一题,重点不是背过代码,而是理解 dummyHead 背后的通用思维。这个思维在后面设计链表、反转链表、合并链表时都能反复用到。
3. 707. 设计链表:Day3 最容易翻车的边界题
707 是那种“看题以为简单,一写就发现一堆边界”的题。题目要求实现 get、addAtHead、addAtTail、addAtIndex、deleteAtIndex。如果第一个测试用例就卡住,不用意外,我也卡了很久。
3.1 开工之前先决定:单链表怎么设计才好写
我先说结论:Day3 阶段用单链表 + dummyHead + size 变量最稳。 不要一上来就追求双链表,也不要只顾着优化 addAtTail 的 O(1) 而引入 tail 指针,除非题目明确要求。
理由是:707 的核心考点是下标边界和指针操作,而不是复杂度。如果维护 tail 指针,那么删除最后一个节点时,需要重新找到倒数第二个节点,tail 要么失效,要么需要额外处理,代码复杂度会明显上升。用单链表多遍历一次尾部,虽然 addAtTail 是 O(n),但换回来的是逻辑简单、不容易出错。
加一个 size 变量非常关键。它可以让你在增删查时先判断下标是否合法,减少很多无效遍历。一个常见的反例是:有人只用 cur->next != nullptr 判断,结果节点数量变化后,各种 index 取值突然就不对了。
3.2 完整实现
下面是基于单链表的实现。这里我把结构体定义在 MyLinkedList 内部,外部不可见也够用。
cpp复制class MyLinkedList {
public:
struct ListNode {
int val;
ListNode* next;
ListNode(int x) : val(x), next(nullptr) {}
};
MyLinkedList() {
dummyHead = new ListNode(0);
size = 0;
}
int get(int index) {
if (index < 0 || index >= size) return -1;
ListNode* cur = dummyHead->next;
for (int i = 0; i < index; i++) {
cur = cur->next;
}
return cur->val;
}
void addAtHead(int val) {
ListNode* newNode = new ListNode(val);
newNode->next = dummyHead->next;
dummyHead->next = newNode;
size++;
}
void addAtTail(int val) {
ListNode* cur = dummyHead;
for (int i = 0; i < size; i++) {
cur = cur->next;
}
cur->next = new ListNode(val);
size++;
}
void addAtIndex(int index, int val) {
if (index < 0) {
index = 0;
} else if (index > size) {
return;
}
ListNode* cur = dummyHead;
for (int i = 0; i < index; i++) {
cur = cur->next;
}
ListNode* newNode = new ListNode(val);
newNode->next = cur->next;
cur->next = newNode;
size++;
}
void deleteAtIndex(int index) {
if (index < 0 || index >= size) return;
ListNode* cur = dummyHead;
for (int i = 0; i < index; i++) {
cur = cur->next;
}
ListNode* toDelete = cur->next;
cur->next = cur->next->next;
delete toDelete;
size--;
}
private:
int size;
ListNode* dummyHead;
};
3.3 逐接口过一遍边界,很多低级 Bug 就清楚了
先看 get:如果 index 合法,从 dummyHead->next 往后走 index 步,就能取到目标节点。index 为 0 时停在第一个节点,index 为 size-1 时停在最后一个节点。
addAtIndex 最需要注意的边界是:index == size 时是允许插入的,此时应该插到链表尾部;而 index > size 是非法操作。很多人会误写成 index >= size,从而把尾插直接当成不合法操作,导致后续测试过不了。另外 index < 0 时,题目默认可以插到头部,但实际测试里基本不会出现负数,不过为了逻辑完整最好处理一下。
addAtTail 的实现,我直接把 cur 从 dummyHead 开始走 size 步,最后停在原来的尾节点上,再在它后面接一个新节点。这个思路可以复用 addAtIndex 的“找前驱”方式,不容易错。
deleteAtIndex 要求删除第 index 个节点,找的是被删节点的前驱节点,所以 cur 从 dummyHead 走 index 步。index 等于 0 时,dummyHead 正好是头节点的前驱,删除头节点也统一处理掉了。
写完后你会发现,707 其实就是把 203 的“删除头节点需要单独处理”这个问题完整放大了一遍,而 dummyHead 把所有接口的前驱查找都统一了。因为带上了 size,我们每个方法都能很自然地判断合法性。
4. 206. 反转链表:迭代写法搞懂一只 temp 的作用,递归写法开始接触回溯思想
反转链表这道题在面试和笔试里出现频率都非常高。很多第一次刷的人觉得它是“改变指针方向”的题目,最后一回到代码就成了三个变量来回交换,完全看不懂。
4.1 迭代:把箭头掰回去,没保存后继就断链了
假设链表是 1 -> 2 -> 3 -> null,目标是 null <- 1 <- 2 <- 3,也就是最终返回的节点是原来的尾节点 3。
最直观的做法是拿两个指针:pre 表示已经反转好的部分的前一个节点,cur 表示当前正在处理的节点。初始化时 pre 为 nullptr,cur 为 head。
每一步做三件事:
- 先保存
cur->next,因为下一步马上要修改cur->next,不保存就找不到后面了。 - 把
cur->next指向 pre,让箭头反过来。 - 让 pre 和 cur 整体往右移动,继续处理下一个节点。
写成代码就是:
cpp复制class Solution {
public:
ListNode* reverseList(ListNode* head) {
ListNode* cur = head;
ListNode* pre = nullptr;
while (cur != nullptr) {
ListNode* temp = cur->next;
cur->next = pre;
pre = cur;
cur = temp;
}
return pre;
}
};
很多人一开始纠结:为什么最后返回 pre 而不是 cur?原因是循环结束时 cur 已经变成 nullptr,说明已经走完了整条链表,而 pre 正好停在原来的最后一个节点上,也就是反转后的新头节点。
我之前和一个人讨论过这个问题,他说自己永远记不住这段顺序。我的建议是画一遍:
code复制初始:nullptr(pre) 1(cur) -> 2(temp) -> 3 -> null
第一轮后:nullptr <- 1(pre) 2(cur) -> 3
第二轮后:nullptr <- 1 <- 2(pre) 3(cur)
第三轮后:nullptr <- 1 <- 2 <- 3(pre) null(cur)
只要画过一遍,就明白 temp 为什么必须存在。处理 1 的时候,如果不先保存 2,执行 cur->next = pre 后,2 就再也找不到了。
4.2 递归写法:真正理解“先反转后面的”,再回头处理当前节点
递归写法代码很短,但对新手的理解成本反而更高:
cpp复制class Solution {
public:
ListNode* reverseList(ListNode* head) {
if (head == nullptr || head->next == nullptr) return head;
ListNode* newHead = reverseList(head->next);
head->next->next = head;
head->next = nullptr;
return newHead;
}
};
它的执行顺序是:先把 head 之后的所有节点反转,拿到新的头节点 newHead,再把当前层做一个很局部的动作:让 head 的下一个节点的 next 指回 head,并把 head 的 next 置空。
我当初看这段代码卡了很久,后来发现不能按普通循环去理解,要按递归的“信任”来理解:假设 reverseList(head->next) 已经帮我把后面的节点全部反转好了,那么当前 head 唯一要做的就是和它后面那个节点调换方向。
拿 1 -> 2 -> 3 举例:
- 递归调用
reverseList(2),假设它返回了3 -> 2这个已经反转完成的链表,newHead 是 3。 - 回到最外层 head=1,此时
head->next仍然是 2,我需要让2->next = 1,也就是head->next->next = head。 - 最后
head->next = nullptr,让 1 成为新的尾节点。
这个过程会给人一种“像在倒放”的感觉。递归本身不要求我手动走到链表尾部,它通过一层层调用,天然把最深处作为递归出口,然后在回溯时逐层处理箭头方向。
4.3 迭代和递归到底该先学哪个
如果你现在是第三天,我建议先把迭代写法滚瓜烂熟。它空间复杂度 O(1),好调试,思路直接。但递归也不能完全不看,因为后面二叉树的很多题都会用到递归逐步返回值的思想。
有个折中方案:第一遍刷 206 时,用迭代 AC,然后照着代码随想录的图,把递归版本自己在纸上推导一遍,不用强迫自己立刻能盲写。等刷到二叉树,再回头重新刷 206 的递归版,会发现那时候理解成本低了很多。
我还发现一个常见的误区:很多人试图用“dummyHead 思想”去解反转链表,结果越写越乱。真实情况是,反转链表不需要虚拟头节点,因为最后返回的新头节点完全由循环/递归确定。给反转题强行套虚拟头节点,是把题做复杂了。
5. Day3 结束之后,最值得做的不是立刻冲 Day4,而是亲手“拆”一遍代码
老实说,Day3 这三道题只要能把代码默写出来,对大部分新手来说已经算完成目标。但想让这一天真正留下积累,建议在刷完题后做几个收尾动作。这些动作比多刷一道题重要得多。
5.1 收尾检查单:能否不看题解解释清楚每个变量的作用
我做了一套自问清单:
- 203 里,遍历条件为什么是
cur->next != nullptr,而不是cur != nullptr? - 707 的
addAtIndex里,为什么插入条件是index <= size,删除条件是index < size? - 206 的迭代实现中,为什么必须保存
cur->next?如果保存的是 pre,会怎么样?
这些问题如果能不看代码回答出来,说明是真懂了。如果答不上来,说明还停留在“默写阶段”,建议明天先重刷错题,再进入新题。不要觉得这会拖慢进度,前期把三题吃透,永远比刷十题但每道都半生不熟强。
5.2 给 Day3 会卡住的人一句经验话
我会直接说:链表题卡住通常是正常的,不是智商问题。
我刷 707 时,第一版代码在 addAtIndex 里把条件写成了 index >= size 就返回,导致 addAtTail 一直过不了。排查了差不多四十分钟,不是不会写,而是题目里“在下标 index 新增节点”这个描述让我默认 index 不能等于 size。后来我把所有接口的边界条件列成一张小表,才彻底分清楚:
| 接口 | 合法范围 |
|---|---|
| get | 0 <= index < size |
| addAtIndex | 0 <= index <= size |
| deleteAtIndex | 0 <= index < size |
一旦表格列出来,后面的 bug 基本就没再犯过。所以如果你也在某个边界上反复出错,先别急着怀疑代码能力,停下来把边界整理成一张表,往往就能定位到问题。
5.3 后续扩展:这三道题能直接铺垫哪些更高频的题型
Day3 学到的三件事,往后都会反复出现:
- dummyHead 在合并有序链表、链表相交节点、删除倒数第 N 个节点等题目里都会用到;
- 707 里的前驱节点思想,是很多链表设计题的通用框架;
- 206 的迭代和递归写法,则是回文链表、反转链表 II、反转二叉树结构的底层模板。
如果再过两周回头看,你可能会觉得 Day3 的题目不难,但在当时这个节点,它帮你建立了“画图理解指针”的习惯。这个习惯比某一道题的代码值钱得多。
Day3 我最大的一个实操体会是:刷题时要允许自己慢。 代码先不用管效率,先把一两个具体测试用例在图上演算清楚,再回到代码里验证。所有的 next 和 null,只有画出来才是自己的。熟练了以后,这三道题完全可以 20 分钟之内写完,但第一天,请老老实实把每一步走完。
