2022 年 10 月 1 日

递归和堆栈

让我们回到函数,并更深入地研究它们。

我们的第一个主题将是递归

如果你不是编程新手,那么它可能很熟悉,你可以跳过本章。

递归是一种编程模式,当一个任务可以自然地分解成几个同类但更简单的任务时,它很有用。或者当一个任务可以简化为一个简单的动作加上一个更简单的同类任务变体时。或者,正如我们很快就会看到的,处理某些数据结构。

当一个函数解决一个任务时,在此过程中它可以调用许多其他函数。部分情况下,一个函数会调用自身。这称为递归

两种思考方式

从简单的事情开始——让我们编写一个函数 pow(x, n),它将 x 提升到 n 的自然幂。换句话说,将 x 乘以自身 n 次。

pow(2, 2) = 4
pow(2, 3) = 8
pow(2, 4) = 16

有两种方法可以实现它。

  1. 迭代思考:for 循环

    function pow(x, n) {
      let result = 1;
    
      // multiply result by x n times in the loop
      for (let i = 0; i < n; i++) {
        result *= x;
      }
    
      return result;
    }
    
    alert( pow(2, 3) ); // 8
  2. 递归思考:简化任务并调用自身

    function pow(x, n) {
      if (n == 1) {
        return x;
      } else {
        return x * pow(x, n - 1);
      }
    }
    
    alert( pow(2, 3) ); // 8

请注意递归变体在根本上是如何不同的。

当调用 pow(x, n) 时,执行将分为两个分支

              if n==1  = x
             /
pow(x, n) =
             \
              else     = x * pow(x, n - 1)
  1. 如果 n == 1,那么一切都微不足道。它被称为递归的,因为它立即产生明显的结果:pow(x, 1) 等于 x
  2. 否则,我们可以将 pow(x, n) 表示为 x * pow(x, n - 1)。在数学中,可以写成 xn = x * xn-1。这称为递归步骤:我们将任务转换为一个更简单的动作(乘以 x)和对同一任务的更简单的调用(pow,其中 n 较低)。下一步进一步简化,直到 n 达到 1

我们还可以说 pow 递归地调用自身,直到 n == 1

例如,要计算 pow(2, 4),递归变体执行以下步骤

  1. pow(2, 4) = 2 * pow(2, 3)
  2. pow(2, 3) = 2 * pow(2, 2)
  3. pow(2, 2) = 2 * pow(2, 1)
  4. pow(2, 1) = 2

因此,递归将函数调用简化为更简单的调用,然后简化为更简单的调用,依此类推,直到结果变得明显。

递归通常更短

递归解通常比迭代解更短。

在这里,我们可以使用条件运算符 ?(而不是 if)重写相同的内容,以使 pow(x, n) 更加简洁且仍然非常可读

function pow(x, n) {
  return (n == 1) ? x : (x * pow(x, n - 1));
}

嵌套调用的最大数量(包括第一个调用)称为递归深度。在我们的例子中,它将恰好为 n

最大递归深度受 JavaScript 引擎限制。我们可以依赖它为 10000,一些引擎允许更多,但 100000 可能超出大多数引擎的限制。有一些自动优化可以帮助缓解这种情况(“尾调用优化”),但它们尚未得到广泛支持,并且仅适用于简单的情况。

这限制了递归的应用,但它仍然非常广泛。在许多任务中,递归思维方式提供了更简单的代码,更易于维护。

执行上下文和堆栈

现在让我们研究递归调用如何工作。为此,我们将深入了解函数。

正在运行的函数的执行过程的信息存储在其执行上下文中

执行上下文是一个内部数据结构,其中包含有关函数执行的详细信息:当前的控制流、当前变量、this 的值(我们在此处不使用它)和其他一些内部详细信息。

一个函数调用恰好有一个与其关联的执行上下文。

