如何用Python遞歸地思考問題?

遞歸是一個很經典的算法,在實際中應用普遍,也是面試中經常會提到的問題。本文就遞歸算法介紹如何在Python中實現遞歸的思想,以及遞歸在Python中使用時的一些注意事項,但願可以對使用Python的朋友提供一些幫助。python


1通俗地認識遞歸面試


爲了更通俗的解釋遞歸,咱們經過一個簡單的例子來講明。聖誕節到了,聖誕老人要給4個小朋友發禮物。每一年過節,聖誕老人都會將禮物一家接一家的送,直到送完。這個過程咱們能夠經過一個簡單的循環來實現,以下:算法

houses = ["Eric's house""Kenny's house""Kyle's house""Stan's house"]

def deliver_presents_iteratively():
    for house in houses:
        print("Delivering presents to", house)


循環執行結果以下:緩存

>>> deliver_presents_iteratively()
Delivering presents to Eric's house
Delivering presents to Kenny's house
Delivering presents to Kyle's house
Delivering presents to Stan's house

可是今天聖誕老人以爲太累了,想偷個懶,不想本身一個個的送了。忽然間腦殼靈光一閃,他想出了一個辦法,可讓孩子們幫他來送禮物,並經過孩子們傳遞下去。這樣不但可讓孩子們感覺過節的氣氛,本身也能夠省一部分力氣,簡直是一箭雙鵰啊。因而乎,聖誕老人開始執行這個策略。數據結構

1. 先指定一名小朋友,而後將全部的工做交給他。app

2. 根據小朋友所負責的房子數量,來分配他們各自的職位和工做內容。ide

  • 若是房子數量>1,那麼他就是一個管理者,並能夠再指定兩名小朋友來幫他完成他負責的工做。函數

  • 若是房子數量=1,那麼他就是一個工做人員,他必須將禮物送到指定的房子。優化


圖片


這就是一個典型的遞歸算法結構。核心的思想就是:若是眼下的問題是一個最簡單的問題,那麼解決它。若是不是最簡單的,那就將問題劃分,直到成爲最簡單問題,再運用一樣的策略進行解決。
spa


用Python語言來實現以上遞歸思想能夠這樣作:

houses = ["Eric's house""Kenny's house""Kyle's house""Stan's house"]

# 每次函數調用都表明一個小朋友負責的工做
def deliver_presents_recursively(houses):
    # 工做人員經過送禮物,來執行工做
    if len(houses) == 1:
        house = houses[0]
        print("Delivering presents to", house)

    # 管理者經過分配工做,來執行所負責的工做
    else:
        mid = len(houses) // 2
        first_half = houses[:mid]
        second_half = houses[mid:]

        # 將工做劃分給另外兩個小朋友
        deliver_presents_recursively(first_half)
        deliver_presents_recursively(second_half)


執行結果以下:

>>> deliver_presents_recursively(houses)
Delivering presents to Eric's house
Delivering presents to Kenny's house
Delivering presents to Kyle's house
Delivering presents to Stan's house

2Python中的遞歸函數


相信經過以上的舉例,你們對遞歸已經有了一個初步的認識。如今來正式地介紹一下遞歸函數的定義。若是一個函數直接或者間接地調用函數自己,那麼就是遞歸函數


這意味着,函數將不斷的調用自己並重複函數的內容,直到達到某個條件才返回一個結果。全部的遞歸函數都有着一樣的結構,這個結構由兩部分組成:基礎部分,遞歸部分。


爲了更好地說明這個結構,咱們舉一個例子說明,來寫一個遞歸函數計算n的階層(n!):

1. 遞歸部分:將原始問題(n!)分解爲最簡單而且相同的小問題。經過將n!分解咱們看到這個更小且相同的問題就是每次與比本身小於1的數字相乘(n*(n-1)!)

