本文包含 LeetCode 141、142、21 三道链表题,每题统一按 ① 题目分析与解题思路 → ② 常见解法(附代码)及讲解 → ③ 知识点总结 组织。
| 题号 | 题目 | 首选解法 | 时间 / 空间 | 与前后题的关系 |
|---|---|---|---|---|
| 141 | 环形链表 | 快慢指针(Floyd 判圈) | O(n) / O(1) | 入门:只判断有没有环 |
| 142 | 环形链表 II | 快慢指针 + 二次相遇 | O(n) / O(1) | 141 的延伸:求环入口 |
| 21 | 合并两个有序链表 | 迭代 + 哑节点(dummy) | O(m+n) / O(1) | 归并排序 148 的基础 |
一、141. 环形链表

① 题目分析与解题思路
题目 :给定链表头结点,判断链表是否存在环。有环返回 true,无环返回 false。
环的定义 :某个节点的 next 指针一直走下去,能再次回到自己;题目说明里的 pos 只用于标注环的位置,不是传入参数。
核心思路(进阶要求 O(1) 空间:快慢指针,即 Floyd 判圈算法 / 龟兔赛跑)
- 慢指针
slow:一次走 1 步;快指针fast:一次走 2 步。 - 链表没有环 :
fast会先走到null,直接返回false。 - 链表有环 :快慢指针最终一定会在环内相遇(
fast绕圈追上slow),相遇则返回true。
原理 :进入环之后,fast 相对于 slow 每步缩小 1 个节点的距离,距离逐格递减、不存在"跳过",所以一定能碰上,不会永远错开。
② 常见解法(附代码)及讲解
解法 1:快慢指针(推荐,O(n) 时间,O(1) 空间,满足进阶)
java
/**
* Definition for singly-linked list.
* class ListNode {
* int val;
* ListNode next;
* ListNode(int x) {
* val = x;
* next = null;
* }
* }
*/
public class Solution {
public boolean hasCycle(ListNode head) {
ListNode slow = head;
ListNode fast = head;
// 只要快指针还能走两步
while(fast != null && fast.next != null){
slow = slow.next; // 慢走1步
fast = fast.next.next; // 快走2步
if(slow == fast){
return true; // 相遇,存在环
}
}
return false; // 快指针走到末尾,无环
}
}
✅ 讲解:
- 循环条件
fast != null && fast.next != null是防 NPE 的关键:fast一次走两步,若fast.next已是null,再取fast.next.next会空指针异常。 - 只要能相遇,一定有环;若退出循环,说明走到链尾,无环。
- 时间复杂度 O(n):无环时遍历一遍;有环时最多遍历一圈多一点(详见 142 题 ③ 的复杂度分析)。
解法 2:哈希集合(HashSet,O(n) 时间,O(n) 空间,好理解,不满足进阶)
思路:遍历链表,把访问过的节点存入集合;每次先判断当前节点是否已经在集合里,存在说明重复访问 → 有环。
java
public class Solution {
public boolean hasCycle(ListNode head) {
Set<ListNode> set = new HashSet<>();
ListNode cur = head;
while(cur != null){
if(set.contains(cur)){
return true;
}
set.add(cur);
cur = cur.next;
}
return false;
}
}
✅ 讲解:
- 存的是节点引用对象 ,不是
val!这是重点,值重复不代表节点重复(原因见下方补充说明)。 - 优点:直观好写;缺点:额外占用内存,面试优先写快慢指针版本。
补充说明:为什么必须存节点引用,而不是 val
链表节点的值完全可以重复 ,所以哈希解法里存的是节点对象(引用地址) ,而不是节点的 val 值。
举个例子:
plain
1 → 2 → 2 → 3
↑
└──┘
这里两个节点 val 都是 2,但它们是内存里两个不同的节点。
- 如果只存
val判断:会误以为遇到重复值就是环,直接误判true。 - 如果存节点引用:只有同一个节点再次被访问,才判定成环,逻辑才正确。
对应到 141 题考点
val只是节点附带的数据,题目没有任何保证 val 唯一;题目给的提示范围-10^5 <= Node.val <= 10^5,也允许重复。- 判断环的本质:是否再次走到同一个节点(同一个内存地址),和值无关。
- 易错写法复盘:❌
HashSet<Integer>存 val 去判断;✅ 正确写法是HashSet<ListNode>存节点引用。
一句话区分 :val 是节点上存的数据,可重复;节点引用(对象地址) 每个节点天生唯一,不会重复。
③ 知识点总结
- 核心结论 :判环的本质是"节点是否被重复访问",与
val无关;快慢指针是最优解(O(1) 空间)。 - 必背模板 :
slow = head, fast = head→while (fast != null && fast.next != null)→ 先移动,再判断是否相等。 - 易错点复盘
- 循环条件只写
fast != null,漏写fast.next != null→ 空指针异常。 - 初始位置写成
slow = head, fast = head.next也能过,但容易逻辑混乱,标准写法都从 head 出发更稳妥。 - 判断相等写在移动之前(顺序错),会漏掉相遇判断。
- 边界忽略空链表:
head == null时直接返回false(模板天然兼容)。 - 哈希写法忘记存节点对象、只存
val→ 重复值导致误判。
- 循环条件只写
- 复杂度对比:快慢指针 O(n) / O(1);HashSet O(n) / O(n)。
- 延伸:142 题是本题的延伸------相遇之后还要继续求出环的入口节点。
二、142. 环形链表 II

