第7章  递归

王二、张三和赵四一日无聊,决定玩击鼓传花讲冷笑话的游戏。王二和张三围成一圈传花,赵四负责击鼓。张三接连讲了几个诸如小菜、狐狸狡猾的笑话。花停在了王二的手中。
王二:这个笑话很短。你要保证听完后不生气我就说。
张三:你说吧。
王二:张三。
张三:怎么了?
王二:笑话说完了,就两个字。
张三欲发怒。
王二:欸,你刚才说好了不会生气的。
张三只好作罢。新一轮开始,花又停在王二的手中。
王二:张三不是个笑话。
张三再次欲发怒。
王二:别生气,我说的是冷笑话,就表示不好笑啊。
花又一次停在王二的手中。
王二:[张三不是个笑话]不是个笑话。
第四次花停在王二的手中。
王二:[[[张三不是个笑话]不是个笑话]不是个笑话]。
……

7.1  调用自身
7.1.1  递归的思路
7.1.2  带累积参数的递归函数
7.2  递归的数据结构
7.2.1  构建列表
7.2.2  树
7.3  递归与迭代
7.3.1  名称
7.3.2  理念和对比
7.3.3  迭代协议
7.3.4  递归协议
7.3.5  搜索树
7.4  尾部递归
7.4.1  调用堆栈
7.4.2  尾部调用优化
7.4.3  怎样算是尾部调用
7.4.4  尾部递归

7.5  递归的效率

我们来计算经典的斐波那契数列。菲波那契数列的通项公式为,当n=0和1时,A(n)=n;当n>=2时,A(n)=A(n-1)+A(n-2)。如果让一个数学不错又刚学习编程的高中生来写计算斐波那契项的函数,结果可能会是这样。

function fibonacci1(n) {const phi = (1 + Math.sqrt(5)) / 2;if (n < 2) {return n;}return (Math.pow(phi, n) + Math.pow(1 - phi, n)) / (phi * (3 - phi));
}
f.log(fibonacci1(10))
//=> 55.007272246494842705

他的思路如下:将等式A(n)=A(n-1)+A(n-2)变形为A(n)+x*A(n-1)=(1+x)*[A(n-1)+1/(1+x)*A(n-2)]。令x=1/(1+x),可得1元2次方程x^2+x-1=0,解出x=[-1+sqrt(5)]/2或[-1-sqrt(5)]/2。因为A(n)+x*A(n-1)构成一个等比数列,再加上初始两项的值,可求得A(n)+x*A(n-1)的值。再利用这个公式递归地消去A(n-1),计算出通项A(n)的值。

这样的解法会让数学老师高兴,计算机老师难过。计算机被当成计算器来用。另外,由于运算中涉及到小数,计算结果与本应是整数的精确值相比有微小的误差,如上面的fibonacci1(10)精确值是55。

正常的计算方法可以采用迭代。

function fibonacci2(n) {if (n < 2) {return n;}let a = 0, b = 1, c;for (let i = 2; i <= n; i++) {c = a + b;a = b;b = c;}return c;
}
f.log(fibonacci2(10))
//=> 55

也可以采用递归。

function fibonacci3(n) {if (n < 2) {return n;}return fibonacci3(n - 1) + fibonacci3(n - 2);
}
f.log(fibonacci3(10))
//=> 55

三个版本中,采用递归的版本最简短,它只是将斐波那契数列的数学定义用编程语言写出来。到现在为止,三个函数表现都还基本不错。但当我们求更大的斐波那契项时,情况开始有变化了。

f.log(fibonacci1(100))
//=> 354224848179261800000
f.log(fibonacci2(100))
//=> 354224848179261800000
f.log(fibonacci3(100))
//=> 一觉醒来还是没有结果

fibonacci1和fibonacci2都很快得出了一致的结果(因为数字太大,fibonacci1返回值中的小数被忽略了),而fibonacci3则永远都得不出结果。出了什么问题呢?

考察fibonacci3的计算过程,可以让我们找出原因。本章所有此前出现的递归函数有一个共同点,返回语句只包含一次递归调用,用数列的语言来说就是,当前项的值只依赖于前一项。而fibonacci3的递归算法在求第n项A(n)时,不仅要利用前一项A(n-1),还要依赖更前一项A(n-2),这导致对此前项的大量重复计算,项数越小,重复的次数越多。令B(i)为第i项被计算的次数,则有

B(i) = 1;  i = n, n - 1

B(i) = B(i + 1) + B(i + 2);  i < n - 1

这样,B(i)形成了一个有趣的逆的斐波那契数列。求A(n)时有:

B(i) = A(n + 1 - i)

换一个角度来看,令C(i)为求A(i)时需要做的加法的次数,则有

C(i) = 0;  i = 0, 1

C(i) = 1 + C(i - 1) + C(i - 2);  i > 1

