LeetCode 206「反转链表」是一道经典的链表算法题,主要考察链表结构、指针操作、迭代与递归,以及原地修改引用。
这道题的核心并不是交换节点的值,而是改变每个节点的 next****指向,让原本从前往后的链表变成从后往前。
题目描述
给定一个单链表的头节点 head,将链表反转,并返回反转后的新头节点。
例如:
原链表:
1 → 2 → 3 → 4 → 5 → null
反转后:
5 → 4 → 3 → 2 → 1 → null
需要注意,反转的是链表节点之间的连接方向,而不是简单地把数组倒过来。
什么是链表?
理解反转链表之前,需要先知道链表的基本结构。
数组通过下标访问元素,而链表由多个节点连接而成。每个节点通常包含两部分:
-
val:当前节点保存的数据。 -
next:指向下一个节点的引用。
例如:
javascript
const node1 = {
val: 1,
next: null
}
const node2 = {
val: 2,
next: null
}
node1.next = node2
此时形成:
node1 node2
┌─────┬──────┐ ┌─────┬──────┐
│ 1 │ next │ ───────→ │ 2 │ null │
└─────┴──────┘ └─────┴──────┘
所以:
javascript
node1.val
// 1
node1.next
// node2 对象
node1.next.val
// 2
也就是说,next 保存的不是下一个节点的数值,而是下一个节点对象的引用。
因此:
node1.next = null
就表示让 node1 不再指向 node2。
反转链表,正是通过不断修改这样的 next 引用完成的。
为什么使用三个指针?
假设原链表是:
1 → 2 → 3 → null
我们希望变成:
3 → 2 → 1 → null
最直接的思路就是遍历链表,将当前节点的 next 指向前一个节点。
但是这里存在一个问题。
原本:
1 → 2 → 3
如果直接执行:
curr.next = prev
当前节点的指向会发生改变,可能导致原来后面的节点无法继续访问。
因此,在修改指向之前,必须先保存下一个节点。
这就是为什么需要三个变量:
| 变量 | 作用 |
|---|---|
prev |
保存已经反转部分的头节点 |
curr |
当前正在处理的节点 |
next |
临时保存原链表的下一个节点 |
三个变量互相配合,就可以在不丢失后续节点的情况下完成反转。
JavaScript 迭代实现
javascript
var reverseList = function(head) {
let prev = null
let curr = head
while (curr) {
const next = curr.next
curr.next = prev
prev = curr
curr = next
}
return prev
}
这段代码虽然很短,但最重要的是理解其中四条语句的执行顺序。
代码执行过程
初始状态:
let prev = null
let curr = head
假设:
null 1 → 2 → 3 → null
↑ ↑
prev curr
prev 初始为 null,因为第一个节点反转后,会成为链表尾节点,它的 next 应该指向 null。
curr 指向头节点,表示从第一个节点开始处理。
循环条件:
while (curr)
意思是:
只要当前节点不为
null,就继续反转。
接下来进入最核心的四条语句。
保存下一个节点
const next = curr.next
当前:
curr → 1
next → 2
先保存节点 2,防止后续修改 curr.next 时失去对剩余链表的访问。
反转当前节点的指向
curr.next = prev
因为最开始:
prev = null
所以相当于:
node1.next = null
此时:
1 → null
2 → 3 → null
节点 1 已经完成反转,而后续链表仍然可以通过 next 找到。
更新 prev
prev = curr
让 prev 指向刚刚完成反转的节点 1。
prev
↓
1 → null
注意:这里不是复制一个新节点,而是让变量 prev 保存 curr 当前引用的节点。
更新 curr
curr = next
让 curr 移动到原来的下一个节点:
prev curr
↓ ↓
1 → null 2 → 3 → null
第一轮完成。
接下来继续处理节点 2。
const next = curr.next // 保存节点 3
curr.next = prev // 2 → 1
prev = curr // prev 指向 2
curr = next // curr 指向 3
此时:
已反转部分:
2 → 1 → null
↑
prev
待处理部分:
3 → null
↑
curr
再执行一轮:
3 → 2 → 1 → null
↑
prev
curr = null
因为:
curr === null
循环结束。
最终执行:
return prev
此时 prev 指向节点 3,它已经成为反转后链表的新头节点。
为什么最后返回 prev,而不是 head?
原链表:
head
↓
1 → 2 → 3 → null
反转以后:
3 → 2 → 1 → null
↑
head
原来的 head 仍然指向节点 1,只是节点 1 已经变成尾节点。
而 prev 最终指向节点 3,也就是新链表的头节点。
因此必须:
return prev
而不是:
return head
递归方式实现
除了迭代,也可以通过递归反转链表。
javascript
var reverseList = function(head) {
if (head === null || head.next === null) {
return head
}
const newHead = reverseList(head.next)
head.next.next = head
head.next = null
return newHead
}
递归的思路是:先反转后面的链表,再把当前节点接到末尾。
例如:
1 → 2 → 3 → null
递归一直向后,直到节点 3。
随后在返回过程中逐层反转:
3 → 2 → null
最后变成:
3 → 2 → 1 → null
其中:
head.next.next = head
用于让下一个节点反过来指向当前节点。
而:
head.next = null
用于断开当前节点原来的向后连接,避免形成环。
递归写法更加简洁,但需要额外的函数调用栈空间。
时间复杂度与空间复杂度
| 实现方式 | 时间复杂度 | 额外空间复杂度 |
|---|---|---|
| 迭代(三指针) | O(n) | O(1) |
| 递归 | O(n) | O(n) |
迭代方式 只遍历链表一次,并且只使用 prev、curr、next 几个变量,因此时间复杂度为 O(n),额外空间复杂度为 O(1)。
递归方式 同样需要处理每个节点一次,因此时间复杂度为 O(n),但最坏情况下递归调用栈需要 O(n) 的额外空间。
面试中通常更推荐掌握迭代写法,因为它不需要额外递归栈,空间效率更高。
总结
反转链表的本质就是改变节点之间的 next****引用方向。
最核心的代码:
javascript
var reverseList = function(head) {
let prev = null
let curr = head
while (curr) {
const next = curr.next
curr.next = prev
prev = curr
curr = next
}
return prev
}
整个过程可以总结为:
保存下一个节点
↓
反转当前节点指向
↓
更新 prev
↓
curr 移动到下一个节点
↓
继续循环
↓
返回 prev
记住这四句:
const next = curr.next // 保存后续节点
curr.next = prev // 改变指向
prev = curr // 更新已反转部分
curr = next // 继续处理下一个节点
一句话总结:先保存后续节点,再反转当前指向,最后移动两个指针,直到整个链表完成反转。
掌握这道题以后,再学习「反转链表 II」「K 个一组翻转链表」等进阶题目,就会更容易理解。