10.遞迴演算法最佳解析

借來方向發表於2020-05-18

關注公眾號 碼哥位元組,設定星標獲取最新推送。後臺回覆 “加群” 進入技術交流群獲更多技術成長。

摘要:遞迴是一種應用非常廣泛的演算法(或者程式設計技巧)。之後我們要講的很多資料結構和演算法的編碼實現都要用到遞迴,比如 DFS 深度優先搜尋、前中後序二叉樹遍歷等等。所以,搞懂遞迴非常重要,否則,後面複雜一些的資料結構和演算法學起來就會比較吃力

推薦使用者註冊領取佣金很多人都遇到過,很多 App 在推廣的時候都是這個套路。「蕭何」引薦「韓信」加入劉邦陣營,「韓信」又引薦了那些年上鋪的兄弟「韓大膽」加入。我們就可以認為「韓大膽」的最終推薦人是「蕭何」,「韓信」的最終推薦人是「蕭何」,而「蕭何」沒有最終推薦人。

用資料庫記錄他們之間的關係,soldier_id 表示士兵 id,referrer_id 表示推薦人 id。

soldier_id reference_id
韓信 蕭何
韓大膽 韓信

那麼問題來了,給定一個士兵 id,如何查詢這個使用者的「最終推薦人」,帶著這個問題,我們正式進入遞迴。

遞迴三要素

有兩個最難理解的知識點,一個是 動態規劃一個是遞迴

大學軍訓,都會經歷過排隊報數,報數過程中自己開小差看見了一個漂亮小學姐,不知道旁邊的哥們剛說的數字,所以再問一下左邊哥們剛報了多少,只要在他說的數字 + 1 就知道自己樹第幾個了,關鍵是現在你旁邊的哥們 看見漂亮小學姐竟然忘記剛剛自己說的數字了,也要繼續問他左邊的老鐵,就這樣一直往前問,直到第一個報數的孩子,然後一層層把數字傳遞到自己。

這就是一個非常標準的遞迴求解過程,問的過程叫「遞」,回來的過程交「歸」。轉換成遞推公式:

f(n)=f(n-1) + 1, 存在 f(1) = 1

f(n) 表示自己的數字,f(n - 1) 表示前面一個人的報數,f(1) 表示第一個人知道自己是第一個報的數字

根據遞推公式,很容易的轉換成遞迴程式碼:

public int f(int n) {
  if (n == 1) return 1;
  return f(n-1) + 1;
}

到底什麼問題可以用遞迴解決呢?總結了三個必要元素,只要滿足一下三個條件,就可以使用遞迴解決。

1.一個問題可以分解多個子問題

就是可以分解恆數字規模更小的問題,比如要知道自己的報數,可以分解『前一個人的報數』這樣的子問題。

2.問題本身與分解後的子問題,除了資料規模不同,求解演算法相同

『求解自己的報數』和前面一個人『求解自己的報數』思路是一模一樣。

3.存在遞迴終止條件

問題分解成子問題的過程中,不能出現無限迴圈,所以需要一個終止條件,就像第一排或者其中任何一個知道自己報數的孩子不需要再詢問上一個人的數字,f(1) = 1 就是遞迴終止條件。

如何編寫遞迴程式碼

其實最關鍵的就是 寫出遞推公式,找到終止條件,然後把遞推公式轉成 程式碼就容易多了。

再舉一個「青蛙跳臺階」的演算法問題,假設有 n 個臺階,每次可以跳 1 個或者 2 個臺階,走這 n 個臺階有多少種走法?

再仔細想想,實際上,根據第一步的走法可以把所有的走法分兩類,第一類是第一步走了 1 個臺階,另一種是第一步走了 2 個臺階。所以 n 個臺階的走法就等於先走 1 階後, n-1 個臺階的走法 + 先走 2 階後, n-2 個臺階的走法。

f(n) = f(n-1) + f(n-2)

繼續分析終止條件,當只有一個臺階的時候不需要再繼續遞迴,f (1) = 1。似乎還不夠,假如有兩個臺階呢?分別用 n = 2、n=3 驗證下。f(2) = 2 也是終止條件之一。

所以該遞迴的終止條件就是 f(1) = 1,f(2) = 2。

f(1) = 1;
f(2) = 2;
f(n) = f(n-1) + f(n-2);

根據公式轉成程式碼則是

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

