JavaScript算法之递归

今天来了解下既爱又恨的 -- 递归javascript

什么是“递归”

给你讲一个故事就明白了,什么故事呢?前端

从前有座山,山里有个庙,庙里有个老和尚在给小和尚讲故事,讲的是从前有座山,山里有个庙,庙里有个老和尚在给小和尚讲故事,讲的是从前有座山。。。java

这就是一个典型的递归,在不考虑岁数等自身的条件下,这将是个死递归,没有终止条件。es6

再举一个例子。不知道你有没有看过一部号称不怕剧透的电影《盗梦空间》。 小李子团队们每次执行任务的时候,都会进入睡眠模式。若是在梦中任务还完不成的话,就再来个梦中梦,继续去执行任务。若是还不行,就再来一层梦。一样,若是须要回到现实的话,也必须得从最深的那层梦中醒来,而后到第一层梦,而后回到现实中。算法

这个过程也能够当作递归。层层梦是递,层层醒是归。

递归本质上是将原来的问题,转化为更小的同一问题 大白话就是 一个函数不断的调用本身。后端

接下来看一个递归的经典例题,就是计算 Fibonacci 数列。数组

指的是这样一个数列:一、一、二、三、五、八、1三、2一、3四、……、x;缓存

代码展现为:bash

function Fibonacci (n) {
  if ( n <= 2 ) {return 1};

  return Fibonacci(n - 1) + Fibonacci(n - 2);
}
复制代码

递归三要素

1.一个问题的解能够分解为几个更小的同一题的解

好比说上面的数列,若是要求解第 10 位数是多少 fn(10),能够分解为求第 9 位数 fn(9) 和第 8 位数 fn(8) 是多少,相似这样分解。数据结构

2.分解后的子问题只存在数据不同的问题。

好比说上面的数列,每次分解后,所造成的子问题求解方式都同样,只是说每次数据规模变了。

3.存在递归终止条件

这个是必须存在的,把问题一层一层的分解下去,可是不能无限循环下去了。 好比说上面的数列,当 n 小于等于 2 的时候,就会中止,此时就已经知道了第一个数和第二个数是多少了,这就是终止条件。不能像老和尚给小和尚讲故事那样,永无止境。

实现递归

首先来分析一个简单的例题,用递归的方式来求解数组中每一个元素的和。

根据上面所讲的三要素,来分解下这个问题。

求解数组 arr 的和咱们能够分解成是第一个数而后加上剩余数的和,以此类推能够获得以下分解:

const arr = [1,2,3,4,5,6,7,...,n];
 sum(arr[0]+...+arr[n]) = arr[0] + sum(arr[1]+...+arr[n]);
 sum(arr[1]+...+arr[n]) = arr[1] + sum(arr[2]+...+arr[n]);
                ....
 sum(arr[n]) = arr[n] + sum([]);
复制代码

而后能够推导出一个公式:

x = 0;
sum(arr, x) = arr[x] + sum(arr,x+1); // x:表示数组的长度
复制代码

再考虑一个终止条件, 当 x 增加到和数组长度同样的时候,就该中止了,并且此时应该返回 0。 因此综上咱们能够得出此题的解:

{
    function sum(arr) {
        const total = function(arr, l) {
            if(l == arr.length) {
                return 0;
            }
            return arr[l] + total(arr, l + 1);
        }
        return total(arr, 0);
    }
    sum([1,2,3,4,5,6,9,10]);
}
复制代码

写递归代码的关键就是找到如何将原来的问题转化为更小的同一问题,而且基于此写出递推公式,而后再推敲终止条件,最后将递推公式和终止条件合成最终代码。

注意事项

1.递归容易堆栈溢出

函数调用会使用栈来保存临时变量。每调用一个函数,都会将临时变量封装为栈帧压入内存栈,等函数执行完成返回时,才出栈。而递归很是耗费内存,由于须要同时保存成千上百个调用帧,当数据规模较大的时候很容易发生“栈溢出”错误(stack overflow)。

好比说一个利用递归求阶乘的函数:

function factorial(n) {
  if (n === 1) return 1;
  return n * factorial(n - 1);
}
复制代码

那如何避免这种错误呢? 咱们能够在代码中添加一个参数,记录递归调用的次数。当大于一个数字的时候,手动设置报错信息。好比说上面的例子:

{
    let count = 0;
    function factorial(n) {
        count ++;
        if (count > 1000) {
            console.error('超过了最大调用次数');
            return;
        }
        if (n === 1) return 1;
        return n * factorial(n - 1);
      }
      factorial(2000)
}
复制代码

