前言:本篇文章只是記錄王爭的數據結構與算法之美的學習筆記,寫下來能強迫自己系統的再過一遍,加深理解。這門課
以實際開發中遇到的問題為例
,引入解決問題涉及到的的數據結構和算法,但不會講的太細,最好結合一本實體書進行學習。
在求解鏈表的問題時,經常會用到遞歸,并且之后的 DFS 深度優先搜索、前中后序二叉樹遍歷等,所以我們必須要了解遞歸。
1. 遞歸
去的過程叫"遞",回來的過程叫"歸",基本上,所有的遞歸問題都可以用遞推公式
來表示。
舉個例子,看電影去時,不知道自己是第幾排了,可以問前面一排的人他是第幾排,只要在他的數字上+1,就可以了,但是前面的人也不清楚,所以他也問他前面的人,這樣一排一排的問,直到問道第一排,第一排的人說我在第一排,然后一排一排的再把數字傳回來,直到你前面的人告訴你他在哪一排,于是就知道答案了,我們用遞推公式表示出來:
f(n) = f(n-1)+1,其中 f(1) = 1
改成遞歸代碼如下:
int f(int n){
if(n == 1) return 1;
return f(n-1) + 1;
}
2. 遞歸需要滿足的三個條件
- 一個問題的解可以分解為幾個子問題的解
- 這個問題與分解之后的子問題,除了數據規模不同,求解思路完全一樣
- 存在
遞歸終止條件
寫遞歸代碼最關鍵的是寫出遞推公式
,找到終止條件
,剩下將遞推公式轉化為代碼就很簡單了。
比如上臺階的問題,每次可以跨 1 個或 2 個臺階,請問走 n 個臺階需要有多少種走法?
我們可以根據第一步的走法把所有走法分為兩類,第一類是第一步走 1 個臺階,另一類是第一步走 2 個臺階,所以 n 個臺階的走法就等于先走 1 階后,n-1個臺階的走法 加上先走 2 階后,n-2個臺階的走法,用公式表達就是:
f(n) = f(n-1) + f(n-2)
再來看下終止條件,f(1) = 1,f(2) = 2,所以遞歸代碼如下所示:
int f(int n){
if(n==1) return 1;
if(n==2) return 2;
return f(n-1) + f(n-2);
}
寫遞歸代碼的關鍵就是:
- 1.找到如何將大問題分解為小問題的規律
- 2.并且基于此寫出遞推公式
- 3.然后再推敲終止條件
- 4.最后將遞推公式和終止條件翻譯成代碼
另外,不要試圖想清楚整個遞和歸的過程,人腦沒辦法都想清楚,如果一個問題A 可以分解為若干子問題 B C D,可以假設 B C D 已經解決,然后在此基礎上思考如何解決問題 A,不需要一層一層往下思考子問題和子子問題的關系,要屏蔽掉細節
。
只要遇到遞歸,就把它抽象成一個遞推公式,不用想一層層的調用關系。
3. 注意事項
3.1 遞歸代碼警惕堆棧溢出
遞歸就是自己調用自己,函數調用會使用棧來保存臨時變量
,每調用一次函數,都會將臨時變量封裝成棧幀壓入內存棧,等函數執行完成返回時,才出棧。如果遞歸求解的數據規模很大,調用層次很深,就會有堆棧溢出
的風險。
那么如何避免呢?
可以限制遞歸調用的最大深
,遞歸調用超過一定深度之后,就不繼續遞歸了,直接返回報錯,比如電影院的那個例子:
// 全局變量,表示遞歸的深度。
int depth = 0;
int f(int n) {
++depth;
if (depth > 1000) throw exception;
if (n == 1) return 1;
return f(n-1) + 1;
}
如果最大深度比較小,可以用此種方法。
3.2 警惕重復計算
第二個上臺階問題的遞歸過程分分解圖:
從圖中可以看到,想要計算f(5),需要先計算 f(4) 和 f(3),而計算 f(4) 還需要計算 f(3),因此,f(3) 就被計算了很多次,這就是重復計算問題。
我們可以用散列表來解決這個問題,保存已經求解過的 f(k),當遞歸調用到 f(k)時,如果之前已經求解過,則直接從散列表中取值,不需要重復計算,代碼如下:
public int f(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
// hasSolvedList可以理解成一個Map,key是n,value是f(n)
if (hasSolvedList.containsKey(n)) {
return hasSolvedList.get(n);
}
int ret = f(n-1) + f(n-2);
hasSolvedList.put(n, ret);
return ret;
}
在空間復雜度的計算上,遞歸調用一次就會在內存棧中保存一次現場數據
,所以上面講到的電影院的遞歸代碼,空間復雜度為 O(n)。
遞歸優點:
- 表達力強
- 代碼簡潔
遞歸缺點:
- 空間復雜度高
- 有堆棧溢出風險
- 存在重復計算、過多函數調用導致耗時較多等問題
4. 遞歸改為非遞歸
所有的遞歸代碼都可以改為迭代循環的非遞歸寫法
,遞歸本身就是借助棧來實現的
,只不過使用的棧是系統或者虛擬機提供的。如果我們自己實現棧,手動模擬入棧、出棧過程,這樣就可以了。
5. 總結
遞歸就是借助棧實現的,需要注意它的空間復雜度。
寫遞歸代碼先要寫出遞推公式,然后找出終止條件,再翻譯成遞歸代碼。
要注意堆棧溢出、重復計算、函數調用耗時多、空間復雜度高問題。
遞歸是需要配合其他數據結構來進行的,在以后的練習題中會經常遇到遞歸了。