測試開發基礎之算法(7): 如何編寫遞歸代碼

遞歸其實一點不神祕,在日常生活中具有廣泛的應用。
比如,你想打聽小D同學的地址,但是你不認識小D,但認識小A,只能向小A打聽小D同學的地址,但是小A也不認識小D,但認識小B,只能向小B打聽小D同學的地址,同樣悲劇的是,小B不認識小D,但認識小C,只能向小C打聽小D同學的地址。正好小C認識小D,問到了小D的地址,就將小D的地址告訴了小B,小B又將地址告訴了小A,小A將地址告訴你了。我們把打聽地址的動作叫做“遞”,把反饋地址的動作叫做“歸”。整個過程可以用下面的圖來表示:
在這裏插入圖片描述

1. 遞歸可解決哪類問題

滿足以下三個條件,就可以用遞歸來解決。

  1. 原始問題的解可以分解爲幾個子問題的解
  2. 原始問題和子問題,只有數據規模的不同,求解思路完全一樣
  3. 存在遞歸終止條件

那我們看看上面你問小D地址的問題,是否可以用遞歸來解決,也就是是否符合上面3個條件呢?

首先,你問小D的地址,可以分解爲你問小A,小A問小B,小B問小C,小C問小D。

其次,你想得到小D的地址,需要經過3個人,而你問小A,小A問小B,小B問小C,小C問小D,只需要問一個人,數據規模不同

最後,終止條件是找到了小D,小D將地址告訴了小C。

2.寫遞歸代碼的套路

以斐波那契數列爲例,菲波那切數列是後一個元素是前兩個相鄰元素的和。比如:# 1,1,2,3,5,8,13,21,34,55,…。那麼我們如何得到第n個數是多少?

求第n個元素,可以先求出n-1和n-2個元素的值,然後再將這兩個求和。最終終止條件是第1個元素和第2個元素都是1。

寫遞歸代碼的關鍵就是找到如何將大問題分解爲小問題的規律,然後按照下面套路即可實現:

  1. 寫出遞推公式,
  2. 推敲終止條件

最後將遞推公式和終止條件翻譯成代碼。

def fibonacci(n):
    if n < 1:  # 遞歸終止條件
        return 0
    if n in [1, 2]:  # 遞歸終止條件
        val = 1
        return val 
    return fibonacci(n - 1) + fibonacci(n - 2)  # 遞歸公式

不過遞歸的算法時間複雜度是非常高的。後面我們需要對其進行優化。這裏重點理解遞歸公式和終止條件的寫法。

3.遞歸方法求階乘

再來看一個適合用遞歸方法解決的問題,求階乘。中學時學過一個正整數的階乘(factorial)是所有小於及等於該數的正整數的積,比如3的階乘是3!= 123,4的階乘是4!=1234,5的階乘是5!=1234*5 。

所以,遞歸求解階乘的套路,遞歸公式是,n的階乘可以表示成n!=n*(n-1)!。終止條件是1!等於1。

翻譯成代碼就是:


def factorial(n):
    if n == 1:  # 終止條件
        return 1 
    return n * factorial(n - 1)  # 遞歸公式

通過前面兩個例子,發現終止條件可能有1個或者多個。在求斐波那契數列時,終止條件包含三個:n0時,f(n)=0;n1時,f(n)=1;n2時,f(n)=1;而在求階乘時,終止條件就一個n1時,f(n)=1。

4.遞歸方法求n個臺階的走法

再鞏固一下遞歸代碼編寫的套路。

假如有 n 個臺階,每次可以跨 1 個臺階或者 2 個臺階,請問走這 n 個臺階有多少種走法?

分析:

我們從第一步開始想,如果第一步跨1個臺階,問題就變成了n-1個臺架有多少種走法。如果第一步跨2個臺階,問題就變成n-2個臺階有多少種走法。 我們把n-1個臺階的走法和n-2個臺階的走法求和,就是n個臺階的走法。用公式表示就是f(n)=f(n-1)+f(n-2)。這就是遞歸公式了。

再來看看終止條件,最後1個臺階就不需要再繼續遞歸了,只有一種走法,就是f(1)=1。我們把這個放到遞歸公式裏面看下,通過這個終止條件能否求出f(2),發現f(2)=f(1)+f(0),也就是僅知道f(1)是不能求出f(2)的,因此要麼知道f(0)的值,或者直接將f(2)作爲一個遞歸終止條件。f(0)表示0個臺階有幾種走法,f(2)表示2個臺階有幾種走法。明顯,f(2)更容易理解一些。所以定爲f(2)=2也是一個終止條件,表示最後2個臺階有兩種走法,即一次跨1個臺階和一次跨2個臺階。

有了f(1)和f(2),就能求出f(3),進而求出f(n)了。轉化成代碼即是:

def walk(n):
    if n == 1:  # 遞歸終止條件
        return 1
    if n == 2:  # 遞歸終止條件
        return 2
    return walk(n - 1) + walk(n - 2)  # 遞歸公式

通過上面三個例子,基本上掌握了編寫遞歸代碼的套路。這裏提個建議,對於閱讀遞歸代碼時,千萬不要試圖想清楚整個遞和歸的過程,一旦你的思路陷入遞和歸的過程,就會發現我們的腦容量就不夠用了。

不管是編寫遞歸還是閱讀遞歸代碼,只要遇到遞歸,我們就把它抽象成一個遞推公式,不用想一層層的調用關係,不要試圖用人腦搞清楚計算機每一步都是怎麼執行的。

