浅谈尾递归

初入豆厂的时候就常常听到一些有经验的老员工谈到尾递归,当时我也不怎么当回事。相信许多初入职场的同窗也跟我同样对于一些与本身所作工做彷佛没有直接关系的东西通常都持排斥的态度。如今回想起来真想扇本身两巴掌,若是当时可以好好的了解一下这些概念的话,或许我能更早地发现编程里面更深层次的乐趣。html

Ruby Title

直到最近翻阅《SICP》这本书,书中再次提到尾递归这个概念,我知道本身逃不掉了,此次必定要把它弄清楚。面试

1. 递归

递归应该是咱们耳熟能详的一个概念了,一般在一些涉及高级计算机的理论的书籍或者课程中都会涉及这个话题。可是,在工做中可以直接运用上的机会并非不少,这也使得它在咱们心目中的位置被神化了。递归其实就是一个函数在调用自身的过程。为了更直观地了解这个概念,咱们从一道面试题开始编程

请你实现一个计算阶乘的函数(只可以接受整数输入)缓存

相信不少朋友都可以信手拈来,下面是Ruby版本的实现ruby

def factorial(n)
  return 1 if n <= 1
  n * factorial(n - 1)
end
复制代码

运行起来也是比较符合预期的bash

> factorial(2)
 => 2
> factorial(10)
 => 3628800
> factorial(30)
 => 265252859812191058636308480000000
复制代码

可是当咱们用这个阶乘函数来换算一个较大的数的时候,就会致使栈溢出的错误了app

> 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报栈溢出的错误。为了解决这种递归过程当中调用栈过深而引起的内存问题,咱们能够借助尾递归。函数

2. 尾递归

上面的计算过程被称为递归计算过程,计算过程当中构造了一个推迟进行的操做所造成的链条(分割线以上),收缩阶段表现为运算实际的执行(分割线如下)。而尾递归则是一种迭代计算过程post

1) 迭代计算

迭代的概念咱们接触得比较多了,通常的编程语言都会有迭代的关键字,好比for,each,while等等。在介绍尾递归以前我先用迭代的方式来实现一个阶乘的计算过程,下面是Ruby的实现,为了更直观,我先用一个外部变量来缓存每一次乘积的结果

a = 1

(1..6).each do |i|
  a = a * i
end

puts a
复制代码

计算结果是同样的,6的阶乘等于720。借鉴这种方式若是咱们可以在递归的过程当中用一个相似a的变量存储计算过程的中间结果,而不是在原有的栈基础上进行叠加,弄成了一个长长的调用栈来延迟执行,那样岂不是可以剩下许多栈的资源?

2) 尾递归版本

咱们能够尝试在递归的过程当中维护一个变量来存储计算过程的中间结果,每次递归的时候把这个结果传送到下一次计算过程,达到特定条件的时候终止程序。在具体分析这个过程以前我先贴出代码

# 用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里面启动尾递归优化。

3. 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........
复制代码

4. 尾声

这篇文章首先用递归的方式来实现阶乘函数,可是咱们发如今计算较大的数的时候就会有栈溢出的现象。这个时候咱们能够采用尾递归来优化咱们原来的阶乘函数,使之可以在常量计算空间内完成整个递归过程。尾递归并非某些语言的专属,许多语言均可以写出尾递归的代码(Ruby, Python等等),但这些语言里面并非全部都可以支持尾递归优化,Ruby默认就没有开启尾递归优化,为此我在最后简单地讲了下在Ruby里面如何修改配置启动尾递归优化,让咱们的尾递归代码可以生效。

参考文档

Happy Coding and Writing!!

相关文章
相关标签/搜索