初入豆廠的時候就常常聽到一些有經驗的老員工談到尾遞歸,當時我也不怎麼當回事。相信許多初入職場的同窗也跟我同樣對於一些與本身所作工做彷佛沒有直接關係的東西通常都持排斥的態度。如今回想起來真想扇本身兩巴掌,若是當時可以好好的瞭解一下這些概念的話,或許我能更早地發現編程裏面更深層次的樂趣。html
直到最近翻閱《SICP》這本書,書中再次提到尾遞歸這個概念,我知道本身逃不掉了,此次必定要把它弄清楚。面試
遞歸應該是咱們耳熟能詳的一個概念了,一般在一些涉及高級計算機的理論的書籍或者課程中都會涉及這個話題。可是,在工做中可以直接運用上的機會並非不少,這也使得它在咱們心目中的位置被神化了。遞歸其實就是一個函數在調用自身的過程。爲了更直觀地瞭解這個概念,咱們從一道面試題開始編程
請你實現一個計算階乘的函數(只可以接受整數輸入)緩存
相信不少朋友都可以信手拈來,下面是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報棧溢出的錯誤。爲了解決這種遞歸過程當中調用棧過深而引起的內存問題,咱們能夠藉助尾遞歸。函數
上面的計算過程被稱爲遞歸計算過程,計算過程當中構造了一個推遲進行的操做所造成的鏈條(分割線以上),收縮階段表現爲運算實際的執行(分割線如下)。而尾遞歸則是一種迭代計算過程。post
迭代的概念咱們接觸得比較多了,通常的編程語言都會有迭代的關鍵字,好比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裏面如何修改配置啓動尾遞歸優化,讓咱們的尾遞歸代碼可以生效。