Python 的尾遞迴優化

2021-08-15 12:31:52 字數 4659 閱讀 3026

有很多時候,使用遞迴的方式寫**要比迭代更直觀一些,以下面的階乘為例:

def

factorial

(n):

if n == 0:

return

1return factorial(n - 1) * n

但是這個函式呼叫,如果展開,會變成如下的形式:

factorial(4)

factorial(3) * 4

factorial(2) * 3 * 4

factorial(1) * 2 * 3 * 4

factorial(0) * 1 * 2 * 3 * 4

1 * 1 * 2 * 3 * 4

1 * 2 * 3 * 4

2 * 3 * 4

6 * 4

24

可以看出,在每次遞迴呼叫的時候,都會產生乙個臨時變數,導致程序記憶體佔用量增大一些。這樣執行一些遞迴層數比較深的**時,除了無謂的記憶體浪費,還有可能導致著名的堆疊溢位錯誤。

但是如果把上面的函式寫成如下形式:

def

factorial

(n, acc=1):

if n == 0:

return acc

return factorial(n - 1, n * acc)

我們在腦內展開一下:

factorial

(4, 1)

factorial

(3, 4)

factorial

(2, 12)

factorial

(1, 24)

factorial

(0, 24)

24

很直觀的就可以看出,這次的 factorial 函式在遞迴呼叫的時候不會產生一系列逐漸增多的中間變數了,而是將狀態儲存在 acc 這個變數中。

而這種形式的遞迴,就叫做尾遞迴。

尾遞迴的定義顧名思義,函式呼叫中最後返回的結果是單純的遞迴函式呼叫(或返回結果)就是尾遞迴。

比如**:

def

foo():

return foo()

就是尾遞迴。但是 return 的結果除了遞迴的函式呼叫,還包含另外的計算,就不能算作尾遞迴了,比如:

def

foo():

return foo() + 1

# return 1 + foo() 也一樣

看上去尾遞迴這種形式的**,可以達到和迭代形式的**同樣的效率與空間複雜度。但是當前絕大部分程式語言,函式呼叫實際上是使用記憶體中的乙個棧來模擬的。這樣在執行尾遞迴函式時,依然可能會遇到堆疊溢位的問題。

使用棧來實現函式呼叫的程式語言,在進行乙個函式呼叫時,可以提前分析出這次函式呼叫傳遞了多少個引數,以及產生了多少個中間變數,還有引數與變數占用記憶體的大小(通常包含這個函式呼叫過程中所有引數、中間變數的這個棧的元素叫做幀)。這樣呼叫前就把棧頂的指標向前指這麼大的記憶體偏移,這樣函式引數、中間變數的記憶體位置就在呼叫前分配好了。函式呼叫完畢時,棧頂指標指回去,就可以瞬間清除掉這次函式呼叫占用的記憶體了。並且使用棧來實現函式呼叫,與絕大部分程式語言的語義相符,比如進行函式呼叫時,呼叫者分配的記憶體空間依然可以使用,變數依然有效。

但是對於遞迴函式來說,就會遇到乙個問題:每次函式呼叫會讓棧的容量增長一點,如果需要進行的遞迴呼叫層級很深,這樣每進行一次遞迴呼叫,即使是不會生成中間變數的尾遞迴,依然隨著函式呼叫棧的增長,整個程序的記憶體佔用量增長。

但理論上講,沒有產生中間變數來儲存狀態的尾遞迴,完全可以復用同乙個棧幀來實現所有的遞迴函式操作。這種形式的**優化就叫做尾遞迴優化。

對於編譯到機器碼執行的**(不管是 aot 還是 jit),簡單來講,只要將 call … ret 指令改為 jump …,就可以復用同乙個棧幀,當然還有很多額外工作需要做。對於解釋執行的**,直譯器本身有很多機會可以動態修改棧幀來做尾遞迴優化。

但是 cpython 的實現並沒有支援尾遞迴優化,並且預設限制了遞迴呼叫層數為 1000(通過 sys.getrecursionlimit 函式可以檢視)。

不過這並不代表我們沒有辦法在 python 中實現尾遞迴優化。實現尾遞迴優化的方式中,如果因為某種原因不能直接控制生成的機器**,也不方便執行時修改棧幀的語言,還有一種方案叫做 through trampolining。

