初入豆厂的时候就经常听到一些有经验的老员工谈到尾递归,当时我也不怎么当回事。相信许多初入职场的同学也跟我一样对于一些与自己所做工作似乎没有直接关系的东西一般都持排斥的态度。现在回想起来真想扇自己两巴掌,如果当时能够好好的了解一下这些概念的话,或许我能更早地发现编程里面更深层次的乐趣。
直到最近翻阅《SICP》这本书,书中再次提到尾递归这个概念,我知道自己逃不掉了,这次一定要把它弄清楚。
递归应该是我们耳熟能详的一个概念了,通常在一些涉及高级计算机的理论的书籍或者课程中都会涉及这个话题。但是,在工作中能够直接运用上的机会并不是很多,这也使得它在我们心目中的位置被神化了。递归其实就是一个函数在调用自身的过程。为了更直观地了解这个概念,我们从一道面试题开始
请你实现一个计算阶乘的函数(只能够接受整数输入)
相信很多朋友都能够信手拈来,下面是 Ruby 版本的实现
def factorial(n)
return 1 if n <= 1
n * factorial(n - 1)
end
运行起来也是比较符合预期的
> factorial(2)
=> 2
> factorial(10)
=> 3628800
> factorial(30)
=> 265252859812191058636308480000000
但是当我们用这个阶乘函数来换算一个较大的数的时候,就会导致栈溢出的错误了
> factorial(100000)
SystemStackError: stack level too deep
from (irb):3:in `factorial'
from (irb):3:in `factorial'
from (irb):3:in `factorial'
.....
Why?我们来简单地分析一下上面的过程。递归确实可以使我们的代码更优雅,但是优雅的背后是要付出代价的。使用递归需要记录函数的调用栈,当调用栈太深的话则将造成栈溢出问题。下面以factorial(6)
为例来展示这个阶乘函数的计算过程
factorial(6)
6 * factorial(5)
6 * (5 * factorial(4))
6 * (5 * (4 * factorial(3)))
6 *(5 * (4 * (3 * factorial(2))))
6 *(5 * (4 * (3 * (2 * factorial(1)))))
##############################
6 * (5 * (4 * (3 * (2 * 1))))
6 * (5 * (4 * (3 * 2)))
6 * (5 * (4 * 6))
6 * (5 * 24)
6 * 120
720
当 n 等于 6 的时候,我们调用栈的深度就为 6。分割线以上的部分是展开的过程,也可以理解成是调用栈堆积的过程,而分割线以下的过程是换算过程,也可以理解为释放调用栈的过程。可以预测调用栈随着我们传入的参数 n 的增长而呈线性增长。回到栈溢出的那条程序,当我们 n 等于 100000 的时候,调用栈的深度超出了预设的阀值,就会导致了 Ruby 报栈溢出的错误。为了解决这种递归过程中调用栈过深而引发的内存问题,我们可以借助尾递归。
上面的计算过程被称为递归计算过程,计算过程中构造了一个推迟进行的操作所形成的链条 (分割线以上),收缩阶段表现为运算实际的执行 (分割线以下)。而尾递归则是一种迭代计算过程。
迭代的概念我们接触得比较多了,一般的编程语言都会有迭代的关键字,比如for
,each
,while
等等。在介绍尾递归之前我先用迭代的方式来实现一个阶乘的计算过程,下面是 Ruby 的实现,为了更直观,我先用一个外部变量来缓存每一次乘积的结果
a = 1
(1..6).each do |i|
a = a * i
end
puts a
计算结果是一样的,6 的阶乘等于 720。借鉴这种方式如果我们能够在递归的过程中用一个类似a
的变量存储计算过程的中间结果,而不是在原有的栈基础上进行叠加,弄成了一个长长的调用栈来延迟执行,那样岂不是能够剩下许多栈的资源?
我们可以尝试在递归的过程中维护一个变量来存储计算过程的中间结果,每次递归的时候把这个结果传送到下一次计算过程,达到特定条件的时候终止程序。在具体分析这个过程之前我先贴出代码
# 用a来存储计算的中间结果,并将结果作为下一次递归的参数
def fact_iter(i, n, a)
a = i * a
return a if i >= n
i += 1
fact_iter(i, n, a)
end
def factorial(n)
fact_iter(1, n, 1)
end
上面的代码并没有最初的递归版本那么优雅了,我另外定义了一个方法fact_iter(i, n, a)
,来分别接收索引
, 最大值
, 累乘值
这三个参数。下面来看一下如今的调用栈又是如何呢?
factorial(6, 1)
fact_iter(1, 1, 6)
fact_iter(1, 2, 6)
fact_iter(2, 3, 6)
fact_iter(6, 4, 6)
fact_iter(24, 5, 6)
fact_iter(120, 6, 6)
fact_iter(720, 7, 6)
可见上面的过程并没有像递归计算过程那样还有一个长长的调用栈,它的调用过程更加平滑,《SICP》把这个过程的总结为
它总能在常量空间中执行迭代型的计算过程,即使这一计算过程是用一个递归过程描述的,具有这一特征的实现称之为尾递归。
OK,具有尾递归特性的代码已经实现了。现在测试一下,它相比于前面的递归版本是否能帮我们节省一些计算资源,避免掉栈溢出的情况。
> factorial(30)
=> 265252859812191058636308480000000
> factorial(100000)
SystemStackError: stack level too deep
from (irb):18:in `fact_iter'
from (irb):23:in `fact_iter'
from (irb):23:in `fact_iter'
from (irb):23:in `fact_iter'
from (irb):23:in `fact_iter'
from (irb):23:in `fact_iter'
from (irb):23:in `fact_iter'
from (irb):23:in `fact_iter'
What? 显然花了那么多时间实现的尾递归版本并没能带来什么实质性的效果,栈依然溢出了。不过这是 Ruby 的问题,它默认没有开启尾递归优化,毕竟每门语言都有它自己的特性,不然如果每门语言都一样的话岂不是少了许多乐趣?接下来我会简单讲讲如何在 Ruby 里面启动尾递归优化。
有些人说Ruby 不支持尾递归优化这个说法并不是十分准确,应该描述成Ruby 默认没有开启尾递归优化这个选项。大家都知道在 Ruby1.9 之后就有了虚拟机这个概念了,在这个版本之后 Ruby 代码都会先编译成字节码,然后把字节码放到虚拟机上面运行。我们可以修改虚拟机的编译选项来启动尾递归优化,相关的配置选项,如下
> require 'pp'
> pp RubyVM::InstructionSequence.compile_option
{:inline_const_cache=>true,
:peephole_optimization=>true,
:tailcall_optimization=>false,
:specialized_instruction=>true,
:operands_unification=>true,
:instructions_unification=>false,
:stack_caching=>false,
:trace_instruction=>true,
:frozen_string_literal=>false,
:debug_frozen_string_literal=>false,
:debug_level=>0}
可见tailcall_optimization
这个尾递归相关的优化选项默认是false
的,另外还有一个跟踪指令的选项trace_instruction
这个默认是true
,我们只需要开启前者,关闭后者就可以启动尾递归优化了。我把配置代码与方法定义的代码分别写到两个 Ruby 的脚本文件中
## config.rb
RubyVM::InstructionSequence.compile_option = {tailcall_optimization: true, trace_instruction: false}
# factorial.rb
def fact_iter(i, n, a)
a = i * a
return a if i >= n
i += 1
fact_iter(i, n, a)
end
def factorial(n)
fact_iter(1, n, 1)
end
在 REPL 环境中分别加载这两个脚本,注意一定要先加载配置文件,然后再定义方法,如果把两个东西都放在同一个脚本里面,Ruby 解析器会同时编译,导致方法定义的时候无法应用到最新的配置。
> require "./config.rb"
=> true
> require "./factorial.rb"
=> true
OK, 这次我们再来计算一次 100000 的阶乘的话就不会再出现栈溢出的情况了,但是计算出来的数字很大,我只能贴出其中一小部分了
> factorial(100000)
282422940796034787429342157802453551847749492609122485057891808654297795090106301787255177141383116361071361173736196295147499618312391802272607340909383242200555696886678403803773794449612683801478751119669063860449261445381113700901607668664054071705659522612980419........
这篇文章首先用递归的方式来实现阶乘函数,但是我们发现在计算较大的数的时候就会有栈溢出的现象。这个时候我们可以采用尾递归来优化我们原来的阶乘函数,使之能够在常量计算空间内完成整个递归过程。尾递归并不是某些语言的专属,许多语言都可以写出尾递归的代码 (Ruby, Python 等等),但这些语言里面并不是所有都能够支持尾递归优化,Ruby 默认就没有开启尾递归优化,为此我在最后简单地讲了下在 Ruby 里面如何修改配置启动尾递归优化,让我们的尾递归代码能够生效。