当函数进行嵌套调用时,将发生以下情况

  • 当前函数暂停。
  • 与其关联的执行上下文被记住在一个称为执行上下文堆栈的特殊数据结构中。
  • 嵌套调用执行。
  • 在它结束之后,从堆栈中检索旧的执行上下文,并且外部函数从它停止的地方恢复。

让我们看看在 pow(2, 3) 调用期间发生了什么。

pow(2, 3)

在调用 pow(2, 3) 的开始,执行上下文将存储变量:x = 2, n = 3,执行流在函数的第 1 行。

我们可以将其勾勒为

  • 上下文:{ x: 2, n: 3, at line 1 } pow(2, 3)

那时函数开始执行。条件 n == 1 为假,因此流继续进入 if 的第二个分支

function pow(x, n) {
  if (n == 1) {
    return x;
  } else {
    return x * pow(x, n - 1);
  }
}

alert( pow(2, 3) );

变量相同,但行发生变化,因此上下文现在是

  • 上下文:{ x: 2, n: 3, at line 5 } pow(2, 3)

要计算 x * pow(x, n - 1),我们需要使用新参数 pow(2, 2)pow 进行子调用。

pow(2, 2)

为了进行嵌套调用,JavaScript 在执行上下文堆栈中记住当前执行上下文。

在这里,我们调用相同的函数 pow,但这绝对无关紧要。该过程对所有函数都是相同的

  1. 当前上下文在堆栈的顶部被“记住”。
  2. 为子调用创建新的上下文。
  3. 当子调用完成时,将从堆栈中弹出先前的上下文,并且其执行继续。

当我们进入子调用 pow(2, 2) 时,这是上下文堆栈

  • 上下文:{ x: 2, n: 2, 在第 1 行} pow(2, 2)
  • 上下文:{ x: 2, n: 3, at line 5 } pow(2, 3)

新的当前执行上下文在顶部(加粗),之前记住的上下文在下面。

当我们完成子调用时,很容易恢复之前的上下文,因为它保留了变量和代码停止的确切位置。

请注意

这里在图片中我们使用单词“行”,因为在我们的示例中行中只有一个子调用,但通常一行代码可能包含多个子调用,例如 pow(…) + pow(…) + somethingElse(…)

因此,更准确的说法是执行在“子调用之后立即”恢复。

pow(2, 1)

该过程重复:在第 5 行进行新的子调用,现在带有参数 x=2n=1

创建了一个新的执行上下文,将之前的执行上下文压入堆栈顶部

  • 上下文:{ x: 2, n: 1, 在第 1 行} pow(2, 1)
  • 上下文:{ x: 2, n: 2, 在第 5 行} pow(2, 2)
  • 上下文:{ x: 2, n: 3, at line 5 } pow(2, 3)

现在有 2 个旧上下文和 1 个当前正在运行的 pow(2, 1)

退出

在执行 pow(2, 1) 时,与之前不同,条件 n == 1 为真,因此 if 的第一个分支有效

function pow(x, n) {
  if (n == 1) {
    return x;
  } else {
    return x * pow(x, n - 1);
  }
}

没有更多嵌套调用,因此函数完成,返回 2

当函数完成时,不再需要它的执行上下文,因此它从内存中删除。将之前的执行上下文从堆栈顶部恢复

  • 上下文:{ x: 2, n: 2, 在第 5 行} pow(2, 2)
  • 上下文:{ x: 2, n: 3, at line 5 } pow(2, 3)

恢复 pow(2, 2) 的执行。它具有子调用 pow(2, 1) 的结果,因此它还可以完成 x * pow(x, n - 1) 的求值,返回 4

然后恢复之前的上下文

  • 上下文:{ x: 2, n: 3, at line 5 } pow(2, 3)

当它完成时,我们得到 pow(2, 3) = 8 的结果。

在这种情况下,递归深度为:3

从上面的插图中可以看出,递归深度等于堆栈中的最大上下文数。

注意内存需求。上下文占用内存。在我们的例子中,求 n 次方实际上需要 n 个上下文的内存,对于 n 的所有较低值。

