前言
今天啃下了链表的经典入门题 ------LeetCode 206. 反转链表。这道题看似简单,却是链表指针操作的基础,之前一直对链表的反转逻辑有点绕,今天花了点时间把迭代和递归两种写法都理透了,做个笔记记录一下,防止之后回头就忘,也方便自己之后复习回顾。

题目回顾
给你单链表的头节点 head ,请你反转链表,并返回反转后的链表。
示例
示例 1:
Plain
输入:head = [1,2,3,4,5]
输出:[5,4,3,2,1]

示例 2:
Plain
输入:head = [1,2]
输出:[2,1]

示例 3:
Plain
输入:head = []
输出:[]
节点定义
题目中的单链表节点定义如下:
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; }
}
解法一:迭代(双指针)法
这是我最推荐的解法,也是生产环境中最常用的写法,非常好理解并且高效。
核心思路
我们可以用两个指针(实际用到了三个,还有一个临时变量存下一个节点),逐个遍历链表的节点,逐个修改每个节点的next指针,让它指向前一个节点,这样遍历完整个链表,就完成了反转。
步骤拆解
我自己整理的操作要点,每一步都不能乱:
-
初始化指针:
pre初始化为null(因为反转后的最后一个节点的 next 就是 null),cur初始化为头节点head,temp用来临时保存下一个节点,防止断链。 -
循环处理,只要
cur不为空,就继续:
-
第一步:存后继 :
temp = cur.next,先把当前节点的下一个节点存下来,不然我们改了cur.next之后,就找不到后面的节点了! -
第二步:反转指针 :
cur.next = pre,把当前节点的 next 指向前一个节点,完成这一个节点的反转。 -
第三步:移动 pre :
pre = cur,把 pre 向后移动一位,到当前节点的位置,准备处理下一个节点。 -
第四步:移动 cur :
cur = temp,把 cur 移动到刚才存的下一个节点的位置,继续处理。
- 循环结束后,
cur已经是 null 了,此时pre正好指向原链表的最后一个节点,也就是反转后的新头节点,返回pre即可。
代码实现
java
class Solution {
public ListNode reverseList(ListNode head) {
//双指针
ListNode pre=null;
ListNode cur=head;
ListNode temp=null;
while(cur!=null){
temp=cur.next; // 保存下一个节点,防止断链
cur.next=pre; // 反转当前节点的指针
pre=cur; // pre向后移动
cur=temp; // cur向后移动
}
return pre;
}
}
踩坑感悟
一开始写的时候,我差点忘了先存temp,直接就改了cur.next,结果改完之后,原来的cur.next就找不到了,链表直接断了,后面的节点全丢了,调试了半天才反应过来!原来修改指针之前,一定要先把后面的节点存下来,这就是链表操作最容易踩的坑!
复杂度分析
-
时间复杂度:O (n),需要遍历整个链表一次,每个节点处理一次。
-
空间复杂度:O (1),只用到了三个指针变量,常数级的额外空间。
解法二:递归法
很多同学熟悉的递归是从后往前的后序递归写法,但我这里用的是和双指针逻辑完全对应的尾递归写法,本质上就是把循环的每一步改成了递归调用,逻辑上和迭代法一一对应,非常好理解!
核心思路
其实就是把双指针里的循环,换成了递归函数的调用:每一次递归,就处理当前的pre和cur,做完和迭代里一样的指针修改,然后把cur传给下一层的pre,把temp传给下一层的cur,直到cur为空,就返回pre,和迭代的终止条件完全一样。
步骤拆解
我整理的递归要点:
-
先写递归终止条件 :当
cur == null的时候,说明所有节点都处理完了,返回pre,和迭代的返回值一样。 -
初始调用 :第一次调用递归函数的时候,初始条件和双指针完全一样:
pre = null,cur = head。 -
递归传递:每一层处理完当前的指针反转之后,下一层递归的参数就是:
-
把当前的
cur传给下一层的pre -
把刚才存的
temp(也就是cur.next)传给下一层的cur其实就是把双指针里的指针移动,换成了递归的参数传递。
代码实现
java
class Solution {
public ListNode reverseList(ListNode head) {
//递归,初始调用,对应双指针的初始状态
return reverse(null,head);
}
public ListNode reverse(ListNode pre,ListNode cur){
// 递归终止条件,和迭代的循环结束条件一致
if(cur == null){
return pre;
}
ListNode temp=cur.next; // 同样,先存下一个节点
cur.next=pre; // 反转当前节点的指针
// 递归调用,传递新的pre和cur,相当于迭代里的指针移动
return reverse(cur,temp);
}
}
两种递归的对比
这里要提一下,很多教程里的递归是另一种从后往前的写法:先递归到链表的末尾,然后从后往前逐个改指针。那种写法也能实现,但是逻辑上绕一点,而我这个尾递归的写法,和双指针的逻辑完全对应,学会了双指针,马上就能写出这个递归,非常好记!
复杂度分析
-
时间复杂度:O (n),同样要处理每个节点一次,递归 n 次。
-
空间复杂度:O (n),因为 Java 不支持尾递归优化,所以递归调用会占用栈空间,递归的深度是 n,所以空间复杂度是 O (n),这也是递归法不如迭代法的地方。
刷题笔记与感悟
做完这道题,最大的感悟就是,链表的操作本质上就是指针的操作 ,很多时候我们不是不会写,而是不敢改指针,总怕改了之后链表断了,其实只要记住:修改指针之前,一定要先把要用到的节点存下来,就不会丢节点了。
之前我一直以为反转链表要新建一个链表,把节点倒着插进去,没想到原来可以原地改指针,直接在原链表上操作,空间复杂度做到 O (1)。
而且这道题不只是算法题,实际开发中也有很多应用:比如我们常用的撤销 / 重做功能,还有浏览器的前进后退历史记录,其实都用到了类似的反转链表的思想,把操作历史反过来,就能实现回退的功能。
另外我也测试了边界情况:空链表、只有一个节点的链表,这两种情况两种方法都能正确处理,比如空链表的话,cur 一开始就是 null,直接返回 pre 也就是 null,完全没问题,不用额外写边界判断,代码的鲁棒性还是很好的。
总结
这道题作为链表的入门题,真的太经典了,两种方法各有优劣:
-
迭代法:空间复杂度低,没有栈溢出的风险,效率高,生产环境首选,也是面试的时候最推荐写的解法。
-
递归法:代码更简洁,逻辑上和迭代一一对应,很好理解,但是有栈溢出的风险,适合用来练习递归思维。
面试的时候,这道题经常会被问到,面试官可能会要求你写出两种解法,所以两种都要掌握!搞定了这道基础的反转链表,之后的反转区间、K 个一组反转链表这些变种题,就有了很好的基础了。