“链表的中间结点”这道题,在面试里出现的频率高到离谱。别看它代码量只有十几行,能考察的东西一点都不少:链表遍历的基本功、边界条件的处理、还有快慢指针这个经典技巧的灵活运用。很多朋友背下了答案,但换种问法就懵了,比如“如果链表长度是偶数,返回的是哪个结点?”或者“为什么快指针要走两步,不是三步?”。
这篇文章我就把这题彻底拆开揉碎,从最朴素的解法讲到快慢指针的数学原理,再到边界条件处理的细节坑,最后把同类题目串起来,希望能帮你把这一块的知识彻底焊死。
1. 题目剖析与核心思路拆解
1.1 先从题目本身说起
题目要求很简单:给定一个非空单链表的头结点 head,要求返回链表的中间结点。如果有两个中间结点(也就是链表长度为偶数时),则返回第二个中间结点。
这个描述里有两个关键信息:第一,链表非空,所以不用处理 head == nullptr 的情况;第二,偶数长度返回第二个中间结点,这是出题人特意设定的语义,直接决定了很多人的边界处理方式。
举个具体例子,链表 1 -> 2 -> 3 -> 4 -> 5,长度为奇数,中间结点是 3;链表 1 -> 2 -> 3 -> 4 -> 5 -> 6,长度为偶数,中间结点有两个:3 和 4,按题目要求要返回 4。
1.2 最朴素的做法:两遍遍历
第一次接触这题的人,大概率是这么干的:先遍历一遍链表,数出链表总长度 n,然后算出中间位置 n / 2,再从 head 走 n / 2 步,就能到达中间结点。
这个过程用生活类比就是:你要在一列排队等待入场的人群中找到站在正中间的那个人,于是你从队头走到队尾数了一遍人数,然后从队头再走一半人数的步数,就精准定位到了那个人。可行,但来回走了两趟。
对应到代码上,就是两个 while 循环。第一个循环走完整个链表统计长度,第二个循环走 n / 2 步寻找目标结点。时间复杂度是 O(n),空间复杂度是 O(1)。
这个解法能通过所有测试用例,但它在面试官眼里只能勉强及格——时间复杂度上它已经是线性的了,看起来没有优化空间,但“遍历两遍”这个事实暗示了一个问题:我们明明只想象一次遍历就能做到这件事。
1.3 快慢指针:一趟遍历定位中间结点
快慢指针的精髓可以一句话概括:两个指针同时从链表头出发,慢指针每次走一步,快指针每次走两步。当快指针到达链表末尾时,慢指针刚好站在中间位置。
还是用排队来类比:你和一个朋友同时从队头出发,你每走一步,朋友就走两步。等朋友到达队尾时,你走的步数正好是他的一半,自然就站在队伍正中间。这个思路把“先测总长度再走一半”的两趟遍历压缩成了一趟,这就是它的核心价值。
很多人的直觉是:这是不是只是常数层面的优化?反正都是 O(n)。如果你真这么想,那就低估了这个技巧的价值。在后续更复杂的链表题里——比如环形链表检测、寻找倒数第K个结点、回文链表判断——快慢指针往往是“保底”的优雅解法,它的意义远不止省一次遍历。搞懂这道题,你就是为后面的这些题打地基。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么快慢指针能成立:数学原理与边界条件
2.1 推导过程:快指针走完时慢指针的位置
设链表长度为 n,慢指针速度为每轮 1 步,快指针速度为每轮 2 步。两个指针同时出发,假设经过 t 轮后快指针到达链表末尾(即 fast 为 nullptr)或抵达最后一个结点(即 fast->next 为 nullptr)。
在快指针停下时,快指针走过的总步数有两种情况,取决于 n 是奇数还是偶数:
- 当
n为奇数(如n = 5)时,快指针恰好经过2轮到达最后一个结点,此时fast->next为nullptr,条件触发,循环停止。慢指针走了t = (n-1)/2步,指向的是第(n+1)/2个结点,也就是链表正中间的那个结点。 - 当
n为偶数(如n = 6)时,快指针经过3轮后到达nullptr,此时fast为nullptr,循环停止。慢指针走了n/2步,指向的是第n/2 + 1个结点。以n = 6为例,慢指针指向第4个结点——这正好是题目要求的“第二个中间结点”。
所以,这个算法天然满足“偶数长度返回第二个中间结点”的要求,不需要额外加特殊判断。
2.2 循环条件的正确写法:fast != null && fast->next != null
这个循环条件可以说是这道题的核心难点,也是绝大多数人出错的地方。很多初学的人会写成 while (fast != null)——这在奇数长度的链表上会出问题,因为快指针会在 fast->next == null 时访问 fast->next->next,直接触发空指针异常。
条件拆开来看,fast != null 负责的是偶数长度时防止快指针走到链表外后继续访问,fast->next != null 负责的是奇数长度时防止快指针在最后一个结点上再往前跳两步。两者必须同时满足,循环才能继续。
这里有个写代码时的小技巧:条件的顺序不能颠倒。fast->next != null 必须放在 fast != null 后面。因为 && 具有短路特性,如果 fast 已经是 nullptr,就不会执行 fast->next,从而避免空指针解引用。反过来写,程序会在运行时直接崩溃。
我见过不少人在这题上栽跟头不是因为不懂思路,而是把这行条件写反了,结果就是几分钟都调不出来。写代码的时候始终记住:在任何要访问指针成员之前,先确保它自身不为空。
2.3 边界条件速查表
为了更直观地说明不同情况下的结果,下面整理了一份速查表:
| 链表长度 | 示例链表 | fast 停止位置 | slow 最终指向 | 返回结点 |
|---|---|---|---|---|
| 1 | 1 |
fast->next == null |
第 1 个 | 1 |
| 2 | 1->2 |
fast == null |
第 2 个 | 2 |
| 3 | 1->2->3 |
fast->next == null |
第 2 个 | 2 |
| 4 | 1->2->3->4 |
fast == null |
第 3 个 | 3 |
| 5 | 1->2->3->4->5 |
fast->next == null |
第 3 个 | 3 |
| 6 | 1->2->3->4->5->6 |
fast == null |
第 4 个 | 4 |
观察表中偶数长度的情况,会发现 slow 都落在“中间两个结点的第二个”上,完美符合题目要求。这个现象不是巧合,正是 while 条件设计的精妙之处。
3. 代码实现与逐行拆解
3.1 先上代码:三种主流语言的对照
这道题在不同语言下的实现差异很小,核心逻辑完全一致,我先把三种主流写法的完整代码贴出来。
java复制// Java 版本
class Solution {
public ListNode middleNode(ListNode head) {
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
return slow;
}
}
cpp复制// 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;
}
};
python复制# 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
3.2 逐行解读关键步骤
第一行,ListNode slow = head; ListNode fast = head; 将两个指针都初始化为链表头。这一步的意义在于,当链表只有 1 个结点时,循环条件直接不满足(因为 fast->next 为空),直接返回 head,结果是正确且无需特殊判断的。
第三行,循环条件 while (fast != null && fast.next != null) 已经详细分析过,它的判定逻辑完整覆盖了奇偶两种长度。
第四、五行是核心更新逻辑:慢指针走一步,快指针走两步。这里有一个顺序问题可以思考:如果先移动 fast 再移动 slow 会怎样?结果是,快指针走的步数比慢指针多,快指针会提前到达末尾,慢指针最终停靠的位置会偏前,对于偶数长度的链表就会返回第一个中间结点,与题目要求矛盾。所以保持这个顺序很重要。
最后返回 slow。此时慢指针精确指向中间结点,无需任何额外处理。
3.3 C++ 结构体链表的基本姿势
既然热搜词里频繁出现“c++结构体链表基本语法”,这里就顺带提一下 C++ 里链表结点的标准定义方式:
cpp复制// 单链表结点结构体
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) {} // 带值和下一结点指针构造
};
这个结构体和 std::list 不同,它是最原始的手写单链表节点定义,在很多算法题和面试手写代码场景中都会直接使用。定义完结点结构体之后,可以用 new ListNode(val) 创建新结点,用 -> 访问内部成员。head 本身就是一个指向链表中第一个结点的指针,当链表为空时 head 为 nullptr。
如果在做面试题时用 C++ 写链表题,建议直接背下这个结构体的写法,可以节省宝贵的现场时间。有些面试官会故意把 struct 里的构造函数去掉,考察你能不能应对,实际做题时需要自己初始化,所以最好养成用带参构造的习惯。
4. 复杂度分析:快慢指针到底快在哪里
4.1 时间复杂度和空间复杂度
时间上,快指针从头走到尾,整个过程只需要遍历链表一次,时间复杂度是 O(n),但常数比“两遍遍历”的写法小得多。
空间上,只额外声明了两个指针变量,不随链表长度变化,所以空间复杂度是 O(1)。
这里需要澄清一个常见的误解:有人认为“快慢指针”既然有快有慢,可能某些情况下会走超过 n 步。实际上,快指针以两步为步长,最多走到链表的最后一个结点或 nullptr,总步数不超过 n。慢指针则最多走 n/2 步。两者中较大的一方决定了整体耗时,所以依旧是一个线性遍历。
4.2 一遍遍历 vs 两遍遍历的差距在哪
有朋友可能会说:“两遍遍历也是 O(n) 啊,实际运行能差多少?”
从大 O 的角度来看,它们确实是同一量级。但如果你在面试中只给出两遍遍历的解法,面试官通常会追问“能不能一次遍历就拿到中间结点”。这道题被收录进各种高频题单,核心目的就是考察候选人对双指针技巧的掌握程度。能熟练写出快慢指针,本身就是算法能力的一个信号。
另外,在实际工程场景里,链表可能非常长,如果每次查找中间结点都要先完整遍历一遍链表拿到长度,再走一半的流程,在多处频繁调用时累积的耗时差异会很明显。如果链表是“流式”数据(比如实时从网络或传感器读入、预先不知道总长度),那么一遍遍历方案的价值就从“性能优化”变成了“唯一可用的方案”——因为你根本不知道总长度是多少。这也是快慢指针这类技巧在实际系统中依然有生命力的原因。
5. 实操过程中最常见的 5 个问题排查
5.1 死循环无法退出
表现:程序运行超时,或者输出结果一直不对。
排查方向:检查快指针的更新有没有写成 fast = fast.next(只走一步)。如果快指针和慢指针速度相同,循环条件可能永远无法满足,或慢指针永远不会到达中间位置。正确写法是 fast = fast.next.next,一次跳两步。
还有一种死循环的情况是,在快指针已经到达最后一个结点后,因为循环条件写成了 while (fast.next != null && fast != null),由于 fast != null 的判断晚于 fast.next 的访问,此时可能已经解引用了空指针(取决于语言),导致运行时错误或未定义行为。
5.2 空指针异常
表现:运行时提示 NullPointerException 或 segmentation fault。
排查方向:绝大多数情况是把循环条件里的判断顺序写反了。fast != null 必须写在前面,fast.next != null 写在后面。另外要检查初始化时,slow 和 fast 是否都指向了 head,如果在循环外面让 fast = head.next,在链表长度为 1 时二次循环就极易越界。
5.3 返回结点总是偏前一个位置
表现:链表长度为偶数时,返回的是第一个中间结点,而不是题目要求的第二个。
排查方向:检查循环条件的符号是否写反,比如写成 while (fast.next.next != null) 之类的形式,导致快指针少跳了若干轮。此时慢指针没有走足够的步数。如果用数学推导验证,正确条件下快指针在偶数长度时应走到 nullptr 才停,如果你的条件让它提前停了,结果自然偏前。
5.4 修改了原链表的结构
表现:函数执行完后,链表被改了。
排查方向:有些变体题(比如寻找中间结点用于拆分链表)会要求你修改指针连接,但这道题只需要返回一个结点,不应该改动任何 next 指针。如果你的代码里出现了 slow.next = ... 或者 fast.next = ... 的赋值,通常就是不小心改了原链表。只声明指针变量、只读取 next 字段、只做指针指向的变化,不要做任何写操作。
5.5 链表长度特别大时性能下降
表现:数据量 $10^6$ 级别以上时,运行时间明显变长。
排查方向:严格来说,快慢指针在这种数据量下依然很快,但如果你用了递归的方式来定位中间结点,可能会因为递归深度过大导致栈溢出或效率问题。链表题尽量用迭代,不要用递归。遍历类操作中每次迭代的开销是固定的,递归不仅有函数调用栈开销,还可能在链表深度较大时直接爆栈。
6. 快慢指针的同类变式题:一个技巧吃透多个考点
6.1 判断链表是否有环
这是快慢指针最著名的应用场景之一。让快指针每次走两步,慢指针每次走一步,如果链表有环,则两个指针必然会相遇;如果没有环,快指针会先到达 nullptr 结束循环。这个判断的逻辑依据是:进入环后,快指针相对慢指针的速度是每轮“接近一步”,既然环有有限个结点,必然会在若干轮后追上。
相比哈希表法(空间复杂度 O(n)),快慢指针只用 O(1) 的空间就完成了环的检测,这也是面试官最想看到的答案。
6.2 寻找倒数第 K 个结点
经典思路是:快指针先走 K 步,然后慢指针和快指针同步向后移动,每次各走一步。当快指针到达末尾时,慢指针指向的位置就是倒数第 K 个结点。
这其实是把“快慢指针”思想泛化了——不一定非得是两倍速,也可以先让快指针先走固定步数,再双指针同步移动,本质上都是利用“两个指针之间距离固定”来定位目标位置。
6.3 回文链表判断
判断一个链表是否构成回文,可以先通过快慢指针找到中间结点,然后把后半段链表反转,再逐一比较前半段和后半段对应的值。这里用快慢指针定位中间结点,是整个算法中至关重要的一步,如果定位不准确,链表反转后对比就会错位。
所以,如果想回文链表这样的题目能顺利做出来,前提就是把本文中的“中间结点”问题彻底吃透。这也是我这篇文章标题用“链表的中间结点”而不是“链表判断回文”的原因:很多题的解法高塔,都是建在这一个看似简单的技巧之上的。
6.4 单链表逆序与“中间结点”组合题
热搜词里还有一个高频考点是“python单链表逆序”“c++链表”,假设面试题改成:“找到链表的中间结点,并将之后的部分逆序”,这就相当于把“找中点”和“链表反转”两个基础操作组合到了一起。你可以用快慢指针先找到中点,再用三指针迭代法反转后半段链表,整体代码也仍然非常简洁。
这种“基础操作组合题”在实际面试中比纯模板题更常见,因为它更能考察基本功是否扎实。如果你的“找中点”里还带着边界错误的隐患,后面反转和拼接的代码就全部建立在错误的地基上,大概率写不完或者写错了。
7. 我的一些实操心得
这道题我这些年给不少人讲过,也见过太多人从“背答案”开始学,却从没认真推导过“为什么是 while (fast != null && fast->next != null)”。我的建议是,遇到这种基础题,花十分钟把数学推导写一遍,再把三种边界情况(长度 1、奇数、偶数)各画一遍图,胜过你把答案背十遍。
画图的思路很简单,直接画链表的方框和箭头,手动模拟仿真两个指针的移动过程。慢指针用 S 表示,快指针用 F 表示,每轮更新一次位置。画完长度 1 和长度 2 这两种情况,你就知道为什么循环条件要同时判断 fast 和 fast.next;画完奇数和偶数两种情况,你就能理解为什么这个算法天然返回“第二个中间结点”。
快慢指针这个技巧,在链表题里的应用远不止以上几种。有序链表寻找中位数、二叉树转有序链表、合并两个有序链表后找中间值……很多复杂一点的问题,底层都能看到快慢指针的影子。把这一个点学透,做同类题目时就能比较容易地举一反三。
如果只能在链表题里挑一个技巧重点掌握,我会毫不犹豫选择快慢指针。它不是最优美的算法,但绝对是最实用、出现频率最高、性价比最高的那一个。希望这篇拆解,能帮你把这块硬骨头啃下来。