① 题目分析与解题思路
题目 :给定链表头结点,找到链表环的入口节点;无环返回 null,不允许修改链表。
与 141 的区别:141 只需要判断有没有环,这一题要精准找到环开始的那个节点。
核心:Floyd 判圈算法的数学推导。设:
- 头节点到环入口的距离:aaa
- 环入口到快慢指针相遇点的距离:bbb
- 相遇点再走回环入口的距离:ccc,环总长:b+cb + cb+c
慢指针路程 s=a+bs = a + bs=a+b;快指针路程 f=a+b+k(b+c)f = a + b + k(b+c)f=a+b+k(b+c),kkk 是快指针在环里绕的圈数。快指针速度是慢指针的 2 倍,即 f=2sf = 2sf=2s:
a+b+k(b+c)=2(a+b)a=c+(k−1)(b+c) \begin{align} a + b + k(b+c) &= 2(a+b) \\ a &= c + (k-1)(b+c) \end{align} a+b+k(b+c)a=2(a+b)=c+(k−1)(b+c)
✅ 结论公式 :头结点到环入口的距离 = 相遇点到环入口的距离(多绕 k−1k-1k−1 圈环)。
操作步骤:
- 快慢指针同起点
head,slow一次 1 步、fast一次 2 步,直到相遇;如果fast走到null,无环返回null。 - 相遇之后,把其中一个指针挪回 head,两个指针现在都一次走 1 步。
- 两个指针再次相遇时,相遇点就是环入口。
② 常见解法(附代码)及讲解
解法 1:快慢指针(O(n) 时间,O(1) 空间,满足进阶要求,面试首选)
java
/**
* Definition for singly-linked list.
* class ListNode {
* int val;
* ListNode next;
* ListNode(int x) {
* val = x;
* next = null;
* }
* }
*/
public class Solution {
public ListNode detectCycle(ListNode head) {
ListNode slow = head;
ListNode fast = head;
// 第一阶段:快慢指针找相遇点
while(fast != null && fast.next != null){
slow = slow.next;
fast = fast.next.next;
// 找到了相遇点
if(slow == fast){
// 第二阶段:一个指针回到头,两个同速前进
ListNode p = head;
while(p != slow){
p = p.next;
slow = slow.next;
}
return p;
}
}
// 快指针走到末尾,无环
return null;
}
}
讲解:
- 第一阶段和 141 环形链表几乎一样,目的只是确认有环并且拿到环内相遇点。
- 第二阶段核心:
p从 head 出发、slow从相遇点出发,两者同速,一定会在环入口碰头。 - 时间复杂度 O(n),最多遍历链表常数次;空间 O(1),只用几个指针。
解法 2:哈希集合(HashSet,O(n) 时间,O(n) 空间,好理解,不满足进阶)
思路 :遍历链表,把节点引用存入集合;第一个重复访问到的节点,就是环入口。
java
public class Solution {
public ListNode detectCycle(ListNode head) {
Set<ListNode> set = new HashSet<>();
ListNode cur = head;
while(cur != null){
if(set.contains(cur)){
return cur; // 第一次重复访问,就是环入口
}
set.add(cur);
cur = cur.next;
}
return null;
}
}
讲解:
- 逻辑直白:环入口是链表中第一个被访问两次的节点。
- 缺点:需要额外集合存节点,空间开销大;适合初学理解,面试优先写快慢指针版本。
- ⚠️ 重点:存
ListNode对象,不是val!(理由同 141 题的补充说明。)
面试追问补充
- 为什么两个指针同速走,一定在入口相遇?→ 就是上面那条数学公式推导。
- 如果链表环很大,会不会影响结论?→ 不会,公式里
k可以是任意正整数。 - 和 141 题对比:141 只判存在性;142 求入口,是 141 的延伸。
③ 知识点总结
为什么复杂度是 O(n) 而不是 O(n²)
快慢指针整个过程,每个节点最多被访问常数次(2~3 次) ,不是嵌套循环,没有 n2n^2n2 的行为,所以是 O(n)。
分开两段看,设链表总节点数 nnn,无环部分长度 aaa,环长 LLL,n=a+Ln = a + Ln=a+L:
- 阶段 1(找相遇点:slow 一步,fast 两步) :慢指针一共走了 a+ba + ba+b 步(bbb 是入口到相遇点距离,b<Lb < Lb<L),步数总量 a+b<a+L=na + b < a + L = na+b<a+L=n,即慢指针最多遍历一遍链表 就相遇;快指针速度是 2 倍,走 2(a+b)2(a+b)2(a+b) 步,依然与 nnn 成正比。
- 误区澄清 :很多人以为"在环里一直绕圈"会变成 O(n²)。但数学上已证明:慢指针在走完第一圈环之前,快指针一定追上它,slow 最多在环内走不到一圈就相遇,不会无限循环、不会多次完整遍历链表。
- 极端例子:整个链表就是一个环(a=0a=0a=0,入口就是 head),slow 刚进环走 L/2L/2L/2 左右,fast 就追上了,总共步数 O(L)=O(n)O(L) = O(n)O(L)=O(n)。
- 阶段 2(找环入口:p 从 head、slow 从相遇点,都一步一步走) :需要再走 aaa 步,a≤na \le na≤n,依然是线性。
两阶段加起来总步数上限:(a+b)+a<n+n=2n(a+b) + a < n + n = 2n(a+b)+a<n+n=2n,2n2n2n 仍属于 O(n),常数倍数不影响复杂度。
对比:什么时候才是 O(n²)
O(n²) 一般是两层循环:
java
for i from 0~n:
for j from 0~n:
...
比如:对每个节点,都从头再遍历一遍去找它,总操作 n×nn \times nn×n。快慢指针只有两个指针同步向前跑,没有回头重扫全表,不存在内层循环。
再和哈希方案对照:哈希 Set 解法遍历一遍链表、每个节点访问一次,是 O(n);快慢指针只是最多多跑一小段,本质同样是线性扫描。
通俗比喻 :跑道一共 nnn 米,slow 慢跑 1m/s、fast 快跑 2m/s,跑道后半段是环形跑道。快跑的人不会跑 n×nn \times nn×n 米,他追上慢跑者时总共跑的距离也就 2 倍慢跑距离,和总跑道长度成正比。
配套小思考题 :如果我写成"对每一个节点 cur,我都开一个指针从头走到 cur,看会不会碰到 cur 第二次",这就是双层遍历,才是 O(n²)。快慢指针完全不是这个逻辑。
易错点复盘
- 第二步速度搞错:很多人忘了二次相遇时两个指针都要一次一步走,还让 fast 一次走两步,直接错。
- 空指针风险 :循环条件
fast != null && fast.next != null漏写,出现空指针。 - 边界 case 忽略:链表只有一个节点且无环、环入口就是 head(整个链表是一个环)。
- 混淆 141 和 142 :141 相遇直接
return true;142 相遇只是第一步,还要二次遍历找入口。 - 数学理解误区 :误以为相遇点就是环入口,这是最常见错误!相遇点只是环内某一点,不一定是入口。
- 哈希解法:依旧不能存
val,必须存节点引用,val可重复。
三、21. 合并两个有序链表

