还不懂递归?读完这篇文章保证你会懂


Posted in Javascript onJuly 29, 2018

前言

这篇文章一个多月前以英文发表在我的个人博客,现在抽空翻译成中文,并补充一些没来得及写的内容。

昨天我发表的《如何在 JS 代码中消灭 for 循环》引起很多争议。为了避免没营养的讨论,我先声明一下。递归性能差是没争议的事实,如果你觉得 for 循环更好,没必要学递归,那看到这里你可以不用看了。这篇文章要展示的大部分代码,仅仅是学习目的,我不推荐在生产环境中用。但是如果你对函数式编程感兴趣,想深入理解一些核心概念,你应该读下去。

今年年初我开始学 Haskell 的时候,我被函数式代码的优雅和简洁俘获了。代码居然还能这样写!用指令式代码要写一堆的程序,用递归几行就解决了。这篇文章里,我会把我在 Haskell 里面看到的递归函数翻译成 JS 和 Python,并尽量每一步解释。最后我会尝试解决递归爆栈(Stack Overflow)的问题。

递归基础

我从 Python 代码开始,然后展示 JS 实现。

很多解释递归的教程是从解释斐波那契数列开始的,我觉得这样做是在用一个已经复杂的概念去解释另一个复杂的概念,没有必要。我们还是从简单的代码开始吧。

运行这段 Python 代码:

def foo():
 foo()

foo()

当然会报错。? foo 函数会一直调用自己。因为我没有告诉它何时停,它会一直执行下去,直到爆栈。那我们稍作修改再运行一下:

def foo(n):
 if n <= 1:
 return
 foo(n-1)

foo(10)

这段代码基本什么都没做,但是这次它不会报错了。我在 foo 函数定义初始就告诉它什么时候该停,然后我每次调用的时候都把参数改一下,直到参数满足判断条件,函数停止执行。

如果你理解了上面两段代码,你已经理解递归了。

从上面的代码我总结一下递归的核心构成:

  • 递归函数必须接受参数。
  • 在递归函数的定义初始,应该有一个判断条件,当参数满足这个条件的时候,函数停止执行,并返回值。
  • 每次递归函数执行自己的时候,都需要把当前参数做某种修改,然后传入下一次递归。当参数被累积修改到符合初始判断条件了,递归就停止了。

现在我们来用 Python 写个 max 函数,找出列表中的最大值。是的,我知道 Python 原生有 max 函数,我重新发明个轮子只是为了学习和好玩。

# 不要用这个函数,还是用原生的 max 吧。
def max2(list):
 if len(list) == 1:
  return list[0]
 head, tail = list[0], list[1:]
 return head if head > max2(tail) else max2(tail)

print max2([3,98,345])
# 345

max2函数接受一个列表作为参数,如果列表长度为 1,函数停止执行并把列表第一个元素返回出去。注意,当递归停止时,它必须返回值。(但是如果你想用递归去执行副作用,而不是纯计算的话,可以不返回值。)如果初始判断条件不满足,把列表的头和尾取出来。接着,我们比较头部元素和尾部列表中最大值的大小(我们先不管尾部列表中最大值是哪个),并把比较结果中更大的那个值返回出去。那我们怎样知道尾部列表中的最大值?答案是我们不用知道。我们已经在 max2 函数中定义了比较两个值,并把大的值返回出去这个行为了。我们只需要把这同一个行为作用于尾部列表,程序会帮我们找到。

下面是 JS 的实现:

const max = xs => {
 if (xs.length === 1) return xs[0];
 const [head, ...tail] = xs;
 return head > max(tail) ? head : max(tail);
};

更多递归的例子

接下来我展示几个我从 Haskell 翻译过来的递归函数。刚刚已经用很大篇幅解释递归了,这些函数就不解释了。

reverse

Python 版:

# Python 内置有 reverse 函数
def reverse2(list):
 if len(list) == 1:
 return list
 head, tail = list[0], list[1:]
 return reverse2(tail) + [x]

print reverse2([1,2,3,4,5,6])
# [6,5,4,3,2,1]

JS 版:

const reverse = xs => {
 if (xs.length === 1) return xs;
 const [head, ...tail] = xs;
 return reverse(tail).concat(head);
};

map

Python 版:

# Python 内置有 map 函数
def map2(f, list):
 if len(list) == 0:
 return []
 head, tail = list[0], list[1:]
 return [f(head)] + map2(f, tail)

print map2(lambda x : x + 1, [2,2,2,2])
# [3,3,3,3]

JS 版:

const map = f => xs => {
 if (xs.length === 0) return [];
 const [head, ...tail] = xs;
 return [f(head), ...map(f)(tail)];
};

zipWith

zipWith 接受一个回调函数和两个列表为参数。他会并行遍历两个列表,并把单遍历到的元素一一对应,传进回调函数,把每一步遍历的计算结果存在新的列表里,最终返回这个心列表。

Python 版:

