☰
快慢指针找链表中点:两种循环条件的区别与实战
2026/9/26 17:33:22 网站建设 项目流程

我在面试的时候,经常让候选人当场写快慢指针找链表中点。代码短,五分钟能写完的人却不多,而写完之后能说清楚fast.next and fast.next.next这个循环条件为什么长这样、为什么不能少写一个的人,就更是凤毛麟角。大多数人的状态是:模板背得滚瓜烂熟,但你把条件换成fast and fast.next,他就会愣住,觉得是不是写错了。

其实两个条件都是对的,只是它们对应的“中点”语义不一样。这篇文章我想把fast.next和fast.next.next这两个判断逐项拆开,讲清楚它们各自在保护什么、停止时快慢指针分别站在哪里、奇数链表和偶数链表的行为差异,以及实际工程里这个中点指针拿来干什么用。无论是准备面试,还是在项目里写链表拆分、归并排序,把这些边界想透了,以后遇到各种变体都不慌。

1. 快慢指针的核心机制:为什么两倍速能精确落在半程

先回到最基础的问题:链表找中点,难点在哪?

单向链表没有下标,每个节点只知道自己的next,你不能像数组那样直接arr[n/2]拿中间元素。最朴素的做法是两遍遍历:第一遍数出链表长度L,第二遍从头走到L/2。这个方案的时间复杂度是 O(n),还挺好理解,唯一的问题是你得先完整走一遍才知道总共有多长,相当于“回头路”走了两趟。

快慢指针的思路则完全不一样。让两个指针同时从头节点出发,快指针每次走两步,慢指针每次走一步。等到快指针撞到链表末尾或者越过末尾时停下,慢指针走过的距离恰好是快指针的一半。快指针走完了整条链,慢指针自然就站在半程上。

这个道理用一个生活类比特别好懂:两辆车同时从起点出发,慢车每小时 60 公里,快车每小时 120 公里,等快车到达终点的那一刻,慢车一定在整条路程的正中间。因为快车速度是慢车两倍,同一个时间里跑出来的路程也是慢车两倍,所以慢车的位置始终是快车位置的二分之一。

写成代码就是最常见的骨架:

slow = head fast = head while fast and fast.next: slow = slow.next fast = fast.next.next

循环每执行一轮,快指针前进两个节点,慢指针前进一个节点。链表长度为L时,循环执行s轮,快指针走了2s步。当2s接近L的时候,s自然接近L/2。这就是为什么快指针必须走两步、慢指针必须走一步——两者速度比正好是 2:1,才能在一趟遍历之内把中点卡出来。

那为什么不像某些题目里那样让快指针走三步、慢指针走一步?因为3:1的速度比对应的是“三分之一处”,而不是“二分之一处”。快指针到终点时,慢指针只走了总长度的三分之一。想找三分点可以这么玩,但代码上你要么维护计数器,要么用一个辅助指针,比next.next这种天然表达“两步”的方式啰嗦得多。next.next正好是两步,这就是语言层面给链表找中点开的一扇便利门。

这个算法真正的价值在于:你不需要事先知道链表长度,不需要一个额外的计数器,也不需要任何容器存储节点引用。空间复杂度 O(1),时间复杂度 O(n),一趟遍历搞定。理解了这个机制,你再看任何“为什么条件长这样”的问题,核心就只有一句话:循环条件要保证快指针每次跳两步时不会越界或踩空。接下来就是把这个“不越界”翻译成代码。

2. 逐一拆解 fast.next 与 fast.next.next:非空保护、两步跳转与短路顺序

文章标题里这个条件,完整的上下文是这种写法:

slow = fast = head while fast.next and fast.next.next: slow = slow.next fast = fast.next.next

这和我们上面看到的标准写法只差了一处:条件里少了最前面的fast。要搞清楚为什么少了fast反而是安全的,就得把两个判断拆开看。

2.1 fast.next:隐含的“指针非空”检查

第一个fast.next,字面意思是“快指针的下一个节点存在”。它有两个作用。

