了解递归的几种姿势 - 函数式编程

什么是递归

递归和迭代是一枚硬币的两面,在不可变的条件下,递归提供了一种更具表现力、强大且优秀的迭代替代方法

递归函数由以下两个主要部分组成:

  • 基准
  • 递归条件

递归主要的核心思想是将问题分解为较小的问题,逐个解决后再组合,构建出整个问题的答案。

具体概念不详述,可谷歌百度自行搜索。递归适合解决类似XML解析、语法树构建,深度遍历等问题。

而在Haskell这种纯函数编程语言里,原本是没有循环结构的,递归是天然代替循环的,比如求和函数(当然,Haskell有原生的sum方法支持)实现,如下所示:

sum' :: Num a => [a] -> a
sum' []  = 0
sum' (x:xs) = x + sum' xs
复制代码

再看阶乘函数的Haskell实现,如下所示:

factorial :: (Integral a) => a -> a  
factorial 0 = 1  
factorial n = n * factorial (n - 1)  
复制代码

你会发现函数的声明基本表达了上述所说的递归两个主要部分。不得不承认,很优雅!

递归适当时候可以优雅的解决迭代不适合处理的问题。掌握递归思考的方式是一个长期训练的过程。

下文将带大家学习几个递归的姿势,由于篇幅有限,不详述原理。

(同学们莫慌,下文将用JavaScript举例,毕竟它才是我目前的恰饭工具哈哈)

求和的几种姿势

考虑给一个数组求和:

const nums = [1, 2, 3, 4, 5];
复制代码

命令式

命令式的开发思维,会很自然写出以下代码:

let total = 0;
for(let i = 0; i < nums.length; i++) {
  total += nums[i];
}

console.log(total); // 15
复制代码

声明式

更进一步,学了点函数式编程的同学会写出以下代码:

const add = (x, y) => x + y;
const sum = (...nums) => nums.reduce(add, 0);

console.log(sum(...nums)); // 15
复制代码

递归

了解递归的同学,写出来以下代码:

function getTotal(sum, num, ...nums) {
  if (nums.length === 0) {
    return sum + num;
  } else {
    return sum + getTotal(num, ...nums);
  }
}

console.log(getTotal(...nums)); // 15
复制代码

但是,目之所及,递归还是很少用的,不仅仅常见的缺乏递归思维问题,也是有性能问题的考虑,大家会发现写递归存在栈溢出的问题:

于是我写了个函数,测试一下Chrome浏览器支持递归的深度是多少?

function getMaximumCallStack(getTotal) {
  const f = n => getTotal(...'1'.repeat(n).split('').map(Number));
  let i = 1;

  while(true) {
    try {
      const res = f(i);
      console.log(`Stack size: ${i}, f(${i})=${res}`);
      i++;
    } catch(e) {
      console.info(`Maximum call stack size: ${i}`);
      break;
    }
  }
}

getMaximumCallStack(getTotal);
复制代码

测试了上述写的getTotal递归,

Chrome宝宝竟然只是到了484层栈就跪了,实在不敢相信!

------------浏览器三八分割线------------

Safari宝宝表现如何呢?

貌似比Chrome好一丢丢,不过也没什么很大的区别...

那这样让我们如何愉快的使用递归呀?

递归的几种优化方式

如上文所述,递归虽然优雅,但是常常会遇到栈溢出的情况,那么这种问题怎么优化呢?以下三种优化方式:

PTC(Proper Tail Calls)

PTC一定要运行在严格模式下,文件开始声明"use strict";

function getTotal_PTC(sum, num, ...nums) {
  sum += num;
  if (nums.length === 0) {
    return sum;
  } else {
    return getTotal_PTC(sum, ...nums);
  }
}

console.log(getTotal_PTC(...nums)); // 15
复制代码

PTC版的递归其实和上文写的递归只有些微写法上的区别:

// 正常递归
return sum + getTotal(num, ...nums);
// PTC版的递归
return getTotal_PTC(sum, ...nums);
复制代码

改成PTC写法之后,支持支持PTC优化的浏览器,可以不断重复利用原有的栈,从而避免了栈溢出的问题。(原理大致上是由于浏览器不用保留记住每一次递归中的值,在这个函数里特指 sum + getTotal(num, ...nums) 中的sum变量值,从而新栈替换旧栈。

支持PTC优化的浏览器不多,目前可能只有Safari支持,仍然为了眼见为实,在Chrome和Safari两个浏览器进行了测试。

运行上述工具方法测试:getMaximumCallStack(getTotal_PTC)

Chrome宝宝很可惜的偷懒了,木有支持~(残念),见下图:

Safari宝宝果然优秀,对其有所支持!跑了一段时间,未见溢出,见下图:

CPS(Continuation Passing Style)

const getTotal_CPS = (function() {
  return function(...nums) {
    return recur(nums, v => v);
  };

  function recur([sum, ...nums], identity) {
    if (nums.length === 0) {
      return identity(sum);
    } else {
      return recur(nums, v => identity(sum + v));
    }
  }
})();

console.log(getTotal_CPS(...nums)); // 15
复制代码

这种优化技巧通过创建额外的包裹函数:

  1. 将值的计算延迟
  2. 避免调用栈的堆积

但是不可避免的消耗了更多的内存用来存放这些多余的包裹函数。 (关于具体原理比较复杂,有空单独写篇文章论述)

Chrome浏览器测试如下图:

仍然栈溢出,但是栈的深度多了很多~

Trampoline

function getTotal_f(sum, num, ...nums) {
  sum += num;
  if (nums.length === 0) {
    return sum;
  } else {
    return () => getTotal_f(sum, ...nums);
  }
}

function trampoline(f) {
  return function trampolined(...args) {
    let result = f(...args);
    while (typeof result == "function") {
      result = result();
    }
    return result;
  };
}

const getTotal_trampoline = trampoline(getTotal_f);

console.log(getTotal_trampoline(...nums)); // 15
复制代码

这种思维技巧将递归巧妙的转换为了迭代! 写法保持了递归的思维,但是经过trampoline工具函数的处理,实际上交给浏览器执行的时候变成了迭代。

Chrome测试如下:

速度飞快!丝滑流畅~

考虑到内存堆栈问题,trampoline还是蛮适合作为折中的方案的。

小结

谨记:递归的目标是写出更具有可读性的代码。所以运用递归时考虑以下两点:

  • 编写迭代循环之前,反思是不是可以用递归更好的表述!
  • 编写递归之前,反思是不是没有必要使用递归?

转载于:https://juejin.im/post/5cfa8fabf265da1ba25250b2

你可能感兴趣的:(了解递归的几种姿势 - 函数式编程)