1. 程式人生 > >動態規劃入門-(差不多一半借鑑左神)

動態規劃入門-(差不多一半借鑑左神)

其實從嚴格意義上說,動態規劃,並不是一種演算法,而是一種程式設計技巧,除去無關運算,降低時間複雜度。

舉個例子:

經典例子1-階乘:

遞迴實現:fac(x)=x*fac(x-1),臨界條件為x==0時,返回1。

以x==4時,解空間樹:

遞迴時由上往下延展,解問題時從下往上。

 

經典例子2-斐波那契:

fib(x)=fib(x-1)+fib(x-2)。

用遞迴的方式描述為:
#include<cstdio>
using namespace std;

int fib(int x){
    if(x<=1)return 1;
    else return fib(x-2)+fib(x-1);
}

int main()
{
    int n;
    scanf("%d",&n);
    printf("%d",fib(n));
    return 0;
}
我們假設n為4,那麼在跑dfs的時候,生成的解空間樹為:

我們可以觀察到

1.雖然有生成2^(n-1)個節點,但是實際上有很多節點進行了重複的運算[這棵樹有兩個Fib(2)和三個Fib(1)]。

2.所有的中間節點在自己所在鏈未延伸到葉子的時候都是未知解的,但是在伸展到葉子節點時,觸碰到了邊界條件,最終所有的值都是從葉子向上回填的,解決的問題的規模逐漸變大,因此才觸發了從小規模向大規模計算以優化dfs的動態規劃技巧。

 

經典例題3-漢諾塔:【下面將逐步開始講一些隱藏在條件內的動態規劃】

漢諾塔的問題是給三根杆子A,B,C,在A上有n個盤子,要求你把這n個盤子放到C上去,盤子之間有大小關係,剛開始在A上的盤子從上至下是從小到大的,不能把大盤子放到小盤子之上。

我們先以n=3為例分析一下過程:

1.先將1從A->C

2.然後將2從A->B

3.再將1從C->B

4.最後將3從A->C

於是,當C上有一個盤子之後,B上累加有n-1個盤子,現在我們完全可以把B當作A’,將A當作B‘,於是問題變成了將A‘上的n-1個盤子放到C上。

總結一下過程:

1.先將n-1個盤子從A->B

2.將n號盤子從A->C

3.將n-1個盤子從B->C

於是問題的規模公式為T(n)=T(n-1)+1+T(n-1)->T(n)=2*T(n-1)+1。

邊界條件為:T(1)=1。

反推得到T(n)=2^n-1,即為漢諾塔在n規模下需要的最小步數。

正常會對1步驟有疑問,因為在遞迴過程裡他們沒有顯式表現出來,但是實際上由於解決1步驟的過程和3步驟是一樣的,因此直接當作是一個子問題的遞迴過程就行。由於C杆上放的是每次遞迴規模下的最大盤子,因此什麼盤子都可以放上去,就可以把它當作是空的。

 

經典例子4-列印所有序列:給你一個串,求出他的所有子集串。

所有子集?離散數學中稱之為冪集。元素個數必定為2^n。

證明:對於每個元素,都有選擇和不選兩種抉擇,n個元素的選擇狀態就會有2^n。【可以用二進位制思考】

 

經典例子5-求牛的數量:第一年有一頭牛,每頭牛在第三年可以生小牛,求第n年有多少頭牛。

 

經典例子6-數字路徑:一個二維陣列,每次可以選擇向右或者向下,求一條路徑,使得從左上角到右小角經過的數字和最小。

對於任意一個非邊界位置位置(i,j),每次可以選擇走(i+1,j)或者(i,j+1)。

我們來看看重複計算,以2*3的矩陣為例:

7 2 3

6 4 9

遞迴的解空間樹:

顯而易見,有重複的待求解狀態狀態。

這裡我們可以普及幾個名詞了:

無後效性:當我們要求(2,2)->(2,3)的最短路徑大小時,對於(1,1)來說,他可以通過向右再向下(1,1)->(1,2)->(2,2)到達該點,也可以通過先向下後向右(1,1)->(2,1)->(2,2)到達該點。無效性指的是(2,2)->(2,3)的最短路徑的求解與如何從(1,1)->(2,2)的過程無關。

重複計算:在樸素遞迴中,由於不會記錄已經計算過的小狀態,所以當第二次碰到的時候,依舊得重新算一次,屬於重複的冗餘計算。

 

經典例子7-湊數:有一個數組,現在給出目標和值aim,問是否可從這些初中選出一些數,正好能湊出數aim。

當我們從這些數中選數的時候,實際上對於任意一個數,都有兩種選擇(選或不選)

#include <cstdio>
int Arr[105];
bool isOk[105][105];
int main() {
    int n,aim,sum=0;
    scanf("%d%d",&n,&aim);
    for(int i=1;i<=n;i++){
        scanf("%d",&Arr[i]);
        sum+=Arr[i];
    }
    isOk[n][aim]=true;
    for(int i=n-1;i>=0;i--){
        for(int j=sum;j>=0;j--){
            if(j+Arr[i+1]<=sum)
            isOk[i][j]=isOk[i+1][j]||isOk[i+1][j+Arr[i+1]];
            else isOk[i][j]=isOk[i+1][j];
        }
    }
    printf("%s",isOk[0][0]?"Ok":"NotOk");
    return 0;
}

 

經典例子8-湊硬幣:

你有1元,2元,5元硬幣各無數個,現在給出x,要求你用最少的硬幣湊出x塊錢。

對於每個規模n,分別求dfs(n-1)、dfs(n-2)、dfs(n-5),分別表示在有n-1時拿1個1元、在有n-2時拿1個2元、在有n-5時拿1個5元:

邊界條件:

1.如果規模n<0,表示不可行解,則忽略此種情況

2.如果規模n=0,表示正好湊數了原問題n的解

以x=6為例,解空間樹為:

太龐大了,博主懶得畫完了>-<,反正可以顯然看出這裡有很多的重複計算。

 

經典例子9-最長上升子序列:

有一個數串,先在要求你找出一個序列,使得這個序列保持單調遞增性,並且給出最大的長度。

思路:假設我們考慮以Ai為結尾元素,用Bi表示以Ai結尾的最長上升序列的長度,當我們要求Bi+1的時候,我們需要用索引index遍歷一遍B陣列前面的i個數,如果Aindex<Ai+1,那麼就可以把Aindex所擁有的最長上升子序列後面接上Ai+1得到Bi+1,在這i個數中求最大值即可,時間複雜度O(n^2)。

優化:如果我們已經有了長度為len的序列,為了使得這個序列更長【後面可接上的數更多】,那就要求在這個長度下的結尾元素儘可能的小。我們設array[i]表示長度為i時,最小的結尾元素。因此當考慮Ai+1的時候,對array陣列二分,更新array即可,最長上升子序列長度就是array陣列的長度。

 

改:最長非下降子序列:用upper_bound()得到第一個比自己大的數即可。

改2:最長非上升子序列:可以用對映將資料的大小關係逆轉,就變成了一個最長非下降子序列問題。

 

經典例子10-最長公共子序列

給兩個串,求他們的最長公共子序列。

思考:

1.如果Ai==Bj,那麼我們只要知道A1~Ai-1和B1~Bj-1的最長公共子序列即可。

2.如果Ai!=Bj,那麼我們應該求的是A1~Ai-1與B1~Bj的最長公共子序列長度和A1~Ai與B1~Bj-1的最長公共子序列長度的大值。