第一,它间接保证了fast本身不为空。在 Python 这类语言里,访问null.next会直接抛异常。而fast.next这个表达式能被安全求值,前提就是fast不是空节点。所以你写fast.next,实际上已经把“fast 非空”这一层检查一起带上了。这就是为什么标题里的写法不需要单独再写一个fast and开头——只要fast.next能成立,fast肯定还站在某个真实节点上。

第二,它同时说明“快指针至少还能再走一步”。如果fast.next是None,说明快指针已经站在链表最后一个节点上,它往前走一步就会越界,循环无论如何都得停。

2.2 fast.next.next:为两步跳转做最终确认

再看第二个fast.next.next。这个判断才是真正决定“快指针能不能走两步”的检查。

在循环体里,快指针执行的是fast = fast.next.next,意思是一口气跳到后面第二个节点上。这个操作要想合法,不仅要求fast.next存在,还必须要求fast.next.next也存在。如果fast.next不为空但fast.next.next是None,说明快指针前面只剩一个节点,走一步可以,走两步就会踩出链表边界。

所以这两个判断合在一起,就是在说一句话:快指针有路可走,而且还有足够长的路能撑住它跳两步。

这种模式其实在日常生活中也常见。过马路要等两个方向都没车才走,fast.next是“第一条车道没车”,fast.next.next是“第二条车道也没车”,两个都满足才能执行fast = fast.next.next这个跨越动作。

2.3 短路求值:顺序天然空指针安全

还有一个很关键的细节藏在语言的求值顺序里。and是从左到右的短路运算符:左边为假时,右边根本不会执行。

while fast.next and fast.next.next这个顺序设计得非常讲究。执行到fast.next.next时,Python 已经确定fast.next不为空,所以访问它肯定安全。也就是说,第一个判断先帮第二个判断探了雷,第二个判断才能放心大胆地读。

如果把顺序反过来写成while fast.next.next and fast.next,逻辑上看着好像还是那三个变量,但真跑起来第一个循环就可能炸。因为当fast.next是None时,fast.next.next这一步就已经在空指针上操作了,后面的and fast.next根本没机会执行。这也是我在实际 code review 里见过最多的问题之一,后面第 5 章我会专门再提。

2.4 循环停止时,指针们站在哪

明白了两个判断的职责,再看循环结束的时刻。

写法while fast.next and fast.next.next停下时,只可能有两种情况:

  1. fast.next为None:快指针已经到了最后一个节点,前方无路可走。
  2. fast.next.next为None:快指针站在倒数第二个节点上,它只能再走一步,走两步就会越界。

不管哪种,此刻快指针都已经走完了它能安全走完的最远距离,最多也就差一步。而慢指针只走了快指针一半的步数,也就是“能安全步数的中点”。这个停止位置就是你要的那个中点指针,只不过在偶数长度链表里,它到底是左边还是右边那个中间节点,还得细看,也就是下一章的问题。

3. 偶数链表的中点选择:不同写法背后的语义差异

很多人背模板时忽略了一个事实:“中点”这个词在偶数长度的链表里本身是有歧义的。一个长度为 4 的链表,节点依次是 1、2、3、4,中间位置到底算 2 还是 3?两者都对,取决于你需要哪种语义。

如果你用的是最常见的while fast and fast.next,走一遍看结果:

链表 1 -> 2 -> 3 -> 4 -> None

  • 初始:slow=1, fast=1
  • fast非空,fast.next=2非空,进入循环:slow=2, fast=3
  • fast非空,fast.next=4非空,进入循环:slow=3, fast=None
  • fast为空,循环退出

最后 slow 停在 3。也就是说,标准写法在偶数长度时返回的是靠右的那一个中间节点,代码社区里一般叫“后驱中点”。

而标题里的写法while fast.next and fast.next.next,同样是 1 -> 2 -> 3 -> 4 -> None:

  • 初始:slow=1, fast=1
  • fast.next=2非空,fast.next.next=3非空,进入循环:slow=2, fast=3
  • fast.next=4非空,fast.next.next=None,条件不满足,循环退出

