用函数式的方式思考——递归

Posted 有赞coder

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了用函数式的方式思考——递归相关的知识,希望对你有一定的参考价值。

点击关注“有赞coder

获取更多技术干货哦~

团队:共享前端


在我们初学函数的时候,函数通常被描述为能独立完成一个功能的单元,并且通常以命令式的方式出现:
   
     
     
   
  1. function fact(n: number): number {

  2. let result = 1;

  3. for (let i = 0; i <= n; i += 1) {

  4. result *= i;

  5. }

  6. return result;

  7. }

代码是在操作数据,把一种形式的数据编程另一种形式。抽象的数据是一种数据,而显示在屏幕上的位图也是一种数据,当然必要的时候我们也可以把函数视作数据。我们可以得到一个数据到界面的映射关系,就像React提倡的那样:

   
     
     
   
  1. Model -> View

或者用函数的形式

   
     
     
   
  1. View = f(Model)

现在我们不讨论React,只讨论函数本身。我们可以把函数,特别是纯函数,看作是数据的映射关系的具体形式。

举个例子,根据幂的递推公式 a[n]=a*a[n-1] ,我们可以很容易得到一个求幂的函数:
   
     
     
   
  1. function exp(a: number, n: number) {

  2. return a * exp(a, n - 1);

  3. }

这个函数很简洁,但是会招来担忧。首先它做了 n次函数调用,会有大量函数调用的开销;其次,它还有爆栈的风险。

于是,我们回到递推公式上来。 首先我们得到这样一个映射关系: a*a[n-1]->a[n] 那么显然,幂的实现应该是这样一个函数,其中 prev 就是 a[n-1]
   
     
     
   
  1. function expImpl(a: number, prev: number): number;

这里还缺少一个递归的终止条件,我们把它加上:
   
     
     
   
  1. function expImpl(a: number, prev: number, i: number, n: number): number;

当然,实现也很简单:
   
     
     
   
  1. function expImpl(a: number, prev: number, i: number, n: number): number {

  2. if (i >= n) {

  3. return prev;

  4. }

  5. return expImpl(a, a * prev, i + 1, n);

  6. }


  7. function exp(a: number, n: number) {

  8. return expImpl(a, 1, 0, n);

  9. }

我们还可以做一个简单的优化,去掉一个变量:
   
     
     
   
  1. function expImpl(a: number, prev: number, i: number) {

  2. if (i <= 0) {

  3. return prev;

  4. }

  5. return expImpl(a, a * prev, i - 1);

  6. }


  7. function exp(a: number, n: number) {

  8. return expImpl(a, 1, n);

  9. }

这两个函数直接返回了另一个函数的执行结果,这种情形称为尾调用。 expImpl 恰好是个递归函数,这就构成了一个尾递归。 现代的编译器都足够聪明,能够将其优化成等价的循环形式:
   
     
     
   
  1. function exp(a: number, n: number) {

  2. let i = n;

  3. let ret = 1;

  4. while (i > 0) {

  5. ret = ret * a;

  6. i--;

  7. }

  8. return ret;

  9. }

于是,我们的递归版本可以视作空间复杂度为O(1)时间复杂度为O(n)。可惜的是,现代浏览器中只有Safari实现了这种优化。我们假定执行环境支持尾递归优化。根据数学上幂的定义,当n为偶数,我们有 (a^(n/2))^2=(a^2)^(n/2)根据这个等式,我们可以得到计算幂递归的对数时间版本(当然,以及它等价的循环版本):

   
     
     
   
  1. function fastExpImpl(a: number, prev: number, i: number) {

  2. if (i <= 0) {

  3. return prev;

  4. }

  5. if (i % 2 === 0) {

  6. return expImpl(a * a, prev, i / 2);

  7. }

  8. // 这里还可以做个小优化 expImpl(a * a, a * prev, (i - 1) / 2);

  9. return expImpl(a, a * prev, i - 1);

  10. }


  11. function fastExp(a: number, n: number) {

  12. return expImpl(a, 1, n);

  13. }

