直觀理解(尾)遞迴函式

JABread發表於2017-12-18

前言

我們都見識了不少關於遞迴與尾遞迴的各種長篇概論,本文將通過對下面幾個問題的直觀體驗,來幫助加深對遞迴的理解。

本文內容目錄:

  • 什麼是呼叫棧?
  • 什麼是遞迴函式?
  • 遞迴的呼叫棧是怎樣?
  • 尾遞迴的呼叫棧是怎樣?
  • 為什麼說尾遞迴的實現在本質上是跟迴圈等價?

Game of Thrones.jpg

什麼是呼叫棧?

是一種常見的資料結構,具有後進先出(LIFO)的特點。 呼叫棧 則是計算機內部對函式呼叫所分配記憶體時的一種棧結構。

什麼是遞迴函式?

遞迴函式 簡單的講,就是函式在內部呼叫自己。

在編寫遞迴函式的時候,我們要注意組成它的兩個條件,分別是:基線條件遞迴條件 (也叫回歸條件)。

遞迴函式其實是利用了分而治之的思想(Divide and Conquer D&C),下面用一個簡單的遞迴函式來說明。

假設我們現在需要一個遞增函式increasing(n),其實現為:

def increasing(n = 0):
	print('n = %d' % n)
	increasing(n + 1)
複製程式碼

我們很容易發現,這樣的程式碼會永不休止的執行,最後會造成棧溢位,簡單的說就是記憶體滿了。因為根本沒人告訴它什麼時候該停下來,所以它不斷的重複執行,造成無限迴圈。

假設遞增的值到100的時候就不再執行,則其實現為:

def increasing(n = 0):
	print('n = %d' % n)
	if n == 100: // --> 基線條件
		return
	else: // --> 遞迴條件
		increasing(n + 1)
複製程式碼

從上面可以看出,遞迴條件指的是函式在內部繼續呼叫自己,基線條件指的是函式不再呼叫自己的情況。

所謂 Divide and Conquer,分別對應的則是遞迴條件和基線條件。

遞迴的呼叫棧是怎樣?

下面我們通過計算一個數的階乘的函式進行解釋。它將會有三個不同版本,分別是遞迴求階乘尾遞迴求階乘for迴圈求階乘

因為這裡要研究遞迴的呼叫棧情況,所以我們先來看看遞迴求階乘的實現:

print('##### 遞迴求階乘 #####')
def  fact(n):
	if n == 1:
		return 1
	else:
		return n * fact(n - 1)

print('result = %s' % fact(4))
複製程式碼

為了更好的解釋說明,我將上面的程式碼略作改動:

print('##### 遞迴求階乘 #####')
def  fact(n):
	if n == 1:
		result = 1
		return result
	else:
		print('current: n = %d, result = %d * fact(%d - 1)' % (n, n, n)) 
		result = fact(n - 1) 
		return n * result 
複製程式碼

改動理由:

  1. 呼叫棧中的函式都保留計算結果變數 result,要特別注意的是呼叫棧中的各個函式內部的變數對函式彼此而言是互相隔離無法訪問的。
  2. 在遞迴條件中列印活躍期的情況。

所謂活躍期,指的是計算機當前所操作的函式執行期。

執行結果為:

##### 遞迴求階乘 #####
current: n = 4, result = 4 * fact(4 - 1)
current: n = 3, result = 3 * fact(3 - 1)
current: n = 2, result = 2 * fact(2 - 1)
result = 24
複製程式碼

其呼叫棧情況:

遞迴函式呼叫棧.png

正常情況下,棧頂函式執行完畢後將彈出。但我們卻看到遞迴函式的呼叫不斷的向呼叫棧壓入執行函式,那麼問題來了,為什麼呼叫棧前面的函式"執行完畢"後不自動彈出呢?

答案是 棧頂函式其實並未執行完成,因為棧頂函式的變數result的值尚未確定,它還需要 下一個遞迴函式返回的值(上下文) 來計算,所以一直處於非活躍期狀態被保留在呼叫棧中。

上面的答案還需完善一下,因為當某個棧頂函式,例如fact(1),在執行到基線條件時,result的值已經確定下來,而無需等待下一個遞迴函式的上下文,所以該棧頂函式真正執行完畢,並彈出呼叫棧。又因為下一個棧頂函式可以拿到已彈棧的函式返回的上下文,因而當彈棧函式交待完成後,也相繼彈出呼叫棧。

尾遞迴的呼叫棧是怎樣?

我們先來看看尾遞迴求階乘的實現:

print('##### 尾遞迴求階乘 #####')
def fact_tail(n):
	return tail_fact_count(n)

