1. 第876题被放进Hot 100,不是因为它难,而是因为它"底盘"
1.1 题目只要一句话,但信息量并不小
Hot 100刷题路线走到第20题,我把它留给了一道看起来一分钟就能写完的题:链表的中间结点。LeetCode 876放在Hot 100里其实挺有迷惑性——代码短、解法固定、不涉及复杂推导,很多人扫一眼就觉得"背个模板就行"。但真到了面试,这道题反而是翻车重灾区。
题目原文很简洁:给定单链表的头节点 head,返回链表的中间节点;如果链表有两个中间节点,则返回第二个中间节点。
注意这句"如果有两个中间节点,则返回第二个"。这是整道题最容易被忽略、也最决定代码形态的一句话。链表长度是偶数时,比如 1->2->3->4,中间节点到底算节点2还是节点3?题目明确告诉你:返回节点3。这个约定直接影响了快慢指针的初始化和终止条件,后面我会专门展开。
为什么这样一道"看起来像送分题"的题目能进Hot 100?我的理解是:它考的不是你会不会找中点,而是你对链表遍历、指针移动、边界条件这三件事的整体掌控力。链表中点在算法面试里属于"地基型操作",后面刷回文链表、重排链表、环形链表,全都要用到它。地基打不牢,后面每一道题都会给你颜色看。
1.2 中间节点的约定:为什么题目要强调"第二个"
我们先捋清楚"中间节点"这个概念在链表里到底怎么定义。
- 长度为奇数:1->2->3->4->5,中间节点明显是节点3,没有歧义。
- 长度为偶数:1->2->3->4,数学上可以说中间有两个节点节点2和节点3。LeetCode 876选择了后者,也就是靠右的那一个。
如果用0-based索引来表示,长度为n的链表,中间节点对应的下标就是 n // 2。这个公式很重要,后面两遍遍历的写法会直接用上。
我在刷题时有个习惯:拿到这种有明确约定的题目,会先把约定写在题解笔记最上面。比如这一题,我会写"偶数长度返回靠右节点,等价于索引 n//2"。这看起来是废话,但它决定了你选择哪种双指针写法。很多教程里教的双指针是"慢指针指向头、快指针指向第二个节点",那种写法找到的是靠左的中间节点。LeetCode 876要的是靠右的,所以你必须用另一套写法。
如果面试时遇到一道"找中间节点"的题但没说偶数情况,我建议先反问面试官一句:"如果链表长度是偶数,您希望返回前一个还是后一个?"这一问能直接展现出你对边界条件的敏感度,比闷头写代码加分得多。
2. 两遍遍历:面试初期最稳的保底写法
2.1 第一遍数长度,第二遍走一半
很多人一上来就写快慢指针,我不反对,但我觉得更稳妥的起点是先掌握两遍遍历。
思路非常简单:第一遍从头走到尾,数出链表长度 n;第二遍从头开始,走 n/2 步(整数除法),停下来的节点就是答案。
对应到本题,因为要返回靠右的中间节点,所以走 n // 2 步是对的。比如 n=5,n//2=2,从头节点开始走两步到达节点3;n=6,n//2=3,走三步到达节点4。这和上一节说的"中间节点下标是 n//2"完全吻合。
Python参考实现是这样:
class Solution: def middleNode(self, head: Optional[ListNode]) -> Optional[ListNode]: length = 0 cur = head while cur: length += 1 cur = cur.next mid_steps = length // 2 cur = head for _ in range(mid_steps): cur = cur.next return cur这段代码的时间复杂度是 O(n),空间复杂度 O(1)。实际提交完全能过,代码也很好理解。我在带新人刷题的时候,会要求他们先把这种写法写顺——因为两遍遍历背后是"先获取全局信息、再定位"的通用思维,很多链表题目都依赖这种思路。
2.2 一个容易把自己绕晕的细节:步数到底怎么算
两遍遍历最常见的翻车点不是循环写错,而是"步数"和"节点编号"混在一起。
有初学者会这样想:5个节点的链表,中间是第3个节点,所以从头部移动 3 步。这是错的。头节点本身已经是第1个节点,想走到第3个节点只需要移动 2 步。而 n//2 恰好就是 2,不需要额外加1。
我见过有人写 mid = (length + 1) // 2 然后循环 mid 次,结果长度是5的时候移动3步,跑到了节点4。这就是没搞清楚"移动步数"和"节点序号"的区别。
我自己的记忆方法是:移动步数永远等于目标节点的0-based下标。中间节点下标是 n//2,那就移动 n//2 步,没有任何例外。长度是偶数6,下标是3,移动3步到节点4;长度是奇数5,下标是2,移动2步到节点3。用一个公式全解决了。
2.3 用数组缓存节点?这个解法要慎用
还有一种很"偷懒"的写法:遍历链表时把所有节点放进一个数组,然后直接返回数组下标 n//2 的元素。
class Solution: def middleNode(self, head: Optional[ListNode]) -> Optional[ListNode]: nodes = [] cur = head while cur: nodes.append(cur) cur = cur.next return nodes[len(nodes) // 2]功能上完全正确,LeetCode提交也能过,思路还特别直白。但我不建议在正式面试中用这个作为主答案,因为它把"链表"问题变成了"数组"问题。你相当于把链表的节点指针都缓存起来了,然后用数组的随机访问能力直接取中间。面试官想考察的是你对链表只能逐节点访问这个特性的理解,你用数组绕过了这个考察点。
如果面试官追问"为什么不用下标访问",你当然可以说链表本身不支持随机访问,所以借助数组缓存。但更好的策略是:先给两遍遍历或快慢指针,再把数组缓存作为一个"额外思路"提一下,而不是把它当主解。
3. 快慢指针:用两步对一步的时间差,把中点"套"出来
3.1 快慢指针的几何直觉:龟兔赛跑
快慢指针是这个问题的经典最优解,没有之一。它的直觉可以用一个生活场景解释:两个人同向跑步,甲的速度是乙的两倍。甲跑到终点的时候,乙一定刚好在跑道的一半位置。我们不需要知道跑道总长,只需要让两个人同时出发,速度差两倍,就能在甲到达终点时抓住乙的位置——而这个位置就是中点。
放到链表里就是:慢指针 slow 每回合移动一个节点,快指针 fast 每回合移动两个节点。快指针到链表末尾时,慢指针恰好停在中间。
这里有个很关键的"恰好":如果链表长度是奇数,快指针会停在最后一个节点上,slow 落在正中间;如果长度是偶数,快指针会越过链表末尾变成 null,slow 落在靠右的那个中间节点上。LeetCode 876要的正好是靠右的节点,所以这个算法天然契合题目约定。
3.2 while fast and fast.next 是唯一的理想终止条件
快慢指针的代码框架是这样的:
class Solution: def middleNode(self, head: Optional[ListNode]) -> Optional[ListNode]: slow = head fast = head while fast and fast.next: slow = slow.next fast = fast.next.next return slow核心就在 while 那一行:fast 和 fast.next 必须同时判空。
为什么两个条件缺一不可?我们来推演一下:
- 奇数长度链表,比如 1->2->3->4->5。移动过程:fast 先是节点3,然后节点5;当 fast 停在节点5时,fast.next 是 null。此时 while 条件 fast.next 为假,循环退出,slow 停在节点3。
- 偶数长度链表,比如 1->2->3->4->6(这里我说6个节点,1->2->3->4->5->6)。移动过程:fast 先是节点3,然后节点5,接着越界变成 null。此时 while 条件 fast 为假,循环退出,slow 停在节点4。
所以:奇数看 fast.next 是否为空,偶数看 fast 是否为空。你必须在条件里同时判断这两个,才能让两种长度都能正确退出。少写一个,要么空指针异常,要么循环退出时机不对。
Java 版本特别注意判空顺序:
class Solution { public ListNode middleNode(ListNode head) { ListNode slow = head, fast = head; while (fast != null && fast.next != null) { slow = slow.next; fast = fast.next.next; } return slow; } }Java的 && 有短路机制,先判断 fast != null,如果 fast 已经为 null 就不会再去访问 fast.next,也就不会抛空指针。所以顺序不能反过来写,不能先写 fast.next != null && fast != null,否则一旦 fast 为 null 就直接报错。
C++版本也是一个套路:
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; } };3.3 一个非常隐蔽的笔误:让 fast 先走一步
很多链表技巧教程里有另一种双指针写法,叫"快指针先指向第二个节点":
slow = head fast = head.next while fast and fast.next: slow = slow.next fast = fast.next.next return slow这套写法找的是靠左的中间节点,不是LeetCode 876要的靠右节点。我们来验证一下:
- 长度2:1->2。初始 fast 在节点2,fast.next 为 null,循环不执行,slow 停在节点1。但题目期望返回节点2。
- 长度4:1->2->3->4。初始 fast 在节点2,进入循环一次后 slow 到节点2、fast 到节点4;下一轮 fast.next 为 null,退出,slow 停在节点2。但题目期望返回节点3。
同样的代码,只是 fast 初始位置差了一个节点,结果就从"靠右中间节点"变成了"靠左中间节点"。这就是为什么我反复强调:这一题必须让 slow 和 fast 同时从 head 出发。
如果面试时你用的是 fast = head.next 这个初始化,然后面试官给了一个偶数长度的例子,当场就会暴露。所以请把"同时从 head 出发"这句话刻在脑子里。
3.4 复杂度与空间优势
快慢指针的时间复杂度是 O(n),因为 fast 每轮移动两个节点,整体遍历次数大约 n/2,仍然是线性级别。空间复杂度 O(1),只用了两个指针变量,没有额外数组,没有递归栈。
这正是面试官期待看到的标准答案。相比两遍遍历,它最大的优势是"一遍遍历解决问题"——fast 在前面探路,slow 在后面记录,等 fast 到达终点时答案已经攥在手里。这种"用时间差代替第二遍扫描"的思路,是双指针技术的核心价值,也是后面很多难题的出发点。
4. 边界值设计与自测用例:比主逻辑更容易被扣分的地方
4.1 空链表与单节点链表
LeetCode 876的题目约束里链表长度在 [1, 100] 之间,所以空链表不会出现在评测用例里。但我在面试中仍然习惯性处理它,因为很多面试官会追问:"如果 head 是 null 呢?"
快慢指针的空链表行为其实很安全:slow 和 fast 都指向 null,while 条件 fast and fast.next 直接短路,返回 null。这就是我们想要的结果,不需要额外加 if。这是这个解法的一个隐藏优点——边界情况被循环条件天然兜住了。
单节点链表 1->null 的情况:fast = 节点1,fast.next = null,while 条件不成立,直接返回 slow 即节点1,正确。
如果你写的是数组缓存的解法,空链表会返回 nodes[0],直接越界。这就是我为什么说数组缓存只适合作为思路补充,不适合当主解——它在边界处理上不够干净。
4.2 偶数长度链表的输出节点
偶数长度是这道题最大的考点。我再把关键场景强调一遍。
链表 1->2->3->4:两个中间节点是节点2和节点3,题目要节点3。快慢指针同时从 head 出发,一轮后 slow 到节点2、fast 到节点3;此时 fast.next = 节点4 不为空,继续第二轮:slow 到节点3、fast 越过节点4变成 null。循环退出,slow 停在节点3,正确。
链表 1->2->3->4->5->6:期望节点4。快慢指针会执行三轮,slow 依次到节点2、节点3、节点4,fast 依次到节点3、节点5、null,然后退出,正确。
如果面试官给偶数长度的用例,你写之前可以先口算一遍:快指针走完一轮的位置决定了 slow 的最终位置。你越能快速口算这些用例,面试官对你就越放心。
4.3 我每次提交前都会跑的五个用例
刷这种链表题,我不建议只靠LeetCode的官方用例。我会在本地或草稿纸上准备一组覆盖各类情况的测试用例,每次写完代码都过一遍,形成固定习惯。这一题我用的是下面这组:
| 链表内容 | 期望输出 | 说明 |
|---|---|---|
| null | null | 防御性测试 |
| [1] | 节点1 | 单节点 |
| [1,2] | 节点2 | 偶数双节点,取靠右 |
| [1,2,3] | 节点2 | 奇数三节点 |
| [1,2,3,4] | 节点3 | 偶数四节点,取靠右 |
| [1,2,3,4,5,6] | 节点4 | 较长偶数,多走几轮验证 |
这套用例的覆盖逻辑是:空、最短奇、最短偶、普通奇、普通偶、再长一点的偶。所有边界都被扫到。如果你在面试时能说出"我平时会用这几种用例自测",这本身就是一个加分的信号。
5. 链表中点不是终点,而是兄弟题的预制件
5.1 在回文链表与重排链表里,它是第一步
Hot 100列表里,链表中点这个操作经常作为某道难题的第一步出现。最典型的是回文链表(LeetCode 234)和重排链表(LeetCode 143)。
回文链表的思路是:先找到中间节点,然后把后半段链表反转,再和前半段逐节点比较。如果中间节点找错,后半段起点就错了,回文判断必然出错。你想想,234题前面有一大堆翻转、比较逻辑要处理,结果第一步就翻车,后面全完。
重排链表更典型,它要求把链表从 L0->Ln->L1->Ln-1->L2->Ln-2 的顺序重新排列。标准解法是三步:快慢指针找中点、把链表分成两半、反转后半段、交替合并。没有链表中点这个"预制件",这道题你连动手的入口都找不到。
所以我在刷Hot 100时会特意把876这种"工具型题目"打上标记,每次刷到兄弟题就直接引用它作为前置知识点,不用重新推导。
5.2 从找中点到找倒数第K个节点和环形链表
双指针的大类里,"找中间节点"和"找倒数第K个节点"是同一套思想的两种体现。
找倒数第K个节点时,让 fast 指针先走 K 步,然后 slow 和 fast 再同步前进,当 fast 到达末尾时 slow 正好停在倒数第K个节点。这和找中间点的逻辑同构——都是让两个指针之间保持一个固定偏移,利用终点位置反推出目标位置。区别只是"偏移是 n/2 还是 K"。
环形链表(LeetCode 141、142)也是双指针的另一分支:慢指针每轮走一步,快指针每轮走两步,如果有环,两个指针最终会相遇。这和找中点的代码长得几乎一模一样,只是循环条件不同——找中点以 fast 是否到达末尾为退出条件,环形链表以 slow 是否追上 fast 为判定条件。
我经常跟刷题的朋友说:双指针在链表里就三件事,找中点、找倒数第K个、判断环。你把这三种模型练成一个整体,比一道一道孤立地刷效率高得多。
6. 面试现场这样讲思路,代码反而写得更顺
6.1 写代码之前,用两句话说清方案
现场写代码之前,我建议你先用两句话把自己的方案讲完整,而不是抄起编辑器就敲。这两句话大概是这样的:
"我会用两个指针同时从头节点出发,慢指针一次走一步,快指针一次走两步。快指针到达链表末尾时,慢指针正好在中间。循环条件是快指针和它的下一个节点都不为空,这样奇数和偶数长度的链表都能正确处理。"
注意这句话里包含了三个信息:指针速度、返回位置、终止条件。你说完这三件事,面试官就已经知道你理解了这道题的关键。后面写代码的容错率会高很多。
6.2 最容易写崩的三个细节
我复盘过很多次的代码提交,发现这道题的翻车点高度集中在三个地方:
第一,fast 初始化成了 head.next,导致偶数链表返回靠左的中间节点。这个问题在3.3节说过,你只要记住"同时从 head 出发"就不会踩坑。
第二,while 条件只写了 fast.next,没有写 fast。这种情况在奇数长度链表上能跑,但是一旦链表长度是偶数,循环会在 fast 为 null 时尝试访问 fast.next,直接抛空指针。Java和C++尤其容易在这里崩。
第三,把 slow 和 fast 的步进顺序写反,先让 fast 跳两步再让 slow 走一步。因为 fast 的移动依赖 slow 当前的位置吗?不依赖。但是逻辑上先走 slow 再走 fast 更符合"每回合同时移动"的直觉,也方便你口述"每轮慢走一步快走两步"。如果先 fast 后 slow,口述和代码不一致,面试官可能会追问,反而增加出错概率。
还有一种写法是 while fast.next and fast.next.next,我前面提过不建议用。它虽然不会空指针,但对偶数长度链表会少走一轮,导致返回靠左节点。也就是说,有些代码能跑但答案不对,这种最危险。
6.3 我的复盘习惯
每刷完一道题,我都会做一个三分钟复盘,步骤是:先凭记忆把代码重写一遍,重点检查退出条件;再想这道题能拆成哪些子问题,能用在哪些后续题目上;最后在笔记本上写一句话总结。
这一题的总结我写的是:"876考点不在找中点,在于明确偶数长度返回靠右节点,所以快慢指针必须同时从 head 出发,循环条件必须同时判断 fast 和 fast.next。"
后来刷回文链表和重排链表时,我反复用这同一个原理,一次都没跑偏。我也建议你试试这个习惯,特别是这种代码很短但边界很多的小题——如果不复盘,今天能过,一周后再写很可能又在同一个地方卡住。