令D(i) = C(i) + 1,有

D(i) = 1;  i = 0, 1

D(i) = D(i - 1) + D(i - 2)

所以D(i)又形成一个斐波那契数列。并可因此得出:

C(n) = A(n + 1) - 1

A(n)是以几何级数增长,所以fibonacci3在n较大时所做的重复计算量会变得十分惊人。与它相对应的采用迭代的程序fibonacci2,有

B(n) = 1;  n为任意值

C(n) = 0;  n = 0, 1

C(n) = n - 1;  n > 1

因而当n增长时,一直能保持很快的速度。

聪明的读者也许已经想到了解决的方法,本书之前介绍的“记忆化”模式的功用正是避免以同样的参数多次调用函数时的重复计算。记忆化普通函数很简单,只需将其传递给memoize函数,返回的就是记忆化的版本。这种方法对递归函数却不适用,因为递归函数体内有对自身的调用,无法利用记忆化的版本,要想记住对某个参数的计算结果,只有用memoize函数类似的写法,修改递归函数。

const fibonacci4 = function () {const memory = new Map();return function fibonacci4(n) {if (m.has(n, memory)) {return m.get(n, memory);}if (n < 2) {m.set(n, n, memory);} else {m.set(n, fibonacci4(n - 1) + fibonacci4(n - 2), memory);}return m.get(n, memory);}
}();

因为这里的参数限定为非负整数,所以用于记忆计算结果的Map,可以换成数组,这样函数可以改写得更简洁,运行速度也更快。

const fibonacci5 = function () {const memory = [0, 1];return function fibonacci5(n) {if (memory.length <= n) {memory[n] = fibonacci5(n - 1) + fibonacci5(n - 2);}return memory[n];}
}();

在这两个版本的递归算法中,虽然形式上在计算第n项时,仍然包含两次递归调用,但实际上对于每个n,函数都只计算了一次,其他对第n项的引用,都是从记忆中读取的,所以求第n项过程中进行的加法运算次数与迭代算法相同,具有同样的可伸缩性。

仔细的读者会发现,迄今为止的三个递归版本,都不算是尾部调用。所以当n很大时,还是会出现调用堆栈耗尽的问题。

fibonacci5(10**8)
//=> Maximum call stack size exceeded

上一节已经介绍了,可以利用累积参数将函数转换成尾部递归。在返回语句只包含一次递归调用的情况下,转换的方法是一目了然的。而对fibonacci3这样返回语句包含两次递归调用的函数,以前的方法就无效了。思路的突破口是,一次递归调用需要一个参数来累积,多次递归调用时,每次调用都需要一个参数来累积。这样就得到fibonacci3尾部递归的版本。

function fibonacci6(n) {return _fibonacci(n, 0, 1);function _fibonacci(n, a, b) {if (n === 0) {return a;}return _fibonacci(n - 1, b, a + b);}
}

最后,我们来比试一下各种版本算法的速度。

export function doUnto(...args) {return function (fn) {return fn(...args);}
}const cTookTime = f.unary(f.curry(f.tookTime, 2));
let fns = f.map(cTookTime, [fibonacci1, fibonacci2, fibonacci4, fibonacci4,fibonacci5, fibonacci5, fibonacci6]);
fibonacci5,fibonacci6]);
f.forEach(f.doUnto(1000), fns);
//=> 4.346655768693734e+208
//=> fibonacci1(1000): 1.828857421875ms
//=> 4.346655768693743e+208
//=> fibonacci2(1000): 0.243896484375ms
//=> 4.346655768693743e+208
//=> fibonacci4(1000): 3.918212890625ms
//=> 4.346655768693743e+208
//=> fibonacci4(1000): 0.126953125ms
//=> 4.346655768693743e+208
//=> fibonacci5(1000): 0.372802734375ms
//=> 4.346655768693743e+208
//=> fibonacci5(1000): 0.156005859375ms
//=> 4.346655768693743e+208
//=> fibonacci6(1000): 0.223876953125ms

多次测试,每个函数花费的时间会有波动,但总体上的排名没有多少出入。从这个结果能读出很多有趣的信息。fibonacci1直接根据斐波那契数列项的公式来计算,因为涉及开方和小数的乘方等运算,并不算快。fibonacci2的迭代算法,名列前茅。fibonacci3没有参赛资格。fibonacci4用映射数据结构作缓存,第一次计算时速度最慢,再次计算时读取缓存,速度最快。fibonacci5用数组作缓存,第一次计算时,速度已经和不需缓存的最快算法在一个数量级上;第二次计算时,依靠读取缓存,速度和fibonacci4差不多。fibonacci6的尾部递归算法,与迭代算法不相上下。

7.6  小结

更多内容,请参看拙著:

《JavaScript函数式编程思想》(京东)

《JavaScript函数式编程思想》(当当)

