快慢指针能检测环是因为其空间复杂度为o(1)且无需哈希计算,通过快指针每次走两步、慢指针走一步,在环中必然相遇;但需前置检查head和head.next是否为空以避免panic。

为什么快慢指针能检测环,而不是用 map 记地址
因为环检测本质是判断「是否重复访问同一节点」,但用 map[*Node]bool 存地址虽然直观,会额外消耗 O(n) 空间;而快慢指针只用两个指针变量,空间复杂度压到 O(1),且无需哈希计算或内存分配——这对高频调用或嵌入式场景很关键。
常见错误是认为「只要快指针追上慢指针就一定有环」,其实前提是链表结构合法(next 指针不乱跳)。如果链表本身有野指针(比如 nil 后还继续解引用),程序会 panic,不是返回 false。
- 必须先检查
head == nil或head.Next == nil,否则fast = fast.Next.Next会 panic - 快指针每次走两步,慢指针走一步,这是数学收敛的最小安全步长组合;用「三步 vs 一步」可能跳过相遇点
- Go 中结构体指针相等性直接比较内存地址,
slow == fast是可靠判断依据,不用重写 Equal 方法
标准实现里容易漏掉的边界条件
典型误判发生在单节点无环、双节点无环、空链表这三种情况。很多人只测试了「有环」用例,结果上线后遇到 panic: runtime error: invalid memory address。
正确写法必须把空检查和单节点检查前置,且循环条件要同时约束快指针的两步可达性:
立即学习“go语言免费学习笔记(深入)”;
func hasCycle(head *ListNode) bool {
if head == nil || head.Next == nil {
return false
}
slow, fast := head, head
for fast != nil && fast.Next != nil {
slow = slow.Next
fast = fast.Next.Next
if slow == fast {
return true
}
}
return false
}-
fast != nil && fast.Next != nil缺一不可:只判fast != nil会导致fast.Next.Next在fast.Next == nil时 panic - 不能把
slow == fast判定放在循环开头,否则初始状态slow == fast == head会直接返回 true - Go 的零值机制让
slow和fast初始化为nil是安全的,但一旦赋值为head,后续所有操作都基于非空假设
如何从环检测扩展到找环入口(LeetCode 142)
检测到环只是第一步;若需返回环起点(即第一个被重复访问的节点),得用数学推导:设头到入口距离为 a,入口到相遇点为 b,环剩余长度为 c,则有公式 2(a + b) = a + b + n(b + c),化简得 a = n(b + c) - b,说明「从头出发走 a 步」和「从相遇点出发走 a 步」会同时抵达入口。
实操上就是在检测到 slow == fast 后,重置一个指针到 head,两个指针同步每次走一步:
for head != slow {
head = head.Next
slow = slow.Next
}
return head // 环入口- 这个逻辑依赖前面检测环的代码已确认存在环,所以不必再判空
- 即使 n > 1(快指针绕环多圈),公式仍成立,所以不用关心具体绕了几圈
- 如果链表无环,这段代码根本不会执行,所以不要把它和检测逻辑混在一个函数里硬塞
在真实项目中要注意的性能与调试陷阱
生产环境链表往往不是裸 *ListNode,而是嵌套在业务结构里(比如带锁的并发安全链表、或含字段校验的 wrapper 结构)。这时候直接拿 Next 字段做快慢指针会出问题。
- 如果链表节点有 mutex,快慢指针并发遍历时可能死锁——必须确保整个检测过程在单 goroutine 内完成,且不触发任何锁操作
- 某些 ORM 或序列化库生成的链表结构,
Next是私有字段或通过方法返回(如node.GetNext()),不能直接用指针运算,得改用接口抽象 - 调试时用
fmt.Printf("%p", node)看地址比打印值更可靠,因为环内节点值可能完全相同(比如全是 0),光看值会误判
环检测本身很简单,难的是怎么让它活在真实代码里:不破坏封装、不引入竞态、不依赖特定内存布局。这些细节比算法本身更常导致线上故障。










