Java 算法 - 遞歸算法


Java 算法 - 遞歸算法

數據結構與算法之美目錄(https://www.cnblogs.com/binarylei/p/10115867.html)

遞歸本質是借助棧的數據結構,加上一個簡單的邏輯算法實現。

遞歸是一種應用非常廣泛的算法,很多數據結構和算法都要用到遞歸,比如 DFS 深度優先搜索、前中后序二叉樹遍歷等等。所以,搞懂遞歸非常重要,否則,后面復雜一些的數據結構和算法學起來就會比較吃力。

我們以斐波那契數列分析一下遞歸算法。

# 斐波那契數列:后一個數等於前兩個數之和
1 1 2 3 5 8 13 21 34 ...

1. 如何編寫遞歸

1.1 遞歸的條件

究竟什么樣的問題可以用遞歸來解決呢?只要同時滿足以下三個條件,就可以用遞歸來解決:

  1. 一個問題的解可以分解為幾個子問題的解,這些分解后的子問題,除了數據規模不同,求解思路完全一樣。

    何為子問題?子問題就是數據規模更小的問題。在斐波那契數列中,就是求出前兩個數之和。

  2. 根據分解后的子問題,寫出遞歸公式。

    在斐波那契數列中,就是 f(n) = f(n -1) + f(n -2)。

  3. 存在遞歸終止條件。

    把問題分解為子問題,把子問題再分解為子子問題,一層一層分解下去,不能存在無限循環,這就需要有終止條件。在斐波那契數列中,存在多個終止條件,也就是 f(1) = 1 和 f(2) = 1,這就是遞歸的終止條件。

1.2 如何編寫遞歸代碼

寫遞歸代碼的關鍵就是找到如何將大問題分解為小問題的規律,並且基於此寫出遞推公式,然后再推敲終止條件,最后將遞推公式和終止條件翻譯成代碼。只要遇到遞歸,我們就把它抽象成一個遞推公式,不用想一層層的調用關系,不要試圖用人腦去分解遞歸的每個步驟。

public int fibonacci(int n) {
    if (n == 1 || n == 2) {
        return 1;
    }
    return fibonacci(n - 1) + fibonacci(n - 2);
}

2. 總結

2.1 注意事項

(1)警惕堆棧溢出

編寫遞歸代碼時,如果遞歸層次太深,會出現堆棧溢出。而堆棧溢出會造成系統性崩潰,后果會非常嚴重。

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

我們又該如何預防堆棧溢出呢?我們可以通過限制遞歸調用的最大深度來解決堆棧溢出問題。但這種做法並不能完全解決問題,因為最大允許的遞歸深度跟當前線程剩余的棧空間大小有關,事先無法計算。如果實時計算,代碼過於復雜,就會影響代碼的可讀性。

  • 如果限制深度比較小,可以限制遞歸深度。比如 10、50,就可以用這種方法,否則這種方法並不是很實用。
  • 如果限制深度比較大,就只能自己模擬一個棧,用非遞歸代碼實現

(2)警惕重復計算

如上的斐波那契數列,計算 f(5) 時需要計算 f(4) 和 f(3),計算 f(4) 又要計算 f(4) 和 f(2),這樣會造成大量的重復計算,效率非常低。我們可以將中間計算結果緩存起來,這樣可以避免大量重復計算。

// 遞歸,動態規划。將計算的中間結果緩存起來。
public int fibonacci(int n) {
    return fibonacci(n, new HashMap<>());
}
private int fibonacci(int n, Map<Integer, Integer> resolved) {
    if (resolved.containsKey(n)) {
        return resolved.get(n);
    }
    
    int value;
    if (n == 1 || n == 2) {
        value = 1;
    } else {
        value = fibonacci(n - 1, resolved) + fibonacci(n - 2, resolved);
    }
    resolved.put(n, value);
    return value;
}

(3)警惕死循環

如果遞歸的數據出現 A-B-C-D-A,則會讓遞歸陷入死循環中。

2.2 非遞歸改寫

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

public int fibonacci(int n) {
    int num1 = 1;
    int num2 = 1;
    if (n == 1 || n == 2) return 1;

    for (int i = 3; i <= n; i++) {
        int tmp = num1;
        num1 = num2;
        num2 = tmp + num2;
    }
    return num2;
}

那是不是所有的遞歸代碼都可以改為這種迭代循環的非遞歸寫法呢?

籠統地講,是的。因為遞歸本身就是借助棧來實現的,只不過我們使用的棧是系統或者虛擬機本身提供的,我們沒有感知罷了。如果我們自己在內存堆上實現棧,手動模擬入棧、出棧過程,這樣任何遞歸代碼都可以改寫成看上去不是遞歸代碼的樣子。但是這種思路實際上是將遞歸改為了 ”手動“ 遞歸,本質並沒有變,而且也並沒有解決前面講到的某些問題,徒增了實現的復雜度。

2.3 遞歸調試

我們平時調試代碼喜歡使用 IDE 的單步跟蹤功能,像規模比較大、遞歸層次很深的遞歸代碼,幾乎無法使用這種調試方式。對於遞歸代碼,你有什么好的調試方法呢?

  1. 打印日志發現,遞歸值。
  2. 結合條件斷點進行調試。

調試遞歸就像寫遞歸一樣,不要被每一步的細節所困,重點在於確認遞推關系與結束條件是否正確,用條件斷點着重調試最初兩步與最終兩步即可。

推薦文章:

  1. 《如何將遞歸調用轉化為非遞歸代碼》:https://mp.weixin.qq.com/s/Ki3WN2AJ5HhxxmaQ0lVh3Q

每天用心記錄一點點。內容也許不重要,但習慣很重要!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM