写给前端工程师看的函数式编程对话 - 2

1,198 阅读6分钟

前篇:写给前端工程师看的函数式编程对话 - 1

第二天:递归 & 尾递归

学生:早啊方,今天讲什么?

方:今天讲递归和尾递归吧

学生:数据不可变什么时候讲呀?

方:不急,数据不可变是贯穿始终的,不用特别去讲它。

学生:哦……

方:我先写一个递归形式的阶乘:

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*/ }

要改写成「尾递归」存在两个问题:

  1. quickSort(small) 之后必须要回头执行 quickSort(big)
  2. 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 上的问答,当然不看也没关系,记住结论就行。

学生:今天的课程让我的脑袋都快炸了,我得去打盘游戏休息一下。

方:行,不过我们最后再复习一下今天所学吧:

  1. 递归(recursion)有一种特殊的形式,叫做「尾递归」
  2. 递归版阶乘可以写成「尾递归」,方法是把上一次的结果传给下一次调用
  3. 递归版斐波那契可以写成「尾递归」,方法是把上两次的结果传给下一次调用
  4. 递归版快排可以写成「尾递归」,方法是把后续操作写成一个函数,传给下一次调用(即 CPS)
  5. 补充一点:递归改写成尾递归还有其他方法,并不只是我上课讲的这么一点

学生:我们今天就讲了这么点内容?我怎么感觉今天讲了很多东西……

方:就这么点,你不会没记住吧?注意记笔记啊!

学生:下次一定,我先去打游戏了,再见!

后续:juejin.cn/post/694082…