基于循环的算法更省内存

function pow(x, n) {
  let result = 1;

  for (let i = 0; i < n; i++) {
    result *= x;
  }

  return result;
}

迭代 pow 使用单个上下文,在此过程中更改 iresult。它的内存需求很小,固定且不依赖于 n

任何递归都可以重写为循环。循环变体通常可以变得更有效。

…但有时重写并非易事,尤其是在函数根据条件使用不同的递归子调用并合并其结果或分支更复杂时。并且优化可能是不必要的,完全不值得付出努力。

递归可以提供更短的代码,更容易理解和支持。并非所有地方都需要优化,我们大多数时候需要的是良好的代码,这就是它被使用的原因。

递归遍历

递归的另一个伟大应用是递归遍历。

想象一下,我们有一家公司。员工结构可以表示为一个对象

let company = {
  sales: [{
    name: 'John',
    salary: 1000
  }, {
    name: 'Alice',
    salary: 1600
  }],

  development: {
    sites: [{
      name: 'Peter',
      salary: 2000
    }, {
      name: 'Alex',
      salary: 1800
    }],

    internals: [{
      name: 'Jack',
      salary: 1300
    }]
  }
};

换句话说,一家公司有部门。

  • 一个部门可能有一个员工数组。例如,sales 部门有 2 名员工:John 和 Alice。

  • 或者一个部门可能分为子部门,比如 development 有两个分支:sitesinternals。它们各自有自己的员工。

  • 当一个子部门发展壮大时,也有可能将其划分为子子部门(或团队)。

    例如,sites 部门将来可能会分为 siteAsiteB 团队。而且它们有可能进一步细分。这不在图片中,只是需要记住的事情。

现在,假设我们想要一个函数来获得所有工资的总和。我们如何做到这一点?

迭代方法并不容易,因为结构并不简单。第一个想法可能是对 company 进行 for 循环,并对第一级部门进行嵌套子循环。但是,然后我们需要更多嵌套子循环来迭代第二级部门(如 sites)中的员工……然后在未来可能出现的第三级部门中再进行另一个子循环?如果我们在代码中放置 3-4 个嵌套子循环来遍历单个对象,它会变得相当难看。

我们尝试递归。

正如我们所看到的,当我们的函数获得一个要汇总的部门时,有两种可能的情况

  1. 要么它是一个带有员工数组的“简单”部门——然后我们可以在一个简单的循环中对工资求和。
  2. 要么它是一个带有 N 个子部门的对象——然后我们可以进行 N 个递归调用来获得每个子部门的总和,并将结果组合起来。

第一种情况是递归的基础,当我们得到一个数组时,这是平凡的情况。

当我们得到一个对象时的第二种情况是递归步骤。一个复杂的任务被分解为较小部门的子任务。它们可能反过来再次分解,但迟早分解将在 (1) 处结束。

该算法可能从代码中更容易读取

let company = { // the same object, compressed for brevity
  sales: [{name: 'John', salary: 1000}, {name: 'Alice', salary: 1600 }],
  development: {
    sites: [{name: 'Peter', salary: 2000}, {name: 'Alex', salary: 1800 }],
    internals: [{name: 'Jack', salary: 1300}]
  }
};

// The function to do the job
function sumSalaries(department) {
  if (Array.isArray(department)) { // case (1)
    return department.reduce((prev, current) => prev + current.salary, 0); // sum the array
  } else { // case (2)
    let sum = 0;
    for (let subdep of Object.values(department)) {
      sum += sumSalaries(subdep); // recursively call for subdepartments, sum the results
    }
    return sum;
  }
}

alert(sumSalaries(company)); // 7700

代码很短,很容易理解(希望如此?)。这就是递归的力量。它也适用于任何级别的子部门嵌套。

以下是调用图

我们可以轻松地看到原理:对于对象 {...} 进行子调用,而数组 [...] 是递归树的“叶子”,它们给出立即结果。

