在鄧俊輝的數據結構教材的1.4節中簡要介紹了一下遞歸問題,在查閱相關資料後,發現遞歸問題包含了如下幾個方面:程序員
接下來結合教材以及網上相關資料對遞歸以及動態規劃問題作一個簡單的膚淺的入門的總結。面試
線性遞歸(linear recursion)
即減而治之(decrease-and-conquer)
的思想:遞歸每深刻一層,待求解問題的規模都縮減一個常數,直至最終蛻化爲平凡的小(簡單)問題。將一個規模爲n
的大問題退化爲一個規模爲n-1
的小問題,直至退化爲規模爲1
的平凡狀況,這種狀況稱之爲遞歸基(base case of recursion)
。
好比以下教材中對數組求和的代碼,若n=0則總和必爲0(這也是最終的平凡狀況,遞歸基);不然通常的,總和可理解爲前n-1個整數(A[0,-1)
)之和,再加上末尾元素A[n-1]
。算法
int sum(int A[], int n) { //數組求和算法(線性遞歸版) if (1 > n) //平凡狀況,遞歸基 return 0; //直接(非遞歸式)計算 else //通常狀況 return sum(A, n - 1) + A[n - 1]; //遞歸:前n - 1向之和,再累計第n - 1項 } //O(n)
又好比下面的數組倒置問題,也就是將數組中各元素的次選先後翻轉。藉助線性遞歸不難解決這一問題,爲此只需注意到並利用以下事實:爲獲得整個數組的倒置,能夠先對換其首、末元素,而後遞歸地倒置除這兩個元素之外的部分。數組
void reverse(int* A, int lo, int hi) { //數組倒置(多遞歸基線性遞歸版) if (lo < hi) { swap(A[lo], A[hi]); //交換A[lo]和A[hi] reverse(A, lo + 1, hi - 1); //遞歸倒置A(lo, hi) } //else隱含了兩遞歸基 } //O(hi - lo + 1)
二分遞歸(binary recursion)
即分而治之(divide-and-conquer)
的思想:將其分解爲若干規模更小的子問題, 再經過遞歸機制分別求解。 這種分解持續進行,直到子問題規模縮減至平凡狀況。在這種狀況下,每一遞歸實例會調用多個遞歸來完成,故稱做多路遞歸(multi-way recursion)
,一般都是將原問題一分爲二,故有二分遞歸。
如下代碼是對數組求和的二分遞歸的實現,新算法的思路是:以居中的元素爲界將數組一分爲二;遞歸地對子數組分別求和;最後,子數組之和相加即爲原數組的總和。數據結構
int sum(int A[], int lo, int hi) { //數組求和算法(二分遞歸版,入口爲sum(A, 0, n - 1)) if (lo == hi) //如遇遞歸基(區間長度已降至1),則 return A[lo]; //直接返回元素 else { //不然(通常狀況下lo < hi),則 int mi = (lo + hi) >> 1; //以居中單元爲界,將原區間間一分爲二 return sum(A, lo, mi) + sum(A, mi + 1, hi); //遞歸對各子數組求和,而後合計 } } //O(hi - lo + 1),線性正比與區間的長度
此處每一個遞歸實例可向下深刻遞歸兩次,故屬於多路遞歸中的二分遞歸。二分遞歸與此前介紹的線性遞歸有很大區別。好比,在線性遞歸中整個計算過程僅出現一次遞歸基, 而在二分遞歸過程當中遞歸基的出現至關頻繁,整體而言有超過半數的遞歸實例都是遞歸基。
二分遞歸的效率問題
如下引用來自鄧俊輝數據結構教材1.4節P24ide
固然,並不是全部問題都適宜於採用分治策略。實際上除了遞歸,此類算法的計算消耗主要來自兩個方面。首先是子問題劃分
, 即把原問題分解爲形式相同、規模更小的多個子問題,好比sum()算法將待求和數組分爲前、後兩段。其次是子解答合併
, 即由遞歸所得子問題的解,獲得原問題的總體解,好比由子數組之和累加獲得整個數組之和。
爲使分治策略真正有效, 不只必須保證以上兩方面的計算都能高效地實現, 還必須保證子問題之間相互獨立——**各子問題可獨立求解, 而無需藉助其它子問題的原始數據或中間結果。不然,或者子問題之間必須傳遞數據,或者子問題之間須要相互調用,不管如何都會致使時間和空間復
雜度的無謂增長。**如下就以Fibonacci數列的計算爲例說明這一點。
Fibonacci數列定於以下:
據此定義,可直接導出以下代碼所示的二分遞歸版fib()算法:函數
int fib(int n) { //計算Fibonacci數列列第n項(二分遞歸版): O(2^n) //若達到遞歸基,直接取值,不然,遞歸計算前兩項,其和即爲正解 return (2 > n) ? n : fib(n - 1) + fib(n - 2); }
基於Fibonacci數列原始定義的這一算法實現, 不只正確性一目瞭然,並且簡潔天然。然而不幸的是,在這種場合採用二分遞歸策略的效率極其低下。實際上, 該算法須要運行O(2n)時間才能計算出第n個Fibonacci數。這一指數複雜度的算法,在實際環境中毫無價值。
對於Fibonacci問題最簡單的實現就是迭代循環,也是一種比較直觀的算法,g
爲當前項的值,f
爲前一項的值,從0開始往前計算直到返回欲求的第n項的數值。學習
int fibI(int n) { //計算Fibonacci數列的第n項(迭代版): O(n) int f = 0, g = 1; //初始化: fib(0) = 0, fib(1) = 1 while (0 < n--) { g += f; f = g - f; } //依據原始定義,經過n次加法和減法計算fib(n) return f; //迒回 }
如下內容主要來自CSDN博客-HankingHu
ui
A : "1+1+1+1+1+1+1+1 =?" A : "上面等式的值是多少" B : *計算* "8!" A : "在上面等式的左邊寫上 "1+" A : "此時等式的值爲多少" B : *quickly* "9!" A : "你怎麼這麼快就知道答案了" A : "只要在8的基礎上加1就好了" A : "因此你不用從新計算由於你記住了第一個等式的值爲8!動態規劃算法也能夠說是 '記住求過的解來節省時間'"
由上面的圖片和小故事能夠知道 動態規劃算法的核心就是記住已經解決過的子問題的解。
動態規劃算法的兩種形式
上面已經知道動態規劃算法的核心是記住已經求過的解,記住求解的方式有兩種: 1.自頂向下的備忘錄法
,2.自底向上
。爲了說明動態規劃的這兩種方法,舉一個最簡單的例子:求斐波拉契數列Fibonacci 。spa
遞歸版本的Fibonacci算法在上面已經有了,此處再也不重複。其執行的遞歸樹以下圖:
上面的遞歸樹中的每個子節點都會執行一次,不少重複的節點被執行,fib(2)被重複執行了5次。因爲調用每個函數的時候都要保留上下文,因此空間上開銷也不小。這麼多的子節點被重複執行,若是在執行的時候把執行過的子節點保存起來,後面要用到的時候直接查表調用的話能夠節約大量的時間。下面就看看動態規劃的兩種方法怎樣來解決斐波拉契數列Fibonacci 數列問題。
public static int Fibonacci(int n) { if(n<=0) return n; int []Memo=new int[n+1]; //備忘錄數組 for(int i=0;i<=n;i++) Memo[i]=-1; return fib(n, Memo); } public static int fib(int n,int []Memo) { //若是已經求出了fib(n)的值直接返回,不然將求出的值保存在Memo備忘錄中。 if(Memo[n]!=-1) return Memo[n]; if(n<=2) Memo[n]=1; else Memo[n]=fib( n-1,Memo)+fib(n-2,Memo); return Memo[n]; }
備忘錄法也是比較好理解的,建立了一個n+1大小的數組來保存求出的斐波拉契數列中的每個值,在遞歸的時候若是發現前面fib(n)
的值計算出來了就再也不計算,若是未計算出來,則計算出來後保存在Memo
數組中,下次在調用fib(n)
的時候就不會從新遞歸了。好比上面的遞歸樹中在計算fib(6)
的時候先計算fib(5)
,調用fib(5)
算出了fib(4)
後,fib(6)
再調用fib(4)
就不會再遞歸fib(4)
的子樹了,由於fib(4)
的值已經保存在Memo[4]
中。
備忘錄法仍是利用了遞歸,上面算法無論怎樣,計算fib(6)
的時候最後仍是要計算出fib(1)
、fib(2)
、fib(3)
……,那麼何不先計算出fib(1)
、fib(2)
、fib(3)
……,呢?這也就是動態規劃的核心,先計算子問題,再由子問題計算父問題。
public static int fib(int n) { if(n<=0) return n; int []Memo=new int[n+1]; Memo[0]=0; Memo[1]=1; for(int i=2;i<=n;i++) { Memo[i]=Memo[i-1]+Memo[i-2]; } return Memo[n]; }
自底向上方法也是利用數組保存了先計算的值,爲後面的調用服務。觀察參與循環的只有i
,i-1
,i-2
三項,所以該方法的空間能夠進一步的壓縮以下。
public static int fib(int n) { if(n<=1) return n; int Memo_i_2=0; int Memo_i_1=1; int Memo_i=1; for(int i=2;i<=n;i++) { Memo_i=Memo_i_2+Memo_i_1; Memo_i_2=Memo_i_1; Memo_i_1=Memo_i; } return Memo_i; }
通常來講因爲備忘錄方式的動態規劃方法使用了遞歸,遞歸的時候會產生額外的開銷,使用自底向上的動態規劃方法要比備忘錄方法好。
跳臺階問題是典型的Fibonacci問題,有如下兩種版本,如下內容主要來自hackbuteer1的CSDN博客:程序員面試100題之二:跳臺階問題(變態跳臺階)。
題目1:一個臺階總共有n級,若是一次能夠跳1級,也能夠跳2級。求總共有多少總跳法,並分析算法的時間複雜度。
分析:
這道題最近常常出現,包括MicroStrategy等比較重視算法的公司都曾前後選用過個這道題做爲面試題或者筆試題。
首先咱們考慮最簡單的狀況。若是隻有1級臺階,那顯然只有一種跳法。若是有2級臺階,那就有兩種跳的方法了:一種是分兩次跳,每次跳1級;另一種就是一次跳2級。
如今咱們再來討論通常狀況。咱們把n級臺階時的跳法當作是n的函數,記爲f(n)。當n>2時,第一次跳的時候就有兩種不一樣的選擇:一是第一次只跳1級,此時跳法數目等於後面剩下的n-1級臺階的跳法數目,即爲f(n-1);另一種選擇是第一次跳2級,此時跳法數目等於後面剩下的n-2級臺階的跳法數目,即爲f(n-2)。所以n級臺階時的不一樣跳法的總數f(n)=f(n-1)+(f-2)。
咱們把上面的分析用一個公式總結以下:
分析到這裏,相信不少人都能看出這就是咱們熟悉的Fibonacci序列。
題目2:一個臺階總共有n級,若是一次能夠跳1級,也能夠跳2級......它也能夠跳上n級。此時該青蛙跳上一個n級的臺階總共有多少種跳法?
分析:
用Fib(n)表示青蛙跳上n階臺階的跳法數,青蛙一次性跳上n階臺階的跳法數1(n階跳),設定Fib(0) = 1;
- 當n = 1 時, 只有一種跳法,即1階跳:Fib(1) = 1;
- 當n = 2 時, 有兩種跳的方式,一階跳和二階跳:Fib(2) = Fib(1) + Fib(0) = 2;
- 當n = 3 時,有三種跳的方式,第一次跳出一階後,後面還有Fib(3-1)中跳法; 第一次跳出二階後,後面還有Fib(3-2)中跳法;第一次跳出三階後,後面還有Fib(3-3)中跳法
- 總計:
Fib(3) = Fib(2) + Fib(1)+Fib(0)=4
;當n = n 時,共有n種跳的方式:
- 第一次跳出一階後,後面還有Fib(n-1)中跳法;
- 第一次跳出二階後,後面還有Fib(n-2)中跳法;
- ……
- 第一次跳出n階後,後面還有 Fib(n-n)中跳法.
- 總計:
Fib(n) = Fib(n-1)+Fib(n-2)+Fib(n-3)+...+Fib(n-n)=Fib(0)+Fib(1)+Fib(2)+...+Fib(n-1)
又由於
Fib(n-1)=Fib(0)+Fib(1)+Fib(2)+...+Fib(n-2)
兩式相減得:Fib(n)-Fib(n-1)=Fib(n-1)
=====》Fib(n) = 2*Fib(n-1)
遞歸等式以下:
HankingHu的博客在最後用一個切鋼條的例子對動態規劃作了更加詳細的說明,但本文再也不繼續介紹了。由於本文主要是數據結構教材中遞歸(線性遞歸、二分遞歸)部分的簡要總結,而後對Fibonacci的典型應用-跳臺階問題進行了介紹。關於動態規劃的問題留待之後完善和總結。
博客對動態規劃總結的很好,之後應該深刻學習一下動態規劃。