206-链表-反转链表

递归 | 链表

好的,让我们详细解析这个反转单链表的算法。首先,我们需要理解一些基础知识,然后一步步解析代码的每个部分。

什么是单链表?

单链表(Singly Linked List)是一种线性数据结构,由一系列节点组成。每个节点包含两部分:

  1. 值(Value):存储具体的数据。
  2. 指针(Next):指向链表中的下一个节点。

例如,链表 1 -> 2 -> 3 -> null 表示一个包含三个节点的单链表,其中 1 指向 22 指向 33 指向 null(表示链表的结束)。

反转单链表是什么意思?

反转单链表就是将链表中的所有节点的指针方向都反过来,使得原来的头节点变成尾节点,原来的尾节点变成头节点。

例如,将 1 -> 2 -> 3 -> null 反转后,变为 3 -> 2 -> 1 -> null

迭代法反转链表的思路

迭代法通过逐个节点地改变指针方向来反转链表。具体步骤如下:

  1. 初始化两个指针

    • prev:指向已经反转的部分,初始为 null
    • curr:指向当前正在处理的节点,初始为 head(链表的头节点)。
  2. 遍历链表,直到 currnull(即到达链表末尾):

    • 临时保存下一个节点tmp = curr.next,因为接下来我们要改变 curr.next 的指向,如果不保存下来,就无法继续遍历。
    • 反转当前节点的指针curr.next = prev,将当前节点的指针指向前一个节点,从而实现反转。
    • 移动 prevcurr 指针
      • prev = curr,将 prev 移动到当前节点,准备处理下一个节点。
      • curr = tmp,将 curr 移动到下一个节点,继续反转。
  3. 返回 prev:当循环结束时,prev 指向新链表的头节点,即原链表的尾节点。

代码解析

让我们逐行解析提供的 JavaScript 代码:

var reverseList = function(head) {
    let [prev, curr] = [null, head];
    while (curr) {
        let tmp = curr.next;    // 1. 临时存储当前节点的下一个节点
        curr.next = prev;       // 2. 反转当前节点的指针,使其指向前一个节点
        prev = curr;            // 3. 移动 prev 到当前节点
        curr = tmp;             // 4. 移动 curr 到下一个节点
    }
    return prev; // 返回新的头节点
};
变量解释
  • prev:前一个节点,初始为 null,因为反转后原链表的头节点将成为新链表的尾节点,其 next 应指向 null
  • curr:当前处理的节点,初始为 head
  • tmp:临时变量,用于保存 curr.next,防止在反转指针后丢失后续节点。
详细步骤示例

假设我们有一个链表 1 -> 2 -> 3 -> null,现在要使用迭代法反转它。

  1. 初始化

    • prev = null
    • curr = 1
  2. 第一次循环(处理节点 1):

    • tmp = curr.next = 2(保存下一个节点)
    • curr.next = prev = null(将 1 的指针指向 null,链表变为 1 -> null)
    • prev = curr = 1prev 现在指向 1
    • curr = tmp = 2curr 移动到 2

    当前链表状态:

    • 已反转部分:1 -> null
    • 待处理部分:2 -> 3 -> null
  3. 第二次循环(处理节点 2):

    • tmp = curr.next = 3(保存下一个节点)
    • curr.next = prev = 1(将 2 的指针指向 1,链表变为 2 -> 1 -> null)
    • prev = curr = 2prev 现在指向 2
    • curr = tmp = 3curr 移动到 3

    当前链表状态:

    • 已反转部分:2 -> 1 -> null
    • 待处理部分:3 -> null
  4. 第三次循环(处理节点 3):

    • tmp = curr.next = null(保存下一个节点)
    • curr.next = prev = 2(将 3 的指针指向 2,链表变为 3 -> 2 -> 1 -> null)
    • prev = curr = 3prev 现在指向 3
    • curr = tmp = nullcurr 移动到 null,结束循环)

    当前链表状态:

    • 已反转部分:3 -> 2 -> 1 -> null
    • 待处理部分:null(循环结束)
  5. 返回结果

    • 最终,prev 指向 3,即反转后的链表头节点。
    • 返回 prev,得到反转后的链表 3 -> 2 -> 1 -> null

复杂度分析

  • 时间复杂度O(n),其中 n 是链表的长度。算法需要遍历每个节点一次。
  • 空间复杂度O(1),只使用了常数级别的额外空间,包括 prevcurrtmp 三个指针。

小结

通过迭代法反转单链表,核心思想是逐个改变节点的指针方向,并使用两个指针(prevcurr)来追踪已反转和待处理的部分。在每一步中,我们保存下一个节点,反转当前节点的指针,然后移动两个指针,直到整个链表被反转。