固然这个数字事先没法估算,只适合一些最大深度比较低的递归调用。

2.警戒递归中的重复计算

好比说上文提到的经典数列:

function Fibonacci (n) {
  if ( n <= 2 ) {return 1};
  return Fibonacci(n - 1) + Fibonacci(n - 2);
}
复制代码

代码很简介,可是却包含了大量的重复计算。

假设要求计算 f(5)

f(5) = f(4) + f(3);

因而会递归计算 f(4) 和 f(3);

接着计算 f(4)

f(4) = f(3)+ f(2);

因而会递归计算f(3)和f(2);
复制代码

能够看到,计算 f(5) 和 f(4) 中都要计算 f(3),但这两次 f(3) 会重复计算,这就是递归的最大问题,对于同一个 f(n),不能复用。

你好奇过计算一个 f(n) 到底须要有多少次递归调用呢?

咱们能够在代码里加一个计数验证一下。

{
    let count = 0;
    function Fibonacci (n) {
        count ++;
      if ( n <= 2 ) {return 1};
    
      return Fibonacci(n - 1) + Fibonacci(n - 2);
    }
    Fibonacci(n); // n: 要计算的值
    console.log(count);
}
复制代码

实验的结果:

f(5) count = 9

f(10) count = 109

f(25) count = 150049

f(35) count = 18454929

f(40) count = 204668309

f(45) … 抱歉,我机器太慢,算不出来
复制代码

能够把代码在你的机器上试试哦,这看似简单的两句代码的时间复杂度却达到了O的指数级。

因此优化算法刻不容缓。

为了不重复计算,能够利用一个对象来保存已经求解过的 f(n)。当递归调用到 f(n) 时,先判断是否求解过了。若是是,则直接从对象中取值返回,不需计算,不然的话,再进行递归,这样就能避免刚讲的问题了。

因此优化后的代码以下:

{
    function Fibonacci() {
        this.obj = {};
        this.count = 0;
    }
    Fibonacci.prototype.getF = function(n) {
        this.count ++;
        if ( n <= 2 ) {return 1};
        if (this.obj.hasOwnProperty(n)) {
            return this.obj[n];
        }
        const ret = this.getF(n - 1) + this.getF(n -2);
        this.obj[n] = ret;
        return ret;
    }
    
    var f = new Fibonacci();
    f.getF(45);
}
复制代码

加入了缓存之后,由上图能够看出来,如今的时间复杂度只是 O(n) 的。

非递归实现

利用递归实现有缺有优,优势是短小精悍;而缺点就是空间复杂度高、有堆栈溢出的风险、存在重复计算、过多的函数调用会耗时较多等问题。因此,在选择算法时,要根据实际状况来选择合适的方式来实现。

通常来讲,递归能够实现的利用 for 循环均可以实现。好比说上文的数组求和。

接下里咱们用 for 循环来改写斐波那契数列。

也比较简单,话很少说,直接行上代码展现:

{
    function fibonacci(n) {
        if (n === 1 || n === 2) {
            return 1;
        }
        let one = 1;
        let two = 1;
        let temp = null;
        for(let i = 3; i <= n; i++) {
            temp = one + two;     // 累加前两个数的和
            one = two;
            two = temp;
        }
        return temp;
    }
    console.log(fibonacci(40));
}
复制代码

此代码的时间复杂度应该一眼就能看出来了吧。

总结

刚开始接触 js 的时候,一直都害怕递归,也不多或者说几乎就不写递归的代码。 但其实学习了之后,发现递归仍是挺可爱的。就像在数学找一组数字的规律同样,能够锻炼咱们的思惟。

好比说 对于刚才用 for 循环改写的斐波那契数列,还有其余解法哦,好比说用数组。

欢迎来讨论哦。

其余优化方式

能够参考阮一峰老师讲的尾递归,连接在下方。

参考文章

ECMAScript 6 入门

有你才完美

自认很菜,建立了一个数据结构和算法的交流群,不限开发语言,前端后端,欢迎各位大佬入驻。

传送门

  1. JavaScript数据结构之栈
  2. JavaScript数据结构之队列
  3. JavaScript 数据结构之队栈互搏
  4. JavaScript数据结构之链表--介绍
  5. JavaScript数据结构之链表--设计
  6. JavaScript 算法之复杂度分析
  7. JavaScript 算法之最好、最坏时间复杂度分析
相关文章
相关标签/搜索