likes
comments
collection
share

成为高级工程师的必经之路——你必须掌握JS递归的那些事儿

作者站长头像
站长
· 阅读数 4

前言

在写这篇博客之前,我其实已经写过一些关于JS递归方面的知识点了。但是我并不是为了赚这么点儿掘力值而水文,所谓温故而知新,可以为师矣,因为我对递归的认识又有了新的理解,所以撰文与大家分享最近的学习心得。

递归的介绍

什么是递归(Recursion)?递归就是函数自己调用自己,递归有两个必须的要求,其中一个要点是大的问题能够划分成子问题解决(即把问题的规模缩小),另外一个要点是,得是问题必须有终止条件

如果缺少了上面的任何一个条件,都会造成最大堆栈调用的错误。

递归的使用场景非常广泛,在深度优先遍历,回溯,分治的算法设计时,基本上都是可以使用递归解决的(所以我们经常时不时就能听到回溯递归,分治递归这样的词)。

递归有个明显的优势就是思路简单,我们只需要把递推关系找到,就能解题。比如在做一些动态规划(递归的逆过程)算法题的时候,我属于比较笨的那种类型的人,把状态转移方程(不知道的同学就把它理解成递推式就可以了)找到了有些时候也不太会写动态规划,哈哈,那就结合把某次递归的结果记下来的优化方式,用递归也能解题。

但是递归有个问题就是内存占用大,如果你是资深Leetcode选手,那你一定知道的事儿就是:如果某题不是必须只能用递归才能解决的问题的话,那么设计的算法运行效率内存的开销远低于该题所有提交的平均水平。

比如,利用递归求斐波纳契数列,斐波那契数列直接就是给出的是一个递推关系,所以用递归来写的话,简直就是手到擒来。f(n) = f(n-1)+ f(n-2),其中f(1)=1,f(2)=1

function fibonacci(n) {
    if(n <= 2) {
        return 1;
    } else {
        return fibonacci(n-1) + fibonacci(n-2)
    }
}

上面的这个函数执行起来是非常慢的,假设我们要求f(5),首先要求得f(5),就必须求得f(4)f(3),因为递归调用,所以JS引擎进入到f(4),要求f(4),必须要求f(3)f(2),注意,之前还有个f(3)还没有求得呢,此刻又开始求f(3)了,明显看到这儿产生了重复计算。好吧,那先不管,先求这个f(3)吧,求这个f(3)又需要求f(2)f(1),好了,f(2)f(1)可以直接知道,所以,f(3)的值跟f(2)(求f(4)产生的f(2),不是求f(3)的那个f(2))已经知道了,那么f(4)就可以求得了,好了,此刻又开始求最开始我们提到的f(3),这个过程就不给大家解释了。经历了这么艰辛的过程,才求得了f(5)

这是我之前写关于算法的章节,画的一个求f(4)的执行栈示意图, 执行过程大致如下: 成为高级工程师的必经之路——你必须掌握JS递归的那些事儿 成为高级工程师的必经之路——你必须掌握JS递归的那些事儿 成为高级工程师的必经之路——你必须掌握JS递归的那些事儿 成为高级工程师的必经之路——你必须掌握JS递归的那些事儿

JS的事件循环与递归

为什么明明在讲递归,却切换到了给大家聊事件循环呢,这就是实际开发的场景了。

你有没有思考过,为什么有些团队在使用定时器的时候不允许使用setInterval而必须要使用setTimeout+递归调用的方式,有些时候是没有任何退出的条件的递归,那为什么浏览器没有卡死呢?这不看起来就跟我们在文章开头提到的递归的两个关键点矛盾吗?不,不矛盾,其实不是一回事儿。

比如这段代码:

function test(i) {
  requestAnimationFrame(() => {
    console.log(i++);
    test(i);
  });
}

JS的调用栈,其实你可以把它理解为它仅仅是关联在一个执行上下文中的(这是我自己的理解,非官方阐述),如果你一直往这个栈里面增加内容,在有限的栈长度下,最终肯定会爆栈。