def zipWith(f, listA, listB):
 if len(listA) == 0 or len(listB) == 0:
 return []
 headA, tailA = listA[0], listA[1:]
 headB, tailB = listB[0], listB[1:]
 return [f(headA, headB)] + zipWith(f, tailA, tailB)

print zipWith(lambda x, y : x + y, [2,2,2,2], [3,3,3,3,3])
# [5,5,5,5]
# 结果列表长度由参数中两个列表更短的那个决定

JS 版:

const zipWith = f => xs => ys => {
 if (xs.length === 0 || ys.length === 0) return [];
 const [headX, ...tailX] = xs;
 const [headY, ...tailY] = ys;
 return [f(headX)(headY), ...zipWith(f)(tailX)(tailY)];
};

replicate

Python 版:

def replicate(n,x):
 if n <= 0:
 return []
 return [x] + replicate(n-1,x)

print replicate(4, 'hello')
# ['hello', 'hello', 'hello', 'hello']

JS 版:

const replicate = (n, x) => {
 if (n <= 0) return [];
 return [x, ...replicate(n - 1, x)];
};

reduce

Python 不鼓励用 reduce,我就不写了。

JS 版:

const reduce = (f, acc, arr) => {
 if (arr.length === 0) return acc;
 const [head, ...tail] = arr;
 return reduce(f, f(head, acc), tail);
};

quickSort

用递归来实现排序算法肯定不是最优的,但是如果处理数据量小的话,也不是不能用。

Python 版:

def quickSort(xs):
 if len(xs) <= 1:
  return xs
 pivot, rest = xs[0], xs[1:]
 smaller, bigger = [], []
 for x in rest:
 smaller.append(x) if x < pivot else bigger.append(x)
 return quickSort(smaller) + [pivot] + quickSort(bigger)

print quickSort([44,14,65,34])
# [14, 34, 44, 65]

JS 版:

const quickSort = list => {
 if (list.length === 0) return list;
 const [pivot, ...rest] = list;
 const smaller = [];
 const bigger = [];
 rest.forEach(x =>
 x < pivot ? smaller.push(x) : bigger.push(x);
 );

 return [...quickSort(smaller), pivot, ...quickSort(bigger)]
};

解决递归爆栈问题

由于我对 Python 还不是特别熟,这个问题只讲 JS 场景了,抱歉。

每次递归时,JS 引擎都会生成新的 frame 分配给当前执行函数,当递归层次太深时,可能会栈不够用,导致爆栈。ES6引入了尾部优化(TCO),即当递归处于尾部调用时,JS 引擎会把每次递归的函数放在同一个 frame 里面,不新增 frame,这样就解决了爆栈问题。

然而,V8 引擎在短暂支持 TCO 之后,放弃支持了。那为了避免爆栈,我们只能在程序层面解决问题了。 为了解决这个问题,大神们发明出了 trampoline 这个函数。来看代码:

const trampoline = fn => (...args) => {
 let result = fn(...args);
 while (typeof result === "function") {
 result = result();
 }
 return result;
};

给trampoline传个递归函数,它会把递归函数的每次递归计算结果保存下来,然后只要递归没结束,它就不停执行每次递归返回的函数。这样做相当于把多次的函数调用放到一次函数调用里了,不会新增 frame,当然也不会爆栈。

先别高兴太早。仔细看 trampoline 函数的话,你会发现它也要求传入的递归函数符合尾部调用的情况。那不符合尾部调用的递归函数怎么办呢?( 比如我刚刚写的 JS 版 quickSort,最后 return 的结果里,把两个递归调用放在了一个结果里。这种情况叫 binary recursion,暂译二元递归,翻译错了勿怪 )

这个问题我也纠结了很久了,然后直接去 Stack Overflow 问了,然后真有大神回答了。要解决把二元递归转换成尾部调用,需要用到一种叫 Continuous Passing Style (CPS) 的技巧。来看怎么把 quickSort 转成尾部调用:

const identity = x => x;

const quickSort = (list, cont = identity) => {
 if (list.length === 0) return cont(list);

 const [pivot, ...rest] = list;
 const smaller = [];
 const bigger = [];
 rest.forEach(x => (x < pivot ? smaller.push(x) : bigger.push(x)));

 return quickSort(smaller, a =>
 quickSort(bigger, b => cont([...a, pivot, ...b])),
 );
};

tramploline(quickSort)([5, 1, 4, 3, 2]) // -> [1, 2, 3, 4, 5]

如果上面的写法难以理解,推荐去看 Kyle Simpson 的这章内容。我不能保证比他讲的更清楚,就不讲了。

屠龙之技

虽然我将要讲的这个概念在 JS 中根本都用不到,但是我觉得很好玩,就加进来了。有些编程语言是不支持递归的(我本科不是学的计算机,不知道是哪些语言),那这时候如果我知道用递归可以解决某个问题,该怎么办?用 Y-combinator.

JS 实现:

function y(le) {
 return (function(f) {
 return f(f);
 })(function(f) {
 return le(function(x) {
 return f(f)(x);
 });
 });
}

