10.遞歸算法最佳解析

關注公衆號 碼哥字節,設置星標獲取最新推送。後臺回覆 「加羣」 進入技術交流羣獲更多技術成長。java

摘要:遞歸是一種應用很是普遍的算法(或者編程技巧)。以後咱們要講的不少數據結構和算法的編碼實現都要用到遞歸,好比 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 = 二、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.隊列-生產消費模式

原創不易,以爲有用但願隨手「在看」「收藏」「轉發」三連。

相關文章
相關標籤/搜索