數據結構與算法-day3-遞歸

個人理解:

遞歸是一種編程技巧(算法),簡單理解其實就是本身調用本身,有點像數學中的java

f(n) = f(n-1) + 1; f(n) = f(n-1) + f(n-2); f(n)=n*f(n-1);程序員

遞歸的優缺點算法

  1. 優勢:代碼的表達力很強,寫起來簡潔。
  2. 缺點:空間複雜度高、有堆棧溢出風險、存在重複計算、過多的函數調用會耗時較多等問題。

如何理解「遞歸」?

遞歸是一種應用很是普遍的算法(或者編程技巧)。以後咱們要講的不少數據結構和算法的編碼實現都要用到遞歸,好比 DFS 深度優先搜索、前中後序二叉樹遍歷等等。因此,搞懂遞歸很是重要,不然,後面複雜一些的數據結構和算法學起來就會比較吃力。編程

e.g:bash

週末你帶着女友去電影院看電影,女友問你,我們如今坐在第幾排啊?電影院裏面太黑了,看不清,無法數,如今你怎麼辦?數據結構

別忘了你是程序員,這個可難不倒你,遞歸就開始排上用場了。因而你就問前面一排的人他是第幾排,你想只要在他的數字上加一,就知道本身在哪一排了。可是,前面的人也看不清啊,因此他也問他前面的人。就這樣一排一排往前問,直到問到第一排的人,說我在第一排,而後再這樣一排一排再把數字傳回來。直到你前面的人告訴你他在哪一排,因而你就知道答案了。數據結構和算法

這就是一個很是標準的遞歸求解問題的分解過程,去的過程叫「遞」,回來的過程叫「歸」。基本上,全部的遞歸問題均可以用遞推公式來表示。剛剛這個生活中的例子,咱們用遞推公式將它表示出來就是這樣的:函數

f(n)=f(n-1)+1 其中,f(1)=1
複製代碼

f(n) 表示你想知道本身在哪一排,f(n-1) 表示前面一排所在的排數,f(1)=1 表示第一排的人知道本身在第一排。有了這個遞推公式,咱們就能夠很輕鬆地將它改成遞歸代碼,以下:編碼

int f(int n) {
  if (n == 1) return 1;
  return f(n-1) + 1;
}
複製代碼

遞歸須要知足的三個條件

那究竟什麼樣的問題能夠用遞歸來解決呢?我總結了三個條件,只要同時知足如下三個條件,就能夠用遞歸來解決。spa

  1. 一個問題的解能夠分解爲幾個子問題的解

何爲子問題?子問題就是數據規模更小的問題。好比,前面講的電影院的例子,你要知道,「本身在哪一排」的問題,能夠分解爲「前一排的人在哪一排」這樣一個子問題。

  1. 這個問題與分解以後的子問題,除了數據規模不一樣,求解思路徹底同樣

好比電影院那個例子,你求解「本身在哪一排」的思路,和前面一排人求解「本身在哪一排」的思路,是如出一轍的。

  1. 存在遞歸終止條件

把問題分解爲子問題,把子問題再分解爲子子問題,一層一層分解下去,不能存在無限循環,這就須要有終止條件。

仍是電影院的例子,第一排的人不須要再繼續詢問任何人,就知道本身在哪一排,也就是 f(1)=1,這就是遞歸的終止條件。

如何編寫遞歸代碼?

我我的以爲,寫遞歸代碼最關鍵的是寫出遞推公式,找到終止條件,剩下將遞推公式轉化爲代碼就很簡單了。

假如這裏有 n 個臺階,每次你能夠跨 1 個臺階或者 2 個臺階,請問走這 n 個臺階有多少種走法?若是有 7 個臺階,你能夠 2,2,2,1 這樣子上去,也能夠 1,2,1,1,2 這樣子上去,總之走法有不少,那如何用編程求得總共有多少種走法呢?

咱們仔細想下,實際上,能夠根據第一步的走法把全部走法分爲兩類,第一類是第一步走了 1 個臺階,另外一類是第一步走了 2 個臺階。因此 n 個臺階的走法就等於先走 1 階後,n-1 個臺階的走法 加上先走 2 階後,n-2 個臺階的走法。用公式表示就是:

f(n) = f(n-1)+f(n-2)
複製代碼

咱們再來看下終止條件。當有一個臺階時,咱們不須要再繼續遞歸,就只有一種走法。因此** f(1)=1**。這個遞歸終止條件足夠嗎?咱們能夠用 n=2,n=3 這樣比較小的數試驗一下。

n=2 時,f(2)=f(1)+f(0)。若是遞歸終止條件只有一個 f(1)=1,那 f(2) 就沒法求解了。因此除了 f(1)=1 這一個遞歸終止條件外,還要有 f(0)=1,表示走 0 個臺階有一種走法,不過這樣子看起來就不符合正常的邏輯思惟了。因此,咱們能夠把f(2)=2 做爲一種終止條件,表示走 2 個臺階,有兩種走法,一步走完或者分兩步來走。

