Python遞歸優化方法

遞歸棧溢出

  Python的遞歸調用棧的深度有限制,默認深度爲998,能夠經過sys.getrecursionlimit()查看。python

  針對遞歸棧溢出,咱們能夠將默認深度設置爲大一些,這樣不會報錯,可是再大的深度總歸是有限的,並且深度越大對內存的佔用也就越大,這對咱們的程序是不利的。因此通常狀況下咱們不要將棧的深度設定太大。app

  但有時候咱們又須要無限但遞歸,這裏咱們就能夠用到尾遞歸。函數

 

尾遞歸

  尾遞歸在不少語言中均可以被編譯器優化, 基本都是直接複用舊的執行棧, 不用再建立新的棧幀, 原理上其實也很簡單, 由於尾遞歸在本質上看的話遞歸調用是整個子過程調用的最後執行語句, 因此以前的棧幀的內容已經再也不須要, 徹底能夠被複用。優化

  須要注意的是, 必定記住尾遞歸的特色: 遞歸調用是整個子過程調用的最後一步,return的時候不能出現計算。不然就不是真正的尾遞歸了, 以下就不是真正的尾遞歸, 雖然遞歸調用出如今尾部spa

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

 

  很明顯遞歸調用並非整個計算過程的最後一步, 計算fib(n)是須要先遞歸求得fib(n-1)和fib(n-2), 而後作一步加法才能獲得最終的結果。線程

以下是尾遞歸指針

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

 

  然而!!!Python語言的編譯器是不支持尾遞歸的!!!

  上面那串紅字是什麼意思呢,即便你用了尾遞歸的語法寫了一串遞歸的代碼,可是最後仍是會報深度問題的錯,由於python的源碼中並無集成對尾遞歸的支持。。。code

  怎麼辦呢?有幾種解決辦法:對象

  1. 修改源碼,若是你不怕會有後續錯誤的話。。
  2. 將上述代碼最後的return改成yield,而後在調用的時候用next,利用生成器實現。(會出一個問題,若是遞歸的函數須要傳參,而參數是會變化的話,你會發現每次調用參數都不會變。。)
  3. 往下看~~

 

  關於Python中的尾遞歸調用有一段神奇的代碼:blog

import sys

class TailCallException(BaseException):
    def __init__(self, args, kwargs):
        self.args = args
        self.kwargs = kwargs

def tail_call_optimized(func):
    def _wrapper(*args, **kwargs):
        f = sys._getframe()
        if f.f_back and f.f_back.f_back and f.f_code == f.f_back.f_back.f_code:
            raise TailCallException(args, kwargs)

        else:
            while True:
                try:
                    return func(*args, **kwargs)
                except TailCallException, e:
                    args = e.args
                    kwargs = e.kwargs
    return _wrapper



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

r = fib(1200, 0, 1) #不報錯!突破了調用棧的深度限制!只要加上裝飾器,尾遞歸就實現了!!

 

  嗯,沒錯,就是這麼簡單。之後要想實現尾遞歸都時候就複製上面裝飾器以上都代碼,而後將遞歸函數加上該裝飾器就OK。

 

以上的代碼是怎樣的工做的呢?

  理解它須要對Python虛擬機的函數調用有必定的理解。其實以上代碼和其餘語言對尾遞歸的調用的優化原理都是類似的,那就是在尾遞歸調用的時候重複使用舊的棧幀, 由於以前說過, 尾遞歸自己在調用過程當中, 舊的棧幀裏面那些內容已經沒有用了, 因此能夠被複用。

  Python的函數調用首先要了解code objectfunction objectframe object這三個object(對象),

  • code object是靜態的概念, 是對一個可執行的代碼塊的抽象, module, function, class等等都會被生成code object, 這個對象的屬性包含了」編譯器」(Python是解釋型的,此處的編譯器準確來講只是編譯生成字節碼的)對代碼的靜態分析的結果, 包含字節碼指令, 常量表, 符號表等等。
  • function object是函數對象, 函數是第一類對象, 說的就是這個對象。當解釋器執行到def fib(...)語句的時候(MAKE_FUNCTION), 就會基於code object生成對應的function object。可是生成function object並無執行它, 當真正執行函數調用的時候, fib(...)這時候對應的字節碼指令(CALL_FUNCITON), 能夠看一下, CPython的源碼, 真正執行的時候Python虛擬機會模擬x86CPU執行指令的大體結構, 而運行時棧幀的抽象就是frame obejct, 這玩意兒就模擬了相似C裏面運行時棧, 寄存器等等運行時狀態, 當函數內部又有函數調用的時候, 則又會針對內部的嵌套的函數調用生成對應的frame object, 這樣看上去整個虛擬機就是一個棧幀連着又一個棧幀, 相似一個鏈表, 當前棧幀經過f_back這個指針指向上一棧幀, 這樣你才能在執行完畢, 退出當前幀的時候回退到上一幀。和C裏執行棧的增加退出模式很像。
  • frame object棧幀對象只有在當前函數執行的時候纔會產生, 因此你只能在函數內經過sys._getframe()調用來獲取當前執行幀對象。經過f.f_back獲取上一幀, f.f_back.f_back來獲取當前幀的上一幀的上一幀(當前幀的「爺爺」)。

  另一個須要注意到的是, 對於任何對尾遞歸而言, 其執行過程能夠線性展開, 此時你會發現, 最終結果的產生徹底能夠從任意中間狀態開始計算, 最終都能獲得一樣的執行結果。若是把函數參數看做狀態(state_N)的話, 也就是tail_call(state_N)->tail_call(state_N-1)->tail_call(state_N-2)->...->tail_call(state_0), state_0是遞歸臨界條件, 也就是遞歸收斂的最終狀態, 而你在執行過程當中, 從任一塊兒始狀態(state_N)到收斂狀態(state_0)的中間狀態state_x開始遞歸, 均可以獲得一樣的結果。

  當Python執行過程當中發生異常(錯誤)時(或者也能夠直接手動拋出raise ...), 該異常會從當前棧幀開始向舊的執行棧幀傳遞, 直到有一箇舊的棧幀捕獲這個異常, 而該棧幀以後(比它更新的棧幀)的棧幀就被回收了。

 