请注意,代码使用了我们之前介绍过的智能功能

  • 方法 arr.reduce 在章节 数组方法 中进行了说明,用于获取数组的总和。
  • 循环 for(val of Object.values(obj)) 用于遍历对象值:Object.values 返回一个数组。

递归结构

递归(递归定义)数据结构是一种在部分中复制自身结构。

我们刚刚在上面的公司结构示例中看到了它。

一家公司的部门

  • 一个人员数组。
  • 或者一个具有部门的对象。

对于 Web 开发人员来说,还有更著名的示例:HTML 和 XML 文档。

在 HTML 文档中,HTML 标签可能包含以下列表

  • 文本片段。
  • HTML 注释。
  • 其他HTML 标签(反过来可能包含文本片段/注释或其他标签等)。

这又一次是一个递归定义。

为了更好地理解,我们将介绍另一个名为“链表”的递归结构,在某些情况下它可能是数组更好的替代方案。

链表

想象一下,我们要存储一个有序的对象列表。

自然的选择将是一个数组

let arr = [obj1, obj2, obj3];

…但数组存在问题。“删除元素”和“插入元素”操作很昂贵。例如,arr.unshift(obj) 操作必须对所有元素重新编号以容纳新的 obj,如果数组很大,则需要时间。arr.shift() 也是如此。

不需要大量重新编号的唯一结构修改是对数组末尾进行的操作:arr.push/pop。因此,当我们必须处理开头时,对于大型队列,数组可能会非常慢。

或者,如果我们确实需要快速插入/删除,我们可以选择另一种称为 链表 的数据结构。

链表元素递归定义为具有以下内容的对象

  • .
  • next 属性引用下一个链表元素null(如果这是结尾)。

例如

let list = {
  value: 1,
  next: {
    value: 2,
    next: {
      value: 3,
      next: {
        value: 4,
        next: null
      }
    }
  }
};

列表的图形表示

用于创建的备用代码

let list = { value: 1 };
list.next = { value: 2 };
list.next.next = { value: 3 };
list.next.next.next = { value: 4 };
list.next.next.next.next = null;

在这里,我们可以更清楚地看到有多个对象,每个对象都有指向邻居的 valuenextlist 变量是链中的第一个对象,因此从它跟踪 next 指针,我们可以到达任何元素。

列表可以轻松地分成多个部分,然后重新连接

let secondList = list.next.next;
list.next.next = null;

加入

list.next.next = secondList;

当然,我们可以在任何位置插入或移除项目。

例如,要添加一个新值,我们需要更新列表的头部

let list = { value: 1 };
list.next = { value: 2 };
list.next.next = { value: 3 };
list.next.next.next = { value: 4 };

// prepend the new value to the list
list = { value: "new item", next: list };

要从中间移除一个值,更改前一个值的next

list.next = list.next.next;

我们让list.next跳过1到值2。值1现在从链中排除。如果它没有存储在其他任何地方,它将自动从内存中移除。

与数组不同,没有大规模重新编号,我们可以轻松地重新排列元素。

当然,列表并不总是比数组好。否则,每个人都只会使用列表。

主要缺点是,我们无法通过其编号轻松访问元素。在数组中,这很容易:arr[n]是一个直接引用。但在列表中,我们需要从第一个项目开始,并nextN次才能获得第 N 个元素。

…但我们并不总是需要这样的操作。例如,当我们需要一个队列甚至一个双端队列时——有序结构必须允许从两端非常快速地添加/移除元素,但不需要访问其中间。

列表可以增强

  • 除了next之外,我们还可以添加属性prev来引用前一个元素,以便轻松返回。
  • 我们还可以添加一个名为tail的变量,引用列表的最后一个元素(并在从末尾添加/移除元素时更新它)。
  • …数据结构可能会根据我们的需要而有所不同。

总结

