概述:为什么链表题容易让初学者卡住
学完栈和队列之后,我们继续看另一类非常基础、也非常高频的数据结构:链表。
数组和链表都可以存储一组元素。
但它们最大的区别在于:
text
数组依靠下标访问元素
链表依靠指针连接元素
这也导致链表题和数组题的思维方式很不一样。
数组题常常在想:
text
下标 i 怎么移动?
区间边界在哪里?
而链表题常常在想:
text
当前节点是谁?
下一个节点是谁?
指针应该先保存还是先修改?
很多初学者做链表题时,最容易出现的问题不是思路完全不会,而是:
- 指针断了
- 节点丢了
- 循环条件写错
- 返回了错误的头节点
- 忘记处理空链表或单节点链表
这篇文章会围绕链表最常见的四个方向展开:
- 链表的基本结构与指针操作
- 反转链表
- 合并两个有序链表
- 快慢指针解决中点、环形链表等问题
学完这篇,你应该能看懂链表指针变化,并能独立写出反转链表、合并链表和快慢指针这几类高频题。
核心概念:链表到底是什么
链表是一种由节点组成的数据结构。
每个节点通常包含两部分:
- 值:当前节点存储的数据
- 指针:指向下一个节点的位置
单链表可以表示成:
text
head
|
v
[1] -> [2] -> [3] -> [4] -> null
在 Java 中,常见的链表节点定义如下:
java
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;
}
}
其中:
val表示节点值next表示下一个节点head表示链表头节点
如果 head == null,说明链表为空。
链表和数组的区别
| 对比项 | 数组 | 链表 |
|---|---|---|
| 内存结构 | 连续存储 | 节点分散存储 |
| 访问元素 | 可通过下标随机访问 | 只能从头逐个遍历 |
查找第 i 个元素 |
O(1) |
O(n) |
| 插入删除节点 | 可能需要移动大量元素 | 修改指针即可 |
| 常见考点 | 下标、区间、双指针 | 指针修改、头节点、快慢指针 |
链表最核心的能力不是快速访问,而是:
通过修改指针关系,改变节点之间的连接方式。
链表题的本质不是操作值,而是操作节点之间的 next 指针。
基础操作:遍历链表
遍历链表是所有链表题的基础。
java
ListNode cur = head;
while (cur != null) {
System.out.println(cur.val);
cur = cur.next;
}
这里的 cur 是一个移动指针。
它从头节点开始,每次移动到下一个节点。
遍历过程:
text
cur
|
v
[1] -> [2] -> [3] -> null
cur
|
v
[1] -> [2] -> [3] -> null
cur
|
v
[1] -> [2] -> [3] -> null
当 cur == null 时,说明已经走到链表末尾。
遍历时常见错误
错误写法:
java
while (cur.next != null) {
cur = cur.next;
}
这个循环会停在最后一个节点,而不是遍历所有节点。
如果你需要处理每个节点,通常应该写:
java
while (cur != null) {
cur = cur.next;
}
当然,如果题目明确需要停在最后一个节点前面,才会使用 cur.next != null。
指针操作:为什么要先保存后修改
链表题最重要的一条经验是:
修改
next指针前,先想清楚后面的节点会不会丢。
比如有链表:
text
1 -> 2 -> 3 -> null
如果当前 cur 指向节点 1:
java
cur.next = null;
那么节点 2 和节点 3 就无法再通过 cur 找到了。
所以在修改指针前,通常要先保存下一个节点:
java
ListNode next = cur.next;
cur.next = null;
cur = next;
这也是反转链表中最关键的动作。
经典题型一:反转链表
题目:
给定单链表头节点
head,请反转链表,并返回反转后的头节点。
示例:
text
输入:1 -> 2 -> 3 -> 4 -> 5 -> null
输出:5 -> 4 -> 3 -> 2 -> 1 -> null
解题思路
反转链表的核心是把每个节点的 next 指针反过来。
原链表:
text
1 -> 2 -> 3 -> null
反转后:
text
3 -> 2 -> 1 -> null
我们使用三个指针:
prev:当前节点反转后应该指向的前一个节点cur:当前正在处理的节点next:提前保存cur.next,防止链表断开后找不到后续节点
初始状态:
text
prev = null
cur = head
每一轮做三件事:
text
1. 保存 next = cur.next
2. 反转 cur.next = prev
3. 两个指针向后移动:prev = cur, cur = next
Java 代码实现
java
class Solution {
public ListNode reverseList(ListNode head) {
ListNode prev = null;
ListNode cur = head;
while (cur != null) {
ListNode next = cur.next;
cur.next = prev;
prev = cur;
cur = next;
}
return prev;
}
}
执行过程
以 1 -> 2 -> 3 -> null 为例:
第一轮:
text
next = 2
1.next = null
prev = 1
cur = 2
结果:
text
1 -> null 2 -> 3 -> null
第二轮:
text
next = 3
2.next = 1
prev = 2
cur = 3
结果:
text
2 -> 1 -> null 3 -> null
第三轮:
text
next = null
3.next = 2
prev = 3
cur = null
结果:
text
3 -> 2 -> 1 -> null
最后 cur == null,循环结束,prev 就是新的头节点。
复杂度分析
- 时间复杂度:
O(n),每个节点处理一次 - 空间复杂度:
O(1),只使用常数个指针
反转链表的关键是先保存 next,再修改 cur.next,最后移动 prev 和 cur。
反转链表的递归写法
反转链表也可以用递归完成。
递归思路是:
先反转后面的链表,再把当前节点接到反转结果的末尾。
代码如下:
java
class Solution {
public ListNode reverseList(ListNode head) {
if (head == null || head.next == null) {
return head;
}
ListNode newHead = reverseList(head.next);
head.next.next = head;
head.next = null;
return newHead;
}
}
递归代码怎么理解
假设链表是:
text
1 -> 2 -> 3 -> null
递归调用:
java
reverseList(2)
会把后半部分反转成:
text
3 -> 2 -> null
此时 head 仍然是节点 1,并且原来的关系还是:
text
1 -> 2
要把 1 接到 2 后面,需要:
java
head.next.next = head;
也就是:
text
2 -> 1
然后断开原来的指向:
java
head.next = null;
否则会形成环:
text
1 <-> 2
递归写法更简洁,但对初学者来说不如迭代直观。
刷题时建议先熟练掌握迭代写法。
递归复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(n),递归调用栈占用空间
经典题型二:合并两个有序链表
题目:
将两个升序链表合并为一个新的升序链表,并返回合并后的头节点。
示例:
text
输入:
1 -> 2 -> 4
1 -> 3 -> 4
输出:
1 -> 1 -> 2 -> 3 -> 4 -> 4
解题思路
这道题和归并排序中的合并过程很像。
每次比较两个链表当前节点的值:
- 谁小,就把谁接到结果链表后面
- 对应链表指针向后移动
- 重复直到其中一个链表为空
- 最后把剩余链表接到结果后面
这里常用一个技巧:虚拟头节点。
什么是虚拟头节点
虚拟头节点也叫 dummy 节点。
它不是最终答案中的真实节点,只是为了简化代码。
如果不用 dummy,就要单独处理第一个节点:
text
结果链表头节点到底应该是 l1 还是 l2?
用了 dummy 后,可以统一写成:
text
dummy -> 已合并部分
最后返回:
java
dummy.next
Java 代码实现
java
class Solution {
public ListNode mergeTwoLists(ListNode list1, ListNode list2) {
ListNode dummy = new ListNode(0);
ListNode cur = dummy;
while (list1 != null && list2 != null) {
if (list1.val <= list2.val) {
cur.next = list1;
list1 = list1.next;
} else {
cur.next = list2;
list2 = list2.next;
}
cur = cur.next;
}
if (list1 != null) {
cur.next = list1;
}
if (list2 != null) {
cur.next = list2;
}
return dummy.next;
}
}
为什么最后可以直接接上剩余链表
因为两个输入链表本身已经是升序的。
当其中一个链表为空时,另一个链表剩下的节点也已经有序。
所以不需要继续一个个比较,直接接到结果链表末尾即可。
复杂度分析
- 时间复杂度:
O(n + m),两个链表每个节点最多访问一次 - 空间复杂度:
O(1),只使用常数个额外指针
其中 n 和 m 分别是两个链表的长度。
虚拟头节点可以统一处理结果链表的连接逻辑,避免单独判断新链表头节点。
经典题型三:删除链表节点
删除节点也是链表题中的基础操作。
如果要删除某个值等于 val 的节点,常见写法如下:
java
class Solution {
public ListNode removeElements(ListNode head, int val) {
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode cur = dummy;
while (cur.next != null) {
if (cur.next.val == val) {
cur.next = cur.next.next;
} else {
cur = cur.next;
}
}
return dummy.next;
}
}
为什么删除节点也适合用 dummy
如果要删除的是头节点,比如:
text
6 -> 1 -> 2 -> null
并且要删除 6,那么头节点会发生变化。
如果不用 dummy,就需要单独处理头节点连续被删除的情况。
用了 dummy 后,所有节点都可以统一看作:
text
删除 cur.next
不管删除的是不是原来的头节点,逻辑都一致。
删除节点时的关键点
删除 cur.next 时,应该写:
java
cur.next = cur.next.next;
此时 cur 不应该立刻向后移动。
因为新的 cur.next 可能仍然是需要删除的节点。
只有当前节点后面的节点不需要删除时,才移动:
java
cur = cur.next;
经典题型四:快慢指针
快慢指针是链表题中的高频技巧。
它通常使用两个指针:
slow:每次走一步fast:每次走两步
由于速度不同,它们可以用来解决很多问题:
- 找链表中点
- 判断链表是否有环
- 找环的入口
- 删除倒数第
N个节点 - 判断回文链表
这一节先讲最基础的两个:找中点和判断环。
快慢指针例题一:寻找链表中点
题目:
给定单链表头节点
head,返回链表的中间节点。
如果有两个中间节点,通常返回第二个中间节点。
示例:
text
1 -> 2 -> 3 -> 4 -> 5
返回 3
1 -> 2 -> 3 -> 4 -> 5 -> 6
返回 4
解题思路
使用两个指针:
text
slow 每次走一步
fast 每次走两步
当 fast 到达链表末尾时,slow 正好走到中间位置。
Java 代码实现
java
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;
}
}
为什么偶数长度返回第二个中点
以:
text
1 -> 2 -> 3 -> 4 -> 5 -> 6
为例。
当 fast 每次走两步到达末尾后,slow 会停在节点 4。
所以这个写法返回的是第二个中间节点。
如果题目要求返回第一个中间节点,可以调整循环条件。
但大多数入门题默认返回第二个中点。
复杂度分析
- 时间复杂度:
O(n) - 空间复杂度:
O(1)
快慢指针例题二:判断链表是否有环
题目:
给定一个链表,判断链表中是否有环。
有环链表示意:
text
1 -> 2 -> 3 -> 4
^ |
|_________|
解题思路
仍然使用快慢指针:
slow每次走一步fast每次走两步
如果链表没有环,fast 最终会走到 null。
如果链表有环,fast 会在环中不断追赶 slow。
因为 fast 比 slow 每轮多走一步,所以它们最终一定会相遇。
Java 代码实现
java
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;
}
}
为什么要判断 fast != null && fast.next != null
因为 fast 每次要走两步:
java
fast = fast.next.next;
如果 fast 或 fast.next 是 null,就会出现空指针异常。
所以循环条件必须保证:
text
fast 当前存在
fast 的下一个节点也存在
复杂度分析
- 时间复杂度:
O(n) - 空间复杂度:
O(1)
当链表题需要判断中点、长度差或是否成环时,可以让两个指针以不同速度移动。
进阶例题:删除链表倒数第 N 个节点
题目:
给定链表头节点
head和整数n,删除链表倒数第n个节点,并返回头节点。
示例:
text
输入:1 -> 2 -> 3 -> 4 -> 5, n = 2
输出:1 -> 2 -> 3 -> 5
解题思路
这道题也可以用快慢指针。
核心是让 fast 先走 n 步,然后 slow 和 fast 一起走。
当 fast 走到链表末尾时,slow 就在待删除节点的前一个位置。
为了方便删除头节点,仍然使用 dummy 节点。
Java 代码实现
java
class Solution {
public ListNode removeNthFromEnd(ListNode head, int n) {
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode fast = dummy;
ListNode slow = dummy;
for (int i = 0; i < n; i++) {
fast = fast.next;
}
while (fast.next != null) {
fast = fast.next;
slow = slow.next;
}
slow.next = slow.next.next;
return dummy.next;
}
}
为什么从 dummy 开始
如果要删除的是第一个节点,例如:
text
1 -> 2 -> 3
n = 3
删除后头节点应该变成 2。
使用 dummy 可以统一处理这种情况。
slow 最终停在待删除节点的前一个节点。
如果待删除的是原头节点,那么 slow 正好停在 dummy 上。
复杂度分析
- 时间复杂度:
O(n) - 空间复杂度:
O(1)
常见坑点:链表题最容易错在哪里
1. 修改指针前没有保存后续节点
反转链表时,如果直接写:
java
cur.next = prev;
cur = cur.next;
这里的 cur.next 已经被改成了 prev,原来的下一个节点丢失了。
正确写法应该先保存:
java
ListNode next = cur.next;
cur.next = prev;
cur = next;
2. 返回了错误的头节点
反转链表后,新的头节点不是原来的 head,而是 prev。
合并链表时,如果用了 dummy,返回的不是 dummy,而是:
java
return dummy.next;
删除节点时,如果头节点可能变化,也应该返回:
java
return dummy.next;
3. 没有处理空链表和单节点链表
很多链表题都需要考虑:
text
head == null
head.next == null
迭代反转链表可以自然处理这两种情况。
但递归写法必须显式判断:
java
if (head == null || head.next == null) {
return head;
}
4. 快慢指针循环条件写错
如果代码中有:
java
fast = fast.next.next;
循环条件通常应该是:
java
while (fast != null && fast.next != null)
不要只判断:
java
while (fast != null)
否则 fast.next 可能为空。
5. 删除节点后指针移动错误
删除链表中所有等于 val 的节点时:
java
if (cur.next.val == val) {
cur.next = cur.next.next;
} else {
cur = cur.next;
}
删除后不要立刻移动 cur。
因为新的 cur.next 可能仍然需要删除。
模板总结:链表题常用写法
链表遍历模板
java
ListNode cur = head;
while (cur != null) {
// 处理 cur
cur = cur.next;
}
反转链表模板
java
ListNode prev = null;
ListNode cur = head;
while (cur != null) {
ListNode next = cur.next;
cur.next = prev;
prev = cur;
cur = next;
}
return prev;
虚拟头节点模板
java
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode cur = dummy;
while (cur.next != null) {
// 根据题目处理 cur.next
}
return dummy.next;
快慢指针模板
java
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
总结
链表题的难点不在于代码很长,而在于指针关系容易写乱。
你可以重点记住下面几句话:
- 链表节点通过
next指针连接 - 修改指针前,先保存后续节点
- 反转链表使用
prev、cur、next - 合并链表和删除节点常用 dummy 节点简化边界
- 快慢指针适合找中点、判环、删除倒数节点
- 如果头节点可能变化,通常应该考虑虚拟头节点
- 循环条件要和指针移动方式匹配,避免空指针异常
链表题一开始写慢一点很正常。
建议每次写代码前,先在纸上画出节点和指针,再决定每一步修改哪个 next。