Leetcode206.反转链表 迭代+递归 【hot100算法个人笔记】【java写法】

前言

今天啃下了链表的经典入门题 ------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指针,让它指向前一个节点,这样遍历完整个链表,就完成了反转。

步骤拆解

我自己整理的操作要点,每一步都不能乱:

  1. 初始化指针:pre 初始化为 null(因为反转后的最后一个节点的 next 就是 null),cur 初始化为头节点 headtemp 用来临时保存下一个节点,防止断链。

  2. 循环处理,只要 cur 不为空,就继续:

  • 第一步:存后继temp = cur.next,先把当前节点的下一个节点存下来,不然我们改了cur.next之后,就找不到后面的节点了!

  • 第二步:反转指针cur.next = pre,把当前节点的 next 指向前一个节点,完成这一个节点的反转。

  • 第三步:移动 prepre = cur,把 pre 向后移动一位,到当前节点的位置,准备处理下一个节点。

  • 第四步:移动 curcur = temp,把 cur 移动到刚才存的下一个节点的位置,继续处理。

  1. 循环结束后,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),只用到了三个指针变量,常数级的额外空间。

解法二:递归法

很多同学熟悉的递归是从后往前的后序递归写法,但我这里用的是和双指针逻辑完全对应的尾递归写法,本质上就是把循环的每一步改成了递归调用,逻辑上和迭代法一一对应,非常好理解!

核心思路

其实就是把双指针里的循环,换成了递归函数的调用:每一次递归,就处理当前的precur,做完和迭代里一样的指针修改,然后把cur传给下一层的pre,把temp传给下一层的cur,直到cur为空,就返回pre,和迭代的终止条件完全一样。

步骤拆解

我整理的递归要点:

  1. 先写递归终止条件 :当cur == null的时候,说明所有节点都处理完了,返回pre,和迭代的返回值一样。

  2. 初始调用 :第一次调用递归函数的时候,初始条件和双指针完全一样:pre = nullcur = head

  3. 递归传递:每一层处理完当前的指针反转之后,下一层递归的参数就是:

  • 把当前的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 个一组反转链表这些变种题,就有了很好的基础了。

相关推荐
MegaDataFlowers1 小时前
静态/动态代理模式
java·开发语言·代理模式
早睡早起好好code2 小时前
InternNav 论文回看
笔记·python·深度学习·学习·算法
Aliex_git2 小时前
前端监控笔记(一)
前端·笔记·学习
2501_945424802 小时前
调试技巧与核心转储分析
开发语言·c++·算法
m0_579393662 小时前
单元测试在C++项目中的实践
开发语言·c++·算法
C_Si沉思2 小时前
C++中的状态模式高级应用
开发语言·c++·算法
编程学习0012 小时前
记一次Java面试
java·面试
MORE_772 小时前
leecode100-跳跃游戏2-贪心算法
算法·游戏·贪心算法