学习笔记六 —— 链表判断环 找入口 找环长

141 阅读10分钟

题目:实现 detectCycle(head),返回环的入口节点(无环返回 null)。
考点:快慢指针数学推导(L = kC - N

以下是对环形链表算法(LeetCode 141/142)的逐层解析,结合面试重难点与变种题型,用通俗语言和逻辑推导展开:


🔍 一、算法原理:快慢指针的数学本质

1. 为什么快慢指针能判断环?

  • 龟兔赛跑模型
    慢指针(龟)每次走1步,快指针(兔)每次走2步。若有环,快指针最终会从后方追上慢指针,如同在环形跑道上快的运动员会套圈慢的运动员。
  • 追及问题的数学证明
    设环长度为 C,慢指针入环时快指针已走 k 圈(k≥0)。
    每走一步,快指针追近慢指针1步(相对速度差为1)。
    初始距离为 dd < C),经过 d 步后必然追上。

2. 如何定位环的入口?

  • 关键公式推导
    • 设头节点到入口距离 L,入口到相遇点距离 X,相遇点到环入口剩余距离 C-X(图例见下图)。
    • 相遇时慢指针路程:L + X
      快指针路程:L + X + nC(多走 n 圈)
    • 因快指针速度为慢指针2倍:
      2(L + X) = L + X + nCL = nC - X
      即:L = (n-1)C + (C - X)
  • 物理意义
    头节点L 步 = 从相遇点(n-1)圈 + (C-X) 步。
    因此,两个指针分别从头节点和相遇点同速出发,必在环入口相遇。

⚠️ 二、面试重难点与陷阱

1. 考察重点

  • 推导能力(90%面试官会追问)
    需现场画出链表环图,并逐步写出 L = nC - X 的推导过程。若跳过推导直接写代码,可能被质疑背诵答案。
  • 边界条件处理
    • 快指针需检查 fastfast.next 非空(避免空指针异常)。
    • 无环时及时返回 null

2. 高频变种题

变种题型考察点解法变形
计算环的长度相遇后再次追击的次数相遇后固定一指针,另一指针单步走直到再相遇
快指针走3步/慢指针走2步步长变化对相遇的影响可能无法相遇(需数学证明)
判断环是否包含特定节点环内搜索先定位入口,再遍历环
带环链表的深拷贝复杂结构处理哈希表记录原节点→新节点映射

3. 为什么必须「快2慢1」?

若快指针走3步:

  • 相对速度差为2步,能否相遇取决于初始距离 d 和环长 C 的奇偶性。
  • 反例:若 d=1, C=2(奇数距离+偶数环长),则快指针永远跳过慢指针无法相遇。

💡 三、通俗理解技巧

生活化比喻

  1. 环的判断(141题)
    两人绕操场跑步:

    • 若操场是环形(有环),快者会追上慢者;
    • 若是直线(无环),快者先到终点。
  2. 环入口定位(142题)

    • 两人相遇后,慢者回到起点,两人同速前进
      从起点到入口的距离 = 相遇点继续走回入口的距离(绕过 n-1 圈)。

面试话术示例

