数据结构-4道经典面试题:从快慢指针到环形链表 II 数学推导
写在前面
前面学习单链表时,我更多在练习头插、尾插、删除、反转等基础操作。真正开始刷链表面试题之后才发现,很多看似完全不同的问题,最终都会回归同一个核心思想:双指针。
这次集中整理四道单链表高频面试题:
- 面试题 02.02:返回倒数第 k 个节点
- LCR 027:回文链表
- LeetCode 160:相交链表
- LeetCode 142:环形链表 II
四道题都用到了双指针,但用法各不相同:固定间距、倍速移动、长度对齐、环形追及。前三道侧重双指针的灵活应用,最后的环形链表 II 则更进一步------代码只有十几行,但背后有两个非常值得深究的问题:
- 为什么慢指针走 1 步、快指针走 2 步,只要存在环就一定能相遇?
- 为什么相遇后让一个指针回到链表头,另一个留在相遇点,同时每次走 1 步,就一定会在入环首节点相遇?
这两个问题我都在纸上画图做了手写推导,最后再补充一部分结合 AI 整理的通用步长扩展结论。
本篇文章对应代码仓库位置:
为了方便调试和使用,其中包括AI补充的基本测试样例。
数据结构/8.13四道经典面试题:从快慢指针到环形链表 II 数学推导 · Luminous/Code_2026 - 码云 - 开源中国
一、统一认识:单链表中的双指针到底在做什么?
四道题统一使用标准单链表结构:
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* struct ListNode *next;
* };
*/
typedef struct ListNode ListNode;
刚接触"双指针"时,很容易把它简单理解成"创建两个指针一起遍历"。但刷完这几道题我觉得,它真正的核心是:人为构造两个指针之间的位置关系,再利用这种关系减少额外的遍历或存储开销。
比如:
- 倒数第 k 个节点:让两个指针保持固定间距
- 回文链表:让两个指针速度不同,用倍速关系定位中点
- 相交链表:先消除长度差,让两个指针拥有相同的剩余路程
- 环形链表:利用快慢指针在环中的相对速度形成追及
四道题放在一起对比,比单独记四份代码更容易形成完整的链表双指针思维。
二、面试题 02.02:返回倒数第 k 个节点
2.1 题目与思路
给定单向链表,返回倒数第 k 个节点的值。 示例:1 -> 2 -> 3 -> 4 -> 5,k = 2,输出 4。
最朴素的做法是先遍历一遍计算链表长度 len,第二次从头走 len-k 步,时间复杂度同样是 O(N),完全可以解题。但这道题更经典的写法是定距双指针:
让快指针 pur 和慢指针 cur 初始都指向 head,先让 pur 向前移动 k 步,人为制造出 k 步的固定间距。随后两个指针同步后移,当快指针走到 NULL 时,慢指针恰好停在倒数第 k 个节点。
2.2 我的 AC 代码
cpp
int kthToLast(struct ListNode* head, int k) {
ListNode* pur = head;
ListNode* cur = head;
// 快指针先走k步,拉开固定间距
for (; k > 0; k--) {
pur = pur->next;
}
// 快慢指针同步后移
while (pur) {
pur = pur->next;
cur = cur->next;
}
return cur->val;
}
题目已保证 k 有效,因此没有额外处理 k 超出链表长度的边界情况。
复杂度分析
- 时间复杂度:O(N)
- 空间复杂度:O(1)
这类题的题型信号很明确:看到"倒数第 k 个""前后相差 k 个"的描述,可以优先想到固定间距双指针。
三、LCR 027:回文链表
回文链表这道题,把我们之前接触过的两个核心操作组合到了一起:快慢指针找中点 + 原地反转链表。
比如 1 -> 2 -> 3 -> 3 -> 2 -> 1,从前向后和从后向前读取结果一致,就是回文链表。
如果不限制空间,最直观的做法是把所有节点值存入数组,再两端向中间比对,但需要 O(N) 额外空间。题目的进阶要求是 O(N) 时间、O(1) 空间,因此需要直接在链表原结构上操作。
3.1 快慢指针寻找链表中点
设置 slow 每次走 1 步,fast 每次走 2 步。当快指针走到链表末尾附近时,慢指针恰好位于链表中间位置。
我使用的循环条件是 while (fast->next && fast->next->next),可以同时兼容奇数和偶数长度的链表。 找到中点后,以 slow->next 为起点,原地反转链表后半段。
3.2 反转辅助函数
cpp
// 反转链表,返回反转后的新头节点
ListNode* reverseList(ListNode* x) {
ListNode* next = NULL;
ListNode* cur = x;
ListNode* pur = cur;
while (cur) {
pur = cur->next;
cur->next = next;
next = cur;
cur = pur;
}
return next;
}
3.3 我的完整 AC 代码
cpp
bool isPalindrome(struct ListNode* head) {
// 空链表、单个节点天然是回文
if (head == NULL || head->next == NULL) {
return true;
}
// 快慢指针寻找链表中点
ListNode* slow = head;
ListNode* fast = head;
ListNode* newhead = head;
while (fast->next && fast->next->next) {
slow = slow->next;
fast = fast->next->next;
}
// 反转后半段链表
ListNode* cur = reverseList(slow->next);
slow->next = NULL;
// 前后两段逐一比对
while (cur) {
if (cur->val != newhead->val) {
return false;
} else {
newhead = newhead->next;
cur = cur->next;
}
}
return true;
}
复杂度分析 找中点、反转后半段、前后比对都是线性遍历,因此:
- 时间复杂度:O(N)
- 空间复杂度:O(1)
这道题我印象最深的还是反转链表的指针维护:修改 cur->next 之前,必须先保存原 cur->next 的指向,否则连接关系一变,后半段链表就会直接丢失。
另外我当前的 AC 代码在比对前断开了前后两段,没有在判断结束后恢复原链表结构。对本题而言不影响结果,但如果是要求保持原结构不变的工程接口,还需要在判断完成后把后半段再次反转回去。
四、LeetCode 160:相交链表
这道题最容易踩的不是代码坑,而是概念坑。 链表相交,判断的不是 A->val == B->val,而是 A == B。真正的相交意味着两个指针指向内存中的同一个节点;哪怕两个节点的值完全相同,只要地址不同,就不算相交。
4.1 长度对齐双指针
两条链表长度不同时,如果同时从头节点出发,即使有公共部分,也无法同时到达交点。
我的思路是:
- 分别遍历统计链表 A、B 的长度
- 计算长度差
diff,让更长的链表先走diff步 - 此时两个指针到链表尾部的剩余长度完全相同
- 两指针同步后移,第一次满足
A == B的节点,就是相交起点
4.2 我的 AC 代码
cpp
struct ListNode* getIntersectionNode(struct ListNode* headA,struct ListNode* headB) {
if(headA == NULL || headB == NULL)
return NULL;
ListNode* A = headA;
ListNode* B = headB;
int lenA = 0, lenB = 0;
// 统计链表A长度
while(A != NULL){
lenA++;
A = A->next;
}
// 统计链表B长度
while(B != NULL){
lenB++;
B = B->next;
}
A = headA;
B = headB;
int diff;
if(lenA > lenB){
diff = lenA - lenB;
// 长链表先走差值步
while(diff--){
A = A->next;
}
}else{
diff = lenB - lenA;
while(diff--){
B = B->next;
}
}
// 同步遍历寻找相交节点
while(A != NULL && B != NULL){
if(A == B){ // 内存地址相等,找到交点
return A;
}
A = A->next;
B = B->next;
}
return NULL; // 无相交节点
}
设两条链表长度分别为 M、N:
- 时间复杂度:O(M+N)
- 空间复杂度:O(1)
4.3 另一种经典方法:双指针换链
还有一种非常简洁的写法:指针 A 走完链表 A 后继续走链表 B,指针 B 走完链表 B 后继续走链表 A。两者总路程都是「A 长 + B 长」,有交点则在交点相遇,无交点则同时变为 NULL。
两种方法复杂度同属最优量级。我个人更习惯长度对齐法,因为"先消去长度差再同步前进"的逻辑更直观;换链法则胜在代码更精简。
五、LeetCode 142:环形链表 II
前三道题侧重双指针的应用,到了环形链表 II,我想进一步搞清楚:为什么这十几行代码一定是正确的?
题目要求:给定链表头节点 head,若链表有环则返回第一个入环节点,无环则返回 NULL。不允许修改链表原有结构,进阶要求 O(1) 额外空间。经典解法就是 Floyd 快慢指针算法。
5.1 我的 AC 代码
cpp
struct ListNode *detectCycle(struct ListNode *head) {
// 快慢指针同时从头节点出发
struct ListNode *slow = head;
struct ListNode *fast = head;
// 快慢指针循环移动,fast走两步,slow走一步
while(fast != NULL && fast->next != NULL){
slow = slow->next;
fast = fast->next->next;
// 快慢指针相遇,说明链表存在环
if(slow == fast){
// p从头部出发,slow从相遇点出发,每次都走一步
struct ListNode *p = head;
while(p != slow){
p = p->next;
slow = slow->next;
}
// p与slow相遇点即为入环第一个节点
return p;
}
}
// 无环返回NULL
return NULL;
}
算法分为两个阶段: 第一阶段:判环 slow 每次走 1 步,fast 每次走 2 步。若 fast 或 fast->next 走到空,说明无环;若 slow == fast,说明两指针在环内相遇,链表一定有环。
第二阶段:找入环首节点 第一次相遇后,新建指针 p 从链表头出发,slow 留在相遇点,两个指针每次都走 1 步,第二次相遇的位置就是入环首节点。
代码很短,但正确性需要严谨推导。
六、证明一:第二次相遇的位置为什么一定是入环首节点?
这一部分完全按照我的手写笔记推导,建议配合手写图对应阅读。


