遞迴的方式和時間

當函式呼叫導致在原始函式呼叫終止之前再次呼叫相同的函式時,會發生遞迴。例如,考慮眾所周知的數學表示式 x!(即因子運算)。為所有非負整數定義了階乘操作,如下所示:

  • 如果數字為 0,則答案為 1。
  • 否則,答案是該數字乘以比該數字小 1 的階乘。

在 Python 中,可以將因子操作的簡單實現定義為如下函式:

def factorial(n):
    if n == 0:
        return 1
    else:
        return n * factorial(n - 1)

遞迴函式有時很難掌握,所以讓我們一步一步地逐步完成。考慮表達 factorial(3)。此和所有函式呼叫都會建立一個新環境。環境基本上只是將識別符號(例如 nfactorialprint 等)對映到其對應值的表。在任何時間點,你都可以使用 locals() 訪問當前環境。在第一個函式呼叫中,唯一定義的區域性變數是 n = 3。因此,列印 locals() 會顯示 {'n': 3}。從 n == 3 開始,返回值變為 n * factorial(n - 1)

在接下來的步驟中,事情可能會變得有些混亂。看看我們的新表達,我們已經知道什麼是 n。但是,我們還不知道 factorial(n - 1) 是什麼。首先,n - 1 評估為 2。然後,2 作為 n 的值傳遞給 factorial。由於這是一個新的函式呼叫,因此建立了第二個環境來儲存這個新的 n。設 A 為第一環境, B 為第二環境。 A 仍然存在且等於 {'n': 3},然而, B (等於 {'n': 2})是當前環境。檢視函式體,返回值又是 n * factorial(n - 1)。在不評估此表示式的情況下,讓我們將其替換為原始的返回表示式。通過這樣做,我們在精神上丟棄B,所以記得要相應地替代 n(即引用Bnn - 1 其使用取代A小號 n’)。現在,原始的返回表示式變為 n * ((n - 1) * factorial((n - 1) - 1))。花一點時間確保你明白為什麼會這樣。

現在,讓我們評估一下這個部分。從 A ’s n == 3 開始,我們將 1 傳遞給 factorial。因此,我們正在創造一個新的環境 C ,它等於 {'n': 1}。同樣,返回值是 n * factorial(n - 1)。因此,讓我們替換原始返回表示式的 factorial((n - 1) - 1)),類似於我們之前調整原始返回表示式的方式。 原始表達現在是 n * ((n - 1) * ((n - 2) * factorial((n - 2) - 1)))

幾乎完成了。現在,我們需要評估 factorial((n - 2) - 1)。這一次,我們正在傳遞 0。因此,這評估為 1。現在,讓我們進行最後一次替換。 原始的迴歸表達現在是 n * ((n - 1) * ((n - 2) * 1))。回想一下原始返回表示式在 A 下計算,表示式變為 3 * ((3 - 1) * ((3 - 2) * 1))。當然,這評估為 6.要確認這是正確答案,請回憶一下 3! == 3 * 2 * 1 == 6。在進一步閱讀之前,請確保你完全理解環境的概念以及它們如何應用於遞迴。

宣告 if n == 0: return 1 被稱為基本案例。這是因為它沒有遞迴。絕對需要一個基本案例。沒有一個,你將遇到無限遞迴。話雖如此,只要你至少有一個基本案例,你就可以擁有任意數量的案例。例如,我們可以按如下方式編寫 factorial

def factorial(n):
    if n == 0:
        return 1
    elif n == 1:
        return 1
    else:
        return n * factorial(n - 1)

你可能還有多個遞迴案例,但我們不會涉及到這種情況,因為它相對不常見並且通常很難進行精神處理。

你還可以進行並行遞迴函式呼叫。例如,考慮 Fibonacci 序列 ,其定義如下:

  • 如果數字為 0,則答案為 0。
  • 如果數字是 1,那麼答案是 1。
  • 否則,答案是前兩個斐波納契數的總和。

我們可以定義如下:

def fib(n):
    if n == 0 or n == 1:
        return n
    else:
        return fib(n - 2) + fib(n - 1)

我不會像使用 factorial(3) 那樣徹底地完成這個功能,但是 fib(5) 的最終返回值等同於以下( 語法無效)表示式:

(
  fib((n - 2) - 2)
  +
  (
    fib(((n - 2) - 1) - 2)
    +
    fib(((n - 2) - 1) - 1)
  )
)
+
(
  (
    fib(((n - 1) - 2) - 2)
    +
    fib(((n - 1) - 2) - 1)
  )
  +
  (
    fib(((n - 1) - 1) - 2)
    +
    (
      fib((((n - 1) - 1) - 1) - 2)
      +
      fib((((n - 1) - 1) - 1) - 1)
    )
  )
)

這變成了 (1 + (0 + 1)) + ((0 + 1) + (1 + (0 + 1))),當然評估為 5

現在,我們將介紹一些詞彙術語:

  • 一個尾呼叫是一個簡單的遞迴函式呼叫,它是返回值之前要執行的最後一個操作。要清楚,return foo(n - 1) 是一個尾呼叫,但 return foo(n - 1) + 1 不是(因為新增是最後一個操作)。
  • 尾呼叫優化 (TCO)是一種自動減少遞迴函式遞迴的方法。
  • 尾呼叫消除 (TCE)是對一個表示式的尾呼叫的減少,該表示式可以在沒有遞迴的情況下進行求值。TCE 是一種 TCO。

尾呼叫優化有很多原因:

  • 直譯器可以最小化環境佔用的記憶體量。由於沒有計算機具有無限的記憶體,過多的遞迴函式呼叫會導致堆疊溢位
  • 直譯器可以減少堆疊幀交換機的數量。

由於多種原因, Python 沒有實現任何形式的 TCO。因此,需要其他技術來限制這種限制。選擇方法取決於用例。通過一些直覺,factorialfib 的定義可以相對容易地轉換為迭代程式碼,如下所示:

def factorial(n):
    product = 1
    while n > 1:
        product *= n
        n -= 1
    return product

def fib(n):
    a, b = 0, 1
    while n > 0:
        a, b = b, a + b
        n -= 1
    return a

這通常是手動消除遞迴的最有效方法,但對於更復雜的函式來說,它可能變得相當困難。

另一個有用的工具是 Python 的 lru_cache 裝飾器,它可用於減少冗餘計算的次數。

你現在已經知道如何避免 Python 中的遞迴,但何時應該使用遞迴?答案是不經常。所有遞迴函式都可以迭代實現。這只是一個弄清楚如何做到這一點的問題。但是,極少數情況下遞迴是可以的。當預期的輸入不會導致大量的遞迴函式呼叫時,遞迴在 Python 中很常見。

如果遞迴是你感興趣的主題,我懇請你學習函式語言,如 Scheme 或 Haskell。在這種語言中,遞迴更有用。

請注意,Fibonacci 序列的上述示例雖然很好地展示瞭如何在 python 中應用定義以及稍後使用 lru 快取,但由於它為每個非基本情況進行 2 次遞迴呼叫,因此執行時間效率低。對函式的呼叫次數呈指數增長至 n
相反,非直觀地,更有效的實現將使用線性遞迴:

def fib(n):
    if n <= 1:
        return (n,0)
    else:
        (a, b) = fib(n - 1)
        return (a + b, a)

但那個問題就是返回一數字。這強調了一些函式實際上並沒有從遞迴中獲得太多收益。