LeetCode 141 · 环形链表(Linked List Cycle)

题目链接:141. 环形链表 - 力扣(LeetCode) 难度:简单 | 标签:哈希表、链表、双指针


一、先看清题目在问什么

给你一个链表的头节点 head,判断链表里有没有环。

如果有某个节点,可以通过连续 next 指针再次走到它自己,就说明有环。

注意题目给的数组形式只是"造链表"的描述:pos 表示尾节点接回到第几个下标(pos = -1 表示无环),这个 pos 是不会传给函数的 ,函数只能拿到 head。

一条链表长什么样

复制代码
 无环:  1 -> 2 -> 3 -> 4 -> nullptr
                           ^
                        走到头,结束
 ​
 有环:  1 -> 2 -> 3 -> 4
             ^         |
             |         v
             +-------- 5      (4 的 next 指回 2)

一旦有环,顺着 next 一直走就永远走不到 nullptr,会无限循环。这就是我们要检测的东西。

函数签名

复制代码
 /**
  * Definition for singly-linked list.
  * struct ListNode {
  *     int val;
  *     ListNode *next;
  *     ListNode(int x) : val(x), next(NULL) {}
  * };
  */
 class Solution {
 public:
     bool hasCycle(ListNode *head) {
     }
 };

二、一个必须先纠正的关键点

你的原始思路写的是:

遍历找到之前出现的数据就说明有环

这里藏着本题最大的一个坑 :要判断"出现过"的是节点本身(指针/地址) ,不是节点里的值 val。

复制代码
 if (p->val == q->val)   // ❌ 错误!值相同不代表是同一个节点
 if (p == q)             // ✅ 正确!地址相同才是同一个节点

举个反例:

复制代码
 链表: 3 -> 7 -> 3 -> nullptr       (pos = -1,无环)

两个节点的 val 都是 3,但它们是两个不同的节点。如果比值,你会误判成"有环"。

一句话记住:链表有没有环,看的是"拓扑结构",跟存的值毫无关系。

下面三种解法,本质上都是在回答同一个问题:我在往前走的过程中,有没有踩到一个我以前来过的节点? 区别只是"怎么记录以前来过哪里"。


三、解法一:朴素双循环(最容易想到的)

思路

对每一个当前节点 cur,回头从头扫一遍它前面所有的节点,看看是不是"同一个节点"出现过。 如果有 → 说明有人指回了老地方 → 有环。

复制代码
 cur 停在 ⑤:
   ① -> ② -> ③ -> ④ -> ⑤ -> ③(环)
                         ^
                         cur
   回头扫:pre 从 ① 走到 cur 前面,逐个比 (pre == cur)

代码

复制代码
 class Solution {
 public:
     bool hasCycle(ListNode *head) {
         for (ListNode *cur = head; cur != nullptr; cur = cur->next) {
             // 回头检查:cur 这个"节点",是不是之前就出现过
             for (ListNode *pre = head; pre != cur; pre = pre->next) {
                 if (pre == cur) return true;   // 撞见了老朋友 -> 有环
             }
         }
         return false;   // 能一路走到 nullptr -> 无环
     }
 };

⚠️ 一个很容易写错的版本(实测踩过的坑:Case 3 head=[1] 输出 true)

把内层循环的条件从 pre != cur 写成 pre != nullptr,看起来"更自然",结果直接错误:

复制代码
 // ❌ 错误写法 —— 任何非空链表都会返回 true
 for (ListNode *cur = head; cur != nullptr; cur = cur->next) {
     for (ListNode *pre = head; pre != nullptr; pre = pre->next) {
         if (pre == cur) return true;   // ← cur 一进来就会"撞见自己"
     }
 }

错在哪 :内层 pre 从 head 一路走到 nullptr,途中必然经过 cur 自己 。 所以只要链表非空,第一轮外层循环时 pre 走到 cur 的位置就 pre == cur 成立,立刻 return true ------ 连无环的 head = [1](LeetCode Case 3)都会被判成有环,输出 true、期望 false。

根因 :我们要找的是"cur 在之前的节点 里出现过",也就是在 [head, cur) 这个左闭右开区间 里找。 cur 自己不在"以前来过"的集合里,所以内层扫描必须在到达 cur 之前停下。

复制代码
 // ✅ 正确写法:内层循环边界是 pre != cur,把 cur 自己排除在外
 for (ListNode *pre = head; pre != cur; pre = pre->next) {
     if (pre == cur) return true;   // 只可能由"别人指回来"触发
 }