之前,在抖音短视频里面看到过渡一讲师袁进老师说现在Chrome浏览器的任务队列已经有好几种优先级的了,但是我没有研究过,并且用大家不熟悉的方式阐述不便于大家的理解,因此,下文仍然以宏任务队列和微任务队列的提法来阐述。

基于requestAnimationFrame(setTimeoutsetImmediatePromiseasync函数)的递归调用,其实它的执行流程是这样的,JS其实是有一个任务队列(其实有很多个任务队列,如微任务队列,定时任务队列等,我们在讨论这个地方时候可以不用关心那么多队列,所以就只讨论一个任务队列就好),首先如果这个任务队列中没有任务,那么JS主线程就休眠,当这任务队列里面有内容的时候,JS主线程就取消休眠,并且从任务队列中取出一个任务来处理(这就是所谓的同步任务),但是,一个同步任务中,又有可能加入新的任务到任务队列中去,之前的requestAnimationFrame函数内递归调用了test函数,就产生了一个新的任务追加到了任务队列里面去,所以,这个递归操作其实导致的结果,是使得JS的主线程每时每刻都有活儿干,而并不会产生最大堆栈调用的错误

这个特性是JS语言本身的特性,开发者需要尤为注意。

以下是test函数的执行流程:

成为高级工程师的必经之路——你必须掌握JS递归的那些事儿
成为高级工程师的必经之路——你必须掌握JS递归的那些事儿
成为高级工程师的必经之路——你必须掌握JS递归的那些事儿
成为高级工程师的必经之路——你必须掌握JS递归的那些事儿
成为高级工程师的必经之路——你必须掌握JS递归的那些事儿

比如下面这段代码,它就是一直在微任务队列里面增加内容,如果我们把数组的内容设置的特别长的话,虽然不会爆栈,但是这会导致浏览器看起来卡死了。

console.log('sync script execution');

const list = Array.from({ length: 100000000 }).map(_ => ({}));
async function fn() {
  const node = list.shift();
  console.log(node);
  while (list.length) {
    await fn();
  }
}

fn();

setTimeout(() => {
  console.log('set timeout execution')
}, 0)

控制台首先打印一下sync script execution,然后就一直开始输出空对象内容,此刻如果我们点击页面元素的话,页面没有任何反应,开发者工具也关闭不了,这是因为我们一直不停的在往浏览器微任务队列里面插入内容,当微任务队列的内容不空的话,就无法开启下一轮事件循环,而我们的操作相当于就是把它放到了宏任务队列里面去等待执行,所以在将来的某一刻,浏览器会响应我们的操作。最后,当微任务队列处理完成之后,浏览器打印set timeout execution,我们的浏览器就卡过了。

所以,凡是跟异步打交道的递归调用,都不能严格意义上的称为递归,它仅仅是看起来像是递归罢了。

递归的优化

关于递归的优化,是本文重点向大家阐述的内容,因为我也是看了一些书籍资料后有了新的理解,这也是我为什么要发这篇文章的原因。

缓存

在计算斐波纳契数列的时候,就已经跟大家提过了,之前的实现过程中,我们进行了大量的重复计算,因此,可以把已经计算过的结果保存下来,然后用到的时候直接返回,避免多次计算。

我们针对前文提到过的斐波那契数列的例子进行改写。

function fibonacci(n, map = new Map()) {
    // 发现已经计算过了,则不再重复计算
    if(map.has(n)) {
        return map.get(n);
    }
    if(n <= 2) {
        return 1;
    } else {
        const subVal1 = fibonacci(n-1, map);
        const subVal2 = fibonacci(n-2, map)
        const sum = subVal1 + subVal2
        // 缓存结果,供后续使用
        map.set(n, sum);
        return sum;
    }
}

有的同学会说,为什么上面的写法,不把subVal1也用Map记录下来呢?没必要,因为在执行fibonacci(n-1, map)这行代码的时候,如果说事先已经缓存了结果的话,其实再执行一次它就相当于是一次很高效的操作,如果没有的话,那还不是一样需要执行,所以只需要在最后缓存一下计算结果即可。

