淺談尾遞迴

lanzhiheng發表於2018-06-04

初入豆廠的時候就經常聽到一些有經驗的老員工談到尾遞迴,當時我也不怎麼當回事。相信許多初入職場的同學也跟我一樣對於一些與自己所做工作似乎沒有直接關係的東西一般都持排斥的態度。現在回想起來真想扇自己兩巴掌,如果當時能夠好好的瞭解一下這些概念的話,或許我能更早地發現程式設計裡面更深層次的樂趣。

Ruby Title

直到最近翻閱《SICP》這本書,書中再次提到尾遞迴這個概念,我知道自己逃不掉了,這次一定要把它弄清楚。

1. 遞迴

遞迴應該是我們耳熟能詳的一個概念了,通常在一些涉及高階計算機的理論的書籍或者課程中都會涉及這個話題。但是,在工作中能夠直接運用上的機會並不是很多,這也使得它在我們心目中的位置被神化了。遞迴其實就是一個函式在呼叫自身的過程。為了更直觀地瞭解這個概念,我們從一道面試題開始

請你實現一個計算階乘的函式(只能夠接受整數輸入)

相信很多朋友都能夠信手拈來,下面是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報棧溢位的錯誤。為了解決這種遞迴過程中呼叫棧過深而引發的記憶體問題,我們可以藉助尾遞迴。

2. 尾遞迴

上面的計算過程被稱為遞迴計算過程,計算過程中構造了一個推遲進行的操作所形成的鏈條(分割線以上),收縮階段表現為運算實際的執行(分割線以下)。而尾遞迴則是一種迭代計算過程

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!!

相關文章