大家好,我是程序员牛奶,链表题是算法面试里的高频考点,尤其是结合双指针出的题目,下面我总结了我做过的题目以及一些常见套路,帮助大家迅速理解双指针链表题。
它不像数组那样可以通过下标随机访问,也不像哈希表那样可以直接查找元素。链表的特点是:只能顺着 next 指针一步一步往后走。
也正因为这个限制,链表题非常考验指针操作能力。
不过,链表题并不是毫无规律。很多经典题目,本质上都可以归纳到几种固定技巧里,尤其是 双指针技巧。
读完本文,你不仅可以掌握单链表的常见算法套路,还可以顺便解决这些题目:
| LeetCode | 力扣 | 核心技巧 |
|---|---|---|
| 21. Merge Two Sorted Lists | 21. 合并两个有序链表 | 虚拟头结点、双指针 |
| 86. Partition List | 86. 分隔链表 | 虚拟头结点、链表拆分 |
| 23. Merge k Sorted Lists | 23. 合并 K 个升序链表 | 优先队列、虚拟头结点 |
| 19. Remove Nth Node From End of List | 19. 删除链表倒数第 N 个结点 | 快慢指针 |
| 876. Middle of the Linked List | 876. 链表的中间结点 | 快慢指针 |
| 141. Linked List Cycle | 141. 环形链表 | 快慢指针 |
| 142. Linked List Cycle II | 142. 环形链表 II | 快慢指针、环起点 |
| 160. Intersection of Two Linked Lists | 160. 相交链表 | 双指针换路 |
| LCR 140. 训练计划 II | LCR 140. 训练计划 II | 链表基础操作 |
一、链表题的核心思维
链表题最重要的不是背代码,而是理解指针的角色。
在大多数链表题里,常见的指针有几类:
-
遍历指针
用来从头到尾扫描链表,比如p、cur。 -
结果链表指针
用来构造新链表,比如dummy、tail。 -
快慢指针
一个走得快,一个走得慢,用来找中点、判断环、找倒数第 k 个节点。 -
双链表指针
分别遍历两条链表,比如合并两个有序链表、判断两个链表是否相交。
链表题还有一个非常重要的技巧:虚拟头结点 dummy。
二、虚拟头结点:链表题里的“安全绳”
很多链表题需要创建一条新链表,或者对原链表进行重组。
这时,如果直接操作头结点,很容易遇到各种边界问题:
- 原链表为空怎么办?
- 新链表第一个节点怎么接?
- 删除的是头结点怎么办?
- 分解链表后怎么拼接?
为了解决这些麻烦,我们经常创建一个虚拟头结点:
ListNode dummy = new ListNode(-1);
ListNode p = dummy;
return dummy.next;
dummy 本身不存储有效数据,它只是一个占位符。真正的结果链表从 dummy.next 开始。
什么时候适合使用虚拟头结点?
当你需要创建一条新链表,或者可能修改链表头部结构时,就可以考虑使用
dummy。
比如:
- 合并两个有序链表;
- 分隔链表;
- 删除倒数第 N 个节点;
- 合并 K 个升序链表。
技巧一:合并两个有序链表
对应题目:
- LeetCode 21. 合并两个有序链表
题目要求我们把两个升序链表合并成一个新的升序链表。
例如:
输入:l1 = [1,2,4], l2 = [1,3,4]
输出:[1,1,2,3,4,4]
这道题的思路很像“拉拉链”。
两个链表就像拉链两侧的齿轮,我们每次比较两个链表当前节点的值,把较小的节点接到结果链表后面。
解题思路
准备三个指针:
p1:遍历链表l1;p2:遍历链表l2;p:负责构造结果链表。
每次比较 p1.val 和 p2.val:
- 谁小,就把谁接到
p.next; - 对应指针向后移动;
p也向后移动。
当其中一个链表为空后,把另一个链表剩余部分直接接上即可。
代码实现
class Solution {
public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
// 虚拟头结点
ListNode dummy = new ListNode(-1);
ListNode p = dummy;
ListNode p1 = l1;
ListNode p2 = l2;
while (p1 != null && p2 != null) {
if (p1.val <= p2.val) {
p.next = p1;
p1 = p1.next;
} else {
p.next = p2;
p2 = p2.next;
}
p = p.next;
}
if (p1 != null) {
p.next = p1;
}
if (p2 != null) {
p.next = p2;
}
return dummy.next;
}
}
技巧二:链表分解
对应题目:
- LeetCode 86. 分隔链表
题目要求:
给定链表 head 和整数 x,把链表分成两部分:
- 小于
x的节点排在前面; - 大于等于
x的节点排在后面; - 保持每个分区内部原来的相对顺序。
例如:
输入:head = [1,4,3,2,5,2], x = 3
输出:[1,2,2,4,3,5]
解题思路
这道题可以理解为:把一条链表拆成两条链表。
我们创建两条新链表:
small链表:存放小于x的节点;large链表:存放大于等于x的节点。
遍历原链表时:
- 如果当前节点值
< x,接到small后面; - 否则,接到
large后面。
最后把 small 和 large 拼起来。
关键细节:为什么要断开原链表?
在把原链表节点接到新链表时,最好断开它原来的 next 指针。
也就是:
ListNode temp = p.next;
p.next = null;
p = temp;
如果不断开,原链表中的旧连接可能残留,最终结果链表可能出现错误,甚至形成环。
这是链表重组题中非常容易踩坑的地方。
代码实现
class Solution {
public ListNode partition(ListNode head, int x) {
// 存放小于 x 的链表
ListNode dummy1 = new ListNode(-1);
// 存放大于等于 x 的链表
ListNode dummy2 = new ListNode(-1);
ListNode p1 = dummy1;
ListNode p2 = dummy2;
ListNode p = head;
while (p != null) {
if (p.val < x) {
p1.next = p;
p1 = p1.next;
} else {
p2.next = p;
p2 = p2.next;
}
// 断开当前节点和原链表的连接
ListNode temp = p.next;
p.next = null;
p = temp;
}
// 拼接两个链表
p1.next = dummy2.next;
return dummy1.next;
}
}
技巧三:合并 K 个有序链表
对应题目:
- LeetCode 23. 合并 K 个升序链表
这道题是合并两个有序链表的升级版。
现在不是两条链表,而是 k 条有序链表。
问题在于:
每次应该从
k个链表当前节点中选出最小的那个节点。
如果每次都遍历 k 个头节点找最小值,效率会比较低。
更好的办法是使用 优先级队列,也就是最小堆。
解题思路
- 创建一个最小堆;
- 把所有非空链表的头结点放入堆中;
- 每次从堆中弹出当前最小节点;
- 把这个节点接到结果链表后面;
- 如果这个节点还有下一个节点,就把下一个节点加入堆;
- 重复直到堆为空。
代码实现
import java.util.PriorityQueue;
class Solution {
public ListNode mergeKLists(ListNode[] lists) {
if (lists.length == 0) {
return null;
}
ListNode dummy = new ListNode(-1);
ListNode p = dummy;
PriorityQueue<ListNode> pq = new PriorityQueue<>(
lists.length,
(a, b) -> a.val - b.val
);
for (ListNode head : lists) {
if (head != null) {
pq.add(head);
}
}
while (!pq.isEmpty()) {
ListNode node = pq.poll();
p.next = node;
if (node.next != null) {
pq.add(node.next);
}
p = p.next;
}
return dummy.next;
}
}
复杂度分析
假设:
- 一共有
k条链表; - 所有链表节点总数为
N。
最小堆中最多有 k 个节点,每次插入或删除的复杂度是 O(log k)。
每个节点都会进堆、出堆一次。
所以总时间复杂度是:
O(N log k)
空间复杂度是:
O(k)
技巧四:寻找倒数第 K 个节点
对应题目:
- LeetCode 19. 删除链表的倒数第 N 个结点
如果让你找链表的正数第 k 个节点,很简单,从头走 k - 1 步即可。
但如果要找倒数第 k 个节点,就不能直接从尾部往前走,因为单链表没有前驱指针。
最直观的方法是:
- 先遍历一遍链表,得到长度
n; - 再找到正数第
n - k + 1个节点。
但这样需要遍历两次。
更优雅的方法是:快慢指针。
解题思路
使用两个指针:
p1先走k步;p2从头开始;- 然后
p1和p2同时走; - 当
p1走到null时,p2正好指向倒数第k个节点。
为什么?
因为 p1 和 p2 之间始终保持 k 个节点的距离。
查找倒数第 K 个节点代码
class Solution {
ListNode findFromEnd(ListNode head, int k) {
ListNode p1 = head;
for (int i = 0; i < k; i++) {
p1 = p1.next;
}
ListNode p2 = head;
while (p1 != null) {
p1 = p1.next;
p2 = p2.next;
}
return p2;
}
}
删除倒数第 N 个节点
要删除倒数第 n 个节点,需要先找到倒数第 n + 1 个节点。
因为单链表删除节点时,需要知道它的前一个节点。
这里也要使用虚拟头结点,避免删除头结点时出错。
代码实现
class Solution {
public ListNode removeNthFromEnd(ListNode head, int n) {
ListNode dummy = new ListNode(-1);
dummy.next = head;
// 找到倒数第 n + 1 个节点
ListNode x = findFromEnd(dummy, n + 1);
// 删除倒数第 n 个节点
x.next = x.next.next;
return dummy.next;
}
private ListNode findFromEnd(ListNode head, int k) {
ListNode p1 = head;
for (int i = 0; i < k; i++) {
p1 = p1.next;
}
ListNode p2 = head;
while (p1 != null) {
p1 = p1.next;
p2 = p2.next;
}
return p2;
}
}
技巧五:寻找链表中点
对应题目:
- LeetCode 876. 链表的中间结点
找链表中点也可以先计算链表长度,再走到中间位置。
但更常见的做法是使用快慢指针:
slow每次走一步;fast每次走两步;- 当
fast到达链表末尾时,slow正好在中间。
代码实现
class Solution {
public ListNode middleNode(ListNode head) {
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
return slow;
}
}
如果链表长度是偶数,这种写法返回的是靠后的那个中间节点。
比如:
链表:1 -> 2 -> 3 -> 4
中间节点有两个:2 和 3
上述代码返回:3
技巧六:判断链表是否有环
对应题目:
- LeetCode 141. 环形链表
判断链表是否有环,也可以使用快慢指针。
思路很直观:
slow每次走一步;fast每次走两步;- 如果链表没有环,
fast最终会走到null; - 如果链表有环,
fast会在环里不断绕圈,最终追上slow。
这就像操场跑步:
如果两个人在环形跑道上,一个跑得快,一个跑得慢,那么快的人迟早会追上慢的人。
代码实现
public class Solution {
public boolean hasCycle(ListNode head) {
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) {
return true;
}
}
return false;
}
}
技巧七:找到环的起点
对应题目:
- LeetCode 142. 环形链表 II
判断是否有环只是第一步,更进一步的问题是:
如果链表有环,环的入口节点在哪里?
这道题依然使用快慢指针。
解题思路
分两步:
第一步:判断是否有环
让 slow 每次走一步,fast 每次走两步。
如果两者相遇,说明有环。
如果 fast 走到 null,说明无环。
第二步:寻找环入口
当 slow 和 fast 相遇后:
- 让其中一个指针回到
head; - 两个指针都每次走一步;
- 它们再次相遇的位置,就是环的入口。
代码实现
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) {
break;
}
}
if (fast == null || fast.next == null) {
return null;
}
slow = head;
while (slow != fast) {
slow = slow.next;
fast = fast.next;
}
return slow;
}
}
原理简单解释
假设:
- 从头结点到环入口的距离是
a; - 环入口到相遇点的距离是
b; - 相遇点再走到环入口的距离是
c。
慢指针走过的距离是:
a + b
快指针走过的距离是:
a + b + c + b
因为快指针速度是慢指针的两倍,所以快指针走的距离是慢指针的两倍。
最终可以推出:
a = c
也就是说:
从头结点走到环入口的距离,等于从相遇点继续走到环入口的距离。
所以,让一个指针回到头结点,另一个留在相遇点,然后一起走,它们会在环入口相遇。
技巧八:判断两个链表是否相交
对应题目:
- LeetCode 160. 相交链表
题目要求:
给定两个链表 headA 和 headB,判断它们是否相交。
如果相交,返回相交节点;如果不相交,返回 null。
注意,这里的“相交”不是值相等,而是两个链表从某个节点开始共用同一段节点。
例如:
A: a1 -> a2
\
c1 -> c2 -> c3
/
B: b1 -> b2 -> b3
这里相交节点是 c1。
难点在哪里?
两条链表长度可能不同。
如果让两个指针分别从 headA 和 headB 同时出发,它们不一定会同时到达交点。
比如:
A 链表更短:
a1 -> a2 -> c1 -> c2
B 链表更长:
b1 -> b2 -> b3 -> c1 -> c2
两个指针同时走,无法对齐公共部分。
解题思路:双指针换路
我们可以让:
p1从链表 A 出发,走完 A 后再走 B;p2从链表 B 出发,走完 B 后再走 A。
这样两个指针走过的总长度相同:
p1 走过:A + B
p2 走过:B + A
如果两个链表相交,它们会在交点相遇。
如果不相交,它们最终会同时走到 null。
代码实现
public class Solution {
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
ListNode p1 = headA;
ListNode p2 = headB;
while (p1 != p2) {
if (p1 == null) {
p1 = headB;
} else {
p1 = p1.next;
}
if (p2 == null) {
p2 = headA;
} else {
p2 = p2.next;
}
}
return p1;
}
}
九、链表双指针技巧总表
| 问题类型 | 常用技巧 | 关键点 |
|---|---|---|
| 合并两个有序链表 | 双指针 + 虚拟头结点 | 每次接较小节点 |
| 分隔链表 | 双虚拟头结点 | 拆成两条链表再拼接 |
| 合并 K 个有序链表 | 最小堆 + 虚拟头结点 | 每次取 k 个头节点中最小值 |
| 找倒数第 K 个节点 | 快慢指针 | 快指针先走 K 步 |
| 删除倒数第 N 个节点 | 快慢指针 + dummy | 找倒数第 N+1 个节点 |
| 找链表中点 | 快慢指针 | 快走两步,慢走一步 |
| 判断链表是否有环 | 快慢指针 | 有环则快慢指针必相遇 |
| 找环入口 | 快慢指针 | 相遇后一个回 head,同速前进 |
| 找两个链表交点 | 双指针换路 | A+B 与 B+A 路径等长 |
十、常见链表题易错点
1. 忘记处理空链表
比如:
head == null
很多题目都可能输入空链表,代码中要注意判断。
2. 删除头结点时出错
删除头结点是链表题最常见的边界问题。
所以删除类题目经常使用:
ListNode dummy = new ListNode(-1);
dummy.next = head;
最后返回:
return dummy.next;
3. 重组链表时没有断开原连接
比如分隔链表时,如果你把原节点接到新链表后面,但没有断开它原来的 next,可能会导致结果链表结构混乱。
推荐写法:
ListNode temp = p.next;
p.next = null;
p = temp;
4. 把节点值相等误认为节点相交
相交链表判断的是节点引用是否相同,不是节点值是否相等。
也就是说:
p1 == p2 // 正确,判断是不是同一个节点
p1.val == p2.val // 错误,只是值相等
5. 快慢指针循环条件写错
找中点、判断环时,常用循环条件是:
while (fast != null && fast.next != null)
这样可以避免访问 fast.next.next 时出现空指针异常。
十一、练习题推荐
如果你刚学完链表双指针,建议按照下面顺序刷题。
基础入门
1. LeetCode 21. 合并两个有序链表
练习重点:
- 虚拟头结点;
- 双指针遍历;
- 链表拼接。
这是链表题最基础的模板题,建议反复写到熟练。
2. LeetCode 86. 分隔链表
练习重点:
- 链表拆分;
- 双 dummy 节点;
- 断开原链表连接。
这道题非常适合训练链表重组能力。
进阶合并
3. LeetCode 23. 合并 K 个升序链表
练习重点:
- 最小堆;
- 虚拟头结点;
- 多链表合并。
如果你已经掌握了合并两个有序链表,这道题就是自然升级版。
快慢指针
4. LeetCode 19. 删除链表的倒数第 N 个结点
练习重点:
- 快指针先走;
- 删除节点需要找到前驱;
- 使用 dummy 处理删除头结点。
5. LeetCode 876. 链表的中间结点
练习重点:
- 快慢指针;
- 偶数长度时返回后一个中点。
6. LeetCode 141. 环形链表
练习重点:
- 快慢指针判断是否相遇;
- 理解有环必相遇的原因。
7. LeetCode 142. 环形链表 II
练习重点:
- 快慢指针相遇;
- 找环入口;
- 理解“一个回头,一个留在相遇点”的原理。
双链表相交
8. LeetCode 160. 相交链表
练习重点:
- 双指针换路;
- 判断节点引用而不是节点值;
- 理解
A + B和B + A路径等长。
十二、最后总结
单链表题看起来花样很多,但核心套路其实很集中。
如果要用一句话概括:
链表题的本质,就是通过指针控制节点之间的连接关系。
其中最常用的技巧有:
-
虚拟头结点
用来简化新链表构造、删除头结点等边界情况。 -
双指针合并
用于合并两个有序链表、分隔链表等问题。 -
快慢指针
用于寻找中点、倒数第 K 个节点、判断环、寻找环入口。 -
双指针换路
用于判断两个链表是否相交。 -
优先队列
用于合并 K 个有序链表。
链表题最怕的不是思路难,而是指针乱。
所以写链表题时,建议你养成几个习惯:
- 先画图,再写代码;
- 多用
dummy简化边界; - 修改
next前先保存后继节点; - 判断相交时比较节点引用;
- 快慢指针注意循环条件;
- 写完后用 2 到 3 个小例子手动模拟。
只要这些基本功扎实,链表题就不会再是一团乱麻。
十三,练习题
链表的分解
-
- 删除排序链表中的重复元素 II
链表的合并
-
- 有序矩阵中第 K 小的元素
-
- 查找和最小的 K 对数字
链表运算题
-
- 两数相加
-
- 两数相加 II
链表环检测
-
- 寻找重复数