尾递归优化

关于尾递归的优化,也是面试中常考的一个问题,这也就是我撰写这篇博客的原因。

尾递归的概念

尾递归调用是函数式编程的一个重要概念,本身非常简单,一句话就能说清楚,就是指某个函数的最后一步是调用另一个函数。

以下三种方式都不是尾递归调用:

// 情况一
function f(x){
  let y = g(x);
  return y;
}

// 情况二
function f(x){
  return g(x) + 1;
}

// 情况三
function f(x){
  g(x);
}

我个人的理解就是,说白了,就是return语句后面跟上一个光秃秃的函数调用,相当于不依赖当前作用域下面的某些参数,其函数的入参可以直接被记录下来,在合适的时候能够当一次普通的调用 那个return语句后面的函数的 那种感觉。

这个思想很重要,之后的一个章节的优化就需要围绕这个理解展开。

以下内容是完全摘录自阮一峰老师的ES6网络博客:

函数调用会在内存形成一个“调用记录”,又称“调用帧”(call frame),保存调用位置和内部变量等信息。如果在函数A的内部调用函数B,那么在A的调用帧上方,还会形成一个B的调用帧。等到B运行结束,将结果返回到AB的调用帧才会消失。如果函数B内部还调用函数C,那就还有一个C的调用帧,以此类推。所有的调用帧,就形成一个“调用栈”(call stack)。

尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用帧,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用帧,取代外层函数的调用帧就可以了。

但是,我个人觉得还算是说的比较玄幻。然后我就和一位曾经就职过腾讯的同事交流了一下,他扎实的技术储备使我非常钦佩,我觉得他的理解方式要简单很多。

成为高级工程师的必经之路——你必须掌握JS递归的那些事儿

所以,从我同事的解释来说,就像我上文说的一样,尾递归就像我们把这个函数调用的所有参数记录下来,然后在合适的时机把这个函数当普通的函数调用一下。如果说不是尾递归,那么需要依赖之前的作用域下面的变量,那引擎就无法做优化了。

但是,问题就是,我觉得非常奇怪的事儿,我根据大家提到的尾递归优化的写法,编写了代码来执行,但是却没有出现我预期的尾递归优化的效果。

首先,我们把之前提到过的求斐波拉契数列的函数写成尾递归的形式。

function fibonacci2 (n , ac1 = 1 , ac2 = 1) {
  if( n <= 1 ) {
      return ac2
  };
  return fibonacci2(n - 1, ac2, ac1 + ac2);
}

关于非尾递归改写成尾递归,我个人的经验就是在原函数的参数上做文章,使得能够把上次的计算结果能够传递给下一次计算,如果一个函数里面有多个递归调用,则无法改写成尾递归,比如快速排序,递归遍历二叉树

我首先用Node 16执行一个很大的数据,但是并没有看到堆栈的优化:

成为高级工程师的必经之路——你必须掌握JS递归的那些事儿

我把这个代码拿到浏览器立马再试一下:

成为高级工程师的必经之路——你必须掌握JS递归的那些事儿 还是不行(我所用的浏览器是Edge浏览器),最后再用谷歌浏览器试一下,也是一样的结果。

成为高级工程师的必经之路——你必须掌握JS递归的那些事儿

刚才,我们提到了,因为尾递归不用考虑对外界的变量的依赖,那假设我们把这些变量存起来,然后把这些变量当一个循环来执行函数不就行了吗?

就算引擎不支持优化,那我们也可以采用编程的手段来进行控制,下一节主要就阐述使用编程的手段进行尾递归优化。

蹦床函数

蹦床函数的实现思路是这样的,当递归进行的时候,业务函数的返回值是自身的调用,那就可以接着循环,如果某个时刻的返回值是非函数类型时,就说明递归已经进行完成。

以下就是本文要提到的(下面这个蹦床函数摘录自阮一峰老师的ES6网络博客)