一句话记住:回头检查时,永远不要把"现在的自己"算进"过去"。 内层循环走到 cur 就必须停(pre != cur),否则 cur 一定会和自己撞上。

顺带一提:改成正确写法后,if (pre == cur) 这行其实永远可以简化成 return true;(循环能正常结束走到 cur,说明没撞上;能触发判断说明撞上了),但保留比较写出来更清晰、不易写错。

复杂度

项目 值 说明
时间复杂度 O(n²) 外层 n 个节点,内层最多扫 n 次
空间复杂度 O(1) 只用了两个指针

为什么它能停下来?

有环时外层 for 确实会死循环,但内层循环会在有限步内找到那个重复节点 并 return true,所以整体一定会结束。

评价

✅ 最符合直觉,不需要任何额外数据结构 ❌ 太慢,n = 10⁴ 时就要比 10⁸ 次量级,会超时


四、解法二:哈希集合(第一次优化 ------ 用空间换时间)

思路

解法一慢,是因为每次都要"重新走一遍"去回忆。 那干脆拿个小本本(哈希集合)记下来:每走到一个新节点,就把它的地址存进去;下次再看这个地址在不在本本里。

复制代码
 走到 ③ -> 不在本本里 -> 记下来 {①②③}
 ...
 又走到 ③ -> 本本里有!-> 有环 ✅

unordered_set 的查找是平均 O(1),所以从"每次 O(n) 地回忆"降到了"每次 O(1) 地查表"。

代码

复制代码
 #include <unordered_set>
 ​
 class Solution {
 public:
     bool hasCycle(ListNode *head) {
         std::unordered_set<ListNode*> seen;   // 小本本:存"节点地址"
 ​
         for (ListNode *cur = head; cur != nullptr; cur = cur->next) {
             if (seen.count(cur)) return true; // 这个节点以前来过 -> 有环
             seen.insert(cur);                 // 没来过,记下来
         }
         return false;
     }
 };

复杂度

项目 值 说明
时间复杂度 O(n) 每个节点进出一次哈希表
空间复杂度 O(n) 最坏要把所有节点地址都存进去

评价

✅ 时间复杂度从 O(n²) 降到了 O(n),非常好理解 ❌ 额外占了 O(n) 的内存 ------ 面试官一般会追问"能不能不用额外空间?"

这是从你的思路 1 最自然的升级:把"用循环反复找"换成"用集合一次性记住"。


五、解法三:快慢指针 Floyd(最优解 ------ 推荐背下来)

思路

不用本本,用两个人跑步:

  • 🐢 慢指针 slow :一次走 1 步

  • 🐇 快指针 fast :一次走 2 步

复制代码
   ① -> ② -> ③ -> ④ -> ⑤
        ^              |
        +--------------+
  • 如果没环 :跑道是直的,跑得快的 fast 一定会先撞到终点 nullptr。→ 返回 false

  • 如果有环 :跑道是环形的,两个人一直在里面转。fast 比 slow 快,它必然会从后面追上 slow 并和它重合。→ 返回 true

打个比方:在操场跑道上跑步,跑得快的人一定会套圈追上跑得慢的人。

为什么一定会追上(不用怕想不明白)

把两人的相对速度 看成 2 - 1 = 1 步/次。 两个人进入环以后,快指针相对慢指针每"回合"缩短 1 个节点的距离。

而环的长度是有限 的(设为 b),距离最多就是 b-1,所以最多再走 b-1 回合,距离一定会缩到 0,也就是相遇。

并且永远不用担心"擦肩而过跳过"------ 相对速度恰好是 1,是一格一格逼近的,只能严格减 1,不可能从 1 直接跳到 -1。

代码(LeetCode 可直接提交,无需 include)

复制代码
 class Solution {
 public:
     bool hasCycle(ListNode *head) {
         ListNode* slow = head;
         ListNode* fast = head;
 ​
         // 注意顺序:必须先判 fast,再判 fast->next,否则可能空指针解引用
         while (fast != nullptr && fast->next != nullptr) {
             slow = slow->next;        // 走 1 步
             fast = fast->next->next;  // 走 2 步
 ​
             if (slow == fast) return true;   // 追上了 -> 有环
         }
         return false;   // fast 走到 nullptr 了 -> 无环
     }
 };

💡 先走再判、还是先判再走? 两个指针初始都指向 head,此时 slow == fast 但还没开始跑 ,不能算相遇。所以必须先走一步再判断上面这种写法是安全的。

手写模拟一遍(有环,环入口是 ②)