有了以上的理論基礎, 就能理解以前代碼的邏輯了:

  1. 尾遞歸函數fib被tail_call_optimized裝飾, 則fib這個名字實際所指的function object變成了tail_call_optimized裏return的_wrapper, fib 指向_wrapper。

  2. 注意_wrapper裏return func(*args, **kwargs)這句, 這個func仍是未被tail_call_optimized裝飾的fib(裝飾器的基本原理), func是實際的fib, 咱們稱之爲real_fib。

  3. 當執行fib(1200, 0, 1)時, 實際是執行_wrapper的邏輯, 獲取幀對象也是_wrapper對應的, 咱們稱之爲frame_wapper。

  4. 因爲咱們是第一次調用, 因此」if f.f_back and f.f_back.f_back and f.f_code == f.f_back.f_back.f_code」這句裏f.f_code==f.f_back.f_back.f_code顯然不知足。

  5. 繼續走循環, 內部調用func(*args, **kwargs), 以前說過這個func是沒被裝飾器裝飾的fib, 也就是real_fib。

  6. 因爲是函數調用, 因此虛擬機會建立real_fib的棧幀, 咱們稱之爲frame_real_fib, 而後執行real_fib裏的代碼, 此時當前線程內的棧幀鏈表按從舊到新依次爲: 舊的虛擬機棧幀,frame_wrapper,frame_real_fib(當前執行幀)

 

real_fib裏的邏輯會走return fib(n-1, b, a+b), 有一個嵌套調用, 此時的fib是誰呢?此時的fib就是咱們的_wrapper, 由於咱們第一步說過, fib這個名字已經指向了_wrapper這個函數對象。

  1. 依然是函數調用的一套, 建立執行棧幀, 咱們稱之爲frame_wrapper2, 注意: 執行棧幀是動態生成的, 雖然對應的是一樣函數對象(_wrapper), 但依然是不一樣的棧幀對象, 因此稱之爲frame_wrapper2。 從此進入frame_wrapper2執行, 注意此時的虛擬機的運行時棧幀的結構按從舊到新爲:
                     舊的虛擬機棧幀、frame_wrapper、frame_real_fib、frame_wrapper2(當前執行棧幀)

  2. 進入frame_wrapper2執行後, 首先獲取當前執行幀, 即frame_wrapper2, 緊接着, 執行判斷, 此時:

    if f.f_back and f.f_back.f_back and f.f_code == f.f_back.f_back.f_code

  以上這句就知足了, f.f_code是當前幀frame_wrapper2的執行幀的code對象, f.f_back.f_back.f_code從當前的執行幀鏈表來看是frame_wrapper的執行幀的code對象, 很顯然他們都是同一個code塊的code object(def _wrapper…..)。因而拋出異常, 經過異常的方式, 把傳過來的參數保留, 而後, 異常向舊的棧幀傳遞, 直到被捕獲, 而以後的棧幀被回收, 即拋出異常後, 直到被捕獲時, 虛擬機內的執行幀是:舊的虛擬機棧幀、frame_wrapper(當前執行幀)

  因而如今恢復執行frame_wrapper這個幀, 直接順序執行了, 因爲是個循環, 同時參數經過異常的方式被捕獲, 因此又進入了return func(*args, **kwargs)這句, 根據咱們以前說的, 尾遞歸從遞歸過程當中任意中間狀態均可以收斂到最終狀態, 因此就這樣, 執行兩個幀, 搞出中間狀態, 而後拋異常, 回收兩個幀, 這樣一直循環直到求出最終結果。

  在整個遞歸過程當中, 沒有頻繁的遞歸一次, 生成一個幀, 若是你不用這個優化, 可能你遞歸1000次, 就要生成1000個棧幀, 一旦達到遞歸棧的深度限制, 就掛了。

  使用了這個裝飾器以後, 最多生成3個幀, 隨後就被回收了, 因此是不可能達到遞歸棧的深度的限制的。

注意: 這個裝飾器只能針對尾遞歸使用。

相關文章
相關標籤/搜索