学习代码 git 仓库地址:gitee.com/zhangning18…
九、递归
一种特殊的方法来操作 树 和 图 数据结构变得更简单,那就是递归。
9.1 理解递归
递归是一种解决问题的方法,它从解决问题的各个小部分开始,直到解决最初的大问题。递归通常涉及函数调用自身。
// 递归是像下面这样能够直接调用自身的方法或函数。
function test(){
test();
}
// 像下面这样间接调用自身的函数,也是递归函数
function fun1(){
fun2();
}
function fun2(){
fun1();
}
如果现在执行 test() ,它会一直执行下去。因此每个递归函数都必须有 基线条件,即一个不再递归调用的条件(停止点),以防止无限递归。
function f1 (one) {
const answer = xxx;// 处理
if (answer === true) {
return true;
}
f1(answer);// 递归调用
}
上面的 f1 会不断地调用自身,直到 answer 为 true。answer 为 true 就是上面代码的基线条件。
9.2 计算一个数的阶乘 n!
就是 n * (n - 1) * ... * 1
// 用代码表示就是
function fn (n) {
let number = n;
if (number < 0) {return undefined;}
let total = 1;
while(number > 1) {
total = number * total
number--;
}
return total;
}
以上就是计算阶乘的代码。
// 使用递归来解决这个问题
function f2 (n) {
if (n === 1 || n === 0) {return 1;}
return n * f2(n - 1);
}
代码看着是不是简洁了很多。
- 调用栈
在上面讲了栈数据结构。在上面使用递归的形式就是一个调用栈的例子。每当一个函数被一个算法调用时,该函数会进入调用栈的顶部。当使用递归的时候,每个函数调用都会堆叠在调用栈的顶部,这是因为每个调用都可能依赖前一个调用的结果。
使用 debugger 打开调试模式,可以在 控制台看到 Call Stack 调用栈中积压的函数调用
通过图来表达各个步骤和调用栈中的行为
当 fn(1) 返回 1 时,调用栈会开始弹出调用,返回结果 ,直到 3 * fn(2) 被计算
- JavaScript 调用栈大小的限制
如果忘记加上用以停止函数递归调用的基线条件,会发生什么?递归并不会无线地执行下去,浏览器会抛出错误,也就是所谓的栈溢出错误。就是超出最大调用栈大小。基本上都在 15 万次左右。
尾调用优化,如果函数内的最后一个操作是调用函数,会通过 跳转指令 而不是 子程序调用 来控制,也就是说,在 es2015 中,这里的代码可以一直执行下去。因此具有停止递归的基线条件非常重要。
9.3 这里学习一下 尾调用优化
学习资源来自 阮一峰:尾调用优化
尾调用(Tail Call )是函数式编程的一个重要概念
9.3.1 什么是尾调用?
尾调用的概念非常简单,就是指 某个函数的最后一步是调用另一个函数。
// 函数 f 的最后一步是调用函数 g,这就叫尾调用。
function f(x) {
return g(x)
}
// 下面两种情况都不属于尾调用
// 1
function f(x) {
let y = g(x)
return y;
}
// 2
function f(x) {
return g(x) + 1;
}
情况 1 是调用函数 g 之后,还有别的操作,所以不属于尾调用,即使语义完全一样
情况 2 也属于 调用后还有操作,即使写在 一行 内
尾调用不一定出现在函数尾部,只要是最后一步操作即可
function f(x) {
if (x > 0) {
return m(x)
}
return n(x)
}
// 这里的 m 函数 和 n 函数都属于尾调用,因为它们都是函数 f 的最后一步操作。
9.3.2 尾调用优化
尾调用之所以与其他调用不同,就在于它的特殊的调用位置。
我们知道,函数调用会在内存中形成一个 ‘调用记录’ ,又称为 ‘调用帧’(call frame),保存调用位置和内部变量等信息。如果在函数 A 的内部调用函数 B,那么在 A 的调用记录上方,还会形成一个 B 的调用记录。等到 B 运行结束,将结果返回到 A, B 的调用记录才会消失。如果函数 B 内部还在调用函数 C,那就还有一个 C 的调用记录栈,以此类推,所有的调用纪律就形成了一个 调用栈(call stack)
尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用记录,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用记录,取代外层函数的调用记录就可以了。
function f() {
let m = 1;
let n = 2;
return g(m + n);
}
f();
// 等同于
function f() {
return g(3)
}
f();
// 等同于
g(3)
以上,如果函数 g 不是尾调用,函数 f 就需要保存内部变量 m 和 n 的值、g 的调用位置等信息。但由于 调用 g 之后 函数 f 就结束了,所以执行到最后一步 完全可以删除 f() 的调用记录,只保留 g(3) 的调用记录。
这就叫做 ‘尾调用优化’(Tail call optimization),即只保留内层函数的调用记录。如果所有函数都是尾调用,那么完全可以做到每次执行时,调用记录只有一项,这将大大节省内存。这就是 ‘尾调用优化’的意义。
9.3.3 尾递归
函数调用自身,称为递归。如果尾调用自身,就成为尾递归。
递归非常耗费内存,因为需要同时保存成千上百个调用记录,很容易发生 ‘栈溢出’错误(stack overflow)。但对于尾递归来说,由于只存在一个调用记录,所以永远不会发生 ‘栈溢出’错误。
// 阶乘函数
function f(n) {
if (n === 1) return 1;
return n * f(n - 1)
}
f(5);// 120
上面代码是一个阶乘函数,计算 n 的阶乘,最多需要保存 n 个调用记录,复杂度O(n)。
如果改写成 尾递归,只保留一个调用记录,复杂度O(1)。
// 阶乘函数
function f(n, total) {
if (n === 1) return total;
return f(n-1, n * total)
}
f(5, 1);// 120
// 执行顺序
// f(5)
// f(4, 5)
// f(3, 20)
// f(2, 60)
// f(1, 120)
// 120
以上可以看出,‘尾调用优化’对递归操作意义重大,所以一些函数式编程语言将其写入了语言规格,ES6 也是如此,第一次明确规定,所有 ES 的实现都必须部署 ‘尾调用优化’。这就是说,ES6 中,只要使用尾递归,就不会发生栈溢出,相当节省内存。
9.3.4 递归函数的改写
尾递归的实现,往往需要改写递归函数,确保最后一步只调用自身。做到这一点的方法,就是把所有用到的内部变量改写成函数的参数。比如上面的实例,阶乘函数 f 需要用到一个中间变量 total,那就把这个中间变量改写成函数的参数。这样做的缺点就是不太直观,第一眼很难看出来,为什么计算 5 的阶乘需要传入两个参数 5 和 1?
两个方法可以解决这个问题,
- 方法一是在为递归函数之外,再提供一个正常形式的函数。
function f(n, total) {
if (n === 1) return total;
return f(n - 1, n * total)
}
function f2(n) {return f(n, 1)}
f2(5);// 120
上面的代码通过一个正常形式的阶乘函数 f2 ,调用尾递归函数 f,看起来正常多了。
函数式编程有一个概念,叫做 柯里化(currying),意思是将多参数的函数转换成单参数的形式,这里也可以使用 柯里化。
function currying(f, n) {
return function(m) {
// 这里为什么还要用 call 啊,call 知识改变了 this 的指向,好像没有什么意义,
// call 和 apply ,就是参数不一样,apply 第二个参数是一个数组,把参数都放在数组里
return f.call(this, m, n);
}
}
function tf(n, total) {
if (n === 1) return total;
return tf(n -1, n * total)
}
const fun = currying(tf, 1);
fun(5);// 120
上面代码通过柯里化,将尾递归函数 tf 变为 只接受 1 个参数的 fun;
- 第二种方法就比较简单了(和我想得一样设默认值,哈哈)
function f(n, total = 1) {
if (n === 1) return total;
return f(n - 1, n * total);
}
f(5) // 120
总结:递归本质上是一种循环操作,纯碎的函数式编程语言没有循环操作命令,所有的循环都用递归实现,这就是为什么尾递归对这些语言极其重要。对于其他支持‘尾调用优化’的语言(比如:es6),只需要知道循环可以用递归代替,而一旦使用递归,就最好使用尾递归。
9.3.5 严格模式
ES6 的尾调用优化只在严格模式下开启,正常模式是无效的。
这是因为在正常模式下,函数内部有两个变量,可以跟踪函数的调用栈。
arguments:返回调用时函数的参数
func.caller:返回调用当前函数的那个函数
尾调用优化发生时,函数的调用栈会改写,因此上面两个变量就会失真。严格模式禁用这两个变量,所以尾调用模式仅在严格模式下生效。
9.4 斐波那契数列
斐波那契数列是另一个可以用递归解决的问题。它是由0 1 1 2 3 5 8 13 21 34 等数组成的序列,由前两个数相加得到后面一个数这种类型序列。
斐波那契数列定义:
- 位置 0 的斐波那契数是 零
- 1 和 2 的斐波那契数是 1
- n(这里 n > 2)的斐波那契数是(n-1)的斐波那契数加上(n-2)的斐波那契数
9.4.1 迭代求斐波那契数
function fibonacciFun(n) {
if (n < 1) return 0;
if (n <= 2) return 1;
let fibNMinus2 = 0;
let fibNMinus1 = 1;
let fibN = n;
for (let i = 2; i <= n; i++) {
fibN = fibNMinus1 + fibNMinus2;
fibNMinus2 = fibNMinus1;
fibNMinus1 = fibN;
}
}
9.4.2 递归求 斐波那契数
function f(n) {
if (n < 1) return 0;
if (n <= 2) return 1;
return f(n -1) + f(n -2)
}
9.4.3 记忆化斐波那契数
还有第三种写 fibonacci 函数的方法,叫做记忆化,记忆化是一种保存前一个结果的值的优化技术,类似于缓存。如果我们分析在计算 fibonacci 时的调用,会发现 fibonacci 被计算了两次,因此可以将它的结果存储下来,这样当需要再次计算它的时候,我们就已经有他的结果了
function fibonacciMemoization(n) {
const memo = [0, 1];
const fibonacci = (n) => {
if (memo[n] != null) return memo[n];
return memo[n] = fibonacci(n - 1, memo) + fibonacci(n - 2, memo);
}
return fibonacci;
}
9.4.4 为什么要用递归?它更快么
迭代的版本比递归的版本快很多,所以这表示递归更慢。但是,这三种不同的方法里面,递归时更容易理解的,需要的代码也是更少的。另外,对一些算法来说,迭代的解法可能不可用,而且有了尾调用优化,递归的多余消耗甚至可能被消除。所以通常使用递归,因为它解决问题会更简单。
9.4.5 小结
本章,学习了怎样写两种著名算法的迭代版本和递归版本:数的阶乘和斐波那契数列。还了解到一种叫做记忆化的优化技术,它可以防止递归算法重复计算一个相同的值。