图 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)。
图 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/