6.1 变量定义
A:链表头结点到入环第一个节点的直链长度B:链表环的总长度b₁:从入环首节点沿环前进,到快慢指针第一次相遇点 N 的距离b₂:从相遇点 N 继续沿环前进,回到入环首节点的距离
显然有恒等关系: B = b_1 + b_2 即: b_1 = B - b_2 ;
设定:slow 每次走 1 步,fast 每次走 2 步,两指针最终在环内节点 N 相遇。 我们需要证明:一个指针从链表头出发,另一个指针从相遇点 N 出发,两者都每次走 1 步,最终会在入环首节点相遇。
6.2 慢指针走过的路程
第一次相遇时,慢指针从链表头出发,先走完长度为 A 的直链,再从入环首节点沿环走 b₁ 到达相遇点 N。 因此慢指针总路程: S_{slow} = A + b_1 。
6.3 快指针走过的路程
设第一次相遇时,快指针在环内已经完整绕行了 s 圈,再从入环首节点走了 b₁ 到达相遇点 N。 因此快指针总路程:S_{fast} = A + sB + b_1 。
6.4 利用速度关系推导
因为 fast 速度是 slow 的 2 倍,相同时间内路程也为 2 倍: S_{fast} = 2S_{slow} ,
代入路程表达式: A + sB + b_1 = 2(A + b_1) 展开整理: sB = A + b_1 A = sB - b_1 ,
再代入 b₁ = B - b₂: A = sB - (B - b_2) 最终得到核心关系式: A = (s-1)B + b_2 ,
这就是手写笔记中推导出的核心公式。
6.5 理解公式的物理意义
现在我们得到: A = (s-1)B + b_2 ,
其中 (s-1)B 代表若干个完整的环长。在环形链表中,指针完整绕行整数圈后,最终停留的节点位置不会改变,因此这部分对最终落点没有影响。
更严谨的数学表达是:
,
也就是说,从环上的位置意义来看,从相遇点向前走 A 步,和向前走 b₂ 步,最终会停在同一个节点。
而根据 b₂ 的定义,它恰好就是从第一次相遇点 N 走到入环首节点的距离;同时,从头节点出发走 A 步,也恰好到达入环首节点。
于是:
- 头节点出发的指针:走 A 步 → 到达入环首节点
- 相遇点 N 出发的指针:走 A 步 ≡ 走 b₂ 步 → 到达入环首节点
两个指针速度完全相同(每次 1 步),因此它们一定会在入环首节点相遇。 这就是第二阶段双指针能够正确找到环入口的原理。
注:手写笔记中为了方便理解,写成了"完整圈数可以忽略,因此 A=b₂"。严格来说 A 和 b₂ 的数值不一定相等,更准确的是环上落点等效。
七、证明二:slow 走 1 步、fast 走 2 步为什么一定能相遇?
上一部分证明的是「假设已经相遇,为什么能找到入口」,但还有一个前提问题:只要链表有环,快慢指针为什么一定能第一次相遇?
这一部分同样对应我的手写推导。