术语

  • 递归是一个编程术语,表示从自身调用函数。递归函数可以用来以优雅的方式解决任务。

    当一个函数调用自身时,称为递归步骤。递归的基础是函数参数,这些参数使任务变得非常简单,以至于函数不会进行进一步的调用。

  • 一个递归定义的数据结构是一个可以使用自身定义的数据结构。

    例如,链表可以定义为一个包含引用列表(或 null)的对象的数据结构。

    list = { value, next -> list }

    像 HTML 元素树或本章中的部门树这样的树也是自然递归的:它们有分支,每个分支都可以有其他分支。

    递归函数可以用来遍历它们,正如我们在sumSalary示例中看到的那样。

任何递归函数都可以重写为迭代函数。有时需要这样做来优化内容。但对于许多任务来说,递归解决方案足够快,而且更容易编写和支持。

任务

重要性:5

编写一个函数 sumTo(n) 来计算数字 1 + 2 + ... + n 的和。

例如

sumTo(1) = 1
sumTo(2) = 2 + 1 = 3
sumTo(3) = 3 + 2 + 1 = 6
sumTo(4) = 4 + 3 + 2 + 1 = 10
...
sumTo(100) = 100 + 99 + ... + 2 + 1 = 5050

制作 3 种解决方案变体

  1. 使用 for 循环。
  2. 使用递归,因为 sumTo(n) = n + sumTo(n-1) 对于 n > 1
  3. 使用 算术级数 公式。

结果示例

function sumTo(n) { /*... your code ... */ }

alert( sumTo(100) ); // 5050

P.S. 哪种解决方案变体最快?哪种最慢?为什么?

P.P.S. 我们能使用递归来计算 sumTo(100000) 吗?

使用循环的解决方案

function sumTo(n) {
  let sum = 0;
  for (let i = 1; i <= n; i++) {
    sum += i;
  }
  return sum;
}

alert( sumTo(100) );

使用递归的解决方案

function sumTo(n) {
  if (n == 1) return 1;
  return n + sumTo(n - 1);
}

alert( sumTo(100) );

使用公式的解决方案:sumTo(n) = n*(n+1)/2

function sumTo(n) {
  return n * (n + 1) / 2;
}

alert( sumTo(100) );

P.S. 自然地,公式是最快的解决方案。它只对任何数字 n 使用 3 个运算。数学有帮助!

循环变体在速度方面排第二。在递归和循环变体中,我们对相同的数字求和。但递归涉及嵌套调用和执行堆栈管理。这也需要资源,所以它更慢。

P.P.S. 一些引擎支持“尾调用”优化:如果递归调用是函数中的最后一个调用,没有执行其他计算,那么外部函数将不需要恢复执行,因此引擎不需要记住其执行上下文。这消除了对内存的负担。但如果 JavaScript 引擎不支持尾调用优化(大多数不支持),将出现错误:超过最大堆栈大小,因为通常对总堆栈大小有限制。

重要性:4

自然数的 阶乘 是一个数字乘以 “数字减一”,然后乘以 “数字减二”,依此类推,直到 1n 的阶乘表示为 n!

我们可以这样编写阶乘的定义

n! = n * (n - 1) * (n - 2) * ...*1

不同 n 的阶乘值

1! = 1
2! = 2 * 1 = 2
3! = 3 * 2 * 1 = 6
4! = 4 * 3 * 2 * 1 = 24
5! = 5 * 4 * 3 * 2 * 1 = 120

任务是编写一个函数 factorial(n),使用递归调用来计算 n!

alert( factorial(5) ); // 120

P.S. 提示:n! 可以写成 n * (n-1)! 例如:3! = 3*2! = 3*2*1! = 6

根据定义,阶乘 n! 可以写成 n * (n-1)!

换句话说,factorial(n) 的结果可以计算为 n 乘以 factorial(n-1) 的结果。并且对 n-1 的调用可以递归地向下递减,直到 1

