1. 这四道题为什么值得放在同一天练:链路操作的本质闭环
说句实在话,链表题在LeetCode里算是“看着简单、上手就懵”的典型。节点、指针、next、head、null,翻来覆去就这么点东西,但真到了写代码的时候,赋值顺序一乱、边界少判一个,整个链表就串了。今天这四道题——24.两两交换链表中的节点、19.删除链表的倒数第N个节点、面试题07.链表相交、142.环形链表II——表面上是四个独立题型,但放在一起做完之后你会发现,它们其实在反复锻炼同几项底层能力:虚拟头节点的使用、指针的移动顺序、循环终止条件的判断、以及“一次遍历”的思维习惯。
这四道题不是随便凑在一块的。它们从易到难,把链表操作里最常考的几类场景全覆盖了:位置交换、按位置删除、跨链表查找、带环链表。每一道题的解法都不是靠“背模板”能解决,而是需要你真正理解链表的内存结构,知道一个指针变量到底存的是什么、next赋值到底改了谁的引用关系。我见过不少同学前面几道链表题刷得飞快,到环形链表就卡住了,其实不是环形链表难,而是前面的“指针操作基本功”没打扎实,快慢指针一上就露馅。
这篇文章我按照训练营当天的节奏来梳理,每道题会讲清楚三件事:核心思路是怎么来的、代码每一步在做什么、以及那些不踩一遍根本意识不到的坑。后面的总结部分我会把这四道题串起来,提炼出一套对链表题通用的做题方法论,这套东西对后续刷反转链表、合并链表、链表排序这些题也完全适用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 两两交换链表中的节点:三指针联动与dummy节点的价值
2.1 题目到底在考什么
24题的要求很直接:给定一个链表,两两交换其中相邻的节点,并返回交换后链表的头节点。比如1->2->3->4变成2->1->4->3,如果节点数是奇数,最后一个节点保持不动。注意,题目要求不能只是改节点的值,必须真正交换节点本身。
很多人第一次做这题,会觉得“交换两个节点”跟“交换两个变量的值”差不多,搞个temp不就行了。但链表和数组最大的区别就在这:数组交换是值交换,位置固定;链表交换是引用交换,一个节点的next指向谁、前一个节点的next指向谁、头节点是谁,全部都要重新梳理。
这题的考点主要集中在三块:第一,虚拟头节点的使用,因为头节点没有前驱节点,交换前两个节点时,需要有一个节点能“指向”新的头节点;第二,三个指针的联动顺序,交换两个节点至少要涉及三个节点的引用关系(前一个节点、当前节点、当前节点的后继);第三,循环终止条件的判断,奇数长度和偶数长度的终止条件不一样。
2.2 dummy节点的两种等价写法
虚拟头节点(dummy node)是链表题里最经典的技巧,核心思想是:给原链表前面加一个临时节点,让头节点也变成“有前驱”的普通节点,这样在处理边界情况时就能统一逻辑。
标准解法是这样:
cpp复制// C++实现
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* dummyHead = new ListNode(0, head);
ListNode* cur = dummyHead;
while (cur->next != nullptr && cur->next->next != nullptr) {
ListNode* tmp1 = cur->next; // 第一个节点
ListNode* tmp2 = cur->next->next->next; // 第三个节点(下一对的起点)
cur->next = cur->next->next; // 步骤1:前驱指向第二个节点
cur->next->next = tmp1; // 步骤2:第二个节点指向第一个节点
cur->next->next->next = tmp2; // 步骤3:第一个节点指向第三个节点
cur = cur->next->next; // cur跳两个节点,进入下一轮
}
return dummyHead->next;
}
};
第一次看这个代码的人很容易懵,尤其是那三个赋值步骤。我建议你用最笨的方法——画图。画一个dummy -> 1 -> 2 -> 3 -> 4 -> null的链表,然后把每行代码执行后指针的指向画出来。你会发现,这三步的核心逻辑是:
- 让前驱节点(cur)先指向“第二个节点”,这样链表从头部就断开了原先后继关系;
- 让“第二个节点”指向“第一个节点”,完成两个节点的互相逆转;
- 让“第一个节点”指向“第三个节点”,把后面未处理的链表接回来。
赋值顺序必须严格这样,如果先做步骤2再做步骤1,cur->next已经变了,后面的指针就全乱了。
也有另一种常见的写法,用三个临时变量保存指针:
cpp复制ListNode* dummyHead = new ListNode(0, head);
ListNode* cur = dummyHead;
while (cur->next && cur->next->next) {
ListNode* first = cur->next;
ListNode* second = cur->next->next;
ListNode* third = second->next; // 可能为nullptr
cur->next = second;
second->next = first;
first->next = third;
cur = first;
}
return dummyHead->next;
这种写法更直观,三个临时变量把相关的节点先“抓稳”,再重新连线。我个人建议新手用第二种,不容易把自己绕晕。
2.3 循环终止条件的坑
这个坑非常经典。while (cur->next != nullptr && cur->next->next != nullptr),两个条件少写一个都不行。
- 如果是偶数长度链表(
1->2->3->4),处理完前两个节点后,cur指向2,此时cur->next指向3,cur->next->next指向4,循环继续;处理完3->4后,cur指向4,cur->next为null,循环结束。两个条件同时不满足。 - 如果是奇数长度链表(
1->2->3),处理完1->2后,cur指向2,此时cur->next指向3,但cur->next->next是null,循环应该结束,最后一个节点不处理。所以必须要有cur->next->next != nullptr这个条件。
如果只写cur->next != nullptr,奇数长度的链表在最后一轮会尝试访问nullptr->next,直接报空指针异常;如果只写cur->next->next != nullptr,当cur本身就是链表中最后一个节点时,cur->next就是null,访问cur->next->next同样会崩溃。两个条件缺一不可,而且顺序不能换,必须先把cur->next判空,再去判断它的next。
提示:C++里
&&运算符是短路求值,左边的表达式为false时,右边的不会执行。所以cur->next != nullptr写在前面,能确保后面的cur->next->next不会对空指针解引用。
还有一个细节是cur = cur->next->next和cur = first的区别。用第二种写法的同学,最后会写cur = first,因为处理完一对节点之后,下一个待处理对的“前驱”就是这一对的第一个节点(现在它已经被换到第二个位置了)。这个位置关系画图会很清楚,别凭直觉跳,容易跳错。
3. 删除链表的倒数第N个节点:一次遍历的窗口思想
3.1 朴素解法的致命问题
19题描述也很短:给定一个链表,删除链表的倒数第n个节点,返回链表的头节点。比如链表1->2->3->4->5,删除倒数第2个节点(也就是4),结果为1->2->3->5。
题目要求说得很清楚,但很多人第一次看到它还是先想到最简单的做法:先遍历一遍链表得到长度L,然后倒数第n个节点就是正数第L-n+1个节点,再遍历到它的前一个节点,执行删除操作。这当然是正确的,但它需要两遍遍历,时间复杂度是O(L),空间复杂度O(1)。
两遍遍历在大多数情况下都能过,但这道题在LeetCode上的进阶要求是:尝试使用一趟扫描实现。面试时如果只写出两遍遍历的版本,面试官大概率会追问“能不能只遍历一遍”。所以双指针的解法是必须掌握的。
3.2 双指针窗口法:为什么fast先走n步是正确的
双指针解法的核心思想非常巧妙:让两个指针之间保持固定的距离n。先让fast指针从头节点出发,走n步;然后再让slow指针也从头节点出发,fast和slow同时一步一步走。当fast走到链表末尾(null)时,slow正好停在倒数第n个节点的前一个节点上。
为什么是这样的?想象一个长度为n的“窗口”,窗口的左端是slow,右端是fast。fast先走n步,窗口的右端就到达了第n+1个节点;然后slow和fast同步移动,窗口整体向后滑动。当fast到达null时,窗口的左端(slow)恰好落在倒数第n+1个节点上,也就是倒数第n个节点的直接前驱。删除操作就变成了slow->next = slow->next->next。
这里有一个普遍的疑惑:为什么要让slow停在“前驱节点”而不是直接停在“目标节点”?因为单链表只能往后走,无法回头。你想删除某个节点,必须拿到它的前驱,把前驱的next跨过它直接指向它的后继。让slow停在目标节点的前一个位置,就是为了后续删除操作的便利。
3.3 虚拟头节点在这里是不可或缺的
如果删除的是“倒数第n个节点”,而链表的长度恰好也是n,那么要删除的节点就是头节点。头节点没有前驱,这时候如果没有虚拟头节点,就要单独写一个分支处理:head = head->next。这既啰嗦又容易漏。
用虚拟头节点之后,上面的问题就消失了。dummy节点作为头节点之前的节点,它的存在让“删除头节点”变成了一种普通情况。完整代码:
cpp复制class Solution {
public:
ListNode* removeNthFromEnd(ListNode* head, int n) {
ListNode* dummyHead = new ListNode(0, head);
ListNode* fast = dummyHead;
ListNode* slow = dummyHead;
// fast先走n+1步
// 为什么是n+1?因为slow和fast都从dummy出发,
// 要让slow最终落在目标节点的前驱,两个指针之间要相差n+1个节点
while (n-- && fast != nullptr) {
fast = fast->next;
}
// 其实更安全的写法是 n--, 再 fast = fast->next
// 这里先让fast多走一步
while (fast != nullptr) {
fast = fast->next;
slow = slow->next;
}
ListNode* delNode = slow->next;
slow->next = slow->next->next;
delete delNode; // C++需要手动释放内存(在LeetCode中可省略)
return dummyHead->next;
}
};
注意这里的一个细节:我是让fast先走n+1步,而不是n步。因为slow和fast都从dummy出发,如果只走n步,fast会停在目标节点上;再同步移动时,fast到达null时slow会停在目标节点的前驱的前一个节点,差了一格。要保证slow最终停在目标节点的前驱,fast必须比slow多走n+1步(从dummy算起)。
提示:LeetCode刷题时,C++的
delete可以省略,因为判题环境不会太纠结内存泄露;但如果是本地练习或者工程实战,记得释放被删除节点的内存,这是个好习惯。
3.4 边界条件的变化题
我面试时遇到过这题的变体:不告诉n是多少,而是告诉你这个n一定合法,问能不能删除倒数第n个节点。还有一个变体是“删除链表的倒数第n个节点到倒数第m个节点之间的所有节点”,处理思路类似,只是fast先走的步数变了,双指针之间的窗口变成了一个区间。核心逻辑没变——用窗口偏移代替二次遍历,这在处理链表问题时是一种很常见的“空间换时间”或“遍历次数换指针关系”的思维方式。
4. 链表相交:长度对齐才是破局点
4.1 相交问题与“找相同”的本质
面试题07的题目表述通常是:给你两个单链表的头节点headA和headB,找出并返回两个单链表相交的起始节点。如果两个链表没有交点,返回null。这里的关键在于,链表的相交不是“值相等”,而是两个链表从某个节点开始,后续的所有节点都完全相同,就是说,它们的next指向同一个内存地址。
链表相交的图形化记忆是经典的“Y形”图:A链和B链的前半段各自独立,到某个节点之后合并成同一条链。很多第一次接触这题的人会误以为两个链表是“X形”交叉——那是不可能的,因为一个节点只有一个next指针,一个节点不可能同时指向两个不同的后继,所以一旦相交,后面一定是重合的。
这题最简单的暴力解法是双重循环,A链的每个节点都和B链的所有节点比较,时间复杂度O(m*n)。但既然能通过坐标对齐的方式一次遍历搞定,为什么要用暴力法呢?
4.2 长度对齐法的核心思路
长度对齐法的逻辑是:如果两个链表相交,那么交点之后的长度一定是相等的。所以两个链表从后往前数,长度差只可能出现在相交点之前。相交点之前的部分,A链和B链长度可能不一样,但在“交点之后的长度相同”前提下,两个链表的长度差就等于交点之前部分的长度差。
操作步骤很简单:
- 分别遍历A链和B链,得到长度lenA和lenB。
- 把较长的链表的头指针先移动
|lenA - lenB|步,让两个链表的剩余长度相等。 - 然后两个指针同步向后移动,边移动边比较——第一个相同的节点就是交点。
- 如果走到null都没有相同节点,说明没有交点,返回null。
cpp复制class Solution {
public:
ListNode* getIntersectionNode(ListNode* headA, ListNode* headB) {
if (headA == nullptr || headB == nullptr) return nullptr;
ListNode* curA = headA;
ListNode* curB = headB;
int lenA = 0, lenB = 0;
while (curA != nullptr) { lenA++; curA = curA->next; }
while (curB != nullptr) { lenB++; curB = curB->next; }
curA = headA;
curB = headB;
// 让curA指向较长的链表
if (lenB > lenA) {
swap(lenA, lenB);
swap(curA, curB);
}
int gap = lenA - lenB;
while (gap--) curA = curA->next;
while (curA != nullptr) {
if (curA == curB) return curA;
curA = curA->next;
curB = curB->next;
}
return nullptr;
}
};
这个解法的时间复杂度是O(m+n),空间复杂度O(1)。在面试中属于能拿满分的标准答案。
还有一个同样优雅的解法:双指针交替遍历。指针pA从headA出发,走到null后跳到headB;指针pB从headB出发,走到null后跳到headA。如果两个链表相交,pA和pB会在交点相遇;如果不相交,它们会同时走到null。这个解法的原理是:pA和pB走的总路程相同,都是lenA+lenB,所以它们一定会在某个时间点到达同一个节点。代码更短,但理解起来不如长度对齐法直观。我个人的建议是:面试时优先讲长度对齐法,逻辑清楚,每一步都能解释为什么;双指针交替法可以作为加分项,面试官问“能不能再优化”时再展示。
4.3 一个容易混淆的点:集合判重方案
很多人看到这题会想到用哈希集合:先把A链的所有节点都存到一个set里,然后遍历B链,第一个出现在set里的节点就是交点。这个方案正确而且简单,时间复杂度O(m+n),但空间复杂度是O(m),不符合“空间复杂度O(1)”的要求。
我在面试中问过候选人的一个问题是:“哈希集合判重和长度对齐法,在工程实践里你会选哪个?”这个问题没有标准答案,但能看出一个人对空间和时间权衡的真实理解。哈希集合的优点是代码简单、不依赖链表长度关系,缺点是空间开销。长度对齐法省空间,但需要先遍历两次获取长度。在链表很短、内存不紧张的场景下,哈希集合完全够用;在追求极致性能的底层系统里,长度对齐法更合适。
5. 环形链表II:快慢指针的数学证明与代码落地
5.1 为什么快慢指针能判断成环
142题对很多人来说是一道“看过答案会写,不看答案就崩”的题。题目要求两件事:判断链表是否有环,如果成环则找到环的入口节点。
第一部分判环,经典的解法是快慢指针:慢指针每次走一步,快指针每次走两步。如果链表中不存在环,快指针会先到达null,直接返回;如果存在环,快指针会“追”上慢指针,两者一定会在环内的某个位置相遇。
为什么快指针每次走两步、慢指针走一步就一定能相遇?直觉上可以这样理解:当慢指针刚进入环时,它和快指针之间的距离是某个值d。此后每走一次,快指针比慢指针多走一步,所以距离d每次减少1。由于d是有限整数,距离迟早会减到0,也就是两者必然相遇。这个论证也解释了为什么快指针走三步、慢指针走一步就不一定保证能遇到——因为每轮距离减少2,可能直接从距离1跳到距离-1(越过对方),在环上错过,导致无法稳定相遇。
5.2 相遇之后:入口位置的数学推导
真正让这道题封神的是第二部分。找到相遇点后,怎么确定环的入口?
这里要用到一个经典的数学结论。设链表中环外部分长度为a(从头节点到环入口的节点数),环内从入口到相遇点的距离为b,从相遇点继续走回到入口的距离为c。注意,环的长度就是b+c。
慢指针从头出发,走到相遇点,一共走了a + b步。快指针走的路程是慢指针的两倍,也就是2(a + b)。快指针的走法可以描述为:从头走到入口(a步),然后在环里绕了至少一圈(假设绕了k圈),再走到相遇点。所以快指针的总路程是a + k(b + c) + b,其中k是正整数。
列等式:
2(a + b) = a + k(b + c) + b
化简得:
a = (k - 1)(b + c) + c
这个式子说明什么?从头节点走到环入口的距离a,等于从相遇点再走c步、然后绕环若干圈后的总距离。换句话说,如果我们现在让两个指针每次都只走一步,一个从头节点出发,一个从相遇点出发,它们必然会在环入口处相遇。
这个结论的理解方式是这样:把“相遇点继续走c步到环入口”看作一个基本单位。从相遇点出发的那个指针,每绕环一圈都会再经过一次入口;从头节点出发的那个指针,走到入口需要a步。由公式可知,当“从头节点出发的指针”走完a步到达入口时,从相遇点出发的指针走完了c + (k-1)*(b+c),恰好也到达入口。两者相遇,那个位置就是入口。
5.3 代码实现与死循环防范
cpp复制class Solution {
public:
ListNode* detectCycle(ListNode* head) {
ListNode* fast = head;
ListNode* slow = head;
// 第一阶段:找相遇点
while (fast != nullptr && fast->next != nullptr) {
fast = fast->next->next; // 快指针一次走两步
slow = slow->next; // 慢指针一次走一步
if (fast == slow) {
// 有环,进入第二阶段
ListNode* index1 = fast; // 相遇点
ListNode* index2 = head; // 头节点
while (index1 != index2) {
index1 = index1->next;
index2 = index2->next;
}
return index1;
}
}
return nullptr; // 无环
}
};
注意,循环条件里必须同时判断fast != nullptr和fast->next != nullptr。因为快指针一次走两步,第一轮可能指向null,第二轮就可能访问到null->next,所以不仅要判当前节点非空,还要判下一个节点非空。这是常见崩溃点。
另一个常见错误是第二阶段里把index1和index2的移动步数搞混。记住,第二阶段里两个指针都是每次走一步,不是快慢指针。很多人在这一步想当然地让一个指针走两步,结果永远等不到相遇。
提示:第二阶段的理论基础是上面的数学推导,它依赖“快指针走两步、慢指针走一步”的初始设定。如果你把快指针改成走三步,第一阶段或许也能相遇,但推导出来的等式就不再是a = (k-1)(b+c)+c,入口位置的结论就失效了。
5.4 一个关于“绕圈次数”的反直觉点
公式里的k代表快指针在相遇前绕环的圈数,它是一个正整数。当环很小、链很长时,k可能比较大;当环恰好位于链表末尾、比较长时,k可能等于1。但无论k是多少,公式最后都化简成与k无关的固定关系,这也是为什么我们能写出一份不依赖具体环大小的代码。想清楚这一点后,你再看第二阶段的代码,就会觉得它是顺理成章的,而不是什么魔法。
6. 从四道题看链表题型的通用做题方法论
6.1 三个反复出现的“反直觉规律”
如果把今天的四道题放在一起复盘,你会发现有几个规律在反复出现:
第一个规律:虚拟头节点几乎是“删除/交换类操作”的标配。 两道需要改动链表结构的题(24题和19题)都用到了dummy节点。原因很现实——头节点没有前驱,而所有涉及链表结构修改的操作都离不开前驱节点。把dummy节点挂上去,头节点就从“特例”变成了“普通节点”,代码里的分支判断少一大半。遇到需要删除节点、交换节点、反转前N个节点这类题,先问自己一句:这里需要处理头节点吗?如果需要,dummy节点就是第一选择。
第二个规律:双指针往往是“一次遍历”的答案。 19题的双指针窗口、142题的快慢指针,本质都是“通过两个指针的相对速度或相对位置,来获取链表中的位置关系信息”。单指针只能看到当前位置,双指针可以看到“当前”与“未来”或“当前位置与目标的距离”。这也是链表题里最常考的空间优化方向——用时间上的先后关系取代空间上的额外存储。
第三个规律:链表的题目,画图比写代码重要。 这不是鸡汤。四道题里,任何一道题你只要把链表结构画出来,标出指针初始位置,然后一步一步模拟指针移动,代码几乎是顺着图“翻译”出来的。反过来,如果不画图,光靠脑补,48题的三步交换、142题的入口推导,很容易漏掉某条引用关系。
6.2 面试时链表题的“检查清单”
在面试环节,链表题写完了不意味着结束。我给自己定的检查清单是这样的,分享出来供参考:
- 空链表:head为nullptr时,代码是否能正确返回?
- 单节点链表:只有一个节点时,循环条件和指针操作是否还成立?
- 头节点被修改的场景:操作后头节点是否变了?如果变了,返回值是否正确?
- 奇偶长度:链表长度是奇数还是偶数,会不会影响循环终止?
- 是否存在环:如果链表有环,你的代码会不会死循环?
- 内存管理:C++环境下,主动delete的节点是否是重连后不再被引用的节点?
这六条几乎覆盖了链表题90%的边界情况。如果每次写完都能主动用这套清单自查一遍,能少踩很多坑。
6.3 从“会做”到“会讲”:训练营的延展建议
单论这四道题本身,掌握了上面的解法,刷题层面已经足够。但如果你是为了面试或者写工程代码,我建议再做一步——试着把解题过程用“讲给别人听”的方式梳理清楚。比如两两交换节点时,你能不能解释为什么需要使用第三个临时节点?环形链表II的入口推导,你能不能不看笔记,完整地把公式推导一遍?“看懂”和“能讲清楚”之间隔着一段很长的距离。
后续可以继续刷的链表题还有很多,比如25.K个一组翻转链表、206.反转链表、21.合并两个有序链表、138.复制带随机指针的链表、234.回文链表。等你把这些题也刷完,再回头看你今天做的这四道,会发现它们其实只是链表操作体系的几个坐标点。今天建立的dummy节点意识、双指针思维、边界检查习惯,在后续所有链表题里都会反复用到。
我在面试候选人的时候,最欣赏的不是那种把答案背得滚瓜烂熟的人,而是能指着代码说清楚“这一行为什么这样写、如果不这样写会怎样”的人。链表题刚好是检验这种能力的最好试金石——因为它的解法往往很短,但每一个字符背后都有真实的结构逻辑。把这四道题真正吃透,比囫囵吞枣刷完四十道题更有价值。