function trampoline(f) {
  while (f && f instanceof Function) {
    f = f();
  }
  return f;
}

我们把之前写的尾递归计算斐波那契数列的函数改写成蹦床函数优化的形式:

function trampoline(f) {
  while (f && f instanceof Function) {
    f = f();
  }
  return f;
}

function fibonacci2 (n , ac1 = 1 , ac2 = 1) {
  if( n <= 1 ) {
      return ac2
  };
  // 本来是返回值,改写成返回一个函数,这样蹦床函数能知道递归还没有结束
  return () => fibonacci2(n - 1, ac2, ac1 + ac2);
}

const fibVal = trampoline(fibonacci2.bind(null, 1000))
console.log(fibVal);

可以看到,上面的这个蹦床函数用起来还是比较难受的,如果说把它写成像lodashdebounce这样的高阶函数那样的话,用起来就比较友好了,所以,我们对其进行重构。

type Func<T, V> = (...args: T[]) => V | Func<T, V>;

function trampoline<T, V>(fn: Func<T, V>) {
  return function trampolined(...args: T[]) {
    let result = fn.bind(null, ...args);
    while (typeof result === "function") {
      result = result();
    }
    return result as V;
  };
}

然后,我们需要这样使用就可以了:

function fibonacci2 (n: number, ac1 = 1 , ac2 = 1) {
  if( n <= 1 ) {
      return ac2
  };
  
  return () => fibonacci2(n - 1, ac2, ac1 + ac2);
}

const fib = trampoline(fibonacci2);
const fibVal = fib(1000);
console.log(fibVal);

经过这种方式封装之后,更贴近我们的实际开发。

真正的尾递归优化

蹦床函数是一种优化方式,并且蹦床函数的优化需要我们改写本来的业务逻辑(原本的逻辑是返回结果就是递归体,需要改写成返回函数,在这个函数中执行递归体),此外阮一峰老师的网络博客里面还给出了一个真正的尾递归优化方式,这种方式我们就不需要调整任何业务逻辑代码了。

function tco(f) {
  var value;
  var active = false;
  var accumulated = [];

  return function accumulator() {
    accumulated.push(arguments);
    if (!active) {
      active = true;
      while (accumulated.length) {
        value = f.apply(this, accumulated.shift());
      }
      active = false;
      return value;
    }
  };
}

这个函数的理论基础就是我在前文提到的我对尾递归的理解,因为不需要依赖上次调用的变量,我们只需要把参数记录下来,当做一次一次的循环调用。

不过这个函数的思路出奇的惊奇,我根据自己的理解,解释一下它的执行流程。

定义一个标记,主要是用来控制执行,因为一旦开始执行,函数其实是像上文提到的是用前面的结果替换后面的结果。

所以,accumulator函数进来,先把第一次的函数参数添加到参数数组记录里面去,此刻,标记还是false,可以进入到循环流程,然后它执行到了if里面的逻辑就立刻把标记设置成了true,好了,这样就相当于过河拆桥了。

现在,它先执行本次调用的结果,所以从参数列表里面取出一个结果开始执行,并且将返回结果设置给value。

可是问题就是,本次执行是一个递归调用,之前的第一次执行,已经把桥拆了,递归调用accumulator就只能把参数都添加到参数数组记录里面去了,这就导致了参数数组的length又不0了,这样while循环就能执行到递归调用结束了,并且,每次执行都把上一次的执行结果给替换掉。

当递归执行完成的时候,把标记初始化,就好比把桥重新搭好,下次调用的时候,又可以重复这样的过程。

使用tco函数来执行一下我们之前尾递归调用形式的斐波拉契数列:

function tco(f) {
  var value;
  var active = false;
  var accumulated = [];

  return function accumulator() {
    accumulated.push(arguments);
    if (!active) {
      active = true;
      while (accumulated.length) {
        value = f.apply(this, accumulated.shift());
      }
      active = false;
      return value;
    }
  };
}

function fibonacci2 (n , ac1 = 1 , ac2 = 1) {
  if( n <= 1 ) {
      return ac2
  };
  return fibonacci2(n - 1, ac2, ac1 + ac2);
}

