☰
链表的中间结点:快慢指针原理、边界条件与工程应用解析
2026/10/6 8:29:54 网站建设 项目流程

作为一个在 LeetCode 上刷了三百多道题、又在实际工程里跟链表打过不少交道的开发者,我一直觉得链表类的题目是最能考验“指针感觉”的。尤其“链表的中间结点”这道题,看起来人畜无害,很多人的第一反应都是“先遍历一遍数出长度,再走一半”,但等你真正理解了快慢指针的玩法,会发现它几乎能串起链表算法里一半的思维模型。今天就把这道题从暴力到最优、从代码到边界、从原理到应用,完完整整拆开聊一遍。

这道题适合谁看?如果你正在准备算法面试、刚接触链表想建立指针直觉,或者在工作中需要自己实现链表相关逻辑,这篇都值得读完。我会把每一步的“为什么”也讲清楚,不是单纯丢个标准答案给你背。

1. 题目到底在问什么:先搞清楚“中间”的定义

1.1 原题描述与输入输出约定

题目很简短:给定一个非空链表的头结点head,返回链表的中间结点。如果有两个中间结点(也就是链表长度为偶数),则返回第二个中间结点。

输入是链表的头指针,输出是某个结点的指针(或者引用),不是结点的值,也不是下标。这一点很关键,很多人写代码时容易把结点和值搞混。

链表结点在 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) {} };

1.2 偶数长度时为什么返回第二个中间结点

这是初学者最容易卡住的一个点。链表1 -> 2 -> 3 -> 4,长度为 4,中间结点到底是 2 还是 3?

LeetCode 876 题的要求是返回第二个,也就是结点 3。原因是这个定义在“找中点做归并排序”等场景里更自然,因为归并排序递归拆分时,把链表切成前一半[1, 2]和后一半[3, 4],3作为后半段的起点,位置刚刚好。如果返回第一个中间结点,拆分时前一半[1, 2, 3]和后一半[4]就失衡了。

但注意,这只是一个约定。面试时如果你不确定,完全可以主动问面试官:“偶数长度时返回第一个还是第二个中间结点?”这种提问不会减分,反而会显得你考虑问题全面。实际工程里两种定义都会出现,你得会灵活切换。

1.3 单结点与空链表的边界约定

题目明确说链表是非空的,所以空链表不用处理。但很多进阶版的题目会允许空链表传入,你的解题代码最好仍然具备鲁棒性。单结点链表的情况,无论是那种定义,返回的都是它自己,这个没有任何争议。

我建议在写任何链表函数时,开头都习惯性地做一次空指针判断,哪怕题目说非空。因为真实工程里你不知道上游会传什么进来,防御性编程的肌肉记忆很重要。

2. 暴力解法不是笨办法,它是思考的起点

2.1 解法一:先缓存到数组,再按下标取中间

这是最没有思维难度的解法:遍历一遍链表,把所有结点指针存进动态数组,然后直接返回下标n/2对应的元素。

C++ 代码:

class Solution { public: ListNode* middleNode(ListNode* head) { vector<ListNode*> nodes; ListNode* cur = head; while (cur) { nodes.push_back(cur); cur = cur->next; } return nodes[nodes.size() / 2]; } };

时间复杂度 O(n),空间复杂度 O(n)。如果链表长度上万,这个方案会额外开辟同样量级的空间。

为什么我要先讲这个解法?因为它是“最容易验证正确性”的方案。当你没有思路时,先写一个能跑的版本,再在这个基础上去优化,是工程师解决问题的正常路径。直接一步到位写出快慢指针当然好,但如果你在面试现场卡住了,先把数组法写出来,至少能证明你理解链表遍历和基本的下标定位。

2.2 解法二:两轮遍历,用长度除以二定位

既然数组法要额外空间,那自然的优化方向就是:先遍历一遍数出链表长度n,然后再从头走n/2步,停下的位置就是中间结点。