function factorial(n) {
  return (n != 1) ? n * factorial(n - 1) : 1;
}

alert( factorial(5) ); // 120

递归的基础是值 1。我们也可以在这里将 0 作为基础,差别不大,但会多一个递归步骤

function factorial(n) {
  return n ? n * factorial(n - 1) : 1;
}

alert( factorial(5) ); // 120
重要性:5

斐波那契数 序列的公式为 Fn = Fn-1 + Fn-2。换句话说,下一个数字是前两个数字的和。

前两个数字是 1,然后是 2(1+1),然后是 3(1+2)5(2+3),依此类推:1, 1, 2, 3, 5, 8, 13, 21...

斐波那契数与 黄金分割 和我们周围的许多自然现象有关。

编写一个函数 fib(n),返回第 n 个斐波那契数。

工作示例

function fib(n) { /* your code */ }

alert(fib(3)); // 2
alert(fib(7)); // 13
alert(fib(77)); // 5527939700884757

P.S. 该函数应该很快。调用 fib(77) 应该不超过几分之一秒。

我们在这里可以尝试的第一个解决方案是递归。

斐波那契数根据定义是递归的

function fib(n) {
  return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}

alert( fib(3) ); // 2
alert( fib(7) ); // 13
// fib(77); // will be extremely slow!

…但对于 n 的大值,它非常慢。例如,fib(77) 可能会挂起引擎一段时间,耗尽所有 CPU 资源。

这是因为该函数进行了太多子调用。相同的值一次又一次地重新计算。

例如,让我们看看 fib(5) 的一部分计算

...
fib(5) = fib(4) + fib(3)
fib(4) = fib(3) + fib(2)
...

在这里,我们可以看到 fib(3) 的值对于 fib(5)fib(4) 都是必需的。因此,fib(3) 将被调用并完全独立地计算两次。

以下是完整的递归树

我们可以清楚地注意到 fib(3) 被计算了两次,而 fib(2) 被计算了三次。计算的总量增长速度远快于 n,即使对于 n=77 也是巨大的。

我们可以通过记住已经计算过的值来优化它:如果说 fib(3) 的值计算了一次,那么我们可以在将来的计算中重复使用它。

另一种变体是放弃递归并使用完全不同的基于循环的算法。

我们不必从 n 转到较低的值,而是可以创建一个从 12 开始的循环,然后将 fib(3) 作为它们的和,然后将 fib(4) 作为前两个值的和,然后是 fib(5),依此类推,直到达到所需的值。在每一步中,我们只需要记住前两个值。

以下是新算法的详细步骤。

开始

// a = fib(1), b = fib(2), these values are by definition 1
let a = 1, b = 1;

// get c = fib(3) as their sum
let c = a + b;

/* we now have fib(1), fib(2), fib(3)
a  b  c
1, 1, 2
*/

现在,我们希望获得 fib(4) = fib(2) + fib(3)

让我们移动变量:a,b 将获得 fib(2),fib(3),而 c 将获得它们的和

a = b; // now a = fib(2)
b = c; // now b = fib(3)
c = a + b; // c = fib(4)

/* now we have the sequence:
   a  b  c
1, 1, 2, 3
*/

下一步给出另一个序列号

a = b; // now a = fib(3)
b = c; // now b = fib(4)
c = a + b; // c = fib(5)

/* now the sequence is (one more number):
      a  b  c
1, 1, 2, 3, 5
*/

…依此类推,直到我们得到所需的值。这比递归快得多,并且不涉及重复计算。

完整代码

function fib(n) {
  let a = 1;
  let b = 1;
  for (let i = 3; i <= n; i++) {
    let c = a + b;
    a = b;
    b = c;
  }
  return b;
}

alert( fib(3) ); // 2
alert( fib(7) ); // 13
alert( fib(77) ); // 5527939700884757

循环从 i=3 开始,因为第一个和第二个序列值被硬编码到变量 a=1b=1 中。

