動態規劃(DP)——通俗易懂!

轉自:阮行止

1. 從一個生活問題談起

先來看看生活中經常遇到的事吧——假設您是個土豪,身上帶了足夠的1、5、10、20、50、100元面值的鈔票。現在您的目標是湊出某個金額w,需要用到儘量少的鈔票。

依據生活經驗,我們顯然可以採取這樣的策略:能用100的就儘量用100的,否則儘量用50的……依次類推。在這種策略下,666=6×100+1×50+1×10+1×5+1×1,共使用了10張鈔票。

這種策略稱爲“貪心”:假設我們面對的局面是“需要湊出w”,貪心策略會盡快讓w變得更小。能讓w少100就儘量讓它少100,這樣我們接下來面對的局面就是湊出w-100。長期的生活經驗表明,貪心策略是正確的。

但是,如果我們換一組鈔票的面值,貪心策略就也許不成立了。如果一個奇葩國家的鈔票面額分別是1、5、11,那麼我們在湊出15的時候,貪心策略會出錯:
  15=1×11+4×1 (貪心策略使用了5張鈔票)
  15=3×5 (正確的策略,只用3張鈔票)
  爲什麼會這樣呢?貪心策略錯在了哪裏?

鼠目寸光。
 
  剛剛已經說過,貪心策略的綱領是:“儘量使接下來面對的w更小”。這樣,貪心策略在w=15的局面時,會優先使用11來把w降到4;但是在這個問題中,湊出4的代價是很高的,必須使用4×1。如果使用了5,w會降爲10,雖然沒有4那麼小,但是湊出10只需要兩張5元。
  在這裏我們發現,貪心是一種只考慮眼前情況的策略。

那麼,現在我們怎樣才能避免鼠目寸光呢?

如果直接暴力枚舉湊出w的方案,明顯複雜度過高。太多種方法可以湊出w了,枚舉它們的時間是不可承受的。我們現在來嘗試找一下性質。

重新分析剛剛的例子。w=15時,我們如果取11,接下來就面對w=4的情況;如果取5,則接下來面對w=10的情況。我們發現這些問題都有相同的形式:“給定w,湊出w所用的最少鈔票是多少張?”接下來,我們用f(n)來表示“湊出n所需的最少鈔票數量”。

那麼,如果我們取了11,最後的代價(用掉的鈔票總數)是多少呢?

明顯cost=f(4)+1=4+1=5\operatorname{cost}=f(4)+1=4+1=5,它的意義是:利用11來湊出15,付出的代價等於f(4)加上自己這一張鈔票。現在我們暫時不管f(4)怎麼求出來。
  依次類推,馬上可以知道:如果我們用5來湊出15,cost就是f(10)+1=2+1=3f(10)+1=2+1=3

那麼,現在w=15的時候,我們該取那種鈔票呢?當然是各種方案中,cost值最低的那一個!
  
  - 取11:cost=f(4)+1=4+1=5\operatorname{cost}=f(4)+1=4+1=5
  - 取5: cost=f(10)+1=2+1=3\operatorname{cost}=f(10)+1=2+1=3
  - 取1: cost=f(14)+1=4+1=5\operatorname{cost}=f(14)+1=4+1=5

顯而易見,cost值最低的是取5的方案。我們通過上面三個式子,做出了正確的決策!

這給了我們一個至關重要的啓示—— f(n)f(n)只與f(n1)f(n-1)f(n5)f(n-5)f(n11)f(n-11) 相關;更確切地說:f(n)=min{f(n1),f(n5),f(n11)}+1f(n)=\min \{f(n-1), f(n-5), f(n-11)\}+1

這個式子是非常激動人心的。我們要求出f(n),只需要求出幾個更小的f值;既然如此,我們從小到大把所有的f(i)求出來不就好了?注意一下邊界情況即可。代碼如下:

在這裏插入圖片描述
我們以O(n)O(n)的複雜度解決了這個問題。現在回過頭來,我們看看它的原理:

  • f(n)f(n)只與f(n1)f(n-1)f(n5)f(n-5)f(n11)f(n-11) 相關。
  • 我們只關心f(w)f(w)的值,不關心是怎麼湊出w的。

這兩個事實,保證了我們做法的正確性。它比起貪心策略,會分別算出取1、5、11的代價,從而做出一個正確決策,這樣就避免掉了“鼠目寸光”!