class Solution { public: ListNode* middleNode(ListNode* head) { int n = 0; ListNode* cur = head; while (cur) { n++; cur = cur->next; } cur = head; for (int i = 0; i < n / 2; i++) { cur = cur->next; } return cur; } };

时间上仍然是 O(n),但实际要走两遍链表(约 1.5n 次访问),空间降到了 O(1)。这个解法是很多人的默认答案,因为它“足够好”,而且在单链表上,能做的操作本来就有限。

2.3 为什么说暴力解在面试里反而可能是加分项

很多刷题攻略喜欢贬低暴力解,我却持相反观点。面试官在你给出暴力解之后追问“能不能再优化一下”,你如果能顺畅地过渡到快慢指针,这恰恰展示了你完整的思维过程:从最朴素的“数长度”到“用两个指针省掉预扫描”,这本身就是一次小小的算法演进。

怕的是什么?怕的是你背了快慢指针的模板,但说不清为什么对、边界怎么处理。那面试官一追问就露馅了。所以本文下面的部分,重点讲清楚原理,而不是只给你一个能通过测试的代码。

3. 快慢指针:一遍遍历拿到答案的核心思路

3.1 快慢指针的运行逻辑与正确性证明

快慢指针的思路用一句话概括就是:让两个指针同时从 head 出发,慢指针每次走一步,快指针每次走两步。当快指针到达链表末尾时,慢指针恰好走到中间。

这个结论看起来像魔术,其实道理非常朴素。假设链表长度为 L,快指针速度是慢指针的 2 倍。快指针走完全程时,慢指针走过的路程自然是 L/2。在单链表这种线性结构里,路程就是步数,也就是结点数,所以慢指针正好停在中间位置。

我用个生活化的类比帮你建立直觉:你和朋友在跑道上跑步,你每秒跑 1 米,朋友每秒跑 2 米。朋友跑完 400 米时,你正好跑完 200 米,也就是跑道的一半。快慢指针就是把这个物理事实直接搬到了链表上。

3.2 标准实现代码与while循环条件拆解

class Solution { public: ListNode* middleNode(ListNode* head) { ListNode* slow = head; ListNode* fast = head; while (fast != nullptr && fast->next != nullptr) { slow = slow->next; fast = fast->next->next; } return slow; } };

Python 版本:

class Solution: def middleNode(self, head: Optional[ListNode]) -> Optional[ListNode]: slow = fast = head while fast and fast.next: slow = slow.next fast = fast.next.next return slow

这个 while 条件是全题最容易写错的地方。我拆开解释一下:

  • fast != nullptr负责处理“链表长度是偶数”的情况。偶数链表走完时,fast 恰好变成空指针,此时 slow 停的位置就是第二个中间结点。
  • fast->next != nullptr负责处理“链表长度是奇数”的情况。奇数链表走完时,fast 恰好停在最后一个结点上,它的 next 是空的,此时 slow 在正中间。

两个条件用逻辑与连接,顺序是fast先判空,再判fast->next。如果你把这个顺序写反了——先访问fast->next再判fast本身,当 fast 已经是空指针时,fast->next会直接触发空指针解引用,程序崩溃。

3.3 一次过一个测试用例:把执行过程在纸上画出来

拿1 -> 2 -> 3 -> 4 -> 5举例。初始 slow = fast = 1。

  • 第 1 步循环:fast 非空且 fast->next 非空(指向 2)。slow 走到 2,fast 走到 3。
  • 第 2 步循环:fast 非空且 fast->next 非空(指向 4)。slow 走到 3,fast 走到 5。
  • 第 3 步判断:fast 非空,但 fast->next 为空。循环退出。
  • slow 停在 3,正好是中间结点。

再拿1 -> 2 -> 3 -> 4举例。初始 slow = fast = 1。

  • 第 1 步循环:slow 走到 2,fast 走到 3。
  • 第 2 步循环:slow 走到 3,fast 走到 nullptr。
  • 第 3 步判断:fast 为空,循环退出。
  • slow 停在 3,正好是第二个中间结点。