slow 停在 2。偶数长度时返回的是靠左的那一个中间节点,也就是“前驱中点”。

同样找中点,两种写法差了整整一个节点位置。这个差异我整理成一张表,方便对照:

链表长度节点序列while fast and fast.next结果while fast.next and fast.next.next结果
1[1]1空指针异常(需前置处理)
2[1, 2]21
3[1, 2, 3]22
4[1, 2, 3, 4]32
5[1, 2, 3, 4, 5]33

看出规律了吗?奇数长度时两种写法完全一样,因为奇数链表只有一个唯一的中间节点。偶数长度时才会分道扬镳:一种取右边的,一种取左边的。

为什么 LeetCode 876 这类题目的官方题解常用while fast and fast.next?因为题目里明确定义了:有中间节点并列时,返回第二个中间节点。而如果你做的是链表归并排序、按中点切分链表这类操作,很多时候你希望拿到的是一半偏左的节点,这样才能保证切出来的左右两段长度尽量均匀,左侧不会比右侧多出两个节点。这时while fast.next and fast.next.next就更合适。

还有一个衍生写法也值得记住:如果你希望快慢指针起点相差一位,让慢指针天然偏左,可以把慢指针初始化为head、快指针初始化为head.next,再配合while fast and fast.next循环。这样在长度为 2 时,slow 停在 1,也得到前驱中点。本质和标题里的写法是同一件事,只是用初始化位置替代了条件判断。

所以不要问“哪个写法是对的”,要问“你现在需要的中间节点是哪一个”。

4. 中点指针的三种实战场景:拆分、回文判断与归并排序

快慢指针找中点本身不是终点,它只是给后面操作提供了一个可靠的锚点。我做项目里用到它最多的是三个场景。

4.1 按中点拆分链表

这是链表归并排序和部分分治算法的基础操作。拿到中点之后,你要把链表切断成左右两条独立链表。关键在于切断前先把右半段的头指针存下来,否则一断开就找不回来了。

常规写法长这样:

def split_linked_list(head): if not head or not head.next: return head, None slow = head fast = head.next # 快指针先走一步,让 slow 最终落在左半段末尾 while fast and fast.next: slow = slow.next fast = fast.next.next right_head = slow.next slow.next = None # 切断左半段和右半段 return head, right_head

这里有个细节:快指针被初始化为head.next,而不是head。这样一来,偶数长度链表里 slow 会稳稳停在左半段的最后一个节点上。比如 1 -> 2 -> 3 -> 4,最终 slow 停在 2,右半段从 3 开始,左半段 1 -> 2,两边长度正好相等。如果这里用标准写法,slow 会停在 3,右半段只有 4 一个节点,左半段却有 3 个节点,长度差被放大,递归排序的分治效果就差一些。

4.2 回文链表判断

判断一个链表是否回文,经典做法是三步:找中点、反转后半段、逐节点比较。这里的中点选择也有讲究。

def is_palindrome(head): if not head or not head.next: return True slow = head fast = head while fast and fast.next: slow = slow.next fast = fast.next.next right_head = reverse(slow) # 反转后半段 left = head right = right_head while right: if left.val != right.val: return False left = left.next right = right.next return True

回文判断里我推荐标准的while fast and fast.next,也就是返回靠右的中点。因为反转后半段的时候,如果中点选得偏左,右半段会比左半段长,比较循环就得额外处理指针越界;选偏右的中点,右半段最多跟左半段一样长,以右半段为循环条件非常安全。奇数长度时中间那个节点会被反转后的自己跟自己比较一次,不影响结果。

4.3 链表归并排序

归并排序对链表特别不友好,因为链表不支持随机访问,没法像数组那样一挥手从中间劈开。这时候快慢指针找中点几乎是唯一灵活的切分手段。递归函数里先找到中点,把链表切成两半,对两半分别排序,再写一个合并两个有序链表的函数把它们合起来。

与 4.1 一样,这里通常也选择前驱中点。原因很简单:切分出来的两个子链表长度越接近,递归深度越均衡,排序效率越稳定。用后驱中点虽然也能跑,但每次左侧都比右侧长,极端情况下递归树的平衡性会变差。

这三个场景的共通点其实都是:先找一个可靠的锚点,再围绕锚点做切断或者对称比较。快慢指针的价值不在于“找到中点”这个动作本身,而在于它为后面这些复杂操作提供了一个不需要预知长度就能得到的稳定基准。

5. 实际编码中容易踩的坑:条件顺序、空指针与引用丢失

光把原理弄通还不够,手写代码时真正耽误时间的往往是几个不起眼的坑。我一个个说。

5.1 条件顺序写反:更早踩雷

有同事为了省事,把条件写成这样:

while fast.next.next and fast.next:

看起来只是把两个判断调换了位置,逻辑上“都是判断那三个变量非空”,对吗?不对。链表只有两个节点的时候,第一次进循环,fast.next存在,但fast.next.next是None。None是个假值,按说循环不该进去,但因为and左求值先看fast.next.next,在你判断它是假值之前,这个表达式本身已经完成了“读取None.next”这个动作,直接抛异常。

所以条件顺序不是排版问题,是执行顺序问题。把更基础的检查放前面,永远是好习惯。

5.2 空链表和单节点的前置处理

标题里的写法尤其要在入口做好防护。空链表时head是None,head.next直接崩溃;单节点时head.next是None,head.next.next同样崩溃。

我习惯在最前面加一层:

if not head or not head.next: return head

这样空链表、单节点都被拦下来,后面的快慢指针逻辑只需要考虑节点数大于等于 2 的情况。别小看这一行,它同时也是很多面试题目的第一个边界考察点。

5.3 切断链表时丢了右半段头指针

拆分链表时,如果先执行slow.next = None,再想取右半段头指针,就已经晚了。因为slow.next已经被置空,你永远拿不到原来的后续节点。

正确顺序是先存后断:

right_head = slow.next slow.next = None

这个顺序错误在真实工程里更容易发生,因为大家都记得要断开,却忘了断开之前先把“未来的头”攥在手里。

5.4 盲目改写成 for 循环

有人觉得 while 循环太朴素,想用固定次数的 for 循环来模拟快指针走两步。这很容易翻车,因为你无法在不知道链表长度的情况下确定循环次数。for 循环要么剩余次数太多导致快指针越界,要么次数不够导致慢指针还没走到中点。

这类问题天生适合 while,因为循环次数由“指针能不能继续安全前进”这个条件动态决定,而不是由预先算好的数字决定。

5.5 快速验证方法:画一个四节点链表

我不止一次在工位上跟人讲边界问题,讲半天不如直接在纸上画一个 4 节点链表,手动走两遍循环,把每次 slow 和 fast 的位置写下来。一轮下来,条件选择带来的差异就全清楚了。

这个方法也适用于你自己写完代码后的自查。写一个简单的辅助函数把数组转成链表,再用不同的数组长度跑一遍,打印每一轮 slow 和 fast 指向的值,往往几秒钟就能定位问题:

def array_to_linked_list(arr): dummy = ListNode(0) cur = dummy for val in arr: cur.next = ListNode(val) cur = cur.next return dummy.next

配合一小段遍历打印代码,边界行为一目了然。

我自己现在的习惯是:拿到这种题先不急着写循环,先问自己一句“循环结束时,快指针应该站在哪个位置”。把停止状态想清楚,条件自然就写出来了。fast.next and fast.next.next看着绕,其实无非是在说“快指针还能再安全地跳两步”。以后你看到while fast and fast.next,或者把快指针初始化为head.next的变体,都能第一时间反应过来它们对应的中点语义是什么。画一个四节点链表手工推两遍,比背任何模板都可靠。

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

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

立即咨询