它與暴力的區別在哪裏?我們的暴力枚舉了“使用的硬幣”,然而這屬於冗餘信息。我們要的是答案,根本不關心這個答案是怎麼湊出來的。譬如,要求出f(15),只需要知道f(14),f(10),f(4)的值。其他信息並不需要。我們捨棄了冗餘信息。我們只記錄了對解決問題有幫助的信息——f(n).

我們能這樣幹,取決於問題的性質:求出f(n),只需要知道幾個更小的f©。我們將求解f©稱作求解f(n)的“子問題”。

這就是DP(動態規劃,dynamic programming).

將一個問題拆成幾個子問題,分別求解這些子問題,即可推斷出大問題的解。

2. 幾個簡單的概念

【無後效性】

一旦f(n)確定,“我們如何湊出f(n)”就再也用不着了。

要求出f(15),只需要知道f(14),f(10),f(4)的值,而f(14),f(10),f(4)是如何算出來的,對之後的問題沒有影響。

“未來與過去無關”,這就是無後效性。

(嚴格定義:如果給定某一階段的狀態,則在這一階段以後過程的發展不受這階段以前各段狀態的影響。)

【最優子結構】

  • 回顧我們對f(n)的定義:我們記“湊出n所需的最少鈔票數量”爲f(n).

  • f(n)的定義就已經蘊含了“最優”。利用w=14,10,4的最優解,我們即可算出w=15的最優解。

  • 大問題的最優解可以由小問題的最優解推出,這個性質叫做“最優子結構性質”。

  • 引入這兩個概念之後,我們如何判斷一個問題能否使用DP解決呢?

能將大問題拆成幾個小問題,且滿足無後效性、最優子結構性質。

3. DP的典型應用:DAG最短路
  問題很簡單:給定一個城市的地圖,所有的道路都是單行道,而且不會構成環。每條道路都有過路費,問您從S點到T點花費的最少費用。

在這裏插入圖片描述
 這個問題能用DP解決嗎?我們先試着記從S到P的最少費用爲f§.
  想要到T,要麼經過C,要麼經過D。從而f(T)=min{f(C)+20,f(D)+10}f(T)=\min \{f(C)+20, f(D)+10\}

好像看起來可以DP。現在我們檢驗剛剛那兩個性質:
  - 無後效性:對於點P,一旦f§確定,以後就只關心f§的值,不關心怎麼去的。
  - 最優子結構:對於P,我們當然只關心到P的最小費用,即f§。如果我們從S走到T是SPQTS \rightarrow P \rightarrow Q \rightarrow T那肯定S走到Q的最優路徑是SPQS \rightarrow P \rightarrow Q。對一條最優的路徑而言,從S走到沿途上所有的點(子問題)的最優路徑,都是這條大路的一部分。這個問題的最優子結構性質是顯然的。

既然這兩個性質都滿足,那麼本題可以DP。式子明顯爲:
f(P)=min{f(R)+wRP}f(P)=\min \left\{f(R)+w_{R \rightarrow P}\right\}

其中R爲有路通到P的所有的點,wRPw_{R \rightarrow P}爲R到P的過路費。

代碼實現也很簡單,拓撲排序即可。

4. 對DP原理的一點討論

【DP的核心思想】

DP爲什麼會快?
  無論是DP還是暴力,我們的算法都是在可能解空間內,尋找最優解。

來看鈔票問題。暴力做法是枚舉所有的可能解,這是最大的可能解空間。
  DP是枚舉有希望成爲答案的解。這個空間比暴力的小得多。

也就是說:DP自帶剪枝。

DP捨棄了一大堆不可能成爲最優解的答案。譬如:
  15 = 5+5+5 被考慮了。
  15 = 5+5+1+1+1+1+1 從來沒有考慮過,因爲這不可能成爲最優解。

從而我們可以得到DP的核心思想:儘量縮小可能解空間。

在暴力算法中,可能解空間往往是指數級的大小;如果我們採用DP,那麼有可能把解空間的大小降到多項式級。

一般來說,解空間越小,尋找解就越快。這樣就完成了優化。

【DP的操作過程】

一言以蔽之:大事化小,小事化了。

將一個大問題轉化成幾個小問題;
  求解小問題;
  推出大問題的解。

【如何設計DP算法】

下面介紹比較通用的設計DP算法的步驟。

首先,把我們面對的局面表示爲x。這一步稱爲設計狀態。
  對於狀態x,記我們要求出的答案(e.g. 最小費用)爲f(x).我們的目標是求出f(T).
找出f(x)與哪些局面有關(記爲p),寫出一個式子(稱爲狀態轉移方程),通過f§來推出f(x).

【DP三連】

設計DP算法,往往可以遵循DP三連:

