直觀理解(尾)遞歸函數

前言

咱們都見識了很多關於遞歸與尾遞歸的各類長篇概論,本文將經過對下面幾個問題的直觀體驗,來幫助加深對遞歸的理解。編程

本文內容目錄:bash

  • 什麼是調用棧?
  • 什麼是遞歸函數?
  • 遞歸的調用棧是怎樣?
  • 尾遞歸的調用棧是怎樣?
  • 爲何說尾遞歸的實如今本質上是跟循環等價?

Game of Thrones.jpg

什麼是調用棧?

是一種常見的數據結構,具備後進先出(LIFO)的特色。 調用棧 則是計算機內部對函數調用所分配內存時的一種棧結構。數據結構

什麼是遞歸函數?

遞歸函數 簡單的講,就是函數在內部調用本身。編程語言

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

遞歸函數實際上是利用了分而治之的思想(Divide and Conquer D&C),下面用一個簡單的遞歸函數來講明。函數

假設咱們如今須要一個遞增函數increasing(n),其實現爲:優化

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

咱們很容易發現,這樣的代碼會永不休止的執行,最後會形成棧溢出,簡單的說就是內存滿了。由於根本沒人告訴它何時該停下來,因此它不斷的重複執行,形成無限循環。ui

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

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

從上面能夠看出,遞歸條件指的是函數在內部繼續調用本身,基線條件指的是函數再也不調用本身的狀況。spa

所謂 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。
  • 小生才疏淺陋,文中不免有錯漏之處,請多多指教,感謝您的閱讀。
相關文章
相關標籤/搜索