深蓝黑背景品牌封面:左侧大标题「链表有环,入口在哪」,副标题「快慢指针:先判环,再定位入口」;右侧原创装饰——四个节点组成的链引入一个由五个节点围成的圆环,箭头首尾相接;顶部标注「经典面试题解析 · 算法」与「Day 03 · 09-21」 图 1:本文封面,点明题目与两阶段解法。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料(见文末来源说明),与任何公司的当前招聘无关。「判断链表是否有环」几乎人人会答,面试官真正想听的是后半句:环的入口节点是哪一个?以及为什么这样做是对的。

想象面试现场,面试官写下题目:给定一个链表的头节点,判断链表中是否有环;如果有,返回环开始的那个节点;进阶要求——用 O(1) 的额外空间。

第一步,审题。

两个子问题要分开:判环和定位入口。最容易想到的做法是哈希表——边遍历边把节点存进 Set,第一次遇到重复节点就是入口。这个解法正确、好写,但空间是 O(n),不满足进阶要求。它依然值得先讲:它是你的正确性基准,也是引出更优解的台阶。

第二步,基础回答:快慢指针判环。

Floyd 判圈算法:慢指针每次走一步,快指针每次走两步。如果链表无环,快指针会先走到末尾;如果有环,快指针进入环后相当于在环形跑道上追慢指针,每轮距离缩短一步,二者必然相遇。判环这一步时间 O(n)、空间 O(1),这半问就答完了。

第三步,进阶回答:入口怎么定位。

设头到入口的距离为 a,入口到相遇点为 b,环的余下部分为 c。相遇时慢指针走了 a+b,快指针走了 a+b 再加上若干圈 k(b+c)。快指针路程是慢指针的两倍,于是 2(a+b) = a+b+k(b+c),化简得 a+b = k(b+c),取 k=1 就是 a = c——从头节点到入口的距离,恰好等于从相遇点绕环剩下的那段距离。

结论直接落地成代码:让一个指针回到头节点,另一个留在相遇点,两个指针同速前进,再次相遇的位置就是环的入口。整个过程时间仍是 O(n),空间 O(1)。

算法图解:左侧链表从头部经 a 步到达环入口,入口再走 b 步到达快慢指针的相遇点,环的余下部分为 c 步;慢指针走一步、快指针走两步,二者必然在环内相遇。右侧推导:相遇时慢指针走了 a+b,快指针走了 a+b+k(b+c),由快指针路程是慢指针两倍得 a+b=k(b+c),取 k=1 得 a=c——于是让一个指针回到头部、另一个留在相遇点,同速前进,再次相遇处就是环的入口。底部标注:时间 O(n)、空间 O(1);用哈希表判环需要 O(n) 空间,不满足进阶要求 图 2:快慢指针相遇后,用 a=c 推导出环入口的定位方法。

第四步,写出可运行的代码。

下面这份实现包含判环、入口定位、哈希表法复核,用 Node.js 22.15.0 直接运行(node detect-cycle.js)即可:

'use strict';

class ListNode {
  constructor(val) {
    this.val = val;
    this.next = null;
  }
}

// 用数组建链表;pos 为尾节点指向的下标,-1 表示无环
function buildList(vals, pos) {
  const nodes = vals.map((v) => new ListNode(v));
  for (let i = 0; i < nodes.length - 1; i++) nodes[i].next = nodes[i + 1];
  if (pos >= 0 && nodes.length > 0) nodes[nodes.length - 1].next = nodes[pos];
  return nodes[0] ?? null;
}

// 第一阶段:快慢指针判环,返回相遇点(无环返回 null)
function detectCycle(head) {
  let slow = head;
  let fast = head;
  while (fast !== null && fast.next !== null) {
    slow = slow.next;
    fast = fast.next.next;
    if (slow === fast) return slow; // 相遇:有环
  }
  return null; // 快指针走到末尾:无环
}

// 第二阶段:一个指针回到头,与相遇点指针同速前进,再次相遇即环入口
function findEntry(head) {
  const meet = detectCycle(head);
  if (meet === null) return null;
  let a = head;
  let b = meet;
  while (a !== b) {
    a = a.next;
    b = b.next;
  }
  return a;
}

// 哈希表法对照:O(n) 空间,验证快慢指针结果
function findEntryBySet(head) {
  const seen = new Set();
  let cur = head;
  while (cur !== null) {
    if (seen.has(cur)) return cur;
    seen.add(cur);
    cur = cur.next;
  }
  return null;
}

const cases = [
  { vals: [3, 2, 0, -4], pos: 1 }, // 有环,入口是 2
  { vals: [1, 2], pos: 0 }, // 有环,入口是 1
  { vals: [1], pos: -1 }, // 无环
  { vals: [1, 2, 3, 4, 5, 6], pos: 2 }, // 有环,入口是 3
];
for (const { vals, pos } of cases) {
  const head = buildList(vals, pos);
  const entry = findEntry(head);
  const check = findEntryBySet(head);
  console.log(
    `vals=[${vals}] pos=${pos} -> 判环 ${detectCycle(head) ? '有环' : '无环'},` +
      `入口 ${entry ? entry.val : 'null'}(哈希法复核 ${check ? check.val : 'null'})`
  );
  if (entry !== check) throw new Error('快慢指针与哈希法结果不一致');
}

实测输出如下:

vals=[3,2,0,-4] pos=1 -> 判环 有环,入口 2(哈希法复核 2)
vals=[1,2] pos=0 -> 判环 有环,入口 1(哈希法复核 1)
vals=[1] pos=-1 -> 判环 无环,入口 null(哈希法复核 null)
vals=[1,2,3,4,5,6] pos=2 -> 判环 有环,入口 3(哈希法复核 3)

四个用例两种解法结果全部一致,包括单节点无环这种容易漏掉的边界。

易错点清单。

一,把节点值相等当成节点相同——判环比较的是节点引用,值可以重复,环是按指针定义的。二,快指针只判 fast !== null 漏判 fast.next !== null,无环时会踩空。三,第二阶段两个指针一个走两步一个走一步,把「同速」记错,推导就全废了。四,讲得出结论 a=c 却推不出来——面试官要的是那个两倍路程的等式,不是背下来的结论。

回到面试现场,建议的答题节奏:先给哈希表法作为正确基准并指出空间 O(n),再上快慢指针判环,然后在纸上画出 a、b、c 三段距离现场推导,最后落到两指针同速前进的代码。推导过程本身就是答案的一半。

快慢指针这种「O(1) 空间做检测」的思想,在 Agent 循环检测、流式数据去重里都能找到影子。你还能想到哪些「用两个速度不同的指针翻盘」的题目?欢迎举例。

参考资料

  • Node.js v22.x 官方 API 文档(文中代码运行环境为 Node.js 22.15.0,Set、Map 等行为以此为准) — https://nodejs.org/docs/latest-v22.x/api/