① 题目分析与解题思路
题目 :给定两个升序(非递减)链表 l1、l2,把它们合并成一条新的升序链表;直接复用原来两个链表的节点,不需要新建节点。
关键点:两个链表本身已经有序;最终链表保持从小到大;可以有空链表。
核心思路(迭代法,最常用):
- 新建一个哑结点(dummy 哨兵节点),用来当作合并链表的开头,方便统一处理边界,避免单独判断头节点是谁。
- 用一个
cur指针,一开始指向 dummy,负责往后拼接节点。 - 循环:只要
l1、l2都不为空,比较两个当前节点的值,把更小的那个接到cur.next,然后对应链表指针后移,cur同步后移。 - 当其中一条链表遍历完了,直接把剩下另一条链表的剩余部分接到
cur.next(剩下的本身有序,不用继续比较)。 - 最终返回
dummy.next,就是合并后的链表头。
递归思路 :把问题看成子问题------选出当前更小的节点,然后把它的 next 指向剩下两个链表合并的结果。
② 常见解法(附代码)及讲解
解法 1:迭代 + dummy 哑节点(推荐,面试首选)
- 时间复杂度:O(m+n),m、n 是两条链表长度,每个节点访问一次。
- 空间复杂度:O(1),只使用几个指针,没有额外开辟链表空间。
java
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode() {}
* ListNode(int val) { this.val = val; }
* ListNode(int val, ListNode next) { this.val = val; this.next = next; }
* }
*/
class Solution {
public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
// 哑结点,哨兵
ListNode dummy = new ListNode(-1);
ListNode cur = dummy;
while(l1 != null && l2 != null){
if(l1.val <= l2.val){
cur.next = l1;
l1 = l1.next;
}else{
cur.next = l2;
l2 = l2.next;
}
cur = cur.next;
}
// 接上剩下没走完的链表
cur.next = l1 == null ? l2 : l1;
return dummy.next;
}
}
讲解:
dummy只是占位符,本身不算有效节点,结果从dummy.next开始。cur是尾指针,永远指向新链表的最后一个节点,用来追加节点。- 循环退出条件:任意一条链表走完;剩下部分直接接上,因为本身有序。
解法 2:递归写法(简洁,用于理解递归思路)
- 时间复杂度:O(m+n)。
- 空间复杂度:O(m+n),递归调用栈深度等于链表总长度。
java
class Solution {
public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
// 递归终止条件:一条链表空了,直接返回另一条
if(l1 == null) return l2;
if(l2 == null) return l1;
if(l1.val <= l2.val){
// 选l1当前节点,l1.next 和 l2继续合并
l1.next = mergeTwoLists(l1.next, l2);
return l1;
}else{
l2.next = mergeTwoLists(l1, l2.next);
return l2;
}
}
}
讲解:
- 递归的含义:返回
l1、l2合并后的有序链表头。 - 谁小谁作为当前头,它的
next交给子递归去合并剩下部分。 - 缺点:递归有调用栈开销,如果链表很长会栈溢出;面试优先迭代版本。
补充对比:迭代 vs 递归
- 迭代:原地拼接,常数空间,工程上更推荐。
- 递归:代码短、优雅,但是有栈开销,适合理解分治思想;归并排序里合并有序数组 / 链表就是这个逻辑。
③ 知识点总结
- 核心结论 :dummy 哨兵节点是链表题的通用技巧------凡是"结果链表的头节点不确定"的场景(合并、删除、分区),都先建 dummy,最后返回
dummy.next。 - 易错点复盘
- 忘记哑结点:头节点处理麻烦,需要写一堆
if判断谁是第一个节点,很容易漏边界。 - 比较之后指针移动混乱:接上节点之后忘记移动
l1/l2,或者忘记移动cur,造成死循环。 - 循环结束后没有拼接剩余链表:循环只处理两个都不为空的情况,剩下那条链表直接丢了。
- 理解错题意去新建节点:题目允许直接复用原有节点,不需要
new ListNode复制 val,复制会浪费空间。 - 空链表边界漏测:
l1为空 /l2为空 / 两者都为空。 - 判断条件写反:
l1.val > l2.val用错方向,合并出来是降序链表。
- 忘记哑结点:头节点处理麻烦,需要写一堆
- 小细节 :比较时用
<=而非<,相等时优先取l1,可保持合并结果的稳定性(与归并排序一致)。 - 延伸 :这一题是归并排序合并阶段的基础,后面 148. 排序链表 会用到这个合并逻辑。
附:三题速记
| 题号 | 关键判据 | 必背要点 |
|---|---|---|
| 141 | 快慢指针能否相遇 | while (fast != null && fast.next != null),先移动再比较 |
| 142 | 相遇后一个指针回 head,同速再走 | 牢记 a=c+(k−1)(b+c)a = c + (k-1)(b+c)a=c+(k−1)(b+c);相遇点 ≠ 环入口 |
| 21 | 双指针归并 + dummy | 收尾 cur.next = l1 == null ? l2 : l1 |