我是誰? ——設計狀態,表示局面
  我從哪裏來?
  我要到哪裏去? ——設計轉移

設計狀態是DP的基礎。接下來的設計轉移,有兩種方式:一種是考慮我從哪裏來(本文之前提到的兩個例子,都是在考慮“我從哪裏來”);另一種是考慮我到哪裏去,這常見於求出f(x)之後,更新能從x走到的一些解。這種DP也是不少的,我們以後會遇到。

總而言之,“我從哪裏來”和“我要到哪裏去”只需要考慮清楚其中一個,就能設計出狀態轉移方程,從而寫代碼求解問題。前者又稱pull型的轉移,後者又稱push型的轉移。

5. 例題:最長上升子序列

扯了這麼多形而上的內容,還是做一道例題吧。

最長上升子序列(LIS)問題:給定長度爲n的序列a,從a中抽取出一個子序列,這個子序列需要單調遞增。問最長的上升子序列(LIS)的長度。
  e.g. 1,5,3,4,6,9,7,8的LIS爲1,3,4,6,7,8,長度爲6。

如何設計狀態(我是誰)?

我們記f(x)f(x) 爲以 axa_{x}結尾的LIS長度,那麼答案就是 max{f(x)}\max \{f(x)\}.
 
 狀態x從哪裏推過來(我從哪裏來)?

考慮比x小的每一個p:如果axa_{x}>apa_{p} ,那麼f(x)可以取f§+1.
  解釋:我們把 axa_{x} 接在 apa_{p}的後面,肯定能構造一個以axa_{x}結尾的上升子序列,長度比以apa_{p}結尾的LIS大1.那麼,我們可以寫出狀態轉移方程了:
  f(x)=maxp<x,ap<ax{f(p)}+1f(x)=\max _{p<x, a_{p}<a_{x}}\{f(p)\}+1

至此解決問題。兩層for循環,複雜度O(n2)O\left(n^{2}\right) .
在這裏插入圖片描述從這三個例題中可以看出,DP是一種思想,一種“大事化小,小事化了”的思想。帶着這種思想,DP將會成爲我們解決問題的利器。

最後,我們一起念一遍DP三連吧——我是誰?我從哪裏來?我要到哪裏去?

6. 習題

一、動態規劃初步·各種子序列問題

一、 DPDP 的意義以及線性動規簡介

動態規劃自古以來是 DALAODALAO 凌虐萌新的分水嶺,但有些OIer認爲並沒有這麼重要——會打暴力,大不了記憶化。但是其實,動態規劃學得好不好,可以彰顯出一個 OIerOIer 的基本素養——能否富有邏輯地思考一些問題,以及更重要的——能否將數學、算籌學(決策學)、數據結構合併成一個整體並且將其合理運用 qwqqwq 。

而我們首先要了解的,便是綜合難度在所有動規題裏最爲簡單的線性動規了。線性動規既是一切動規的基礎,同時也可以廣泛解決生活中的各項問題——比如在我們所在的三維世界裏,四維的時間就是不可逆式線性,比如我們需要決策在相同的時間內做價值儘量大的事情,該如何決策,最優解是什麼——這就引出了動態規劃的真正含義:

在一個困難的嵌套決策鏈中,決策出最優解。

二、動態規劃性質淺談

首先,動態規劃和遞推有些相似(尤其是線性動規),但是不同於遞推的是:

遞推求出的是數據,所以只是針對數據進行操作;而動態規劃求出的是最優狀態,所以必然也是針對狀態的操作,而狀態自然可以出現在最優解中,也可以不出現——這便是決策的特性(布爾性)。

其次,由於每個狀態均可以由之前的狀態演變形成,所以動態規劃有可推導性,但同時,動態規劃也有無後效性,即每個當前狀態會且僅會決策出下一狀態,而不直接對未來的所有狀態負責,可以淺顯的理解爲——
Future never has to do with past time ,but present does.
現在決定未來,未來與過去無關。

三、扯正題——子序列問題

(一)一個序列中的最長上升子序列( LISLIS )

例:由6個數,分別是: 1 7 6 2 3 4,求最長上升子序列。

評析:首先,我們要理解什麼叫做最長上升子序列:1、最長上升子序列的元素不一定相鄰 2、最長上升子序列一定是原序列的子集。所以這個例子中的 LISLIS 就是:1 2 3 4,共4個

1、 n2n^{2}做法
首先我們要知道,對於每一個元素來說,最長上升子序列就是其本身。那我們便可以維護一個 dpdp 數組,使得 dp[i] 表示以第 i 元素爲結尾的最長上升子序列長度,那麼對於每一個 dp[i] 而言,初始值即爲 1 ;

