數據結構與算法之“遞歸”

  • 2020-6-16
    • 十步殺一人,千里不留行。事了拂衣去,深藏身與名。
      李白 – 《俠客行 》

一、概述

在這裏插入圖片描述
遞歸,在數學與計算機科學中,是指在函數的定義中使用函數自身的方法。也就是說,遞歸算法是一種直接或者間接調用自身函數或者方法的算法。
遞歸是一種應用非常廣泛的算法(或者編程技巧)。很多數據結構和算法的編碼實現都要用到遞歸,比如 DFS 深度優先搜索、前中後序二叉樹遍歷等等。

去的過程叫“遞”,回來的過程叫“歸”。基本上,所有的遞歸問題都可以用遞推公式來表示。
諸如

f(n)=f(n-1)+1 其中,f(1)=1

用代碼表示爲

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

二、遞歸滿足的三個條件

那究竟什麼樣的問題可以用遞歸來解決呢?
只要同時滿足以下三個條件,就可以用遞歸來解決。

  • 1、一個問題的解可以分解爲幾個子問題的解
    何爲子問題?子問題就是數據規模更小的問題。
  • 2、這個問題與分解之後的子問題,除了數據規模不同,求解思路完全一樣。
  • 3、存在遞歸終止條件
    把問題分解爲子問題,把子問題再分解爲子子問題,一層一層分解下去,不能存在無限循環,這就需要有終止條件。

三、如何編寫遞歸代碼

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

來個例子

假如這裏有 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 這樣比較小的數試驗一下。
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 兩層之間的關係即可,不需要一層一層往下思考子問題與子子問題,子子問題與子子子問題之間的關係。屏蔽掉遞歸細節,這樣子理解起來就簡單多了。
因此,編寫遞歸代碼的關鍵是,只要遇到遞歸,我們就把它抽象成一個遞推公式,不用想一層層的調用關係,不要試圖用人腦去分解遞歸的每個步驟。

四、使用遞歸的注意事項

  • 遞歸代碼要警惕堆棧溢出
    在實際的軟件開發中,編寫遞歸代碼時,我們會遇到很多問題,比如堆棧溢出。而堆棧溢出會造成系統性崩潰,後果會非常嚴重。爲什麼遞歸代碼容易造成堆棧溢出呢?我們又該如何預防堆棧溢出呢?

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

    如何避免出現堆棧溢出呢?

    • 限制遞歸調用的最大深度
      遞歸調用超過一定深度(比如 1000)之後,我們就不繼續往下再遞歸了,直接返回報錯。
      但這種做法並不能完全解決問題,因爲最大允許的遞歸深度跟當前線程剩餘的棧空間大小有關,事先無法計算。如果實時計算,代碼過於複雜,就會影響代碼的可讀性。所以,如果最大深度比較小,比如 10、50,就可以用這種方法,否則這種方法並不是很實用。
  • 遞歸代碼要警惕重複計算
    使用遞歸時還會出現重複計算的問題。對於上文中的那個爬樓梯的例子,如果我們把整個遞歸過程分解一下的話,那就是這樣的:
    在這裏插入圖片描述
    從圖中,我們可以直觀地看到,想要計算 f(5),需要先計算 f(4) 和 f(3),而計算 f(4) 還需要計算 f(3),因此,f(3) 就被計算了很多次,這就是重複計算問題。
    爲了避免重複計算,我們可以通過一個數據結構(比如散列表)來保存已經求解過的 f(k)。當遞歸調用到 f(k) 時,先看下是否已經求解過了。如果是,則直接從散列表中取值返回,不需要重複計算,這樣就能避免剛講的問題了。
    • 時間、空間效率低
      在時間效率上,遞歸代碼裏多了很多函數調用,當這些函數調用的數量較大時,就會積聚成一個可觀的時間成本。在空間複雜度上,因爲遞歸調用一次就會在內存棧中保存一次現場數據,所以在分析遞歸代碼空間複雜度時,需要額外考慮這部分的開銷。

五、將遞歸代碼轉換成非遞歸代碼

遞歸有利有弊,利是遞歸代碼的表達力很強,寫起來非常簡潔;而弊就是空間複雜度高、有堆棧溢出的風險、存在重複計算、過多的函數調用會耗時較多等問題。所以,在開發過程中,我們要根據實際情況來選擇是否需要用遞歸的方式來實現。

一般來說:遞歸代碼一般可以使用,迭代循環的非遞歸寫法
爬樓梯在不使用遞歸的代碼如下|:

nt 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;
}

六、參考資料

  • 王爭 – 《極客時間|數據結構與算法之美》
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章