5.避免堆棧溢出和重複計算

編寫遞歸代碼時,我們會遇到很多問題,比較常見的一個就是堆棧溢出,而堆棧溢出會造成系統性崩潰,後果會非常嚴重。什麼是堆棧溢出呢?

函數調用會使用棧來保存臨時變量。每調用一個函數,都會將臨時變量封裝爲棧幀壓入內存棧,等函數執行完成返回時,纔出棧。系統棧或者虛擬機棧空間一般都不大。如果遞歸求解的數據規模很大,調用層次很深,一直壓入棧,就會有堆棧溢出的風險。

那麼,如何避免出現堆棧溢出呢?

通常可以在代碼中限制遞歸調用的最大深度的方式來解決這個問題。比如Python語言,限制了遞歸深度,當遞歸深度過高,則會拋出:
RecursionError: maximum recursion depth exceeded in comparison異常,防止系統性崩潰。

我們在代碼中也可以自己設置遞歸的深度,比如限制n最大不能超過100,代碼如下

def walk(n):
    if n == 1:
        return 1
    if n == 2:
        return 2
    if n > 100:
        raise RecursionError("recursion depth exceede 100")
    return walk(n - 1) + walk(n - 2)

除此之外,使用遞歸時還會出現重複計算的問題。什麼意思?拿走臺階那個例子來說明。
比如計算6個臺階的走法f(6),過程如下圖:
在這裏插入圖片描述
從圖中,我們可以直觀地看到,想要計算 f(5),需要先計算 f(4) 和 f(3),而計算 f(4) 還需要計算 f(3),因此,f(3) 就被計算了很多次,這就是重複計算問題。

那麼怎麼解決這個問題?爲了避免重複計算,我們可以通過一個數據結構(比如散列表)來保存已經求解過的 f(k)。當遞歸調用到 f(k) 時,先看下是否已經求解過了。如果是,則直接從散列表中取值返回,不需要重複計算,這樣就能避免剛講的問題了。

修改下計算臺階走法的代碼,解決重複計算的問題:

data = dict()  # 保存中間結果


def walk(n):
    if n == 1:
        return 1
    if n == 2:
        return 2
    if n > 100:
        raise RecursionError("recursion depth exceede 100")
    if n in data:  # 如果在中間結果中,則直接返回,不用進入遞推公式再次計算
        print(n, data)
        return data[n]
    result = walk(n - 1) + walk(n - 2) 
    data[n] = result
    return result


print(walk(6))

6. 迭代循環代替遞歸

遞歸代碼都可以改爲迭代循環的非遞歸寫法。比如上面臺階走法的遞歸代碼可以改造成下面這種循環迭代的寫法。

def walk_by_iteration(n):
    if n == 1:
        return 1
    if n == 2:
        return 2
    result = 0
    pre = 2
    prepre = 1
    for i in range(3, n + 1):
        result = pre + prepre
        prepre = pre
        pre = result
    return result


print(walk_by_iteration(6))

這種思路實際上是將遞歸改爲了“手動”遞歸,本質並沒有變,而且也並沒有解決前面講到的某些問題。

7.leetcode練習題:各位相加

給定一個非負整數 num,反覆將各個位上的數字相加,直到結果爲一位數。
示例:
輸入: 38
輸出: 2
解釋: 各位相加的過程爲:3 + 8 = 11, 1 + 1 = 2。 由於 2 是一位數,所以返回 2。
進階:
你可以不使用循環或者遞歸,且在 O(1) 時間複雜度內解決這個問題嗎?
來源:力扣(LeetCode)
鏈接:https://leetcode-cn.com/problems/add-digits

根據要求,求一個數各個位之和,直到和爲個位數,即 n=a* 100+b* 10+c,求f(n)=a+b+c,
如果f(n)<10,則return f(n),否則return f(f(n));以此類推。遞歸終止條件是f(n)<10,遞歸公式是f(f(n))。遞歸代碼是:

def get_sum(num: int) -> int:
    if num < 10:  # 遞歸終止條件
        return num
    return get_sum(get_sum(num // 10) + (num % 10))  # 遞歸公式

不過這道題,利用遞歸公式,算法的時間複雜度比較高,是O(n),這個問題還可以歸納法,得到一個複雜度爲O(1)的算法。
當數字爲0-9時,結果爲它本身,
當數字大於9,且爲9的倍數時,結果爲9,
當數字大於9,且不爲9的倍數時,結果爲該數mod 9 的餘數。

def get_sum(num: int) -> int:
    if num > 9:
        num = num % 9
        if num == 0:
            return 9
    return num

8. 總結

遞歸有利有弊,利是遞歸代碼的表達力很強,寫起來非常簡潔;而弊就是空間複雜度高、有堆棧溢出的風險、存在重複計算、過多的函數調用會耗時較多等問題。所以,在開發過程中,我們要根據實際情況來選擇是否需要用遞歸的方式來實現。

遞歸代碼雖然簡潔高效,但是,遞歸代碼也有很多弊端。比如,堆棧溢出、重複計算、函數調用耗時多、空間複雜度高等,所以,在編寫遞歸代碼的時候,一定要控制好這些副作用。

參考文獻

  1. https://www.cnblogs.com/schut/p/10625111.html
  2. https://juejin.im/post/5d85cda3f265da03b638e918
  3. https://time.geekbang.org/column/intro/126?code=vYLsf%2F9ydTb8LyFk-UikatPQjcI-4FecJoiTMxlIwSU%3D&utm_term=SPoster
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章