《JavaScript函数式编程思想》(亚马逊)

《JavaScript函数式编程思想》(天猫)

《JavaScript函数式编程思想》——递归相关推荐

  1. 《JavaScript函数式编程思想》

    自序 伴随着Web技术的普及,JavaScript已成为应用最广泛的编程语言之一.由于其在Web前端编程中的统治地位.语言本身的表现力.灵活性.开源的本质和ECMAScript标准近年来的快速发展,J ...

  2. 《JavaScript函数式编程思想》——名称

    第1章  名称 一般对函数式编程的介绍都会从一等值和纯函数等概念开始,本书却准备在那之前先花些篇章讨论两个通常未得到足够重视的主题:名称和类型系统.前者包括名称绑定.作用域和闭包等内容,后者包括类型的 ...

  3. JavaScript函数式编程思想

    欢迎访问个人网站 最开始接触函数式编程的时候是在小米工作的时候,那个时候看老大以前写的代码各种 compose,然后一些 ramda 的一些工具函数,看着很吃力,然后极力吐槽函数式编程,现在回想起来, ...

  4. JavaScript 函数式编程思想

    来自 Professor Frisby's Mostly Adequate Guide to Functional Programming 英文版本 中文版本-版本较老 函数式编程是一种编程范式, 所 ...

  5. SegmentFault 技术周刊 Vol.16 - 浅入浅出 JavaScript 函数式编程

    函数式编程(Functional Programming),一看这个词,简直就是学院派的典范. 以至于从 Lisp 的创世,到 Scheme.Haskell.Clean.Erlang.Miranda. ...

  6. 云函数与函数式编程思想结合会产生什么?

    导语   函数式编程是一种编程范式,也就是如何编写程序的方法论.随着函数式编程被更多人广泛的关注.很多古老的函数式编程语言都重获新生,就连Java这样的老牌的编程语言都开始往函数式编程的方式开始靠近. ...

  7. SegmentFault 技术周刊 Vol.16 - 浅入浅出 JavaScript 函数式编程 1

    函数式编程(Functional Programming),一看这个词,简直就是学院派的典范. 以至于从 Lisp 的创世,到 Scheme.Haskell.Clean.Erlang.Miranda. ...

  8. JavaScript 函数式编程(一)

    零.前言 说到函数式编程,想必各位或多或少都有所耳闻,然而对于函数式的内涵和本质可能又有些说不清楚. 所以本文希望针对工程师,从应用(而非学术)的角度将函数式编程相关思想和实践(以 JavaScrip ...

  9. javascript函数式编程上

    我们一起来学习javascript函数式编程 文章目录 前言 一.函数式编程是什么? 二.专业术语 1.纯函数 2.纯度和幂等性 3.偏应用(partial application)函数 4.函数的柯 ...

最新文章

  1. 剑指offer:链表中环的入口结点
  2. 读书笔记1 : program paradigm
  3. 解决VS2010复制代码中文乱码的问题
  4. 3.运算符与表达式,控制流
  5. android+read_logs这权限有什么用,READ_LOGS是正常或危险的Android权限吗?
  6. adnroid中LayoutParams的简单总结
  7. 友图自动排料软件使用简介
  8. matlab浮点转定点的函数,FPGA基础知识17(Matlab中滤波器的定点化 浮点运算转换为定点运算)...
  9. 魅族16xs可升级Android10吗,手机评测 篇十一:用了魅族16Xs半个月,谈谈我的使用感受...
  10. MTK 驱动(59)---音频基础知识
  11. 730-高并发下秒杀系统怎么设计?
  12. 论文阅读:Domain Information Enhanced Dependency Parser
  13. 微信小程序中输出大于号和小于号
  14. 湖南大学ACM程序设计新生杯大赛(同步赛)L - Liao Han
  15. 中小学计算机网络培训心得体会,计算机网络培训心得体会
  16. 匀速运动,太空版愤怒的小鸟
  17. Eclipse 创建 Android 模拟器
  18. 【121期】面试官:什么是熔断?什么是服务降级?
  19. 微信小程序和百度小程序差异(持续更新)
  20. “哥伦布”华为,与智能联接新大陆

热门文章

  1. 拉勾网8亿估值的背后
  2. c语言经典程序表白6,经典C语言程序设计100例(6)
  3. 微信小程序 --- 标签自定义属性说明、及使用方式
  4. 用PYTHON爬虫简单爬取网络小说
  5. 计算机无法进入桌面,开机无法进入桌面的问题有哪些原因导致
  6. occt-wasm配置——emsdk配置web版opencascade
  7. 【真人手指动画制作软件】万彩手影大师教程 | 添加图片
  8. java-php-python-农村留守儿童帮扶系统计算机毕业设计
  9. 人脸识别成“网红” 与交通行业结下不解情缘
  10. 软件序列号、验证码备忘