“我们先用快慢指针判断是否有环。若相遇,说明有环;此时将慢指针放回头部,两指针同速前进,再相遇时一定是环入口——因为从头部走 L 步和从相遇点走 L + nC 步等价,而 L 刚好是到入口的距离。”
(配合画图解释 L = nC - X


💎 四、总结与刷题策略

  • 核心题目
    • 必刷 LeetCode 141(判断环)142(找入口)287(找环长)
  • 加分技巧
    • 主动解释「为什么快指针走2步」:确保相遇的必然性。
    • 对比哈希法(O(n)空间)和快慢指针(O(1)空间)的优劣。
  • 关联实际场景
    React Fiber 的任务调度环用链表实现中断恢复,需快速定位环节点。

环形链表面试题的核心在于数学推导的清晰表达边界条件的严谨性。理解追及问题的物理本质后,即使变种题也能举一反三。

下面我们按照题目顺序,用JavaScript实现LeetCode 141(判断环)、142(找入口)、287(找环长)。每道题我会先给出代码,再结合环的数学原理和工程细节进行解析,最后说明面试考察点。


⚙️ 1. LeetCode 141 - 环形链表(判断环)

题目要求:检测链表中是否存在环,返回布尔值。

function hasCycle(head) {
    // 边界:空链表或单节点无环
    if (!head || !head.next) return false; 
    
    let slow = head;  // 慢指针(一次一步)
    let fast = head;  // 快指针(一次两步)
    
    while (fast && fast.next) {
        slow = slow.next;          // 慢指针走1步
        fast = fast.next.next;     // 快指针走2步
        if (slow === fast) return true; // 相遇即有环
    }
    return false; // 快指针走到终点,无环
}

关键点解析

  1. 快慢指针的必然相遇

    • 若有环,快指针相对慢指针的速度差为1步/次,相当于慢指针静止时快指针以1步/次靠近,必在环内相遇。
    • 若快指针遇到null,说明链表有终点,无环。
  2. 为什么快指针走2步?

    • 步数比为2:1可保证所有环长下必然相遇(如环长C,初始距离差d < C,经过d次移动必追上)。
    • 若步数比为3:1,可能因环长奇偶性导致永不相遇(如d=1, C=2)。
  3. 面试陷阱

    • 需检查fast && fast.next避免空指针异常。
    • 若链表节点数≤10⁴,也可用计数器法(计数>10⁴返回true),但空间O(1)更优。

🔍 2. LeetCode 142 - 环形链表 II(找入口)

题目要求:返回环的入口节点,无环则返回null

function detectCycle(head) {
    if (!head || !head.next) return null;
    
    let slow = head;
    let fast = head;
    let hasCycle = false;
    
    // 第一阶段:判断是否有环
    while (fast && fast.next) {
        slow = slow.next;
        fast = fast.next.next;
        if (slow === fast) {
            hasCycle = true;
            break;
        }
    }
    if (!hasCycle) return null; // 无环直接退出
    
    // 第二阶段:找入口
    let ptr = head;
    while (ptr !== slow) {
        ptr = ptr.next; // 新指针从头部出发
        slow = slow.next; // 原慢指针继续走
    }
    return ptr; // 相遇点即入口
}

数学原理与推导

  • 公式推导
    设头到入口距离L,入口到相遇点X,环长C
    相遇时:慢指针路程 = L + X,快指针 = L + X + nC(多走n圈)。
    因快指针速度是慢指针2倍:2(L + X) = L + X + nCL = nC - X
    结论L = 从相遇点再走nC - X(即C - X + (n-1)C),等价于从头部走L步。

  • 通俗理解
    两人在环内相遇后,让一人回到起点,两人同速前进,下次相遇点一定是入口。因为:

    • 从起点走L步到达入口;
    • 从相遇点走L步(= nC - X),相当于绕环n-1圈再走C-X,正好到入口。

面试注意

  • 需分两阶段写代码(先判环、再找入口),避免逻辑混杂。
  • 主动解释L = nC - X的推导过程(90%面试官会追问)。

📏 3. LeetCode 287 - 寻找环的长度(变形题)

题目要求:给定有环链表,返回环的长度(节点数)。

function cycleLength(head) {
    if (!head || !head.next) return 0;
    
    let slow = head;
    let fast = head;
    let hasCycle = false;
    
    // 先判断是否有环
    while (fast && fast.next) {
        slow = slow.next;
        fast = fast.next.next;
        if (slow === fast) {
            hasCycle = true;
            break;
        }
    }
    if (!hasCycle) return 0; // 无环返回0
    
    // 固定相遇点,单指针绕环一圈计数
    let length = 1;
    let temp = slow.next; // 从相遇点下一个节点开始
    while (temp !== slow) {
        length++;
        temp = temp.next;
    }
    return length;
}

关键逻辑

  1. 数学关系
    环长C = 从相遇点出发,回到相遇点所走的步数。
  2. 优化技巧
    • 无需重新定位入口,直接利用判环时的相遇点。
    • 从相遇点出发,单指针遍历直到返回原点,计数即为C

时间复杂度

  • 判环:O(n)
  • 计算环长:O(C)(C为环长),整体仍为O(n)。

💎 三题关联与面试考点总结

题目核心技巧考察重点变种方向
141(判环)快慢指针追及边界处理、步长选择快指针走3步是否可行?
142(找入口)双指针同速定位数学推导能力(L = nC - X)不修改链表的前提下如何做?
287(找环长)固定点绕圈计数利用已有信息减少遍历次数如何同时返回入口和环长?

刷题建议

  1. 先独立实现141,再基于其代码扩展142(避免重复造轮子)。
  2. 287题可看作142的补充,实际面试中可能要求“一次性返回入口和环长”。
  3. 终极检验:尝试用一套代码解决142+287(先找入口,再从入口走一圈算长度)。

提示:若面试官要求空间O(1)且禁止修改节点(如不能添加tag属性),快慢指针是唯一解。哈希表法(Map存储节点)虽直观,但空间O(n)可能被扣分。

你的代码实现了检测链表环、定位环入口、计算环长度的功能,但需注意 LeetCode 287(寻找重复数)与此代码解决的问题不同。以下是对代码的详细分析及优化建议:


🔍 一、代码功能分析

正确实现的部分

  1. 环检测(LeetCode 141/142)

    • 快慢指针(slowfast)相遇时,说明有环(符合Floyd判环算法)。
    • 定位入口节点:将ptr移回头部,与slow同速前进,相遇点即为环入口(数学推导 L = nC - X 成立)。
  2. 环长度计算(LeetCode 287的变形)

    • 从入口节点(slow)出发,遍历一圈计数length,逻辑正确。

⚠️ 潜在问题

  1. 边界条件:自环(单个节点成环)

    • 若环仅有一个节点(如 head.next = head),temp = slow.next 会指向自身,while (temp !== slow) 不执行 → 返回length=1(正确)。
  2. 287题不适用

    • LeetCode 287是数组找重复数,需将数组视为隐式链表(索引跳转),而你的代码直接操作链表节点,不适用于数组。

🔧 二、改进建议

1. 修复逻辑严谨性

  • 问题:当环不存在时,fastfast.next可能为null,但代码未在循环外检查fast有效性。
  • 修复:在第二阶段前显式检查fast状态:
    if (!fast || !fast.next) return { hasCycle: false, ptr: null, length: 0 }; // 确保无环
    

2. 性能与可读性优化

function cycleLink(head) {
  if (!head || !head.next) return { hasCycle: false, ptr: null, length: 0 };

  let slow = head, fast = head;
  while (fast && fast.next) {
    slow = slow.next;
    fast = fast.next.next;
    if (fast === slow) break; // 相遇即停
  }

  // 显式检查无环
  if (!fast || !fast.next) return { hasCycle: false, ptr: null, length: 0 };

  let ptr = head;
  while (ptr !== slow) {
    ptr = ptr.next;
    slow = slow.next; // 同速找入口
  }

  let length = 1;
  let temp = slow.next;
  while (temp !== ptr) { // 直接从入口ptr开始计数
    length++;
    temp = temp.next;
  }
  return { hasCycle: true, ptr, length };
}

3. LeetCode 287的解法差异

若需解决287题(数组找重复数),需转换思路:

function findDuplicate(nums) {
  let slow = nums[0], fast = nums[0];
  do {
    slow = nums[slow];
    fast = nums[nums[fast]];
  } while (slow !== fast);

  slow = nums[0];
  while (slow !== fast) {
    slow = nums[slow];
    fast = nums[fast];
  }
  return slow; // 重复数即环入口
}

原理:将数组值视为指针,重复数导致环,入口即重复数。


💎 三、总结

功能你的代码标准要求是否匹配
检测环LeetCode 141/142
定位环入口LeetCode 142
计算环长度环长度问题
解决数组重复数LeetCode 287

结论

  • 当前代码完美解决链表环检测+入口定位+环长度计算,但不适用于LeetCode 287(需数组转换)。
  • 优化后代码通过显式边界检查更健壮,可直接用于链表环问题。

测试建议:用以下用例验证

  1. 自环:head = {val:1, next: null}; head.next = head;
  2. 多节点环:3→2→0→-4→2(入口为2,环长=3)
  3. 无环链表:1→2→3→null