const fib = tco(fibonacci2);
const fibVal = fib(1000)

递归转动态规划

递归转动态规划这一章节属于帮助对算法还不太了解的同学拓展知识面的一个小节。

递归解决问题的过程中,我们可以看到,最终始终是要回归到最小问题解的。

那既然需要一步一步的拆分问题,然后求得这些子问题的解之后,最终还需要把这些子问题的解合并成最终的答案。那为什么我们不一开始就直接从子问题开始求解,然后直接将子问题合并成最终问题的解呢?

所以直接从子问题推导出最终答案的解题过程,这种解决问题的算法就叫做动态规划(Dynamic Programming),我们一般简称DP。

动态规划在计算过程中,从子问题的解推导出总问题的逻辑关系,我们称之为状态转移方程式,但是问题就是在于这个状态转移方程式非常难以推导,所以很多的程序员都觉得它难(我也不例外)。

以下就向大家展示使用动态规划的方式求斐波那契数列的方式:

function fibonacci3(n) {
    let ac1 = 1;
    let ac2 = 1;
    // 初始化为1,因为,第一项和第二项都是1,在循环一次都不执行的时候,保证返回值总是正确的
    let sum = 1;
    for(let i = 3; i<=n; i++) {
        // 已经求得了本次的结果
        sum = ac1 + ac2;
        // 因为斐波那契数列只需要最近的两个值,所以,远一点儿的那个值就可以丢弃了
        // 将ac2赋值给ac1,这个操作就像是丢弃掉较远的那个值
        ac1 = ac2;
        // 将sum赋值给ac2,使得最近的两个数值都把持我们预期的效果,以便于可以求得下一次的结果
        ac2 = sum;
    }
    return sum;
}

不过,需要注意的一点是,不是所有的问题都可以使用动态规划。动态规划常用于求解具有以下特征的问题:

  1. 最优子结构性质(Optimal Substructure) :问题可以分解为若干个相互独立且相似的子问题,每个子问题都有一个最优解。这意味着问题的整体最优解可以通过合并子问题的最优解来获得。
  2. 重叠子问题(Overlapping Subproblems) :在解决问题的过程中,同一个子问题可能会被多次重复求解。DP算法通过记忆化存储已经解决过的子问题的解来避免重复计算,从而提高效率。
  3. 状态转移方程(State Transition Equation) :问题的最优解可以通过子问题的最优解以某种方式组合得到。这种组合关系通常通过状态转移方程来表示,描述了问题的各个状态之间如何相互关联。

比如遍历一个二叉树就不具备上述的特征,那肯定就不可能用动态规划来解决问题了。

总结

本文旨在向大家阐述尾递归优化,并且给出了两个尾递归优化的解决方案。

递归是实际开发中出现频率超高的场景,但是如果你能够明确知道这个场景可以有非递归的实现,那从一个程序员的职业素养来说,就不要使用递归。不是所有的递归都能够写成尾递归

尤其是我跟后端同事在聊天的时候,他们只要谈到递归第一反应就是数据的量级考量,小公司可能用户量少还好,但大公司就不一样了,一旦数据量超载,那结果将会是灾难性的(“又不是不能用”,抱歉,这次是真的不能用了,哈哈哈,小心p0级别的事故砸到你头上)。对于我们前端来说基本不会有这样海量数据的业务场景,所以实际的使用心智负担倒也不是那么重。

本文也根据我在实际开发中遇到的问题,向大家阐述了异步编程下的递归与普通递归的区别,请各位读者加以体会。

最后,以递归的契机向大家解释了动态规划的基本处理原理,有技术追求的同学可以自行钻研一下,这部分的知识点能够提高你的编程认知。

对于本文阐述的内容有任何疑问的同学可以在评论区留言或私信我。

如果大家喜欢我的文章,可以多多点赞收藏加关注,你们的认可是我最好的更新动力,😁。

转载自:https://juejin.cn/post/7331070709115404298
评论
请登录