第二天:递归 & 尾递归
学生:早啊方,今天讲什么?
方:今天讲递归和尾递归吧
学生:数据不可变什么时候讲呀?
方:不急,数据不可变是贯穿始终的,不用特别去讲它。
学生:哦……
方:我先写一个递归形式的阶乘:
f = n => {
if(n<=1) {
return 1
} else {
return n * f(n-1)
}
}
然后用「代入法」来计算一下 f(4):
f(4)
= 4 * f(3)
= 4 * (3 * f(2))
= 4 * (3 * (2 * f(1)))
= 4 * (3 * (2 * 1))
= 4 * (3 * 2)
= 4 * 6
= 24
注意计算的「形状」,看起来像是一个大于号(>):
方:先递进,后回归,这就是递归,英文叫做 recursion。
学生:原来还可以这么理解递归。那我可不可以认为:「递」是调用栈压栈的过程,「归」是弹栈的过程。
方:可以,但不够全面,我之前说过,有些语言是没有调用栈的,所以压栈弹栈只是递归的一种实现方式而已,尽管这种方式很主流。
学生:那伪递归是怎么回事呢?
方:还是先给例子,比阶乘还简单
print = (n) => {
if(n===0){return}
console.log(n)
return print(n-1) // 此处 return 可以省略不写,因为没有返回值
}
然后使用「代入法」来计算 print(4):
print(4)
= print(3)
= print(2)
= print(1)
= 结束
注意这次计算的形状是「一条直线」:
学生:这也是递归吧?
方:是也不是。
学生:怎么讲?
方:如果你问的是中文概念「递归」,那么这次计算没有「递」和「归」的过程,所以它不算递归。
学生:确实没有先递再归。
方:但如果你问的是递归的英文概念「recursion」,其中文意思是「重现」,那这次计算确实是在不断重现 print 调用,所以它是递归。
学生:你的意思是 recursion 翻译为递归是有问题的?应该翻译为「重现」?
方:我不敢这么说。但为了准确起见,我们用「递归」来表示计算形状为大于号的 recursion,用「尾递归」来表示计算形状为直线的 recursion。「尾递归」的代码特征是 recursion 出现在代码的最后一句(即尾位)
学生:原来是尾巴的「尾」,我还以为是伪装的「伪」。
方:怪我没说清楚咯?
学生:那为什么 return n * f(n-1) 不是尾递归呢? f(n-1) 不也是写在最后一句吗?
方:因为调用完 f(n-1) 还要将其结果与 n 相乘,所以 f(n-1) 是倒数第二步,不是最后一步,算不得尾递归。
学生:原来如此。那「尾递归」的优点就是不用「归」吗?
方:可以这么理解。
学生:可是,尾递归版的阶乘怎么写?我想不出来
方:多问我你就能写出来了,我先写一个给你看看
f = (n, result = 1) =>
n > 1 ? f(n-1, result * n) : result
代入运行一下:
f(4)
= f(3, 4)
= f(2,12)
= f(1,24)
= 24
看,这条线直不直?
学生:啊,你为了不「归」,居然把上一步的计算结果,当做参数,传给了下一次调用
方:对。
学生:我怎么没想到。
方:以后你就会想到了。
学生:那如果是复杂的递归呢?能不能写成尾递归?比如斐波那契:
fib = (n) =>
n === 0 ? 0 :
n === 1 ? 1 :
fib(n-1) + fib(n-2)
求 fib(4) 的过程是这样:
fib(4)
= f(3) + f(2) // 简写为 f
= (f(2) + f(1)) + (f(1) + f(0))
= ((f(1) + f(0)) + f(1)) + (f(1) + f(0))
= ((f(1) + f(0)) + f(1)) + (1 + 0)
= ((f(1) + f(0)) + f(1)) + 1
= ((f(1) + f(0)) + 1) + 1
= ((1 + 0) + 1) + 1
= (1 + 1) + 1
= 2 + 1
= 3
这种递归不好写成尾递归吧?
方:稍微动点脑不就可以了嘛:
fib = (n, prevResult = 0, result = 1) =>
n === 0 ? prevResult :
n === 1 ? result :
fib(n - 1, result, prevResult + result)
求一下 fib(4) 的值:
fib(4)
= fib(3, 1, 1)
= fib(2, 1, 2)
= fib(1, 2, 3)
= 3
这不就妥了?
学生:好家伙!你把前两次的值,都当作参数,传给了下一次的调用了。而你的 n 只是用来计数的,当 n 等于 1 你就返回上一次的结果!
方:对!是不是很简单?
学生:那……那……那快排有没有尾递归写法?
方:有是有,但我怕你看不懂,所以我先把你昨天写的「递归版」快排放在这里:
/*1*/ quickSort = (array) => {
/*2*/ if(array.length <= 1) {return array}
/*3*/ let [pivot, ...rest] = array
/*4*/ let small = rest.filter(i => i<=pivot)
/*5*/ let big = rest.filter(i => i>pivot)
/*6*/ return [...quickSort(small), pivot, ...quickSort(big) ]
/*7*/ }
要改写成「尾递归」存在两个问题:
- quickSort(small) 之后必须要回头执行 quickSort(big)
- quickSort(big) 执行完了之后必须要回头把 quickSort(small)、pivot、quickSort(big)拼接起来
对吧?
学生:是呀,看起来不可能把结果提前算出来,传给下一次函数调用,我不信你这次还能改成尾递归
方:那就不提前算出结果,不就结了
学生:什么叫做不提前算出结果?
方:直接看代码吧:
/*1*/ qs = (array, next) => {
/*2*/ if(array.length <= 1) { return next(array)}
/*3*/ const [pivot, ...rest] = array
/*4*/ const small = rest.filter(i=>i<=pivot)
/*5*/ const big = rest.filter(i=>i>pivot)
/*6*/ qs(small, (sortedSmall)=>{
/*7*/ qs(big, (sortedBig)=>{
/*8*/ next([...sortedSmall, pivot, ...sortedBig])
})
})
}
qs([1,10,5,2,7,3,8,4,9,6], (result)=>{
console.log(result)
})
// 输出 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
前面 5 行没有变化,你主要看第 6 ~ 8 行。
学生:好家伙,你把原本的
/*4*/ let small = rest.filter(i => i<=pivot)
/*5*/ let big = rest.filter(i => i>pivot)
/*6*/ return [...quickSort(small), pivot, ...quickSort(big) ]
改成了这个样子
/*6*/ qs(small, (sortedSmall)=>{
/*7*/ qs(big, (sortedBig)=>{
/*8*/ next([...sortedSmall, pivot, ...sortedBig])
这个改动的作用是,把后续的所有操作写成一个函数,当做参数传给下一次的 quickSort!
方:对!
学生:确实,把原来需要回头调用的东西往后传,就不用回头了,太聪明了!我已无话可说……但我总感觉哪里怪怪的。
方:你第一次见这样的代码当然觉得怪咯,习惯就好,这种代码风格叫做 CPS(Continuation-passing style),以后有机会还会再讲。
学生:那,所有的递归,都能改写成尾递归吗?
方:是的,具体的讨论你可以看这个 StackOverflow 上的问答,当然不看也没关系,记住结论就行。
学生:今天的课程让我的脑袋都快炸了,我得去打盘游戏休息一下。
方:行,不过我们最后再复习一下今天所学吧:
- 递归(recursion)有一种特殊的形式,叫做「尾递归」
- 递归版阶乘可以写成「尾递归」,方法是把上一次的结果传给下一次调用
- 递归版斐波那契可以写成「尾递归」,方法是把上两次的结果传给下一次调用
- 递归版快排可以写成「尾递归」,方法是把后续操作写成一个函数,传给下一次调用(即 CPS)
- 补充一点:递归改写成尾递归还有其他方法,并不只是我上课讲的这么一点
学生:我们今天就讲了这么点内容?我怎么感觉今天讲了很多东西……
方:就这么点,你不会没记住吧?注意记笔记啊!
学生:下次一定,我先去打游戏了,再见!