做链表题做多了,你会发现有不少代码长相差不多,但边界条件写法天差地别。就拿“快慢指针找链表中点”来说,网上搜到的模板基本都是这几行:
slow = head fast = head while fast and fast.next: fast = fast.next.next slow = slow.next但很多人贴代码的时候根本不解释,为什么循环条件非得是fast and fast.next,而不是fast.next and fast.next.next,也不是fast and fast.next.next?你照着写没问题,可一旦你自己想改一版、想处理更复杂的场景,立刻翻车。
这篇文章不打算重复“快慢指针是什么”这种入门科普,而是专门把fast.next and fast.next.next这个写法的来龙去脉讲透:它背后到底在防御什么,链表长度奇偶会怎样影响结果,以及在不同语言里写这个条件时容易踩哪些坑。适合刚把链表基础过完、开始刷中等题的人,也适合那些代码写了好几年但没细想过边界条件的工程岗同学。
1. 先彻底搞懂快慢指针为什么能找中点
1.1 快慢指针的物理模型
很多人把快慢指针理解成一个数学技巧,其实它更像一个“两个人跑步”的问题。slow每次走一步,fast每次走两步,两个人同时从链表的head出发。因为fast的速度恰好是slow的两倍,所以在同一段时间内,fast走过的路程一定是slow的两倍。
当fast走到链表末尾(null)时,它走过的总路程就是链表的完整长度 L。那么此时slow走过的路程就是 L/2。在一个单链表里,slow从头部开始移动,它停下来的位置就是链表的第 L/2 个节点——也就是中点附近。
这个原理用一句话概括就是:路程比例等于速度比例,快指针全程跑完时,慢指针刚好跑了一半。
1.2 为什么不能快指针走三步、慢指针走一步
有同学会问,那让fast每次走三步不是更快吗?理论上fast走到末尾时slow也走过了 L/3,但这只能用来找“三等分点”,不是“中点”。而且链表节点是离散的,走三步时fast很容易直接越界跳过末尾节点,判空逻辑会更麻烦。
快指针走两步是经过权衡的选择:第一步先到next,第二步再到next.next,每一步都有明确的判空机会,代码可读性和安全性都更好。很多实际面试题(比如后面要说的环形链表、回文链表)也都是基于“速度差为 1”这个前提来设计的,走三步反而把简单问题复杂化了。
2. 循环条件的三版写法,差一个 next 结果完全不同
2.1 三个候选写法对比
在实际代码里,找中点的循环条件主要有三种写法:
| 写法 | 循环条件 | 奇数长度结果 | 偶数长度结果 |
|---|---|---|---|
| A | while fast and fast.next | 正中点 | 偏右的“下中点” |
| B | while fast.next and fast.next.next | 正中点 | 偏左的“上中点” |
| C | while fast and fast.next.next | 可能空指针 | 下中点(不推荐) |
注意看,写 A 和写 B 之间,只是把“先判断谁”换了位置,但对偶数长度的链表来说,得到的中点位置完全不同。这是很多初学者第一次卡住的地方:明明代码看起来差不多,怎么结果不一样?
2.2 奇数长度链表跑一遍
先拿长度 5 的链表举例:
1 -> 2 -> 3 -> 4 -> 5 -> None 初始:slow=1, fast=1 第1轮:slow=2, fast=3 第2轮:slow=3, fast=5 第3轮判断:fast.next 是 None,循环停止 结果:slow=3,正好是中点这种情况下,A、B 两种写法都能正确返回节点 3,因为奇数长度链表不存在“上中点”和“下中点”之分,只有一个正中间节点。
2.3 偶数长度链表跑一遍
再看长度 6 的链表:
1 -> 2 -> 3 -> 4 -> 5 -> 6 -> None 初始:slow=1, fast=1 第1轮:slow=2, fast=3 第2轮:slow=3, fast=5 第3轮:slow=4, fast=None 第4轮判断:fast 为 None,循环停止(写法A) 结果:slow=4写法 A 因为先判断fast是否为空,所以 fast 走到None之后循环立刻结束,此时 slow 停在了节点 4,也就是“下中点”。如果把循环条件换成写法 B,判断的就是fast.next,第 3 轮结束后 fast 已经不在 5 而是在None,再去取fast.next会直接报错,所以 B 在第 3 轮判断前就停止了,slow 停在节点 3,也就是“上中点”。
总结一句话:判断顺序决定偶数长度时中点偏左还是偏右。
3. 深入拆解fast.next and fast.next.next的每一层含义
3.1 先把判空顺序说清楚
如果你选择写法 B:
while fast.next and fast.next.next: fast = fast.next.next slow = slow.nextPython 的and是短路求值的:先判断fast.next,如果它是None,后面的fast.next.next根本不会执行,程序不会报错。只有fast.next不是空节点时,才继续判断fast.next.next。
换句话说,这一行代码本质上是在问两个问题:
- 当前节点的下一个节点存在吗?
- 如果存在,下下个节点存在吗?
两个问题都回答“是”,快指针才敢往前走两步。只要能往前走两步,慢指针也就能安全地往前走一步。
3.2 为什么必须是fast.next.next,而不是fast.next
假设你把条件改成while fast.next,那么快指针每次只走一步,慢指针也走一步。这就不叫快慢指针了,退化成两个同步移动的指针,循环结束后 slow 会停在链表的最后一个节点上,根本得不到中点。
3.3 如果漏掉fast.next的判断会怎样
假设你的代码写成了:
while fast.next.next: fast = fast.next.next slow = slow.next在链表长度为奇数的时候,最后一轮循环开始时fast恰好指向最后一个节点,它的next是None,紧接着你去访问None.next,在 Python 里会出现AttributeError: 'NoneType' object has no attribute 'next',在 C/C++ 里就是经典的“段错误”,程序直接崩溃。
所以fast.next的判断不是可有可无的,它的核心作用就是防止快指针走到最后一个节点后“还想再走一步”。
3.4 漏掉最外层的fast判断会怎样
如果是偶数长度链表,快指针最后一轮会从倒数第二个节点跳到None。此时如果不判断最外层的fast是否为空,下一轮循环进到里面访问fast.next一样会崩溃。
这就是为什么写法 A 要用while fast and fast.next——它同时防御了fast本身为None和fast.next为None这两种情况。而写法 B 用while fast.next and fast.next.next隐含了一个前提:进入循环前你已经确认链表至少有一个节点,因为只要进入循环体,fast.next必须存在,否则连判断都做不了。
3.5 中点的定义不同,代码选择就不同
实际工程和算法题里,“中点”有时候不是唯一的。
- 如果你的目的是把链表对半拆分成两段,比如归并排序的
split步骤,通常希望偶数长度时左半段和右半段节点数量一致,那可以用写法 B 返回上中点。 - 如果题目明确说要返回“中间偏右的节点”,比如 LeetCode 876 题“Middle of the Linked List”,那就要用写法 A。
代码本身没有绝对的对错,但你必须清楚每一版代码返回的是哪一个节点,以及题目到底要哪一个。
4. 多语言实现细节,空指针永远是第一道坎
4.1 Python 写法与典型坑
class ListNode: def __init__(self, val=0, next=None): self.val = val self.next = next def find_middle(head): if not head: return None slow = head fast = head while fast and fast.next: fast = fast.next.next slow = slow.next return slowPython 里最典型的坑是:你可能会在while fast.next and fast.next.next时忘记处理空链表head is None。如果head本身是None,fast = head就等于None,然后循环条件里的fast.next会直接报AttributeError。所以写完循环条件之前,一定要先想清楚入参能不能为空。
4.2 C/C++ 写法与指针判空
struct ListNode { int val; ListNode *next; ListNode(int x) : val(x), next(nullptr) {} }; ListNode* findMiddle(ListNode* head) { if (head == nullptr) return nullptr; ListNode* slow = head; ListNode* fast = head; while (fast != nullptr && fast->next != nullptr) { fast = fast->next->next; slow = slow->next; } return slow; }C++ 里最容易犯的错误是把&&写成||。一旦写成||,当fast已经为nullptr时,fast->next依然会被执行,段错误当场教你做人。
另外注意fast->next->next这一步在 C++ 里是连环取指针:先取fast->next,如果它已经为空,第二步->next一样会崩。所以&&短路求值在 C++ 里不是可选项,而是保命符。
4.3 JavaScript 写法与 null
function findMiddle(head) { if (!head) return null; let slow = head; let fast = head; while (fast && fast.next) { fast = fast.next.next; slow = slow.next; } return slow; }JS 里null和undefined都是假值,所以while (fast && fast.next)写起来很自然。需要特别注意的是,链表节点里的next一定要初始化为null,如果某个节点意外被初始化为undefined,后面的.next访问虽然不会报错,但返回结果会是undefined,进而导致后续链式操作出现更诡异的 bug。
4.4 边界条件速查表
| 链表长度 | 写法 A 返回 | 写法 B 返回 | 写法 C 是否安全 |
|---|---|---|---|
| 0 | None(需提前判空) | 直接崩溃 | 直接崩溃 |
| 1 | 节点1 | 节点1 | 崩溃 |
| 2 | 节点2 | 节点1 | 节点2 |
| 3 | 节点2 | 节点2 | 节点2 |
| 4 | 节点3 | 节点2 | 节点3 |
| 5 | 节点3 | 节点3 | 节点3 |
这个表建议你收藏。面试时如果能快速说出“偶数长度下 A 返回下中点,B 返回上中点”,比闷头写代码要加分不少。
5. 快慢指针的价值远不止“找中点”
5.1 用在中点之后:回文链表判断
回文链表是面试高频题,常规思路就是三步:
- 用快慢指针找到链表后半段的起始点。
- 把后半段反转。
- 用两个指针从头和后半段同时遍历,逐一比较节点值。
这里找中点时,你要特别留意使用哪种中点定义。比如1 -> 2 -> 2 -> 1这条偶数长度链表,快慢指针用写法 A 会返回第二个 2,反转后半段后从头比较,逻辑上是顺的。但如果你习惯性用了写法 B,返回第一个 2,后半段就变成了2 -> 1,最后比较时头部 1 对不上 2,代码就误判了。
我当初在这上面踩过坑,后来总结成一个习惯:凡是涉及找中点后需要“切半”的题目,先明确题目要上中点还是下中点,再动笔写循环条件。这一条能帮你省下大量调试时间。
5.2 用在环形检测:Floyd 判圈算法
快慢指针更出名的应用是检测链表是否有环。同一个起点,slow走一步、fast走两步,如果链表有环,那么fast一定会追上slow;如果无环,fast会先走到None。
这里循环条件通常这样写:
while fast and fast.next: fast = fast.next.next slow = slow.next if fast == slow: return True return False注意,找环的时候我们不再关心中点位置,只关心两个指针是否相遇。但循环条件的判空逻辑和找中点完全一致,本质都是“快指针在向前跳之前,必须先确认自己有地方可跳”。
5.3 用在找倒数第 K 个节点
找链表倒数第 K 个节点,也可以看作一种更广义的快慢指针:一个指针先走 K 步,然后两个指针以相同速度移动,当前面的指针到达末尾时,后面的指针正好停在倒数第 K 个节点上。
这种用法里两个指针速度相同,但起始位置不同。和找中点的“速度不同”形成互补,二者合在一起,你会发现快慢指针玩的其实只有两件事:速度差和起始偏移。
5.4 扩展到数组:原地去重也是快慢指针
很多人不知道,数组有序原地去重用的也是快慢指针思想:慢指针指向“已处理区域的末尾”,快指针遍历整个数组。遇到不重复的数字,就把快指针的值复制到慢指针的下一个位置。
这类题目的核心逻辑和前文链表找中点一样:利用两个指针的偏移量来表达某种状态关系。一旦你形成了这种抽象视角,再看各种变体题就会觉得是同一套东西在不同数据结构上的投影。
6. 实操中的坑与排查实录
6.1 我调试时碰到的三个典型报错
第一个是“奇数长度链表空指针”。我早期写过一版代码,循环条件用的是while fast.next.next,链表长度是 5,最后一轮判断时 fast 指向节点 5,它的next是None,代码直接抛AttributeError。当时的第一反应是“怎么还会越界”,后来画出链表走位图才明白,问题出在缺少fast.next这层保护。
第二个是“偶数长度中点不符合预期”。用while fast and fast.next处理长度为 6 的链表,slow 最终停在节点 4,而我希望它停在节点 3。这不是报错,是逻辑偏差,比崩溃更隐蔽,因为程序完全正常跑完了。
第三个是“空链表没有提前处理”。入参为None时,fast = head得到None,while fast and fast.next虽然能安全退出,但如果你在循环外面立刻访问slow.val,同样会崩。所以函数入口处if not head: return None这个判断千万别省。
6.2 排查思路:画表跑一遍比空想快十倍
无论你写的是哪种语言的版本,遇到快慢指针问题,我强烈建议不要只在脑子里模拟,画一张类似下表的走位表:
| 轮次 | fast 位置 | slow 位置 | 循环条件判断结果 |
|---|---|---|---|
| 初始 | 1 | 1 | - |
| 1 | 3 | 2 | 通过 |
| 2 | 5 | 3 | 通过(若 fast.next 存在) |
| 3 | None 或 不执行 | 4 | 停止 |
这张表画完,你的代码会在哪一轮停止、slow 停在哪,一目了然。排查边界问题的时候,这个方法比读十遍代码都管用。
6.3 面试实战表达的小技巧
我面试别人的时候,看到过很多候选人能写出正确代码,但问“为什么循环条件要这么写”就卡壳。其实面试官不是真的纠结语法,而是想看你对边界条件的理解深度。
建议分三步回答:
- 先说明快慢指针的数学原理,速度两倍导致路程两倍,快指针到末尾时慢指针在中点。
- 再说明判空动机,
fast.next防止奇数长度时快指针访问空节点,最外层的fast防止偶数长度时快指针跳到None之后继续访问。 - 最后主动补充“偶数长度时我这版返回的是下中点,如果题目需要上中点,可以把条件改成
while fast.next and fast.next.next”。
这套回答下来,面试官基本能确认你不是在背模板。
6.4 不同题目如何快速决定用哪种写法
我自己刷题时会做一个小决策:
题目是否明确要求“两个中点中偏右的那个”? 是 -> 用 while fast and fast.next 否 -> 看后面是否要切半 是 -> 用 while fast.next and fast.next.next 否 -> 优先用 while fast and fast.next这个决策流程帮我减少了很多“代码能跑但结果不对”的尴尬情况。说到底,快慢指针本身不难,难的是在不同场景下选择正确的边界条件,并清楚自己写出来的代码到底会落在哪个节点上。
根据我个人经验,链表相关的算法题里,边界条件占了一大半的出错率。快慢指针找中点又是一个最典型的例子。你要是能在纸上把奇偶长度的走位各画一遍,把fast.next和fast.next.next每个判空步骤都解释清楚,后面再做环形链表、回文链表、链表排序这类题,都会顺很多。建议你现在就打开编辑器,跑一遍文中的代码,手动改几次条件,亲眼看看不同写法在奇偶长度链表上带来的差异。踩过坑之后,这个知识点就真的是你的了。