n! = x (n−1x (n−2x (n−3) ⋅⋅⋅⋅ x 3 x 2 x 1
n! 
x (n−1)!


2. 基礎部分:上面的遞歸部分將大的問題分解爲一個個相同的小問題,可是確定不會無限制的遞歸下去。咱們須要找到一個不能繼續往下遞歸的中止條件,也就是基礎部分。經過不斷分解n!咱們發現最後到達1的時候不能再繼續遞歸了,所以,1!就是咱們最後的基礎部分。

n! = x (n−1)! 
n! 
x (n−1x (n−2)!
n! 
x (n−1x (n−2x (n−3)!


n! 
x (n−1x (n−2x (n−3) ⋅⋅⋅⋅ x 3!
n! 
x (n−1x (n−2x (n−3) ⋅⋅⋅⋅ x 3 x 2!
n! 
x (n−1x (n−2x (n−3) ⋅⋅⋅⋅ x 3 x 2 x 1!

知道了遞歸結構中的這兩個部分,咱們在Python中來實現n!的遞歸算法:

def factorial_recursive(n):
    # 基礎部分: 1! = 1
    if n == 1:
        return 1

    # 遞歸部分: n! = n * (n-1)!
    else:
        return n * factorial_recursive(n-1)


執行結構以下:

>>> factorial_recursive(5)
120

雖然知道如何寫出一個遞歸算法了,可是對於程序背後的原理咱們也是要了解的。程序背後的底層場景是:每次遞歸調用會添加一個桟幀(包含它的執行內容)到棧,不斷添加直到達到了基礎部分的中止條件,而後棧再依次解開每一個調用並返回它的結果,能夠參考下圖。


3狀態維持


當處理遞歸函數時,每次遞歸調用都有本身的執行上下文,即每次遞歸調用之間的狀態都是獨立的。當咱們想每次遞歸的時候都更新一個狀態,並獲得最後的更新結果,那該怎麼辦呢?爲了維持遞歸中想要維持的狀態,咱們有兩種方法可使用:

  • 將狀態嵌入到每一次的遞歸調用中做爲參數。

  • 將狀態設置爲全局變量。


咱們使用一個例子來講明上面提到的兩種方法。好比,咱們要使用遞歸計算1+2+3...+10,這裏咱們必需要維持的狀態就是累積和


將狀態做爲參數遞歸調用

下面咱們使用第一種方法,即將狀態嵌入每次遞歸中維持狀態,來實現上面例子。

def sum_recursive(current_number, accumulated_sum):
    # 基礎部分
    # 返回最後狀態
    if current_number == 11:
        return accumulated_sum

    # 遞歸部分
    # 將狀態嵌入到每次遞歸調用中
    else:
        return sum_recursive(current_number + 1, accumulated_sum + current_number)


執行結果以下:

# 傳遞初始狀態
>>> sum_recursive(10)
55


圖片


設置狀態爲全局變量

下面咱們使用第二種方法,即設置全局變量,來實現上面例子。

# 全局變量
current_number = 1
accumulated_sum = 0


def sum_recursive():
    global current_number
    global accumulated_sum
    # 基礎部分
    if current_number == 11:
        return accumulated_sum
    # 遞歸部分
    else:
        accumulated_sum = accumulated_sum + current_number
        current_number = current_number + 1
        return sum_recursive()


執行結果以下:

>>> sum_recursive()
55

一般我更喜歡使用將狀態做爲函數參數的方法實現遞歸,由於全局變量是有一些弊端的。

4Python中的遞歸數據結構


若是一個數據結構能夠分解成一個個和本身同樣的更小的版本,那麼這個數據結構也能夠是遞歸的。列表就是一個遞歸數據結構的典型例子。下面,讓咱們就來驗證一下。如今有一個空的列表,而且能夠在列表上使用的惟一操做規定以下:

# 返回一個新的列表,返回結果爲在input_list表頭添加一個新元素
def attach_head(element, input_list):
    return [element] + input_list


經過使用空列表和attach_head操做,咱們就能夠生成任何列表了。例如,咱們想生成 [1,46,-31,"hello"]:

attach_head(1,                                                  # Will return [146-31"hello"]
            attach_head(46,                                     # Will return [46-31"hello"]
                        attach_head(-31,                        # Will return [-31"hello"]
                                    attach_head("hello", [])))) # Will return ["hello"]


上面實現過程以下:

圖片


遞歸數據結構和遞歸函數能夠一塊兒配合使用。一般咱們能夠將遞歸數據結構做爲遞歸函數的參數來實現遞歸。由於咱們知道了遞歸數據結構是遞歸的,咱們就能夠輕易地將遞歸數據結構拆分爲一個個更小而且相同小的問題,而後經過遞歸進行解決。


下面就是一個將列表做爲遞歸函數參數的例子,遞歸部分是利用了列表的切片操做,不斷切分列表爲更小的部分,中止條件就是直到列表爲空。

def list_sum_recursive(input_list):
    # 基礎部分
    if input_list == []:
        return 0

    # 遞歸部分
    else:
        head = input_list[0]
        smaller_list = input_list[1:]
        return head + list_sum_recursive(smaller_list)


執行結構以下:

>>> list_sum_recursive([123])
6

但列表並非惟一的遞歸數據結構。其它的還包括集合,樹,字典等

5遞歸的注意事項


在咱們用Python實現遞歸的過程當中,也有一些地方須要注意。


遞歸效率問題


咱們經過舉一個例子來講明,好比咱們要使用遞歸實現斐波那契數列。

遞歸部分: Fn = Fn-1 + Fn-2

基礎部分: F0 = 0 and F1 = 1


在Python中實現遞歸:

def fibonacci_recursive(n):
    print("Calculating F""(", n, ")", sep="", end=", ")

    # 基礎部分
    if n == 0:
        return 0
    elif n == 1:
        return 1

    # 遞歸部分
    else:
        return fibonacci_recursive(n-1) + fibonacci_recursive(n-2)


執行結果以下:

>>> fibonacci_recursive(5)
Calculating F(5),
Calculating F(4), 
Calculating F(3), 
Calculating F(2), 
Calculating F(1), 
Calculating F(0), 
Calculating F(1), 
Calculating F(2), 
Calculating F(1), 
Calculating F(0), 
Calculating F(3), 
Calculating F(2), 
Calculating F(1), 
Calculating F(0), 
Calculating F(1),

5

咱們發現計算過程當中有不少重複計算的部分,這樣會嚴重影響咱們遞歸實現的效率。那該如何優化一下呢?

Python中有一個強大的裝飾器:lru_cache,它主要是用來作緩存,能把相對耗時的函數結果進行保存,避免傳入相同的參數重複計算。LRU全稱爲Least Recently Used,相信好多朋友都知道這個算法,這裏不進行詳細講解了。


下面咱們來看一下加入裝飾器lru_cache以後效果如何。

from functools import lru_cache

@lru_cache(maxsize=None)
def fibonacci_recursive(n):
    print("Calculating F""(", n, ")", sep="", end=", ")

    # 基礎部分
    if n == 0:
        return 0
    elif n == 1:
        return 1

    # 遞歸部分
    else:
        return fibonacci_recursive(n-1) + fibonacci_recursive(n-2)


執行結果以下:

>>> fibonacci_recursive(5)
Calculating F(5), 
Calculating F(4), 
Calculating F(3), 
Calculating F(2), 
Calculating F(1), 
Calculating F(0),

5

從結果發現一些重複的計算過程已經消失,這樣就節省了不少時間,提高了遞歸的運行效率。但要注意的是:lru_cache是經過使用一個字典來緩存結果的,所以函數的位置和關鍵字參數(字典中的keys)必須是散列的。

遞歸深度問題


Python不支持tail-call elimination(尾調用消除)。所以,若是咱們使用了更多的桟幀,而且超過了默認的調用棧的深度,那麼你將會引發棧溢出的問題。


咱們經過getrecursionlimit觀察默認的遞歸深度限制,默認爲3000。因此,這個咱們須要注意一下。

>>> import sys
>>> sys.getrecursionlimit()
3000


一樣還有,Python的可變數據結構不支持結構化共享,若是把它們當成了不可變數據結構,那麼這將會對咱們的空間和GC(垃圾回收)效率形成很很差的影響。由於這樣作會沒必要要地複製不少可變對象做爲結尾,下面舉了一個簡單的例子說明。


>>> input_list = [123]
>>> head = input_list[0]
>>> tail = input_list[1:]
>>> print("head --", head)
head -- 1
>>> print("tail --", tail)
tail -- [23]

tail是經過複製建立的,所以,若是咱們在很大的列表上遞歸地重複用這個複製操做,那麼就會對咱們的空間和GC效率產生壞的影響。

https://realpython.com/python-thinking-recursively/

相關文章
相關標籤/搜索