复制代码
 初始:      slow=①  fast=①
 第1轮后:   slow=②  fast=③      不等
 第2轮后:   slow=③  fast=⑤      不等
 第3轮后:   slow=④  fast=②      不等  (fast 已经进环又绕回来了)
 第4轮后:   slow=⑤  fast=④      不等
 第5轮后:   slow=②  fast=②      相等 ✅ 返回 true

复杂度

项目 值 说明
时间复杂度 O(n) 线性扫描,最多绕环一圈多
空间复杂度 O(1) 只用了两个指针

评价

✅ 时间最优、空间最优 ,代码只有 8 行 ✅ Top 100 高频题的标准答案,面试必须会


六、三种解法横向对比

解法 记录"来过没有"的方式 时间复杂度 空间复杂度 推荐度
一、朴素双循环 每次回头重新扫一遍 O(n²) O(1) ⭐ 理解用,会超时
二、哈希集合 用 set 存地址 O(n) O(n) ⭐⭐⭐ 好写,面试可作过渡答案
三、快慢指针 用相对速度判断相遇 O(n) O(1) ⭐⭐⭐⭐⭐ 最优,写这个

进化路径也很好记:

复制代码
 想法:找重复节点
   │
   ├─ 用时间换空间 ──> 双循环:每个节点都回头扫(慢,O(n²))
   │
   └─ 换个方式记录 ──> 哈希集合:用小本本记下来(快,但占内存 O(n))
                       │
                       └─ 还能更好吗?──> 快慢指针:靠"套圈"物理性质判断(O(1) 空间)

七、易错点清单

# 坑 正确做法
1 拿 p->val == q->val 判断重复 必须比指针 p == q
2 双循环内层写成 pre != nullptr,把 cur 自己也扫进去,任何非空链表都返回 true 内层边界必须是 pre != cur,把 cur 自己排除在"过去"之外
3 while 条件写成 while (fast->next && fast->next->next) 用 while (fast != nullptr && fast->next != nullptr),且顺序不能反(短路保护)
4 两个指针初始就相等,以为有环 先走一步再判断(见上面的代码)
5 快指针步长写成 3、4 可以工作但循环条件更复杂,步长 2 是最简形式
6 忘记边界:head == nullptr 上面三种解法都天然兼容(循环直接不进)
7 想在有环时用 for 计数到底多长 有环时无法"走到头"才停,必须靠相遇/nullptr 判停

边界用例自查

用例 期望 为什么
head = nullptr false 空链表无环
单节点,next = nullptr false fast->next 为空,循环不进
单节点,next = 自己 true 走一步后 slow 和 fast 都回到自己,相等
两个节点互相指 true 第 1 轮后相遇

八、延伸:找到环的入口节点(LeetCode 142)

141 的姊妹题。只需要在快慢指针相遇后再多加几行:

结论 :快慢指针相遇时,把其中一个指针放回 head ,然后两个指针都改成一次走 1 步 ,它们再次相遇的位置,就是环的入口。

复制代码
 class Solution {
 public:
     ListNode *detectCycle(ListNode *head) {
         ListNode *slow = head, *fast = head;
         while (fast != nullptr && fast->next != nullptr) {
             slow = slow->next;
             fast = fast->next->next;
             if (slow == fast) {          // 第一次相遇:确认有环
                 slow = head;             // 一个回到起点
                 while (slow != fast) {   // 两个都走 1 步
                     slow = slow->next;
                     fast = fast->next;
                 }
                 return slow;             // 再次相遇处 = 环入口
             }
         }
         return nullptr;
     }
 };

为什么成立(记住这几个量):

复制代码
 head ──a──> 环入口 ──b──> 相遇点 ──c──> (绕回环入口)
 ​
 第一次相遇时,slow 走了 a + b,fast 走了 2(a + b)
 fast 比 slow 多走的,恰好是「若干圈环长」:
     2(a + b) - (a + b) = k(b + c)
  => a + b = k(b + c)
  => a = (k-1)(b + c) + c

右边 c 是"从相遇点再走到入口"的距离,(k-1)(b+c) 是整圈(绕几圈不影响终点)。 所以 a(head 到入口的距离) == 从相遇点再走 c + 若干圈 ,两者同时以 1 步/次前进,必然在环入口碰头。


九、一句话总结

判断链表有没有环,最快的方法是让两个指针一快一慢同时跑 :fast 跑到 nullptr 就是没环,追上 slow 就是有环 ------ 时间 O(n),空间 O(1)。

建议练习顺序:先手写解法一确认理解 → 再写解法二体会"空间换时间" → 最后默写解法三到能 30 秒写出来。