直觀理解(尾)遞歸函數

前言

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

本文內容目錄:

  • 什么是調用棧?
  • 什么是遞歸函數?
  • 遞歸的調用棧是怎樣?
  • 尾遞歸的調用棧是怎樣?
  • 為什么說尾遞歸的實現在本質上是跟循環等價?
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。
  • 小生才疏淺陋,文中難免有錯漏之處,請多多指教,感謝您的閱讀。
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容