• 从JS角度直观理解递归的本质


    让我们写一个函数 pow(x, n),它可以计算 xn 次方。换句话说就是,x 乘以自身 n 次。

    有两种实现方式。

    1. 迭代思路:使用 for 循环

      function pow(x, n) {
        let result = 1;
      
        // 在循环中,用 x 乘以 result n 次
        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. 否则,我们可以用 x * pow(x, n - 1) 表示 pow(x, n)。在数学里,可能会写为 xn = x * xn-1。这叫做 一个递归步骤:我们将任务转化为更简单的行为(x 的乘法)和更简单的同类任务的调用(带有更小的 npow 运算)。接下来的步骤将其进一步简化,直到 n 达到 1

    这是一个很简单的例子,我们从直觉上很容易理解递归,但是有很多人会有一种不可名状的困惑,这个问题可以总结为“为什么会这样?”

    现在我们来研究一下递归调用是如何工作的。为此,我们会先看看函数底层的工作原理。

    有关正在运行的函数的执行过程的相关信息被存储在其 执行上下文 中。

    执行上下文 是一个内部数据结构,它包含有关函数执行时的详细细节:当前控制流所在的位置,当前的变量,this的值(此处我们不使用它),以及其它的一些内部细节。

    一个函数调用仅具有一个与其相关联的执行上下文。

    当一个函数进行嵌套调用时,将发生以下的事儿:

    • 当前函数被暂停;
    • 与它关联的执行上下文被一个叫做 执行上下文堆栈 的特殊数据结构保存;
    • 执行嵌套调用;
    • 嵌套调用结束后,从堆栈中恢复之前的执行上下文,并从停止的位置恢复外部函数。

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

    pow(2, 3)

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

    我们将其描绘如下:

    • Context: { 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) );
    

    变量相同,但是行改变了,因此现在的上下文是:

    • Context: { x: 2, n: 3, at line 5 } pow(2, 3)

    为了计算 x * pow(x, n - 1),我们需要使用带有新参数的新的 pow 子调用 pow(2, 2)

    pow(2, 2)

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

    这里我们调用相同的函数 pow,但这绝对没问题。所有函数的处理都是一样的:

    1. 当前上下文被“记录”在堆栈的顶部。
    2. 为子调用创建新的上下文。
    3. 当子调用结束后 —— 前一个上下文被从堆栈中弹出,并继续执行。

    下面是进入子调用 pow(2, 2) 时的上下文堆栈:

    • Context: { x: 2, n: 2, at line 1 } pow(2, 2)
    • Context: { x: 2, n: 3, at line 5 } pow(2, 3)

    新的当前执行上下文位于顶部(粗体显示),之前记住的上下文位于下方。

    当我们完成子调用后 —— 很容易恢复上一个上下文,因为它既保留了变量,也保留了当时所在代码的确切位置。

    请注意:

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

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

    pow(2, 1)

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

    新的执行上下文被创建,前一个被压入堆栈顶部:

    • Context: { x: 2, n: 1, at line 1 } pow(2, 1)
    • Context: { x: 2, n: 2, at line 5 } pow(2, 2)
    • Context: { 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

    函数完成后,就不再需要其执行上下文了,因此它被从内存中移除。前一个上下文恢复到堆栈的顶部:

    • Context: { x: 2, n: 2, at line 5 } pow(2, 2)
    • Context: { x: 2, n: 3, at line 5 } pow(2, 3)

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

    然后,前一个上下文被恢复:

    • Context: { 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 层递归调用来求每一个子部门的薪资,然后将它们合并起来。

    第一种情况是由人员数组构成的部门,这种情况很简单,是最基础的递归。

    第二种情况是我们得到的是对象。那么可将这个复杂的任务拆分成适用于更小部门的子任务。它们可能会被继续拆分,但很快或者不久就会拆分到第一种情况那样。

    这个算法从代码来看可能会更简单:

    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}]
      }
    };
    
    // 用来完成任务的函数
    function sumSalaries(department) {
      if (Array.isArray(department)) { // 情况(1)
        return department.reduce((prev, current) => prev + current.salary, 0); // 求数组的和
      } else { // 情况(2)
        let sum = 0;
        for (let subdep of Object.values(department)) {
          sum += sumSalaries(subdep); // 递归调用所有子部门,对结果求和
        }
        return sum;
      }
    }
    
    alert(sumSalaries(company)); // 7700
    

    代码很短也容易理解(希望是这样?)。这就是递归的能力。它适用于任何层次的子部门嵌套。

    下面是调用图:

    请添加图片描述

    我们可以很容易地看到其原理:对于对象 {...} 会生成子调用,而数组 [...] 是递归树的“叶子”,它们会立即给出结果。

  • 相关阅读:
    Java包装类
    Sentinel-限流降级
    Java反射机制(一)
    Springboot结合Mockito写单元测试实践和原理
    【AICFD案例操作】潜艇阻力AI预测分析
    网络工程师入门必懂华为认证体系,附系统学习路线分享
    扩散模型在图像生成中的应用:从真实样例到逼真图像的奇妙转变
    http1和http2的主要区别
    Java学习笔记(十七)
    RHCE第四天作业
  • 原文地址:https://blog.csdn.net/2301_77186032/article/details/139377163