用映射的思维,我们来讨论下老朋友,斐波那契数列。初学递归都能写出这样一个简单的递归版本:


   
     
     
   
  1. function fib(n: number): number {

  2. if (n <= 2) {

  3. return 1;

  4. }

  5. return fib(n - 1) + fib(n - 2);

  6. }

显然,这不是一个优秀的实现。这里做了大量的重复计算,同时有爆栈的风险。根据斐波那契数列的递推公式,我们可以得到这样一个关系:

   
     
     
   
  1. a[n - 2] + a[n - 1] -> a[n]

因此这个函数应该是这样的,其中 a 代表 a[n-1] b 代表 a[n-2]
   
     
     
   
  1. function fibImpl(a: number, b: number): number;

这里还缺少递归的终止条件:
   
     
     
   
  1. function fibImpl(a: number, b: number, i: number, n: number): number;

当然,和计算幂的时候一样,我们可以优化掉一个参数,于是得到了一个简单的空间复杂度为O(1),时间复杂度为O(n)的递归版本:

   
     
     
   
  1. function fibImpl(a: number, b: number, i: number): number {

  2. if (i <= 1) {

  3. return a;

  4. }

  5. return fibImpl(a + b, a, i - 1);

  6. }


  7. function fib(n: number): number {

  8. return fibImpl(1, 0, n);

  9. }

以及等价的循环版本:

   
     
     
   
  1. function fib(n: number): number {

  2. let a = 1;

  3. let b = 0;

  4. let i = n;

  5. while (i > 1) {

  6. a = a + b;

  7. b = a;

  8. i--;

  9. }

  10. return a;

  11. }

在这个版本中,我们有这样一组变换规则:

   
     
     
   
  1. a <- a + b

  2. b <- a

我们称之为T变换。 通过观察可以发现,从1和0开始将T反复应用 n 次将产生出一对数 Fib(n+1) Fib(n) 换句话说,斐波那契数可以通过将 T(n) (变换T的n次方)应用于对偶 (1,0) 而产生出来。 现在将T看作是变换族 T(p,q) p=0 q=1 的特殊情况,其中 T(p,q) 是对于对偶 (a,b) 按照 a<-bq+aq+ap b<-bp+aq 规则的变换。 请证明,如果我们应用变换 T(p,q) 两次,其效果等同于应用同样形式的一次变换 T(p', q') ,其中 p' q' 可以由 p q 计算出来。 这样,就像 fastExp 一样,我们可以得到一个空间复杂度为O(1),时间复杂度为O(log n)的斐波那契数列函数。 这是《计算机程序的构造和解释》的练习1.19,你可以自行完成这个过程。
通过对老朋友斐波那契数列的思考,我们发现,通过函数式的方式思考可以有效的简化问题,从而得到一个简单的递归版本。 当我们的执行环境不具备自动优化尾调用的时候,在必要的情况下,我们可以很容易的手动把它优化为一个等价的循环形式。 这就是函数式思维带来的优势。


扩展阅读




活动预告


有赞移动技术团队将于2019年12月7日, 举办“有赞移动技术沙龙”。本次沙龙将会聚焦质量与效率,通过分享有赞过去几年中在移动基础设施建设方面的探索与创新尝试,希望能够抛砖引玉,和大家一起探讨移动技术的未来发展之路。


了解更多活动详情,可以点击文末“ 阅读原文”哦~



Vol.235



以上是关于用函数式的方式思考——递归的主要内容,如果未能解决你的问题,请参考以下文章

《Java8实战》读书笔记12:函数式编程

《Java8实战》读书笔记12:函数式编程

今日好书丨《The Little Schemer:递归与函数式的奥妙》

如何以递归方式思考?

如何以递归方式思考?

递归式的三种求解方式