1. 四道题练的不是代码,是"处理链表指针"的四种意识
刷到 Day07 这天题单的时候,我恰好处于一种状态:前面反转链表那几题还算顺畅,但到两两交换和环形链表时明显卡住了。后来复盘才发现,卡住的原因不是题目本身多难,而是我对"谁改写了谁的 next"这件事没有形成直觉。链表题和数组题最大的不同就在这:数组下标不会因为操作顺序而改变,但链表里一个节点可能同时被好几个前驱指向,你只要改错一根指针,后续的引用就全断了。
这一天的四道题——24. 两两交换链表中的节点、19. 删除链表的倒数第 N 个结点、面试题 02.07. 链表相交、142. 环形链表Ⅱ——放到一起看,其实是四个递进层次的练习:先学会局部重排相邻节点的指针,再去处理"从尾部数第几个"的位置定位,然后解决两条链表的公共尾部对齐,最后上升到环的检测与入口数学推导。如果你正卡在某个地方,我建议不要一道题一道题孤立地刷,而是先把这四道题背后的操作模板拉通,下面我按我当时的思路完整拆一遍。
1.1 链表题从"会遍历"到"会操作"的转折点
很多人在 Day04、Day05 觉得链表不难,因为那些题的共同点是"找到某个节点,然后读它的值"。但 Day07 的问题全部变成了"站在某个节点面前,修改附近几根指针",这完全是另一码事。
举一个最直观的例子。两两交换链表中的节点,表面上只是把 1->2->3->4 变成 2->1->4->3,但实际操作时,你面对的是一组互相纠缠的引用:dummy 要指向 2,1 要指向 3,2 要指向 1。这三步只要顺序错一步,链表就断掉。更麻烦的是,如果你不用虚拟头节点,交换完第一组后,新链表的头节点已经变成了旧链表的第二个节点,后续每一组的前驱都不一样,逻辑分支会多到让人想摔键盘。
所以我倾向于把这一天的核心总结成四件事:
- 虚拟头节点:统一处理"头节点被修改"的情况。
- 双指针:用两个指针的"位置差"或者"速度差"解决问题。
- 数学推导:环形链表入口不能只靠感觉,必须落成公式。
- 画图:在动代码之前,先把指针指向在纸上画出来。
这四件事不是并列的技巧,而是链表进阶必须内化的思维习惯。后面的四道题,每一道都在逼你用这四种意识中的某一种或几种。
1.2 这四道题分别对应哪套操作模板
我先把四道题的核心特征列成一张表,你刷的时候可以对照着看:
| 题目 | 核心操作 | 主要套路 | 时间复杂度 |
|---|---|---|---|
| 24. 两两交换链表中的节点 | 相邻节点重新接线 | 虚拟头节点 + 暂存指针 | O(n) |
| 19. 删除链表的倒数第 N 个结点 | 从尾部定位并删除 | 虚拟头节点 + 快慢指针 | O(n) |
| 面试题 02.07. 链表相交 | 两条链表的公共节点 | 长度对齐或双指针换路 | O(m+n) |
| 142. 环形链表Ⅱ | 检测环并找入口 | 快慢指针 + 数学推导 | O(n) |
从这张表能看出,四道题里三道都要用到双指针,两道要用虚拟头节点。所以别看题多,真正要熟练掌握的底层能力就两个:一个是"怎么安全地改指针",一个是"怎么用两个指针消除位置信息差"。下面逐题展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 两两交换链表中的节点:先保留再改指向,顺序错一步就断链
24 题是这一天里最像"手工活"的题目。它不涉及什么高深算法,纯粹考验你对指针操作顺序的敏感度。题目要求不能改节点值,只能改引用关系,所以唯一的办法就是把 1->2 的链路拆掉,重新接成 2->1。
我当时的第一反应是直接写 head 和 head->next 的交换,结果写到一半就乱了,因为交换完前两个节点,新的头节点已经不是原来的 head,后面要继续处理 3->4 这一组时,我找不到"3 和 4 的前驱"了。后来换用虚拟头节点,整个问题的结构立刻清晰了很多。
2.1 迭代解法:虚拟头加三根指针,一次画图就懂
虚拟头节点 dummy 的作用,简单说就是给整条链表加一个"假前驱",让第一组节点和后面的每一组节点在逻辑上完全一致。这样我们只需要维护一个 cur 指针,让它永远指向"即将交换的这一组节点的前一个节点"。
具体过程如下,我建议你边看边画:
- 初始链表:dummy -> 1 -> 2 -> 3 -> 4,cur = dummy。
- 用 first = cur->next(节点 1),second = cur->next->next(节点 2)暂存两个要交换的节点。
- 先把 second 接到 cur 后面:cur->next = second,此时 dummy 指向 2。
- 再把 first 的 next 接到 second 原本的后继上:first->next = second->next,也就是 1 指向 3。
- 最后把 second 的 next 接回 first:second->next = first,也就是 2 指向 1。
完成这三步后,链表变成 dummy -> 2 -> 1 -> 3 -> 4。还要把 cur 移到 first 上,因为下一组要交换 3 和 4,而 3 的前驱就是 1。写成代码就是:
cpp复制struct ListNode {
int val;
ListNode *next;
ListNode() : val(0), next(nullptr) {}
ListNode(int x) : val(x), next(nullptr) {}
ListNode(int x, ListNode *next) : val(x), next(next) {}
};
class Solution {
public:
ListNode* swapPairs(ListNode* head) {
ListNode* dummy = new ListNode(0);
dummy->next = head;
ListNode* cur = dummy;
while (cur->next != nullptr && cur->next->next != nullptr) {
ListNode* first = cur->next;
ListNode* second = cur->next->next;
// 开始交换
cur->next = second;
first->next = second->next;
second->next = first;
// cur 移动到下一组的前驱位置
cur = first;
}
ListNode* result = dummy->next;
delete dummy;
return result;
}
};
这里最容易犯的错误是第 4 步先执行 second->next = first,然后再去取 second->next 的后继。一旦你先改了 second->next,原来的节点 3 就丢了,因为没有任何变量还指向它。所以每次操作链表前,先问自己一句:这个节点的后继被改之前,我有没有把它原来的后继暂存下来?
循环条件 while (cur->next && cur->next->next) 也值得说一句。它保证了 cur 后面至少有两个节点才进入交换逻辑。如果链表是奇数个节点,最后一组就剩一个节点,不满足条件,自然不会被访问到,也就不会出现空指针问题。
2.2 递归解法:理解"返回值如何接回链表"
24 题还有一个很经典的递归写法,代码短到让人怀疑是不是漏了什么:
cpp复制class Solution {
public:
ListNode* swapPairs(ListNode* head) {
if (head == nullptr || head->next == nullptr) {
return head;
}
ListNode* newHead = head->next;
head->next = swapPairs(newHead->next);
newHead->next = head;
return newHead;
}
};
简单说,递归函数 swapPairs(head) 的语义是"返回 head 开始两两交换后的新头节点"。对于首层调用,newHead 是 head->next;head->next 应该指向后面子链表的交换结果,也就是 swapPairs(newHead->next);最后把 newHead->next 接回 head,返回 newHead。
我非常建议在掌握迭代之后再写一遍递归版,因为它能帮你更深刻地理解"函数返回值如何自然地作为链表链接中的一环"。但如果你在面试中遇到这题,我一般推荐优先写迭代版,因为递归的空间复杂度是 O(n),而且递归的调用关系一旦没想清楚,调试起来比迭代麻烦得多。
2.3 我踩过的第一个空指针坑
这题我第一次提交时,循环条件写的是 while (cur && cur->next),结果链表是 1->2 的时候没报错,链表是 1->2->3->4 的时候在第二轮访问 cur->next->next 时直接段错误。原因是 cur 已经移动到节点 1,而节点 1 的 next 被改成了 3,此时 3 后面确实有 4,但如果链表是奇数个节点,比如 1->2->3,第二轮 cur->next 是 3,3->next 是 nullptr,cur->next->next 就访问了空指针的 next 字段。
正确的是只在 cur->next 和 cur->next->next 都存在时才操作。另外测试时要覆盖空链表、单节点、奇数长度、偶数长度四种情况,不要只拿官方示例测一遍就提交。
3. 删除链表的倒数第 N 个结点:快慢指针差一步就是天堂和地狱
19 题看起来比 24 题简单,实际上藏着一个非常容易搞错的细节:快指针到底先走 n 步还是 n+1 步。我见过不少人在这一步卡住,原因是没有从"删除节点需要找到它的前驱"这个角度去推导。
3.1 两趟遍历怎么做,以及为什么不够
最直观的做法很容易想到:第一趟遍历求出链表长度 L,第二趟从头走到第 L-N 个节点,它就是待删节点的前驱,然后执行前驱节点的 next 指向 next->next。但这个做法要循环两遍,虽然时间复杂度还是 O(L),题目最后却有一个进阶要求:能不能用一趟扫描完成?
面试题里"一趟扫描"往往不是硬性要求,但它背后的信号是:你有没有掌握双指针这种更通用的技巧。所以在刷题阶段,我建议直接把双指针写法当成标准解法来练,两趟遍历可以理解为"用来验证答案对不对的对照版"。
3.2 快慢指针的 n+1 步差是怎么推出来的
我们设链表长度为 L,dummy 作为辅助头节点。想要删除倒数第 N 个节点,就必须让 slow 停留在待删节点的前一个节点上。也就是说:
- 待删节点是从后往前第 N 个节点。
- 它的前驱是从后往前第 N+1 个节点。
- 从 dummy 的位置开始看,dummy 是"从后往前第 L+1 个节点"。
如果让 fast 从 dummy 出发,先走 N+1 步,然后 fast 和 slow 同步移动,当 fast 走到 nullptr 时,slow 恰好走了 L+1-(N+1)=L-N 步,也就是停在了从后往前第 N+1 个节点上,正好是待删节点的前驱。这就是为什么 fast 要先走 n+1 步,而不是 n 步。
画个图会更清楚。链表 dummy -> A1 -> A2 -> A3,L=3,N=2,也就是要删除 A2。fast 先走 3 步,走到 nullptr;此时 slow 还停在 dummy。因为 fast 和 slow 之间差 3 步,当 fast 在 nullptr 时,slow 的位置就是 dummy 往后数 3 个节点,也就是 A1,而 A1 正是 A2 的前驱。于是 slow->next = slow->next->next,删除完成。
如果把"先走 n 步"和"先走 n+1 步"对照起来看,区别就非常清楚了:先走 n 步,fast 到 nullptr 时 slow 会停在待删节点本身,单链表又拿不到前驱,还得额外维护一个 prev 指针,代码立刻复杂一截。
3.3 完整实现与边界测试
cpp复制class Solution {
public:
ListNode* removeNthFromEnd(ListNode* head, int n) {
ListNode* dummy = new ListNode(0);
dummy->next = head;
ListNode* fast = dummy;
ListNode* slow = dummy;
// fast 先走 n+1 步
for (int i = 0; i <= n; ++i) {
fast = fast->next;
}
// 同步移动,