through trampolining 的大概實現方式是,在遞迴函式呼叫時,先插入乙個 trampolining(蹦床) 函式。在用這個蹦床函式來呼叫真正的遞迴函式,並且修改遞迴函式的函式體,不讓它再次進行遞迴的函式呼叫,而是直接返回下次遞迴呼叫的引數,由蹦床函式來進行下一次遞迴呼叫。這樣一層一層的遞迴呼叫就會變成由蹦床函式一次一次的迭代式函式呼叫。

並且這種 through trampolining 的尾遞迴優化,未必由程式語言本身(編譯器 / 執行時)提供,一些靈活性比較強的語言本身就能實現這個過程。比如這裡有一段使用 cpython 的實現**。這段**全文如下(稍微修改了一下,以便能夠在 python3 下執行):

#!/usr/bin/env python3

# this program shows off a python decorator(

# which implements tail call optimization. it

# does this by throwing an exception if it is

# it's own grandparent, and catching such

# exceptions to recall the stack.

import sys

class

tailrecurseexception

(baseexception):

def__init__

(self, args, kwargs):

self.args = args

self.kwargs = kwargs

deftail_call_optimized

(g):

""" this function decorates a function with tail call

optimization. it does this by throwing an exception

if it is it's own grandparent, and catching such

exceptions to fake the tail call optimization.

this function fails if the decorated

function recurses in a non-tail context.

"""deffunc

(*args, **kwargs):

f = sys._getframe()

if f.f_back and f.f_back.f_back \

and f.f_back.f_back.f_code == f.f_code:

raise tailrecurseexception(args, kwargs)

else:

while

1: try:

return g(*args, **kwargs)

except tailrecurseexception as e:

args = e.args

kwargs = e.kwargs

func.__doc__ = g.__doc__

return func

@tail_call_optimized

deffactorial

(n, acc=1):

"calculate a factorial"

if n == 0:

return acc

return factorial(n-1, n*acc)

print(factorial(10000))

# prints a big, big number,

# but doesn't hit the recursion limit.

@tail_call_optimized

deffib

(i, current = 0, next = 1):

if i == 0:

return current

else:

return fib(i - 1, next, current + next)

print(fib(10000))

# also prints a big number,

# but doesn't hit the recursion limit.

僅僅暴露了乙個 tail_call_optimized 裝飾器,就可以對符合條件的函式進行尾遞迴優化。

這段**的實現原理和上面提到的 through trampolining 相同。但是作為純 python 的**,裝飾器並不能修改被裝飾的函式體,來讓函式只返回下次遞迴需要的引數,不在進行遞迴呼叫。而它的神奇之處就在於,通過裝飾器來在每次遞迴呼叫函式前,丟擲乙個異常,然後將這次呼叫的引數寫進異常物件,再在蹦床函式裡捕獲這個異常,並將引數讀出來,然後進行迭代呼叫。

python遞迴 Python 與尾遞迴優化

有很多時候,使用遞迴的方式寫 要比迭代更直觀一些,以下面的階乘為例 def factorial n if n 0 return 1 return factorial n 1 n 但是這個函式呼叫,如果展開,會變成如下的形式 factorial 4 factorial 3 4 factorial 2 ...

尾遞迴優化

尾遞迴就是遞迴語句在函式最後執行,且無需對返回值進行進一步操作。編譯器會對這種遞迴進行優化,在進入深層遞迴時候,不是在遞迴棧進行入棧操作,而是直接覆蓋棧頂。線性遞迴與尾遞迴區別如下 線性遞迴 1 2 3 4 5 longrescuvie longn 尾遞迴 1 2 3 4 5 6 7 8 9 10 ...

尾遞迴優化

什麼是尾遞迴 尾遞迴就將遞迴呼叫寫在函式的尾部return 尾遞迴的好處 解決傳統遞迴的棧溢位問題 尾遞迴適合的業務場景 1.需要遞迴優化的函式沒有用timeout等非同步佇列進行遞迴呼叫函式自己 2.需要遞迴優化的遞迴函式的返回值不是每次都返回,而是條件性返回 尾遞迴優化後的遞迴demo meth...