快慢指针找链表中间节点:循环条件与边界条件深度解析
2026/9/19 12:43:15 网站建设 项目流程

做链表题做多了,你会发现有不少代码长相差不多,但边界条件写法天差地别。就拿“快慢指针找链表中点”来说,网上搜到的模板基本都是这几行:

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 三个候选写法对比

在实际代码里,找中点的循环条件主要有三种写法:

写法循环条件奇数长度结果偶数长度结果
Awhile fast and fast.next正中点偏右的“下中点”
Bwhile fast.next and fast.next.next正中点偏左的“上中点”
Cwhile 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.next

Python 的and是短路求值的:先判断fast.next,如果它是None,后面的fast.next.next根本不会执行,程序不会报错。只有fast.next不是空节点时,才继续判断fast.next.next

换句话说,这一行代码本质上是在问两个问题:

  1. 当前节点的下一个节点存在吗?
  2. 如果存在,下下个节点存在吗?

两个问题都回答“是”,快指针才敢往前走两步。只要能往前走两步,慢指针也就能安全地往前走一步。

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恰好指向最后一个节点,它的nextNone,紧接着你去访问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本身为Nonefast.nextNone这两种情况。而写法 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 slow

Python 里最典型的坑是:你可能会在while fast.next and fast.next.next时忘记处理空链表head is None。如果head本身是Nonefast = 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 里nullundefined都是假值,所以while (fast && fast.next)写起来很自然。需要特别注意的是,链表节点里的next一定要初始化为null,如果某个节点意外被初始化为undefined,后面的.next访问虽然不会报错,但返回结果会是undefined,进而导致后续链式操作出现更诡异的 bug。

4.4 边界条件速查表

链表长度写法 A 返回写法 B 返回写法 C 是否安全
0None(需提前判空)直接崩溃直接崩溃
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. 把后半段反转。
  3. 用两个指针从头和后半段同时遍历,逐一比较节点值。

这里找中点时,你要特别留意使用哪种中点定义。比如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,它的nextNone,代码直接抛AttributeError。当时的第一反应是“怎么还会越界”,后来画出链表走位图才明白,问题出在缺少fast.next这层保护。

第二个是“偶数长度中点不符合预期”。用while fast and fast.next处理长度为 6 的链表,slow 最终停在节点 4,而我希望它停在节点 3。这不是报错,是逻辑偏差,比崩溃更隐蔽,因为程序完全正常跑完了。

第三个是“空链表没有提前处理”。入参为None时,fast = head得到Nonewhile fast and fast.next虽然能安全退出,但如果你在循环外面立刻访问slow.val,同样会崩。所以函数入口处if not head: return None这个判断千万别省。

6.2 排查思路:画表跑一遍比空想快十倍

无论你写的是哪种语言的版本,遇到快慢指针问题,我强烈建议不要只在脑子里模拟,画一张类似下表的走位表:

轮次fast 位置slow 位置循环条件判断结果
初始11-
132通过
253通过(若 fast.next 存在)
3None 或 不执行4停止

这张表画完,你的代码会在哪一轮停止、slow 停在哪,一目了然。排查边界问题的时候,这个方法比读十遍代码都管用。

6.3 面试实战表达的小技巧

我面试别人的时候,看到过很多候选人能写出正确代码,但问“为什么循环条件要这么写”就卡壳。其实面试官不是真的纠结语法,而是想看你对边界条件的理解深度。

建议分三步回答:

  1. 先说明快慢指针的数学原理,速度两倍导致路程两倍,快指针到末尾时慢指针在中点。
  2. 再说明判空动机,fast.next防止奇数长度时快指针访问空节点,最外层的fast防止偶数长度时快指针跳到None之后继续访问。
  3. 最后主动补充“偶数长度时我这版返回的是下中点,如果题目需要上中点,可以把条件改成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.nextfast.next.next每个判空步骤都解释清楚,后面再做环形链表、回文链表、链表排序这类题,都会顺很多。建议你现在就打开编辑器,跑一遍文中的代码,手动改几次条件,亲眼看看不同写法在奇偶长度链表上带来的差异。踩过坑之后,这个知识点就真的是你的了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询