是的,你看到的这种写法是 JavaScript 中的数组解构赋值(destructuring assignment)语法,它可以让你在一行中同时赋值多个变量。我们来详细解释一下这个简化实现的代码,并解释为什么它能够正常工作。

代码解析

首先看一下简化后的代码:

var reverseList = function(head) {
    let [prev, curr] = [null, head];
    while (curr) {
        [curr.next, prev, curr] = [prev, curr, curr.next];
    }
    return prev;
};
变量说明
  • prev: 指向已经反转的部分的最后一个节点,初始为 null
  • curr: 当前正在处理的节点,初始为 head
  • curr.next: 当前节点的下一个节点,最终会被反转为指向 prev
数组解构赋值

while 循环中,使用了数组解构赋值的方式:

[curr.next, prev, curr] = [prev, curr, curr.next];

这行代码的含义是:

  1. 右侧的数组 [prev, curr, curr.next] 会被计算并生成一个新数组。

    • prev 是当前反转部分的最后一个节点(初始为 null)。
    • curr 是当前处理的节点(在第一次循环时为 head)。
    • curr.next 是当前节点的下一个节点(在第一次循环时为 head.next)。
  2. 左侧的解构 将数组中的值依次赋给 curr.nextprevcurr

    • curr.next 被赋值为 prev,这实现了当前节点指向前一个节点的反转。
    • prev 被赋值为 curr,更新 prev 为当前节点,准备进行下一次反转。
    • curr 被赋值为 curr.next,移动到下一个节点,继续反转。
整体执行流程
  1. 初始化

    • prevnull,表示反转部分的尾部。
    • curr 指向链表的头节点。
  2. 循环过程

    • 每次循环会反转当前节点的指针,并更新 prevcurr
    • 具体来说,curr.next 会指向 prev,然后 prevcurr 会分别更新为当前节点和下一个节点。
  3. 结束条件

    • curr 变为 null 时,循环结束,此时 prev 指向反转后链表的头节点。
  4. 返回结果

    • 返回 prev,这就是反转后的链表头节点。

为什么可以这样写?

这种写法的优势在于它的简洁性和可读性。通过数组解构赋值,我们可以在一行中同时更新多个变量,减少了临时变量的使用,使代码更紧凑。

在 JavaScript 中,数组解构赋值是一个非常强大且常用的特性,特别是在处理多个变量赋值时,可以使代码更加优雅。


尾递归法是一种非常有趣的递归技术,它通过将递归调用作为函数的最后一步来优化性能。接下来,我们将详细解析这个使用尾递归法反转链表的实现,并解释其原理和优点。

尾递归法的思路

在尾递归法中,我们使用两个指针 prevcurr 来保存当前的推进状态,直到 curr 为空时返回结果。与普通递归不同,尾递归的关键在于递归调用是函数的最后一个操作,这使得编译器或解释器可以优化调用栈,避免增加额外的调用栈深度。

具体步骤
  1. 定义入口函数

    • reverseList(head):这个函数是对外的入口,调用内部的 reverse 函数,初始化 prevnullcurrhead
  2. 递归函数

    • reverse(prev, curr):这是实际执行反转的递归函数。
    • 基准条件:如果 currnull,表示已经遍历到链表的末尾,此时返回 prev,即反转后的链表的头节点。
    • 在每次调用中:
      • 先保存 curr.next 到一个临时变量 tmp,以便在反转指针后仍能访问下一个节点。
      • 将当前节点 currnext 指向 prev,实现反转。
      • 递归调用 reverse,传入新的 prev(当前节点 curr)和 tmp(下一个节点)。

代码解析

让我们逐行解析提供的 JavaScript 代码:

var reverseList = function(head) {
    return reverse(null, head); // 初始化 prev 为 null,curr 为 head
};

function reverse(prev, curr) {
    if (!curr) return prev; // 基准条件:如果 curr 为空,返回 prev
    let tmp = curr.next; // 保存下一个节点
    curr.next = prev; // 反转当前节点的指针
    return reverse(curr, tmp); // 递归调用,推进到下一个节点
}
变量说明
  • prev: 用于存储已经反转部分的最后一个节点,初始为 null
  • curr: 当前正在处理的节点,初始为 head
  • tmp: 临时变量,用于保存当前节点的下一个节点,以便在反转后仍能访问。

尾递归过程示例

