怎么写递归

以前我很少写递归,因为感觉写递归需要灵感,很难复制。看了《The Little Schemer》后,我发现写递归其实是有套路的。递归只需要想清楚 2 个问题:

  1. 什么情况不需要计算
  2. 大问题怎么变成小问题

举例

1. 判断数组是否包含某元素

const has = (element, arr) => {};
  • 什么情况不需要计算?
    数组为空时不需要计算,一定不包含。

    const has = (element, arr) => {
      if (arr.length === 0) return false;
    };
    
  • 怎么把大问题变成小问题?
    arr 的长度减小,向数组为空的情况逼近。
    arr 中取出第一个元素和 element 比较:

    1. 相同:返回 true
    2. 不相同:求解更小的问题。
    const has = (element, arr) => {
      if (arr.length === 0) return false;
      else if (arr[0] === element) return true;
      else return has(element, arr.slice(1));
    };
    

2. 删除数组的某个元素

const del = (element, arr) => {};
  • 什么情况不需要计算?
    数组为空时不需要计算,返回空数组。

    const del = (element, arr) => {
      if (arr.length === 0) return [];
    };
    
  • 怎么把大问题变成小问题?
    arr 的长度减小,向空数组的情况逼近。
    arr 中取出第一个元素和 element 比较:

    1. 相同:返回数组余下元素。
    2. 不相同:留下该元素,再求解更小的问题。
    const del = (element, arr) => {
      if (arr.length === 0) return [];
      else if (arr[0] === element)
        return arr.slice(1);
      else
        return [
          arr[0],
          ...del(element, arr.slice(1))
        ];
    };
    

3. 阶乘、斐波那契

阶乘、斐波那契用递归来写也是这个套路,代码结构都是一样的。

先列出不需要计算的情况,再写大问题和小问题的转换关系。

const factorial = n => {
  if (n === 1) return 1;
  else return n * factorial(n - 1);
};
const fibonacci = n => {
  if (n === 1) return 1;
  else if (n === 2) return 1;
  else return fibonacci(n - 1) + fibonacci(n - 2);
};

4. 小孩子的加法

小孩子用数数的方式做加法,过程是这样的:

3 颗糖 加 2 颗糖 是几颗糖?

小孩子会把 3 颗糖放左边,2 颗糖放右边。
从右边拿 1 颗糖到左边,数 4,
再从右边拿 1 颗糖到左边,数 5,
这时候右边没了,得出有 5 颗糖。

这也是递归的思路。

const add = (m, n) => {};
  • n = 0 时,不需要计算,结果就是 m

    const add = (m, n) => {
      if (n === 0) return m;
    };
    
  • 把问题向 n = 0 逼近:

    const add = (m, n) => {
      if (n === 0) return m;
      else return add(m + 1, n - 1);
    };
    

当然 m = 0 也是不需要计算的情况。
选择 m = 0 还是 n = 0 作为不需要计算的情况 决定了 大问题转成小问题的方向。


Continuation Passing Style

const add1 = m => m + 1;

add1 的返回结果乘 2,通常这么写:

add1(5) * 2;

Continuation Passing Style 来实现是这样的:

const add1 = (m, continuation) =>
  continuation(m + 1);

add1(5, x => x * 2);

add1 加一个参数 continuation,它是一个函数,表示对结果的后续操作。


我们用 Continuation Passing Style 来写写递归。

以下用

  1. CPS 代替 Continuation Passing Style
  2. cont 代替 continuation

1. 阶乘

const factorial = (n, cont) => {
  if (n === 1) return cont(1);
  else return factorial(n - 1, x => cont(n * x));
};
  • 如果 n === 1,把结果 1 交给 cont
  • 如果 n > 1,计算 n - 1 的阶乘,
    n - 1 阶乘的结果 xn,交给 cont

这个 factorial 函数该怎么调用呢?
cont 可以传 x => x,这个函数传入什么就返回什么。

factorial(5, x => x);

  • 之前的写法:

    const factorial = n => {
      if (n === 1) return 1;
      else return n * factorial(n - 1);
    };
    

    递归调用 factorial 不是函数的最后一步,还需要乘 n
    因此编译器必须保留堆栈。

  • 新写法:

    const factorial = (n, cont) => {
      if (n === 1) return cont(1);
      else
        return factorial(n - 1, x => cont(n * x));
    };
    

    递归调用 factorial 是函数的最后一步。
    做了尾递归优化的编译器将不保留堆栈,从而不怕堆栈深度的限制。

也就是说:可以通过 CPS 把递归变成尾递归。


2. 斐波那契

const fibonacci = (n, cont) => {
  if (n === 1) return cont(1);
  else if (n === 2) return cont(1);
  else
    return fibonacci(n - 1, x =>
      fibonacci(n - 2, y => cont(x + y))
    );
};
  • 如果 n === 1,把结果 1 交给 cont
  • 如果 n === 2,把结果 1 交给 cont
  • 如果 n > 2
    计算 n - 1 的结果 x
    计算 n - 2 的结果 y
    x + y 交给 cont

3. CPS 尾递归使用误区

  • cont 传入的参数不是最终结果。

    CPScont 是对结果的后续操作。
    也就是说 cont 传入的参数需要是最终结果。
    不满足这一点,就不能叫做 CPS

    错误代码示例:

    const factorial = (n, cont) => {
      if (n === 1) return cont(1);
      else
        return factorial(n - 1, x => cont(n) * x);
    };
    

    上述代码和之前代码的区别如下:

    x => cont(n) * x; // 现在 错误的写法
    x => cont(n * x); // 之前 正确的写法
    

    错误的写法中,cont 传入的参数不再是最终结果了,如果这么调用:

    factorial(5, console.log);
    

    期望控制台打印 120
    实际控制台打印 5

  • CPS,却不是尾递归。

    const factorial = (n, cont) => {
      if (n === 1) return cont(1);
      else
        return cont(factorial(n - 1, x => n * x));
    };
    

    以上写法称得上是 CPS 了,但却不是尾递归。

    注意这段代码:

    cont(factorial(n - 1, x => n * x));
    

    factorial 的递归调用不是函数的最后一步,cont 的调用才是最后一步。


4. 验证 CPS 尾递归优化

截止到 2019 年 11 月,只有 Safari 浏览器宣称支持尾递归优化。

用从 1 加到 N 的例子试验了一下,Safari 13.0.3:

  • 一般递归:堆栈溢出。

    "use strict";
    
    const sum = n => {
      if (n === 1) return 1;
      else return n + sum(n - 1);
    };
    
    sum(100000);
    
  • CPS 尾递归:正常算出结果。

    "use strict";
    
    const sum = (n, cont) => {
      if (n === 1) return cont(1);
      else return sum(n - 1, x => cont(n + x));
    };
    
    sum(1000000, x => x);
    

最后想说的

用以前的方式写递归 还是 用 CPS 写递归,只是写法上不同,思想都是一样的,都是要搞清:

  1. 什么情况不需要计算
  2. 大问题怎么变成小问题
posted @ 2019-11-22 17:21  apolis  阅读(1152)  评论(0编辑  收藏  举报