劃重點了:寫遞迴大媽的關鍵就是找到如何將大問題分解成小問題的規律,並且基於此寫出遞推公式,再推出終止條件,租後將地推公式和終止條件翻譯成程式碼。

對於遞迴程式碼,我們不要試圖去弄清楚整個遞和歸的問題,這個不適合我們的正常思維,我們大腦更適合平鋪直敘的思維,當看到遞迴切勿妄想把遞迴過程平鋪展開,否則會陷入一層一層往下呼叫的迴圈。

當遇到一個問題 1 可以分解若干個 2,3,4 問題,我們只要假設 2,3,4 已經解決,在此基礎上思考如何解決 A。這樣就容易多了。

所以當遇到遞迴,編寫 程式碼的關鍵就是 把問題抽象成一個遞推公式,不要想一層層的呼叫關係,找到終止條件。

防止棧溢位

遞迴最大的問題就是要防止棧溢位以及死迴圈。為何遞迴容易造成棧溢位呢?我們回想下之前說過的棧資料結構,不清楚的朋友可以翻閱歷史文章。函式呼叫會使用棧來儲存臨時變數,每次呼叫一個函式都會把臨時變數封裝成棧幀壓入執行緒對應的棧中,等方法結束返回時,才出棧。如果遞迴的資料規模比較大,呼叫層次很深就會導致一直壓入棧,而棧的大小通常不會很大就會導致堆疊溢位的情況。

Exception in thread "main" java.lang.StackOverflowError

如何防止呢?

我們只能在程式碼裡面限制最大深度,直接返回錯誤,使用一個全域性變數表示遞迴的深度,每次執行都 + 1,當超過指定閾值還沒有結束的時候直接返回錯誤。

警惕重複計算

青蛙跳臺階的問題就有重複計算的問題,我們試著把遞迴過程分解下,想要計算 f(5),需要先計算 f(4) 和 f(3),而計算 f(4) 還需要計算 f(3),因此,f(3) 就被計算了很多次,這就是重複計算問題。為了避免重複計算,我們可以通過一個資料結構(比如 HashMap)來儲存已經求解過的 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 (hasSolvedMap.containsKey(n)) {
    return hasSovledMap.get(n);
  }

  int ret = f(n-1) + f(n-2);
  hasSovledMap.put(n, ret);
  return ret;
}

遞迴的空間複雜度因為每次呼叫都會在棧上儲存一次臨時變數,所以它的空間複雜度就是 O(N),而不是 O(1)。

如何將遞迴轉換成非遞迴程式碼

遞迴有利有弊,遞迴寫起來很簡潔,而不好的地方就是空間複雜度是 O(n),有堆疊溢位風險,存在重複計算。要根具體情況來選擇是否需要遞迴。

還是軍訓排隊報數的例子,如何變成非遞迴。

f(n) = f(n-1) +1;

public int f(n) {
  int r = 1;
  for(int i = 2; i <= n; i++) {
    r += 1;
  }
  return r;
}

對於臺階問題也是可以改成迴圈實現。

public int f(int n) {
  if (n == 1) return 1;
  if (n == 2) return 2;

  int ret = 0;
  int pre = 2;
  int prepre = 1;
  for (int i = 3; i <= n; ++i) {
    ret = pre + prepre;
    prepre = pre;
    pre = ret;
  }
  return ret;
}

尋找最佳推薦人

現在遞迴說完了,我們如何解答開篇的問題:根據士兵 id 找到最佳推薦人?

public int findRootReferId(int soldierId) {
  Integer referId = "select reference_id from [table] where soldier_id = soldierId";
  if (referId == null) return soldierId;
  return findRootReferId(referId);
}

遞迴是一種非常高效、簡潔的編碼技巧。只要是滿足“三個條件”的問題就可以通過遞迴程式碼來解決。

不過遞迴程式碼也比較難寫、難理解。編寫遞迴程式碼的關鍵就是不要把自己繞進去,正確姿勢是寫出遞推公式,找出終止條件,然後再翻譯成遞迴程式碼。

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

碼哥位元組

推薦閱讀

1.跨越資料結構與演算法

2.時間複雜度與空間複雜度

3.最好、最壞、平均、均攤時間複雜度

4.線性表之陣列

5.連結串列導論-心法篇

6.單向連結串列正確實現方式

7.雙向連結串列正確實現

8.棧實現瀏覽器的前進後退

9.佇列-生產消費模式

原創不易,覺得有用希望隨手「在看」「收藏」「轉發」三連。

相關文章