假设我们有一个链表 1 -> 2 -> 3 -> null,现在要使用尾递归法反转它。

  1. 第一次调用(处理节点 1):

    • prev = null
    • curr = 1
    • tmp = 2(保存下一个节点)
    • curr.next = prev(将 1 的指针指向 null
    • 递归调用 reverse(null, 2)
  2. 第二次调用(处理节点 2):

    • prev = 1
    • curr = 2
    • tmp = 3(保存下一个节点)
    • curr.next = prev(将 2 的指针指向 1
    • 递归调用 reverse(2, 3)
  3. 第三次调用(处理节点 3):

    • prev = 2
    • curr = 3
    • tmp = null(保存下一个节点)
    • curr.next = prev(将 3 的指针指向 2
    • 递归调用 reverse(3, null)
  4. 基准条件触发(处理节点 null):

    • prev = 3
    • curr = null
    • 返回 prev,即 3

最终,我们得到了反转后的链表 3 -> 2 -> 1 -> null

优点与复杂度分析

  • 时间复杂度O(n),与迭代法和普通递归法相同,遍历每个节点一次。
  • 空间复杂度O(1),如果 JavaScript 引擎支持尾调用优化(TCO),则不会增加额外的调用栈空间。否则,它与普通递归的空间复杂度为 O(n)

尾递归的优势

尾递归法的一个主要优势在于它可以避免递归调用栈的增长,从而减少栈溢出的风险,尤其是在处理长链表时。在某些编程语言中,编译器会对尾递归进行优化,使得尾递归的性能接近迭代法。


尾递归和自递归是递归的两种不同形式,它们在结构和性能特性上有显著的区别。下面我们将详细解释这两种递归的含义、区别以及它们的应用场景。

自递归(普通递归)

定义:自递归是指一个函数直接调用自身来解决问题。在自递归中,递归调用可以在函数的任何位置,通常是在执行某些操作后,再进行递归调用。

特点

  • 调用栈:每次递归调用都会将当前函数的状态(包括局部变量和执行上下文)推入调用栈,因此如果递归深度较大,会占用较多的栈空间,可能导致栈溢出。
  • 返回值:在返回值时,通常需要在每一层递归中进行一些处理,可能涉及到多个操作。

示例
考虑计算阶乘的自递归实现:

function factorial(n) {
    if (n === 0) return 1; // 基准条件
    return n * factorial(n - 1); // 递归调用
}

在这个例子中,递归调用 factorial(n - 1) 不是函数的最后一步,因此它是一个普通的自递归。

尾递归

定义:尾递归是一种特殊类型的递归,其中递归调用是函数的最后一个操作。在尾递归中,函数在调用自身之前不需要执行任何后续操作,也不需要对递归调用的结果进行处理。

特点

  • 优化:许多语言的编译器或解释器(如某些实现的 JavaScript 和其他语言如 Scheme 和 Haskell)可以对尾递归进行优化(尾调用优化,TCO)。这意味着在尾递归情况下,编译器可以重用当前函数的堆栈帧,而不是在调用栈中增加新的帧,从而避免栈溢出。
  • 性能:由于尾递归可以被优化,通常在处理深层递归时性能更好,尤其是在处理大数据结构时。

示例
考虑计算阶乘的尾递归实现:

function factorialTail(n, accumulator = 1) {
    if (n === 0) return accumulator; // 基准条件
    return factorialTail(n - 1, n * accumulator); // 尾递归调用
}

在这个例子中,递归调用 factorialTail(n - 1, n * accumulator) 是函数的最后一步,因此这是尾递归。

为什么分这两种递归

  1. 性能差异

    • 普通自递归可能会导致栈溢出,尤其是在递归深度很大时,因为每次递归都会增加新的调用栈。
    • 尾递归可以通过尾调用优化来避免这个问题,从而在处理深层递归时性能更好。
  2. 实现方式

    • 自递归可能需要在每一层递归中保存状态,可能需要在返回时进行额外的计算。
    • 尾递归则允许编译器优化调用过程,减少不必要的状态保存和计算。
  3. 适用场景

    • 尾递归适合用于需要处理大量数据或深层递归的场景,特别是在需要高效内存使用的情况下。
    • 自递归则更常见于简单的递归问题,或者当问题的解决方案自然地适合普通递归的结构时。

总结

尾递归和自递归是递归的两种形式,最大的区别在于递归调用的位置及其对调用栈的影响。理解这两者的区别可以帮助我们在编写递归算法时选择合适的方法,以提高效率和避免潜在的栈溢出问题。在实际编程中,选择哪种递归方式通常取决于具体问题的性质和所使用的编程语言的特点。希望这个解释能帮助你更好地理解尾递归和自递归的区别!如果还有其他问题,请随时问我。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值