def tail_fact_count(n, result = 1):
	if n == 1:
		return result
	else:
		print('current: n = %d, result = %d' % (n, result))
		print('next: n = %d, result = %d' % (n - 1, result * n))
		print('----------------')
		return tail_fact_count(n - 1, n * result)

print('result = %s' % fact_tail(4))
複製程式碼

同樣的,我們將上述程式碼略作改動:

print('##### 尾遞迴求階乘 #####')
def fact_tail(n):
	result = tail_fact_count(n)
	return result

def tail_fact_count(n, result = 1):
	if n == 1:
		return result
	else:
		print('current: n = %d, result = %d' % (n, result))
		print('next: n = %d, result = %d' % (n - 1, result * n))
		print('----------------')
		result = n * result
		n = n - 1
		return tail_fact_count(n, result)

print('result = %s' % fact_tail(4))
複製程式碼

執行結果為:

##### 尾遞迴求階乘 #####
current: n = 4, result = 1
next: n = 3, result = 4
----------------
current: n = 3, result = 4
next: n = 2, result = 12
----------------
current: n = 2, result = 12
next: n = 1, result = 24
----------------
result = 24
複製程式碼

我們再來看看它的呼叫棧情況:

尾遞迴函式呼叫棧.png

尾遞迴函式呼叫棧.png

仔細對比前面遞迴函式的呼叫棧情況,我們可以看出遞迴與尾遞迴呼叫棧的兩個明顯不同點:

  1. 尾遞迴的呼叫棧明顯比遞迴的呼叫棧清爽很多。
  2. 尾遞迴彈棧順序是由上至下執行;而遞迴彈棧順序是由下至上執行的。(這裡的彈棧順序指的不是物理順序)

我們再來看看前面遞迴函式的實現。在遞迴實現中,result的值因為需要 下一個遞迴函式返回的值 來計算才能確定,所以棧頂函式(設A)一直在呼叫棧中停留等待下一個棧頂函式(設B)的返回值,一旦下一個棧頂函式(B)返回了確切的result值,那麼當B交待完成之後就會彈出,所謂交待即是因為上一個棧頂函式A需要下一個棧頂函式即B的返回值,當A拿到了B的值就是交待完成了。以此類推,遞迴的彈棧順序則如圖所示由下往上彈出。

那麼尾遞迴究竟做了什麼貓膩?

尾遞迴其實在result的值上做了貓膩。在尾遞迴的實現中,result的值在當前棧頂函式中已經確定下來了,並經計算後交待給下一個棧頂函式。所以當棧頂函式完成了它的使命(把result值傳遞給下一個執行函式),它就會愉快的在呼叫棧上彈出。

歸納來講:

  • 遞迴函式需要將整個函式作為上下文來完成 目的
  • 尾遞迴則把 目的 在當前函式中完成,並交待給下一個函式。

在本例子中的 目的 指的是確定result值。

為什麼說尾遞迴的實現在本質上是跟迴圈等價?

按照慣例,先上程式碼。但是為了更好的理解與尾遞迴的聯絡,最好還是花個十幾秒思考一下如何實現for迴圈求階乘吧~

為了減少篇幅,直接貼上略作修改的程式碼:

print('##### for迴圈求階乘 #####')
def fact_for(n):
	if n == 1:
		return 1
	else:
		result = 1
		for i in range(n, 0, -1):
			print('current: n = %d, result = %d' % (i, result))
			result = for_fact_count(i, result)
		return result
	
def  for_fact_count(n, result = 1):
	return n * result

print('result = %s' % fact_for(4))

複製程式碼

執行結果為:

##### for迴圈求階乘 #####
current: n = 4, result = 1
current: n = 3, result = 4
current: n = 2, result = 12
current: n = 1, result = 24
result = 24
複製程式碼

當我們思考如何使用for迴圈去實現求階乘的過程中,我們會想到用一個變數去儲存計算的值。在上述程式碼中指的就是 result (= 1)

為了便於理解for迴圈與尾遞迴,我設計了這麼一個函式 for_fact_count(n, result = 1),它接收 當前result值並經計算後重新整理result值

在不影響for迴圈的實現我已經將其與尾遞迴的實現做了相似的轉化(連名字的都好相似啦),所以請開始你的表演,把for迴圈求階乘的呼叫棧畫出來吧~

結語

  • 雖說本文使用了Python進行編碼解釋,但是目前大多數程式語言都沒有針對尾遞迴做優化,Python直譯器也沒有,所以即便使用了尾遞迴進行求階乘,在執行過程中還是會造成棧溢位。而Xcode在debug環境下不會對尾遞迴做優化,需將其設為release。
  • 小生才疏淺陋,文中難免有錯漏之處,請多多指教,感謝您的閱讀。

相關文章