7.1 环形追及模型
仍然沿用统一变量:
A:链表头到入环首节点的直链长度B:环的总长度
当慢指针走完直链 A、刚进入环时,快指针因为速度更快,已经位于环内某个位置,两者之间形成了初始追及距离 b₂。 这个问题可以抽象成标准的环形跑道追及模型。
设:
v_slow:慢指针每轮移动步数v_fast:快指针每轮移动步数- 速度差:
Δv = v_fast - v_slow λ₁:为完成追及,快指针相对慢指针额外跨过的完整环数λ₂:追及过程经历的总迭代轮次
要完成追及,快指针相对慢指针需要补上的总距离为 b₂ + λ₁B;经过 λ₂ 轮后,由速度差积累的相对路程为 λ₂Δv。 因此追及成立的核心方程:
。
7.2 代入标准 Floyd 算法
标准算法中 v_slow = 1,v_fast = 2,因此速度差: Δ v = 2 - 1 = 1 ,追及方程简化为:
。
对于任意正整数环长 B 和任意合法初始追及距离 b₂,都一定存在非负整数 λ₁、λ₂ 满足等式:只需取任意非负 λ₁,令 λ₂ = b₂ + λ₁B 即可。
更直观的理解:fast 每轮比 slow 多走 1 步,它们在环上的相对位置每次前进 1 个节点。环内一共只有 B 个位置,因此有限次移动后,相对位置一定会归零,也就是 slow == fast。
结论:当 slow 每次走 1 步、fast 每次走 2 步时,只要链表存在环,两指针一定会在环内相遇。
八、高阶扩展:如果 fast 和 slow 的步长不是 2 和 1 呢?
这部分属于拓展知识,标准情况已经足够应对刷题和面试。以下是在手写推导基础上,结合 AI 整理的通用结论。
从通用追及方程出发:
,其中 Δv = v_fast - v_slow。
整理为二元一次不定方程标准形式:
,
根据贝祖定理 :不定方程 ax + by = c 存在整数解的充要条件是 gcd(a,b) | c。 对应到本题,快慢指针能够相遇的充要条件为:
即:
。
通俗来说:环长与速度差的最大公约数,必须能整除两指针的初始相对偏移,才能保证存在相遇时刻。
8.1 为什么 1 和 2 的组合永远成立?
标准算法中 Δv = 1,因此 gcd(B, 1) = 1。而 1 可以整除任意整数,因此无论环长多少、初始偏移是多少,相遇条件恒成立。 这也从数论角度解释了为什么 slow=1、fast=2 是对任意环结构都稳定的组合。
8.2 速度差大于 1 会怎样?
比如 slow 走 1 步、fast 走 3 步,速度差 Δv = 2。 此时要求 gcd(B, 2) | b₂。若环长 B 是偶数,则 gcd(B, 2) = 2;如果此时初始偏移 b₂ 是奇数,2 无法整除奇数,两指针就会周期性错位,永远无法落在同一个节点上。
因此步长差大于 1 时,并不能对所有环结构保证相遇,这也是实际算法统一使用 1、2 步长的核心理论原因。
九、四道题放在一起重新理解双指针
刷完这四道题,最大的收获不是多记住了四份 AC 代码,而是开始能把不同题目对应到不同的双指针模型。
| 题目 | 双指针模式 | 核心思路 | 时间复杂度 | 空间复杂度 |
|---|---|---|---|---|
| 倒数第 k 个节点 | 定距双指针 | 快指针先走 k 步 | O(N) | O(1) |
| 回文链表 | 倍速双指针 | 找中点 + 原地反转 | O(N) | O(1) |
| 相交链表 | 长度对齐 | 先消除长度差 | O(M+N) | O(1) |
| 环形链表 II | 环形追及 | Floyd 判环 + 路程推导 | O(N) | O(1) |
以后再遇到类似问题,可以先从题目描述中寻找信号:
- "倒数第 k 个" → 优先制造固定间距
- "链表中点" → 优先 slow 1 步、fast 2 步
- "两个链表公共节点" → 优先让两指针剩余路程相同
- "链表是否有环" → 优先 Floyd 快慢指针追及
当这种映射关系逐渐形成,刷题就不再完全依赖"有没有见过原题"。
十、写在最后
四道题里最让我有收获的还是环形链表 II。
如果只为了通过 LeetCode,记住"慢 1 快 2 → 第一次相遇 → 一个回 head → 同步走 → 第二次相遇即入口"基本就够用了。但这样记下来,过段时间难免又会问自己:为什么?
这次自己在纸上画出 A、B、b₁、b₂,从路程公式一步步推到 A = (s-1)B + b₂,才真正想通头指针和相遇点指针为什么能一起走到入口。而快慢指针必然相遇的问题,也能抽象成环形追及方程,甚至延伸到最大公约数和贝祖定理。
当然,正常笔试面试不需要把通用情况推导到这么深。对我来说,这部分扩展最大的意义不是"面试要背贝祖定理",而是让我看到:很多看似技巧的算法,背后都有可以继续深挖的数学逻辑。
所以现在刷数据结构题,我也越来越不想只留下一份 AC 代码。如果一道经典题背后有值得琢磨的指针关系、边界条件、复杂度分析、数学推导,还是值得拆开吃透一次。
毕竟面试真正会追问的,从来不只是"这道题你会不会写",而是"为什么你这样写一定是对的"。能回答第二个问题,才算真正把一道经典题学透了。