刷代码随想录到Day4,链表part02的24、19、160、142这四道题时,我特别想吐槽一句:前面Day3的反转链表只要理清一条链,part02的四道题却要同时管理两个甚至两个移动主体。第24题两两交换,第一次写时指针乱到怀疑人生;第19题倒数第N又总是差一个偏移量;第160题相交我一开始用哈希,勉强过了;第142题看题解秒懂,自己推又卡了半小时。我后来把四道题放到一起看,发现它们不是四个独立知识点,而是同一套链表工具的三个应用场景:虚拟头节点处理边界、双指针控制距离差、画图避免指针迷路。这篇文章不是给你抄答案,而是把我为什么这样写、哪里容易错、以及怎么验证写对,完整捋一遍。适合刚刷完链表part01、准备进入part02的人,也适合已经刷过一遍但对双指针边界不熟的人当作复盘。
1. 链表part02的隐藏主线:虚拟头、双指针、画图
1.1 为什么这四道题要放在同一天刷
如果把四道题只看表面,24是交换相邻节点、19是删除节点、160是找交点、142是找环入口,似乎没什么直接关联。但实际做下来你会发现,它们都在追问同一件事:在一条只能从前往后走的链表里,如何精准表达两个节点之间的“相对位置”。
链表和数组最大的差异是,数组可以通过下标直接跳到任意位置,链表不行。你拿到一个节点指针,能访问的只有它自己和它的next,再往前就没了。所以单链表的题目几乎不会让你去“随机访问”,而是让你反复处理“前一个节点是谁、当前节点是谁、下一个节点会不会丢”。这四道题刚好覆盖了三种最常见的链表操作:
- 24题考验“一对节点的重连顺序”;
- 19题考验“两个指针保持固定间隔移动”;
- 160和142考验“两个指针走完整条链后如何相遇”。
这个认知很重要。我一开始把每道题当新知识学,结果每道都记不住。后来发现,只要掌握了虚拟头节点和双指针这两板斧,四道题的代码思路会被压缩到很短的几条规则里。
1.2 第一板斧:虚拟头节点,专门解决“头节点被改”的问题
写链表操作时,最烦的就是删除或交换后头节点变了。比如第24题,原来的head和第二节点交换后,head变成了原来的第二个节点;第19题删除倒数第N个节点,如果N正好等于链表长度,删除的其实是原head。如果不做特殊处理,每次都要写一段if head is None or head.next is None之类的边界分支,代码会很难看,也容易漏。
虚拟头节点dummy的逻辑就是:在真正的head前面额外放一个哨兵节点,让所有操作都从dummy开始。这样不管原来的head怎么变,最后都返回dummy.next,边界情况被统一成一个普通场景。
有人说“dummy又没实际值,会不会影响判断”?不会。它只是给你提供了一个“前驱节点”,让删除、交换这一类需要前驱才能完成的操作,对头节点和中间节点一视同仁。我在Day3做反转链表时还没用顺这个技巧,到Day4的24和19题,才发现dummy是刚需。
1.3 第二板斧:双指针,本质是把“位置差”变成“步数差”
双指针在链表题里有两种形态。第一种是同向移动但速度不同,比如第19题的两个指针同样都是每次走一步,但fast会先出发N步,于是两者之间始终保持一个固定间隔;第二种是相对速度不同,比如第142题的fast每次走两步,slow每次走一步,两者之间距离会不断缩短,最终相遇。
160题看起来不太像双指针,但它其实也利用了双指针:两个指针分别从两个链表的头出发,走完自己那条链后换到对方那条链继续走。这本质上是用“交换路径”让两个指针的总路程趋同,从而达到在交点相遇的目的。
很多人会把“双指针”和“快慢指针”混为一谈,觉得反正都是两个指针。其实它们解决的问题完全不同:同向双指针解决的是“如何定位某个区间”,快慢指针解决的是“是否存在环/环在哪”。如果你在纸上把每种双指针走过的路径画出来,会发现它们根本不是一回事。这也是为什么我强烈建议:链表题不要光在脑子里转,一定要画图。
1.4 第三板斧:画图,画到能预判每一步
我画图的方法很朴素:用方框表示节点,方框里的数字表示值,方框之间的箭头表示next。操作前先把当前所有指针变量标在对应节点旁,然后只按照代码顺序更新箭头。每画一步,都问自己一句:现在还有没有别的指针指向被我改掉的箭头?如果有,下一步就还能找到它;如果没有,说明节点已经丢了。
第24题我第一遍代码写错,就是因为只盯着“交换后谁在前”,没注意到node1.next = node2.next必须先执行,否则node1后面整个链条都会断掉。这种错误,光看代码很难发现,但画图后非常直观。
所以我的建议是:不要急着刷量。每道题先把图画三遍:第一遍照着题解画,第二遍关掉题解自己画,第三遍用两个随机测试用例画。画完再写代码,错误率会低很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 24. 两两交换链表中的节点:先把三根指针的移动顺序焊死
2.1 为什么题目不允许改值,反而更有价值
这道题开头有一句非常关键的限制:“不能只是单纯改变节点内部的值”。如果允许改值,操作会简单很多:直接把两个节点的val交换,链表结构完全不动。但面试官就是想让你练“怎么改next”,因为真实场景中节点内部可能不止一个val,还可能挂着其他复杂数据,不是每次都能让你轻松换值。
所以做这道题前要先接受一个事实:你手里能用的“操作”只有重新连接next,最终让节点顺序从1->2->3->4变成2->1->4->3。
2.2 迭代写法:dummy、prev、node1、node2四者的分工
先上一个最朴素的迭代实现:
python复制class Solution:
def swapPairs(self, head: ListNode) -> ListNode:
dummy = ListNode(0, head)
prev = dummy
while prev.next and prev.next.next:
node1 = prev.next
node2 = prev.next.next
node1.next = node2.next
node2.next = node1
prev.next = node2
prev = node1
return dummy.next
整个循环里一共只有四个关键节点:
prev:当前要交换的两个节点前一个节点;node1:要被交换到后面的第一个节点;node2:要被交换到前面的第二个节点;node2.next:后面还未处理的那段链表。
每次循环处理一对,处理完后把prev指向node1,因为下一对节点的前一个节点,就是已经成为“后一个节点”的node1。
2.3 为什么要按 node1.next -> node2.next -> prev.next 的顺序操作
很多人写这段代码时,喜欢先执行prev.next = node2,觉得这就完成了交换。问题在于,一旦你先把dummy指向node2,node1虽然还在原处,但你想用它时,它是否还找得到?
我们按正确顺序走一遍。
原状:prev.next指向node1,node1.next指向node2,node2.next指向后面的一段链。
第一步,把node1.next指向node2.next。这一步暂时打破了node1->node2的连接,让node1和后面的链表先联系起来。
第二步,把node2.next指向node1。这时node2反而指回了node1,形成局部回环:node2 -> node1 -> 后面链表。
第三步,把prev.next指向node2。这一步才让整个链表外部和node2接上。至此,原来的prev之后不再是node1,而是node2,交换完成。
如果一开始先做prev.next = node2,会有什么后果?prev确实指向了node2,但node1还能通过prev.next.next访问到吗?可以,前提是你提前存了node1 = prev.next。所以也不是绝对不行。但如果第二步先执行node2.next = node1,node1后面的原链表会直接丢失,因为node1原来的next已经被覆盖成自己了。这就是“先把旧next存起来,再改指针”的重要性。
2.4 停在哪里:奇数节点和偶数节点的退出条件
循环条件的写法是while prev.next and prev.next.next,这个条件等价于“后面至少还有两个节点”。因为两两交换至少需要一对节点,只有一个节点时不需要交换。
链表长度为偶数时,例如1->2->3->4,循环会处理(1,2)和(3,4)两组,最后prev落在node1即节点2,此时prev.next指向节点3,但prev.next.next为空,所以退出。
链表长度为奇数时,例如1->2->3,循环处理(1,2)后,prev落在节点2,此时还有一个节点3。因为prev.next存在,但prev.next.next为None,条件不满足,节点3保持原样停在末尾。这是正确行为:奇数个节点时最后一个节点不需要交换。
我起初犯过一个错误:把while条件写成while node1 and node1.next,但每次循环末尾没有妥善更新node1,结果循环里node1乱指。后来统一用prev.next来判断,就不容易错了。判断条件只看“下一对是否存在”,和“当前在哪”解耦,逻辑更干净。
3. 19. 删除链表的倒数第N个节点:快慢指针的偏移量差一步,结果差很多
3.1 直观解法:两次遍历并不丢人,但进阶解法要掌握
如果不知道题目要求“一次遍历”或只要求实现,最简单的办法是先走一遍链表算出总长度len,然后删除正数第len - n + 1个节点。这需要两个循环,时间复杂度O(n),空间O(1),很直观。
但第19题在LeetCode上明确要求“尝试使用一趟扫描实现”。面试里也经常作为“你会不会双指针”的考察点。所以我建议两种方法都要会写:两次遍历用于快速验证思路,一次遍历用于展示你对双指针间隔的理解。
3.2 fast先走 n+1 步,为什么不是 n 步
我先给一次遍历的代码:
python复制class Solution:
def removeNthFromEnd(self, head: ListNode, n: int) -> ListNode:
dummy = ListNode(0, head)
fast = dummy
slow = dummy
for _ in range(n + 1):
fast = fast.next
while fast:
fast = fast.next
slow = slow.next
slow.next = slow.next.next
return dummy.next
核心思想:让fast先走出n+1步,然后fast和slow同时每次走一步。因为fast比slow多走了n+1步,当fast走到链表末尾的None时,slow就停在距离末尾正好n+1个节点的地方。此时slow位于待删除节点的前一个节点,直接执行slow.next = slow.next.next即可。
这里最容易困惑的问题是:为什么是n+1而不是n?
因为你要删除的是倒数第n个节点,而删除动作需要知道它的前一个节点。如果fast先走n步,当fast走到None时,slow会落在待删除节点本身,而不是它的前驱。虽然你仍然可以通过记录slow前面的节点完成删除,但那会让代码复杂不少,也很容易把自己绕晕。
一个更符合直觉的验证:链表只有1个节点,n=1。这时要删除唯一节点。dummy指向这个节点,fast先走2步(n+1),第一次走到原head,第二次走到None;slow还停在dummy,删除后返回dummy.next也就是空链表。非常干净。
3.3 从dummy出发还是从head出发,边界差别很大
很多人在写双指针时,习惯把fast和slow都初始化为head。但第19题如果fast和slow都从head出发,fast先走n+1步时很可能出现这样的问题:链表长度为n时,fast下一步就走完了链表,恰好停在None;如果你执行for _ in range(n+1),第一次循环后fast就变None,第二次循环还在None上取.next,直接报错。
所以头节点永远不要参与“前驱”判断,除非你已经为它准备了虚拟前驱。从dummy出发,fast和slow都从dummy开始,不仅让删除头节点变得自然,也让循环退出条件变的简单:while fast保证只要fast没有走到None,slow就继续往后走。
3.4 用几个边界用例验证写的是不是真对
写完这道题后,我固定会跑四个测试用例,分别是:
[1,2,3,4,5], n=2:删除中间节点;[1,2], n=2:删除头节点;[1,2], n=1:删除尾节点;[1], n=1:删除唯一节点。
第一个用例可以验证算法主流程,后三个分别检验头删除、尾删除、单节点删除。只要这四个都通过,代码一般没问题。
我还遇到过一种非常隐蔽的错法:把while fast错写成while fast.next。这样在fast已经走到None时会直接报NoneType has no attribute next。所以循环条件一定写成while fast,不是while fast.next,这一点要刻进肌肉记忆里。
4. 160. 链表相交:双指针“换路”为什么能走到同一个交点
4.1 先搞清楚题目里“相交”的定义
这道题最容易踩的坑,是把“链表相交”理解成“两个链表里有相同的数字”。题目说的相交,指的是两个链表从某个节点开始,后面的所有节点都完全相同,也就是说两个指针最终指向了同一个节点对象。
用Python写的时候,判断是否相交应该用is或==判断节点对象,而不是比较val。比如1->2->3和9->2->3,虽然都有值为2的节点,但它们不是同一个节点,不算相交。如果面试时题目表述不清楚,可以先问面试官:“这里的相等是指节点引用相等,还是值相等?”这是一个很好的加分动作。
4.2 哈希集合解法:直观但不省空间
如果只要求AC,最无脑的做法是用一个哈希集合保存链表A的所有节点,然后遍历链表B,第一个出现在集合里的节点就是交点。
python复制class Solution:
def getIntersectionNode(self, headA: ListNode, headB: ListNode) -> ListNode:
seen = set()
p = headA
while p:
seen.add(p)
p = p.next
p = headB
while p:
if p in seen:
return p
p = p.next
return None
时间复杂度O(m+n),空间复杂度O(m)。这个方法的好处是思路简单、不容易出错,坏处是面试官很可能追问一句:“能不能不用额外空间?”这时候就要用到双指针。
4.3 双指针“换路”法:让两个指针的总路程相等
双指针解法的代码非常短:
python复制class Solution:
def getIntersectionPoint(self, headA: ListNode, headB: ListNode) -> ListNode:
pA, pB = headA, headB
while pA is not pB:
pA = pA.next if pA else headB
pB = pB.next if pB else headA
return pA
理解这段代码,关键是明白两个指针分别走了多少步。
假设链表A在交点前的长度为a,链表B在交点前的长度为b,公共部分长度为c。pA先走完A,路程是a+c;然后切到B继续走,走到交点时又走了b,总路程是a+c+b。pB先走完B,路程是b+c;然后切到A继续走,走到交点时又走了a,总路程是b+c+a。两者相等。
所以当两个指针走到交点时,它们一定处于同一个节点。如果两个链表不相交,那么pA和pB会分别走完两条链表的所有节点,最终同时变成None,循环退出,返回None。
这个思路可以用一句话总结:两条相交的链表,只是交点前的长度不同,让两个指针各自多走一段对方的路,就能把长度差抹平。
4.4 注意:换路条件要用 pA is None,而不要用 pA.next is None
我看到很多人在写这个解法时,错误地把换路条件写成了pA.next = headB,想的是“链表走到最后一个节点时再去另一条链”,结果在两条链表不相交时陷入死循环。
原因是:如果两条链表不相交,pA和pB都会走到各自的尾节点,尾节点的next是None。如果条件判断的是pA.next is None,那么两个指针会同时各自回到头节点,然后再次同时走到尾节点,永远循环下去。
正确写法是“走完整个链表,也就是pA变成None时,再从另一条链表头部开始”。这样当两条链表都不相交时,pA和pB会同时从各自链表走到None,并在None处相遇,退出循环。注意这里返回的pA就是None,正好符合无交点的情况。
4.5 与第19题的联系:一个是固定偏移,一个是交换起点
做160题时,我一直在想它和19题有什么共通点。19题是让两个指针从同一个起点出发,但一个先走N步,另一个后出发,靠“固定偏移”定位位置;160题是两个指针从不同起点出发,通过“互换剩余路径”让总路程相等。
两者都属于双指针控制“走过的距离”的套路。理解了这层关系,后面刷环形链表时,就不会觉得“怎么又是双指针”了。
5. 142. 环形链表II:用1和2的步长,不只是因为跑得快
5.1 判断有没有环,为什么快慢指针一定能相遇
这道题先说结论:一个fast每次走两步,一个slow每次走一步,如果链表有环,它们一定会在环内相遇;如果没有环,fast会先走到None。
很多人的疑问是:fast会不会把slow“跳过去”?在单链表环里不会。因为fast比slow快1步,相当于slow不动时,fast每一步相对slow靠近1个节点。链表是一个离散结构,fast和slow之间一旦距离为0,它们就在同一节点相遇,不存在“擦肩而过”的情况。
下面这个角度也很有用:slow进入环时,fast已经在环内某个位置。此时从slow的视角看,fast在它前面最多不超过一整圈。fast相对slow每步追近1,所以一定能在slow走完一圈前追上它。
5.2 相遇后,怎么找环的入口
先给出相遇点之后寻找环入口的代码:
python复制class Solution:
def detectCycle(self, head: ListNode) -> ListNode:
slow = head
fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast:
p = head
q = slow
while p is not q:
p = p.next
q = q.next
return p
return None
关键理解在相遇后的那一段while。这里不推导过于抽象的公式,我用字母拆开讲。
设:
- x:从链表头到环入口的距离;
- y:从环入口到快慢指针第一次相遇点的距离;
- z:从第一次相遇点继续走,回到环入口的距离;
- L:环的总长度,L = y + z。
slow从head出发到相遇点,走过的距离是x + y。fast从head出发到相遇点,走过的距离是x + y + kL,其中k表示fast在环内比slow多走的圈数。因为fast走过的路程恰好是slow的两倍,所以有:
code复制2(x + y) = x + y + kL
x + y = kL
x = kL - y
当k=1时,x = L - y,而L - y恰好等于z。也就是说,从头节点走到环入口的距离x,等于从相遇点继续走到环入口的距离z。
因此,在第一次相遇后,让一个指针p从head开始,另一个指针q从相遇点开始,两者每次都走一步。当p走完x到达环入口时,q也恰好走完z到达环入口,两个指针在这里相遇。这个节点就是我们要找的环入口。
如果k大于1,也就是fast多跑了好几圈,公式也能说明:从相遇点出发,每多走一整圈还会回到相遇点,最终依然会在绕了一些圈之后和从头出发的指针在环入口相遇。实际代码不需要关心k的具体值,只要两个指针同步走,总会相遇在环入口。
5.3 为什么这里不用把fast步长设成3甚至更大
有些追求“更快”的人会想:fast走3步、slow走1步,是不是更早遇到?判断有没有环,理论上fast每次走3步也不是不行,但寻找环入口时,刚才的两倍关系就失效了。如果非要从非标准步长重新推导,还要处理相遇点在环内不同相位的情况,复杂度高出很多,完全没有必要。
代码随想录里那道经典的追及问题也告诉我们,快慢指针的“快”不是越快越好,而是要让路程关系容易算。fast走2、slow走1,已经是判断环并求入口的最小自洽模型。我刷题时试过把fast改成走3步,结果很多边界用例都不好测,后来老老实实改回2步。算法题求的是稳定可解释,不是追求微秒级优势。
5.4 无环时的退出条件:fast and fast.next
环形链表II的循环条件是while fast and fast.next。如果没有环,fast会走到某个节点的next为None,或者fast本身变成None,循环退出,返回None。
这个条件也要配合fast一次走两步来理解。fast每次从当前节点跳到next.next,所以必须先保证当前节点不为空,同时下一个节点也不为空。否则当你执行fast = fast.next.next时会报空指针错误。如果还在用Java、C++这类语言,这一步尤其要小心。
单节点链表是另一个容易出错的地方。head指向一个节点且没有环时,第一次循环fast.next为None,条件不成立,直接返回None,正确。整个链表自成一个环的case,fast最终会和slow在head节点相遇,然后通过入口查找逻辑返回head,正确。这两种边界都不需要额外写if。
5.5 环形链表和相交链表的本质联系
142题和160题看起来一题问环、一题问相交,但其实是同一个几何模型的两种表现。如果把相交链表看成两条从不同入口进入同一个“公共尾部”的路,那么环形链表就是一条从某点进入“环形循环”的路。两道题都要处理“如何从终点反推起点”的问题。
刷到Day4的最后,我越来越觉得链表题大部分不会刁难你非常复杂的数学推导,它们考验的是能否把“下一个节点在哪”一直记在脑子里。
6. Day4收尾,我给自己定的链表题自查清单
6.1 四道题的最优解一览
做完一组题,我习惯把它们的解题套路和复杂度整理成一张表,方便以后快速回顾。
| 题目 | 核心思路 | 时间复杂度 | 空间复杂度 | 关键点 |
|---|---|---|---|---|
| 24. 两两交换节点 | 迭代 + 虚拟头节点 | O(n) | O(1) | 修改next的顺序 |
| 19. 删除倒数第N个节点 | 虚拟头 + 双指针偏移 | O(n) | O(1) | fast先走n+1步 |
| 160. 链表相交 | 双指针交换路径 | O(n) | O(1) | 用pA is None触发换路 |
| 142. 环形链表II | 快慢指针 + 追及推导 | O(n) | O(1) | 相遇后从头和相遇点同步走 |
如果你用哈希集合解160,空间复杂度会变成O(n),时间仍是O(n)。如果面试不限制空间,哈希集合是更不容易错的保底方案;如果追求最优解,就按表里这个思路写。
6.2 我给自己定的四条“死规矩”
第一,只要涉及删除或头节点可能变化,第一行先写dummy = ListNode(0, head)。虚拟头节点不会让你多付出多少代码量,却能消灭一大类头节点特判问题。
第二,只要有两个指针在移动,先写清楚两个指针分别停在哪。比如19题,slow要停在待删除节点的前一个节点;142题,相遇后p和q要在环入口相遇。移动之前不知道终点,代码基本是瞎写。
第三,修改任何next指针之前,先问自己这条旧next后面还有没有人需要访问。如果需要,就先把它存进临时变量。24题和19题都犯了这条规则的典型错误,比如node1.next = node2.next前不存node2.next,后面就再也拿不到那一截链表了。
第四,写完代码立刻跑四个用例:空链表、单节点、删除头节点、删除尾节点。链表题很多坑都藏在边界里,不主动测等于没写完。
6.3 后续可以顺路刷的题
Day4之后,如果想巩固链表操作,我建议按难度顺序刷几道关联题:
-
- K 个一组翻转链表:把24题从“两个一组”升级成“K个一组”;
-
- 旋转链表:需要先成环再断开,和环形链表相关;
-
- 链表的中间结点:快慢指针最简单的入门,可以用来验证双指针理解;
-
- 重排链表:综合了找中点、反转链表、合并三个操作,是很好的终极大题。
这几道题不是Day4的必刷项,但做完之后再回头看24、19、160、142,会轻松很多。
6.4 一个坚持了两遍的笨办法
说实话,Day4我刷了两遍。第一遍基本靠题解硬啃,看了忘,忘了看;第二遍才算是真正掌握了。第二遍我没有再看题解,而是给自己出三个问题:prev应该指向哪?fast退出循环时停在哪?为什么循环条件长这样?能不看答案把这三个问题讲明白,代码基本就写对了。
链表part02的四道题,不能靠背。背得了24的代码,背不了142的推导。把这些为什么想清楚,再回头刷题,你会发现自己不是在做新题,而是在反复验证那几条已经印在脑子里的链表操作规则。
