Java 算法 - 遞歸算法
數據結構與算法之美目錄(https://www.cnblogs.com/binarylei/p/10115867.html)
遞歸本質是借助棧的數據結構,加上一個簡單的邏輯算法實現。
遞歸是一種應用非常廣泛的算法,很多數據結構和算法都要用到遞歸,比如 DFS 深度優先搜索、前中后序二叉樹遍歷等等。所以,搞懂遞歸非常重要,否則,后面復雜一些的數據結構和算法學起來就會比較吃力。
我們以斐波那契數列分析一下遞歸算法。
# 斐波那契數列:后一個數等於前兩個數之和
1 1 2 3 5 8 13 21 34 ...
1. 如何編寫遞歸
1.1 遞歸的條件
究竟什么樣的問題可以用遞歸來解決呢?只要同時滿足以下三個條件,就可以用遞歸來解決:
-
一個問題的解可以分解為幾個子問題的解,這些分解后的子問題,除了數據規模不同,求解思路完全一樣。
何為子問題?子問題就是數據規模更小的問題。在斐波那契數列中,就是求出前兩個數之和。
-
根據分解后的子問題,寫出遞歸公式。
在斐波那契數列中,就是 f(n) = f(n -1) + f(n -2)。
-
存在遞歸終止條件。
把問題分解為子問題,把子問題再分解為子子問題,一層一層分解下去,不能存在無限循環,這就需要有終止條件。在斐波那契數列中,存在多個終止條件,也就是 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 的單步跟蹤功能,像規模比較大、遞歸層次很深的遞歸代碼,幾乎無法使用這種調試方式。對於遞歸代碼,你有什么好的調試方法呢?
- 打印日志發現,遞歸值。
- 結合條件斷點進行調試。
調試遞歸就像寫遞歸一樣,不要被每一步的細節所困,重點在於確認遞推關系與結束條件是否正確,用條件斷點着重調試最初兩步與最終兩步即可。
推薦文章:
- 《如何將遞歸調用轉化為非遞歸代碼》:https://mp.weixin.qq.com/s/Ki3WN2AJ5HhxxmaQ0lVh3Q
每天用心記錄一點點。內容也許不重要,但習慣很重要!