我强烈建议你第一次学这个算法时,不要只在脑子里想,拿支笔在草稿纸上把指针移动画出来。画两三组不同长度的链表之后,快慢指针的直觉就彻底长在你身上了,比背十遍代码都管用。

4. 边界条件比主逻辑更值得较真

4.1 空链表、单结点、双结点各自的行为验证

虽然题目说链表非空,但作为工程习惯,我们还是验证一下边界情况:

  • 空链表:直接返回 nullptr,在 LeetCode 环境里不会有这组输入,但在本地测试时你会感谢自己的防御性判断。
  • 单结点链表[1]:while 条件不成立,slow 不动,返回 head,正确。
  • 双结点链表[1, 2]:循环一次,slow 走到 2,fast 走到 nullptr。返回 2,即第二个中间结点,符合题意。

4.2 如果题目要求返回第一个中间结点,循环条件怎么改

实际面试中经常有变体:偶数长度时要求返回第一个中间结点(比如[1, 2, 3, 4]要返回 2)。此时只需要把循环条件改成:

while (fast->next != nullptr && fast->next->next != nullptr) { slow = slow->next; fast = fast->next->next; }

差别在哪?原来的条件是“快指针还有继续走的空间”,改后的条件是“快指针还能两步两步地走”。对于[1, 2, 3, 4],初始 fast 指向 1,fast->next指向 2,fast->next->next指向 3,条件成立,slow 走到 2,fast 走到 3。下一步判断,fast->next指向 4,但fast->next->next是空,循环退出,slow 停在 2。

理解这个改动的前提是:你要时刻意识到,链表长度的奇偶会影响快指针停在哪里。你要的不是“快指针走到尽头时慢指针在哪”,而是“快指针走到某个特定位置时慢指针在哪”。慢指针的位置本来就是由快指针的步数决定的,所以调整快指针的步数规则,就能控制慢指针的最终落点。

4.3 我在本地调试时踩过的坑与验证方法

说一个我自己的真实经历。有次我在本地写这个算法的变体,一时手快把条件写成了while (fast && slow->next),结果链表长度为奇数时没问题,偶数时直接死循环。原因是 slow->next 在链表尾部是空,但 fast 还没到空,两个条件一组合,循环条件永远为真,slow 在链表尾部原地打转。

那次排查花了快二十分钟,最后是打印 slow 和 fast 的步数才发现问题。所以我后来养成了两个习惯:

第一,涉及指针移动的循环,先在循环体里加步数计数器,调试时打印每轮步数,确认各自走了多少步。

第二,不要试图一次性把一个包含三四条件判断的链表循环写对,先运行几个最小用例(空、单结点、双结点、奇数、偶数),确认边界行为符合预期,再往复杂场景扩。

5. 找到中间结点之后:它在真实算法里的位置

5.1 归并排序中利用中间结点拆分链表

链表归并排序的第一步就是找到链表的中点,把链表切成前后两半。标准做法是在快慢指针基础上多维护一个prev指针,用于把前半段的尾结点的 next 置空,完成真正意义上的切分。

ListNode* splitList(ListNode* head) { if (!head || !head->next) return head; ListNode* slow = head; ListNode* fast = head; ListNode* prev = nullptr; while (fast && fast->next) { prev = slow; slow = slow->next; fast = fast->next->next; } if (prev) prev->next = nullptr; return slow; }

注意这里为什么需要prev:如果不把中间结点的前一个结点的 next 置空,前半个链表会一直延伸到后半段,递归排序时访问范围就不可控了。写链表的拆分算法时,prev指针几乎是标配。

5.2 回文链表判断:中点之后反转后半段再比较