那麼dp數組怎麼求呢?我們可以對於每一個 i ,枚舉在 i 之前的每一個元素 j ,然後對於每一個 dp[j] ,如果元素 i大於元素 j ,那麼就可以考慮繼承,而最優解的得出則是依靠對於每一個繼承而來的 dp 值,取 max .

 for(int i=1;i<=n;i++)
    {
        dp[i]=1;//初始化 
        for(int j=1;j<i;j++)//枚舉i之前的每一個j 
        if(data[j]<data[i] && dp[i]<dp[j]+1)
        //用if判斷是否可以拼湊成上升子序列,
        //並且判斷當前狀態是否優於之前枚舉
        //過的所有狀態,如果是,則↓ 
        dp[i]=dp[j]+1;//更新最優狀態 

    }

最後,因爲我們對於 dpdp 數組的定義是到i爲止的最長上升子序列長度,所以我們最後對於整個序列,只需要輸出 dp[n]dp[n] ( nn 爲元素個數)即可。

從這個題我們也不難看出,狀態轉移方程可以如此定義:

下一狀態最優值=最優比較函數(已經記錄的最優值,可以由先前狀態得出的最優值)
——即動態規劃具有 判斷性繼承思想

2、 nlognnlogn 做法
我們其實不難看出,對於 n2n^{2}做法而言,其實就是暴力枚舉:將每個狀態都分別比較一遍。但其實有些沒有必要的狀態的枚舉,導致浪費許多時間,當元素個數到了 10410^{4}10510^{5}以上時,就已經超時了。而此時,我們可以通過另一種動態規劃的方式來降低時間複雜度:

將原來的dp數組的存儲由數值換成該序列中,上升子序列長度爲i的上升子序列,的最小末尾數值

這其實就是一種幾近貪心的思想:我們當前的上升子序列長度如果已經確定,那麼如果這種長度的子序列的結尾元素越小,後面的元素就可以更方便地加入到這條我們臆測的、可作爲結果、的上升子序列中。

qwq一定要好好看註釋啊!

int n;
    cin>>n;
    for(int i=1;i<=n;i++)
    {
        cin>>a[i];
        f[i]=0x7fffffff;
        //初始值要設爲INF
        /*原因很簡單,每遇到一個新的元素時,就跟已經記錄的f數組當前所記錄的最長
        上升子序列的末尾元素相比較:如果小於此元素,那麼就不斷向前找,直到找到
        一個剛好比它大的元素,替換;反之如果大於,麼填到末尾元素的下一個q,INF
                就是爲了方便向後替換啊!*/ 
    }
    f[1]=a[1];
    int len=1;//通過記錄f數組的有效位數,求得個數 
    /*因爲上文中所提到我們有可能要不斷向前尋找,
    所以可以採用二分查找的策略,這便是將時間複雜
    度降成nlogn級別的關鍵因素。*/ 
    for(int i=2;i<=n;i++)
    {
        int l=0,r=len,mid;
        if(a[i]>f[len])f[++len]=a[i];
        //如果剛好大於末尾,暫時向後順次填充 
        else 
        {
        while(l<r)
        {   
            mid=(l+r)/2;
            if(f[mid]>a[i])r=mid;
    //如果仍然小於之前所記錄的最小末尾,那麼不斷
    //向前尋找(因爲是最長上升子序列,所以f數組必
    //然滿足單調) 
            else l=mid+1; 
        }
        f[l]=min(a[i],f[l]);//更新最小末尾 
        }
    }
    cout<<len;

但是事實上, nlognnlogn 做法偷了個懶,沒有記錄以每一個元素結尾的最長上升子序列長度。那麼我們對於 n2n^{2}的統計方案數,有很好想的如下代碼(再對第一次的 dpdp 數組 dpdp 一次):

for(i = 1; i <= N; i ++){
    if(dp[i] == 1) f[i] = 1 ;
    for(j = 1; j <= N: j ++)
        if(base[i] > base[j] && dp[j] == dp[i] - 1) f[i] += f[j] ;
        else if(base[i] == base[j] && dp[j] == dp[i]) f[i] = 0 ;
    if(f[i] == ans) res ++ ;
    }

但是 nlognnlogn 呢?雖然好像也可以做,但是想的話會比較麻煩,在這裏就暫時不討論了 qwqqwq ,但筆者說這件事的目的是爲了再次論證一個觀點:時間複雜度越高的算法越全能