因此,遞歸終止條件就是 f(1)=1,f(2)=2。這個時候,你能夠再拿 n=3,n=4 來驗證一下,這個終止條件是否足夠而且正確。

咱們把遞歸終止條件和剛剛獲得的遞推公式放到一塊兒就是這樣的:

f(1) = 1;
f(2) = 2;
f(n) = f(n-1)+f(n-2)
複製代碼

有了這個公式,咱們轉化成遞歸代碼就簡單多了。最終的遞歸代碼是這樣的:

int f(int n) {
  if (n == 1) return 1;
  if (n == 2) return 2;
  return f(n-1) + f(n-2);
}
複製代碼

那麼如何去思考遞歸問題呢

若是一個問題 A 能夠分解爲若干子問題 B、C、D,你能夠假設子問題 B、C、D 已經解決,在此基礎上思考如何解決問題 A。並且,你只須要思考問題 A 與子問題 B、C、D 兩層之間的關係便可,不須要一層一層往下思考子問題與子子問題,子子問題與子子子問題之間的關係。屏蔽掉遞歸細節,這樣子理解起來就簡單多了。

所以,編寫遞歸代碼的關鍵是,只要遇到遞歸,咱們就把它抽象成一個遞推公式,不用想一層層的調用關係,不要試圖用人腦去分解遞歸的每一個步驟。

遞歸代碼要警戒堆棧溢出

在實際的軟件開發中,編寫遞歸代碼時,咱們會遇到不少問題,好比堆棧溢出。而堆棧溢出會形成系統性崩潰,後果會很是嚴重。爲何遞歸代碼容易形成堆棧溢出呢?咱們又該如何預防堆棧溢出呢?

「棧」那一節講過,函數調用會使用棧來保存臨時變量。每調用一個函數,都會將臨時變量封裝爲棧幀壓入內存棧,等函數執行完成返回時,纔出棧。系統棧或者虛擬機棧空間通常都不大。若是遞歸求解的數據規模很大,調用層次很深,一直壓入棧,就會有堆棧溢出的風險

好比前面的講到的電影院的例子,若是咱們將系統棧或者 JVM 堆棧大小設置爲 1KB,在求解 f(19999) 時便會出現以下堆棧報錯:

Exception in thread "main" java.lang.StackOverflowError
複製代碼

那麼,如何避免出現堆棧溢出呢?

咱們能夠經過在代碼中限制遞歸調用的最大深度的方式來解決這個問題。遞歸調用超過必定深度(好比 1000)以後,咱們就不繼續往下再遞歸了,直接返回報錯。仍是電影院那個例子,咱們能夠改形成下面這樣子,就能夠避免堆棧溢出了。

// 全局變量,表示遞歸的深度。
int depth = 0;
 
int f(int n) {
  ++depth;
  if (depth > 1000) throw exception;
  
  if (n == 1) return 1;
  return f(n-1) + 1;
}
複製代碼

但這種作法並不能徹底解決問題,由於最大容許的遞歸深度跟當前線程剩餘的棧空間大小有關,事先沒法計算。若是實時計算,代碼過於複雜,就會影響代碼的可讀性。因此,若是最大深度比較小,好比 十、50,就能夠用這種方法,不然這種方法並非很實用。

遞歸代碼要警戒重複計算

除此以外,使用遞歸時還會出現重複計算的問題。剛纔我講的第二個遞歸代碼的例子,若是咱們把整個遞歸過程分解一下的話,那就是這樣的:

從圖中,咱們能夠直觀地看到,想要計算 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 hasSovledList.get(n);
  }
  
  int ret = f(n-1) + f(n-2);
  hasSovledList.put(n, ret);
  return ret;
}
複製代碼

怎麼將遞歸代碼改寫爲非遞歸代碼?

咱們剛說了,遞歸有利有弊,

  • 利是遞歸代碼的表達力很強,寫起來很是簡潔;
  • 而弊就是空間複雜度高、有堆棧溢出的風險、存在重複計算、過多的函數調用會耗時較多等問題

那咱們是否能夠把遞歸代碼改寫爲非遞歸代碼呢?好比剛纔那個電影院的例子,咱們拋開場景,只看 f(x) =f(x-1)+1 這個遞推公式。咱們這樣改寫看看:

int f(int n) {
  int ret = 1;
  for (int i = 2; i <= n; ++i) {
    ret = ret + 1;
  }
  return ret;
}
複製代碼

一樣,第二個例子也能夠改成非遞歸的實現方式。

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;
}
複製代碼
相關文章
相關標籤/搜索