python遞迴 Python 與尾遞迴優化

2021-10-11 19:47:07 字數 4794 閱讀 5297

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

def factorial(n):  

if n == 0:

return 1

return 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

def tail_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.

"""def func(*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

def factorial(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

def fib(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 與尾遞迴優化​aisk.me

python的遞迴 Python 遞迴

鍥子 先看一段函式 defstory s 從前有個山,山里有座廟,廟裡老和尚講故事,講的什麼呢?print s story story 初識遞迴 遞迴的定義 在乙個函式裡再呼叫這個函式本身 現在我們已經大概知道剛剛講的story函式做了什麼,就是在乙個函式裡再呼叫這個函式本身,這種魔性的使用函式的方...

Python 遞迴與尾遞迴 事例詳解

小明帯了一筆錢出去旅遊,出門的第一天花了 半,加10元,往後的每天都花之前一天剩下的半,加10元,第八天的早上時候小明只剩下了10元錢,寫段 計算小明出去的時候帶了多少錢?一般遞迴 每一級遞迴,都會呼叫函式本身,會建立新的棧,隨著遞迴深度的增加,建立的棧會越來越多,最終會造成爆棧。def money...

python遞迴函式例項 python遞迴函式

python遞迴函式 什麼是遞迴?遞迴,就是在函式執行中自己呼叫自己 示例 def recursion n 定義遞迴函式 print n 列印n recursion n 1 在函式的執行種呼叫遞迴 recursion 1 呼叫函式 這個函式在不斷的自己呼叫自己,每次呼叫n 1,看下執行結果 998t...