判断一个链表是否是回文结构(如1 -> 2 -> 2 -> 1),常规模板是三步:找中间结点、反转后半段、同步比较前半段和反转后的后半段。这里对偶数长度的处理就必须“返回第二个中间结点”,因为反转后半段时我们要从对称轴的右侧开始。

如果你在这个场景用了“返回第一个中间结点”的版本,反转的起点就偏了,比较结果会错。

5.3 其他依赖“中位”定位的链表操作

找中间结点还常用于:

  • 从中间结点开始遍历或处理剩余部分(比如某些题目要求先处理链表后半段)。
  • 交替打印前后半段的值。
  • 在对链表做某种平衡操作时,寻找切分点。
  • 需要在一个长度未知的链表中快速估计规模时,通过慢指针位置反推快指针步数。

这些场景单独拿出来都不难,但共同点是都依赖“在不预知长度的情况下,用双指针做相对定位”的思想。你把这个思想吃透了,以后遇到“求倒数第 k 个结点”“求链表成环的入口”这类题,会明显感觉轻松许多。

6. 快慢指针家族的题型对比与通用心法

6.1 对比:环形链表、倒数第k个结点、中间结点

快慢指针不只用于求中点,它是一整个家族。我把常见的三种列在下面,放在一起对比它们的指针速度差与停止条件:

题目指针速度停止条件核心目标
链表的中间结点快=2,慢=1快指针到达末尾慢指针停在中间
环形链表检测快=2,慢=1快指针追上慢指针判断是否有环
倒数第k个结点快先走k步,再同步走快指针到达末尾慢指针停在倒数第k个

对比之后会发现一个共通规律:快慢指针的本质,是用两个不同速度的指针在一条未知长度的链表上制造一个相对位移。中间结点是“快走完全程,慢走一半”;倒数第 k 个是“快先透支 k 步,然后匀速,最后快走完时慢正好离尾部 k 步”;环形检测是“如果有环,速度差会让快指针每一轮逼近慢指针一步,最终追上”。

6.2 为什么快指针每次多走一步而不是一步以上

你可能会有个疑问:快指针为什么非得一次走两步,不能走三四步?只要速度差大于 0,环检测最终都能追上,为什么统一用差 1?

从正确性角度讲,快指针走三步也能找中点,但停止条件的处理会变得非常别扭。快指针一次走三步,链表长度为奇数时,快指针可能越过末尾,你得单独判断“越过”和“正好停在末尾”两种情形,代码复杂度直线上升。快指针一次走一步、慢指针不走,那是数数,不是找中点。两步是数学上最简洁、停止条件最干净的选择。

从工程角度讲,一次走两步还隐含了一件事:快指针每次移动的步数跟慢指针的步数保持倍数关系,这样慢指针的最终位置才正好是快指针路径的中点。改成三步,慢指针就不再是简单的一半了。

6.3 从一道题看一类问题的共同陷阱

我见过不少人在快慢指针题目上交了“看似能跑”的代码,测试用例少了就通过,多了就崩。绝大多数问题都出在空指针解引用和死循环上。

总结下来三句话:

  • 先判空再访问成员,尤其是fast->next->next这种链式访问,任何一环都可能为空。
  • 循环退出后,慢指针的位置取决于循环条件本身,如果你改了循环条件,慢指针落点就会变,测试时每个变体都要单独验证。
  • 本地测试时不要只测 LeetCode 用例,至少补齐长度为 0、1、2、3、4、5 的六组数据。

说实话,“链表的中间结点”这道题本身并不难,难点在于你能否顺手把边界条件和变体问题都处理得滴水不漏。而这恰恰是算法面试真正考察的东西:不是代码背得多熟,而是在细节和异常面前能否保持清晰。

对我个人来说,这道题之所以值得反复咀嚼,是因为它培养的快慢指针直觉,几乎成了我解决链表类问题的默认武器。你如果能把这道题的原理和变体都吃透,后面遇到的很多链表题,都会发现似曾相识。

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

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

立即咨询