这种方法称为 自底向上的动态规划

重要性:5

假设我们有一个单链表(如章节 递归和栈 中所述)

let list = {
  value: 1,
  next: {
    value: 2,
    next: {
      value: 3,
      next: {
        value: 4,
        next: null
      }
    }
  }
};

编写一个函数 printList(list),该函数逐个输出列表项。

使用循环和递归分别编写两个解决方案变体。

哪种更好:使用递归还是不使用递归?

基于循环的解决方案

基于循环的解决方案变体

let list = {
  value: 1,
  next: {
    value: 2,
    next: {
      value: 3,
      next: {
        value: 4,
        next: null
      }
    }
  }
};

function printList(list) {
  let tmp = list;

  while (tmp) {
    alert(tmp.value);
    tmp = tmp.next;
  }

}

printList(list);

请注意,我们使用临时变量 tmp 来遍历列表。从技术上讲,我们可以使用函数参数 list 代替

function printList(list) {

  while(list) {
    alert(list.value);
    list = list.next;
  }

}

…但那是不明智的。将来我们可能需要扩展函数,对列表执行其他操作。如果我们更改 list,那么我们将失去这种能力。

谈到好的变量名,这里的 list 是列表本身。它的第一个元素。它应该保持这种状态。这很清晰且可靠。

从另一方面来说,tmp 的作用仅限于列表遍历,就像 for 循环中的 i 一样。

递归解决方案

printList(list) 的递归变体遵循一个简单的逻辑:要输出一个列表,我们应该输出当前元素 list,然后对 list.next 执行相同的操作

let list = {
  value: 1,
  next: {
    value: 2,
    next: {
      value: 3,
      next: {
        value: 4,
        next: null
      }
    }
  }
};

function printList(list) {

  alert(list.value); // output the current item

  if (list.next) {
    printList(list.next); // do the same for the rest of the list
  }

}

printList(list);

现在哪种更好?

从技术上讲,循环更有效。这两个变体执行相同操作,但循环不会为嵌套函数调用花费资源。

从另一方面来说,递归变体更短,有时更容易理解。

重要性:5

按相反的顺序输出前一个任务 输出单链表 中的单链表。

使用循环和递归分别编写两个解决方案。

使用递归

这里的递归逻辑有点棘手。

我们需要先输出列表的其余部分,然后 输出当前部分

let list = {
  value: 1,
  next: {
    value: 2,
    next: {
      value: 3,
      next: {
        value: 4,
        next: null
      }
    }
  }
};

function printReverseList(list) {

  if (list.next) {
    printReverseList(list.next);
  }

  alert(list.value);
}

printReverseList(list);

使用循环

循环变体也比直接输出复杂一点。

无法在我们的 list 中获取最后一个值。我们也不能“返回”。

所以我们可以先按照直接顺序遍历项目,并将它们记在一个数组中,然后按相反的顺序输出我们记住的内容

let list = {
  value: 1,
  next: {
    value: 2,
    next: {
      value: 3,
      next: {
        value: 4,
        next: null
      }
    }
  }
};

function printReverseList(list) {
  let arr = [];
  let tmp = list;

  while (tmp) {
    arr.push(tmp.value);
    tmp = tmp.next;
  }

  for (let i = arr.length - 1; i >= 0; i--) {
    alert( arr[i] );
  }
}

printReverseList(list);

请注意,递归解决方案实际上执行完全相同的操作:它遵循列表,记住嵌套调用链(在执行上下文堆栈中)中的项目,然后输出它们。

教程地图

评论

在评论之前阅读此内容...
  • 如果您有改进建议,请提交 GitHub 问题或提交拉取请求,而不是发表评论。
  • 如果您无法理解文章中的某些内容,请详细说明。
  • 要插入几行代码,请使用 <code> 标记,要插入多行代码,请将它们包装在 <pre> 标记中,要插入 10 行以上的代码,请使用沙盒(plnkrjsbincodepen...)