单链表专题(三)-刷题复盘篇:从快慢指针到环形链表 II 数学推导

数据结构-4道经典面试题:从快慢指针到环形链表 II 数学推导

写在前面

前面学习单链表时,我更多在练习头插、尾插、删除、反转等基础操作。真正开始刷链表面试题之后才发现,很多看似完全不同的问题,最终都会回归同一个核心思想:双指针

这次集中整理四道单链表高频面试题:

  • 面试题 02.02:返回倒数第 k 个节点
  • LCR 027:回文链表
  • LeetCode 160:相交链表
  • LeetCode 142:环形链表 II

四道题都用到了双指针,但用法各不相同:固定间距、倍速移动、长度对齐、环形追及。前三道侧重双指针的灵活应用,最后的环形链表 II 则更进一步------代码只有十几行,但背后有两个非常值得深究的问题:

  1. 为什么慢指针走 1 步、快指针走 2 步,只要存在环就一定能相遇?
  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 -> 5k = 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 长度对齐双指针

两条链表长度不同时,如果同时从头节点出发,即使有公共部分,也无法同时到达交点。

我的思路是:

  1. 分别遍历统计链表 A、B 的长度
  2. 计算长度差 diff,让更长的链表先走 diff
  3. 此时两个指针到链表尾部的剩余长度完全相同
  4. 两指针同步后移,第一次满足 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 步。若 fastfast->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 = 1v_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 代码。如果一道经典题背后有值得琢磨的指针关系、边界条件、复杂度分析、数学推导,还是值得拆开吃透一次。

毕竟面试真正会追问的,从来不只是"这道题你会不会写",而是"为什么你这样写一定是对的"。能回答第二个问题,才算真正把一道经典题学透了。

相关推荐
爱编程的小新☆3 小时前
【LeetCode】从递归到 Flood Fill:5 道题吃透 DFS 的选择、回溯与标记
java·算法·leetcode·深度优先·回溯·flood fill
Water_Sunzhipeng3 小时前
2024牛客暑期多校训练营1
算法
hetao17338374 小时前
2026-08-09~08-14 hetao1733837 的刷题记录
c++·算法
技术小黑4 小时前
RNN算法实战系列06 | LSTM 实现糖尿病探索与预测
rnn·算法·lstm
fīɡЙtīиɡ ℡4 小时前
大模型结构化输出
人工智能·学习
疯狂打码的少年4 小时前
【数据结构】二叉树的性质(五大性质+计算)
数据结构·笔记·算法
Dave1205464 小时前
Day17:Agent Memory高级设计——短期记忆、长期记忆与向量语义记忆
人工智能·学习
小唔w4 小时前
2026年AI培训行业观察:主流学习渠道一览
人工智能·学习
wabs6664 小时前
关于图论【A*算法 | 卡码网127.骑士的攻击的思考】
数据结构·算法·图论·卡码网·广搜的改进版
CIO_Alliance5 小时前
AI认知系列(3)| 数据、算法、算力、场景四要素协同
人工智能·算法·ipaas·系统集成·企业cio联盟·企业级ai化转型