3 、輸出路徑
只要記錄前驅,然後遞歸輸出即可(也可以用棧的)

下面貼出 n2n^{2} 的完整代碼qwq

#include <iostream>
using namespace std;
const int MAXN = 1000 + 10;
int n, data[MAXN];
int dp[MAXN]; 
int from[MAXN]; 
void output(int x)
{
    if(!x)return;
    output(from[x]);
    cout<<data[x]<<" ";
    //迭代輸出 
}
int main()
{
    cin>>n;
    for(int i=1;i<=n;i++)cin>>data[i];

    // DP
    for(int i=1;i<=n;i++)
    {
        dp[i]=1;
        from[i]=0;
        for(int j=1;j<i;j++)
        if(data[j]<data[i] && dp[i]<dp[j]+1)
        {
            dp[i]=dp[j]+1;
            from[i]=j;//逐個記錄前驅 
        }
    }

    int ans=dp[1], pos=1;
    for(int i=1;i<=n;i++)
        if(ans<dp[i])
        {
            ans=dp[i];
            pos=i;//由於需要遞歸輸出
    //所以要記錄最長上升子序列的最後一
    //個元素,來不斷回溯出路徑來 
        }
    cout<<ans<<endl;
    output(pos);

    return 0;
}

(二)兩個序列中的最長公共子序列( LCS )

1、譬如給定2個序列:

1 2 3 4 5

3 2 1 4 5
試求出最長的公共子序列。

qwq 顯然長度是 3 ,包含
3 4 5 三個元素(不唯一)

解析:我們可以用 dp[i][j]來表示第一個串的前 i 位,第二個串的前j位的 LCS 的長度,那麼我們是很容易想到狀態轉移方程的:

如果當前的 A1[i] 和 A2[j] 相同(即是有新的公共元素) 那麼

dp[ i ] [ j ] = max(dp[ i ] [ j ], dp[ i-1 ] [ j-1 ] + 1);
如果不相同,即無法更新公共元素,考慮繼承:

dp[i][j]=max(dp[i−1][j],dp[i][j−1])
那麼代碼:

#include<iostream>
using namespace std;
int dp[1001][1001],a1[2001],a2[2001],n,m;
int main()
{
    //dp[i][j]表示兩個串從頭開始,直到第一個串的第i位 
    //和第二個串的第j位最多有多少個公共子元素 
    cin>>n>>m;
    for(int i=1;i<=n;i++)scanf("%d",&a1[i]);
    for(int i=1;i<=m;i++)scanf("%d",&a2[i]);
    for(int i=1;i<=n;i++)
     for(int j=1;j<=m;j++)
      {
        dp[i][j]=max(dp[i-1][j],dp[i][j-1]);
        if(a1[i]==a2[j])
        dp[i][j]=max(dp[i][j],dp[i-1][j-1]+1);
        //因爲更新,所以++; 
      }
    cout<<dp[n][m];
}

2 、而對於洛谷 P1439
而言,不僅是卡上面的樸素算法,也考察到了全排列的性質:

對於這個題而言,樸素算法是 n2n^{2} 的,會被 10510^{5}卡死,所以我們可以考慮 nlogn 的做法:

因爲兩個序列都是 1~n的全排列,那麼兩個序列元素互異且相同,也就是說只是位置不同罷了,那麼我們通過一個 map 數組將 A 序列的數字在 B 序列中的位置表示出來——

因爲最長公共子序列是按位向後比對的,所以a序列每個元素在b序列中的位置如果遞增,就說明b中的這個數在a中的這個數整體位置偏後,可以考慮納入 LCS ——那麼就可以轉變成 nlogn 求用來記錄新的位置的map數組中的 LIS 。

最後貼 AC 代碼:

#include<iostream>
#include<cstdio>
using namespace std;
int a[100001],b[100001],map[100001],f[100001];
int main()
{
    int n;
    cin>>n;
    for(int i=1;i<=n;i++){scanf("%d",&a[i]);map[a[i]]=i;}
    for(int i=1;i<=n;i++){scanf("%d",&b[i]);f[i]=0x7fffffff;}
    int len=0;
    f[0]=0;
    for(int i=1;i<=n;i++)
    {
        int l=0,r=len,mid;
        if(map[b[i]]>f[len])f[++len]=map[b[i]];
        else 
        {
        while(l<r)
        {   
            mid=(l+r)/2;
            if(f[mid]>map[b[i]])r=mid;
            else l=mid+1; 
        }
        f[l]=min(map[b[i]],f[l]);
        }
    }
    cout<<len;
    return 0
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章