const factorial = y(function(fac) {
 return function(n) {
 return n <= 2 ? n : n * fac(n - 1);
 };
});

factorial(5); // 120

factorial函数不用递归实现了递归。

展示这段代码不是为了炫技。这根本不是我写的代码,是我从 Douglas Crockford (《JS 语言精髓》的作者)的课程里学到的。看到这段代码时我感到很激动,惊叹计算机科学的精妙和优雅。很多人把程序员职业当做是搬砖的,但是我不这么看。我在学习 CS 的过程中感受更多的是人类智力的不可思议和计算机科学中体现的普遍认识论规律。

总结

以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,如果有疑问大家可以留言交流,谢谢大家对三水点靠木的支持。

Javascript 相关文章推荐
js限制文本框只能输入整数或者带小数点的数字
Apr 27 Javascript
JavaScript实现的经典文件树菜单效果
Sep 08 Javascript
JavaScript实现输入框(密码框)出现提示语
Jan 12 Javascript
关于JS变量和作用域详解
Jul 28 Javascript
关于JS Lodop打印插件打印Bootstrap样式错乱问题的解决方案
Dec 23 Javascript
AngularJS+bootstrap实现动态选择商品功能示例
May 17 Javascript
简单实现jQuery轮播效果
Aug 18 jQuery
解决vue build打包之后首页白屏的问题
Mar 06 Javascript
layui layer select 选择被遮挡的解决方法
Sep 21 Javascript
微信小程序单选框自定义赋值
May 26 Javascript
JS实现canvas简单小画板功能
Jun 23 Javascript
vue 解决在微信内置浏览器中调用支付宝支付的情况
Nov 09 Javascript
如何在js代码中消灭for循环实例详解
Jul 29 #Javascript
Vue-cli3项目配置Vue.config.js实战记录
Jul 29 #Javascript
vue权限路由实现的方法示例总结
Jul 29 #Javascript
JS高级技巧(简洁版)
Jul 29 #Javascript
js运算符的一些特殊用法
Jul 29 #Javascript
不得不知的ES6小技巧
Jul 28 #Javascript
JS 中可以提升幸福度的小技巧(可以识别更多另类写法)
Jul 28 #Javascript
You might like
php生成QRcode实例
2014/09/22 PHP
php递归调用删除数组空值元素的方法
2015/04/28 PHP
解析WordPress中的post_class与get_post_class函数
2016/01/04 PHP
Laravel执行migrate命令提示:No such file or directory的解决方法
2016/03/16 PHP
php删除一个路径下的所有文件夹和文件的方法
2018/02/07 PHP
详解Laravel5.6 Passport实现Api接口认证
2018/07/27 PHP
jQuery入门问答 整理的几个常见的初学者问题
2010/02/22 Javascript
一些常用的JavaScript函数(json)附详细说明
2011/05/25 Javascript
提取字符串中年月日的函数代码
2013/11/05 Javascript
javascript日期验证之输入日期大于等于当前日期
2015/12/13 Javascript
动态加载js、css的简单实现代码
2016/05/26 Javascript
关于动态生成dom绑定事件失效的原因及解决方法
2016/08/06 Javascript
jQuery过滤选择器经典应用
2016/08/18 Javascript
jQuery EasyUI的TreeGrid查询功能实现方法
2017/08/08 jQuery
vue.js在标签属性中插入变量参数的方法
2018/03/06 Javascript
详解在create-react-app使用less与antd按需加载
2018/12/06 Javascript
vue登录页面cookie的使用及页面跳转代码
2019/07/10 Javascript
如何在JavaScript中创建具有多个空格的字符串?
2020/02/23 Javascript
[03:51]吞吞映像 每周精彩击杀top10第二弹
2014/06/25 DOTA
[05:15]2018年度CS GO社区贡献奖-完美盛典
2018/12/16 DOTA
利用Django框架中select_related和prefetch_related函数对数据库查询优化
2015/04/01 Python
python定时器(Timer)用法简单实例
2015/06/04 Python
Python在Console下显示文本进度条的方法
2016/02/14 Python
numpy添加新的维度:newaxis的方法
2018/08/02 Python
python列表使用实现名字管理系统
2019/01/30 Python
土木工程专业个人求职信
2013/12/05 职场文书
银行实习鉴定
2013/12/13 职场文书
文员的职业生涯规划发展方向
2014/02/08 职场文书
入党介绍人评语
2014/05/06 职场文书
科级干部群众路线教育实践活动个人对照检查材料
2014/09/19 职场文书
不服从公司安排检讨书
2014/09/24 职场文书
雾霾停课通知
2015/04/24 职场文书
门球健将观后感
2015/06/16 职场文书
歌咏比赛主持词
2015/06/29 职场文书
MySQL 重命名表的操作方法及注意事项
2021/05/21 MySQL
macos系统如何实现微信双开? mac登录两个微信以上微信的技巧
2022/07/23 数码科技