動態規划算法


 

動態規划 算法是通過拆分問題,定義問題狀態和狀態之間的關系,使得問題能夠以遞推(或者說分治)的方式去解決。 [1]  

動態規划算法的基本思想與分治法類似,也是將待求解的問題分解為若干個子問題(階段),按順序求解子階段,前一子問題的解,為后一子問題的求解提供了有用的信息。在求解任一子問題時,列出各種可能的局部解,通過決策保留那些有可能達到最優的局部解,丟棄其他局部解。依次解決各子問題,最后一個子問題就是初始問題的解。

 

基本思想與策略

編輯

動態規划算法的基本思想與分治法類似,也是將待求解的問題分解為若干個子問題(階段),按順序求解子階段,前一子問題的解,為后一子問題的求解提供了有用的信息。在求解任一子問題時,列出各種可能的局部解,通過決策保留那些有可能達到最優的局部解,丟棄其他局部解。依次解決各子問題,最后一個子問題就是初始問題的解。
由於動態規划解決的問題多數有重疊子問題這個特點,為減少重復計算,對每一個子問題只解一次,將其不同階段的不同狀態保存在一個二維數組中。

適用情況

編輯
能采用動態規划求解的問題的一般要具有3個性質:
(1)最優化原理:如果問題的最優解所包含的子問題的解也是最優的,就稱該問題具有最優子結構,即滿足最優化原理。
(2)無后效性:即某階段狀態一旦確定,就不受這個狀態以后決策的影響。也就是說,某狀態以后的過程不會影響以前的狀態,只與當前狀態有關。
(3)有重疊子問題:即子問題之間是不獨立的,一個子問題在下一階段決策中可能被多次使用到。(該性質並不是動態規划適用的必要條件,但是如果沒有這條性質,動態規划算法同其他算法相比就不具備優勢)

求解的基本步驟

編輯
動態規划所處理的問題是一個多階段決策問題,一般由初始狀態開始,通過對中間階段決策的選擇,達到結束狀態。這些決策形成了一個決策序列,同時確定了完成整個過程的一條活動路線(通常是求最優的活動路線)。如圖所示。動態規划的設計都有着一定的模式,一般要經歷以下幾個步驟,如下圖所示:
初始狀態→│決策1│→│決策2│→…→│決策n│→結束狀態
(1)划分階段:按照問題的時間或空間特征,把問題分為若干個階段。在划分階段時,注意划分后的階段一定要是有序的或者是可排序的,否則問題就無法求解。
(2)確定狀態和狀態變量:將問題發展到各個階段時所處於的各種客觀情況用不同的狀態表示出來。當然,狀態的選擇要滿足無后效性。
(3)確定決策並寫出狀態轉移方程:因為決策和狀態轉移有着天然的聯系,狀態轉移就是根據上一階段的狀態和決策來導出本階段的狀態。所以如果確定了決策,狀態轉移方程也就可寫出。但事實上常常是反過來做,根據相鄰兩個階段的狀態之間的關系來確定決策方法和狀態轉移方程。
(4)尋找邊界條件:給出的狀態轉移方程是一個遞推式,需要一個遞推的終止條件或邊界條件。
一般,只要解決問題的階段、狀態和狀態轉移決策確定了,就可以寫出狀態轉移方程(包括邊界條件)。實際應用中可以按以下幾個簡化的步驟進行設計:
(1)分析最優解的性質,並刻畫其結構特征。
(2)遞歸的定義最優解。
(3)以自底向上或自頂向下的記憶化方式(備忘錄法)計算出最優值
(4)根據計算最優值時得到的信息,構造問題的最優解

算法實現

編輯
動態規划的主要難點在於理論上的設計,也就是上面4個步驟的確定,一旦設計完成,實現部分就會非常簡單。使用動態規划求解問題,最重要的就是確定動態規划三要素:
(1)問題的階段
(2)每個階段的狀態
(3)從前一個階段轉化到后一個階段之間的遞推關系。
遞推關系必須是從次小的問題開始到較大的問題之間的轉化,從這個角度來說,動態規划往往可以用遞歸程序來實現,不過因為遞推可以充分利用前面保存的子問題的解來減少重復計算,所以對於大規模問題來說,有遞歸不可比擬的優勢,這也是動態規划算法的核心之處。
確定了動態規划的這三要素,整個求解過程就可以用一個最優決策表來描述,最優決策表是一個二維表,其中行表示決策的階段,列表示問題狀態,表格需要填寫的數據一般對應此問題的在某個階段某個狀態下的最優值(如最短路徑,最長公共子序列,最大價值等),填表的過程就是根據遞推關系,從1行1列開始,以行或者列優先的順序,依次填寫表格,最后根據整個表格的數據通過簡單的取舍或者運算求得問題的最優解。
f(n,m)=max{f(n-1,m), f(n-1,m-w[n])+P(n,m)}
 
 
for(j=1; j<=m; j=j+1) // 第一個階段
   xn[j] = 初始值;
 
 for(i=n-1; i>=1; i=i-1)// 其他n-1個階段
   for(j=1; j>=f(i); j=j+1)//f(i)與i有關的表達式
     xi[j]=j=max(或min){g(xi-1[j1:j2]), ......, g(xi-1[jk:jk+1])};
 
t = g(x1[j1:j2]); // 由子問題的最優解求解整個問題的最優解的方案
 
print(x1[j1]);
 
for(i=2; i<=n-1; i=i+1)
{  
     t = t-xi-1[ji];
 
     for(j=1; j>=f(i); j=j+1)
        if(t=xi[ji])
             break;
}

參考 :https://baike.baidu.com/item/%E5%8A%A8%E6%80%81%E8%A7%84%E5%88%92%E7%AE%97%E6%B3%95/15742703?fr=aladdin

 

前言

最近在牛客網上做了幾套公司的真題,發現有關動態規划(Dynamic Programming)算法的題目很多。相對於我來說,算法里面遇到的問題里面感覺最難的也就是動態規划(Dynamic Programming)算法了,於是花了好長時間,查找了相關的文獻和資料准備徹底的理解動態規划(Dynamic Programming)算法。一是幫助自己總結知識點,二是也能夠幫助他人更好的理解這個算法。后面的參考文獻只是我看到的文獻的一部分。
動態規划算法的核心

理解一個算法就要理解一個算法的核心,動態規划算法的核心是下面的一張圖片和一個小故事。

 



這里寫圖片描述

A * "1+1+1+1+1+1+1+1 =?" *

A : "上面等式的值是多少"
B : *計算* "8!"

A *在上面等式的左邊寫上 "1+" *
A : "此時等式的值為多少"
B : *quickly* "9!"
A : "你怎么這么快就知道答案了"
A : "只要在8的基礎上加1就行了"
A : "所以你不用重新計算因為你記住了第一個等式的值為8!動態規划算法也可以說是 '記住求過的解來節省時間'"

   

由上面的圖片和小故事可以知道動態規划算法的核心就是記住已經解決過的子問題的解。
動態規划算法的兩種形式

上面已經知道動態規划算法的核心是記住已經求過的解,記住求解的方式有兩種:①自頂向下的備忘錄法 ②自底向上。
為了說明動態規划的這兩種方法,舉一個最簡單的例子:求斐波拉契數列Fibonacci 。先看一下這個問題:

Fibonacci (n) = 1;   n = 0

Fibonacci (n) = 1;   n = 1

Fibonacci (n) = Fibonacci(n-1) + Fibonacci(n-2)



以前學c語言的時候寫過這個算法使用遞歸十分的簡單。先使用遞歸版本來實現這個算法:

public int fib(int n)
{
    if(n<=0)
        return 0;
    if(n==1)
        return 1;
    return fib( n-1)+fib(n-2);
}
//輸入6
//輸出:8



先來分析一下遞歸算法的執行流程,假如輸入6,那么執行的遞歸樹如下:

 


這里寫圖片描述
上面的遞歸樹中的每一個子節點都會執行一次,很多重復的節點被執行,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)
    {

        if(Memo[n]!=-1)
            return Memo[n];
    //如果已經求出了fib(n)的值直接返回,否則將求出的值保存在Memo備忘錄中。               
        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;
    }


一般來說由於備忘錄方式的動態規划方法使用了遞歸,遞歸的時候會產生額外的開銷,使用自底向上的動態規划方法要比備忘錄方法好。
你以為看懂了上面的例子就懂得了動態規划嗎?那就too young too simple了。動態規划遠遠不止如此簡單,下面先給出一個例子看看能否獨立完成。然后再對動態規划的其他特性進行分析。
動態規划小試牛刀

例題:鋼條切割

這里寫圖片描述

這里寫圖片描述
這里寫圖片描述
這里寫圖片描述
上面的例題來自於算法導論
關於題目的講解就直接截圖算法導論書上了這里就不展開講。現在使用一下前面講到三種方法來來實現一下。
①遞歸版本

public static int cut(int []p,int n)
    {
        if(n==0)
            return 0;
        int q=Integer.MIN_VALUE;
        for(int i=1;i<=n;i++)
        {
            q=Math.max(q, p[i-1]+cut(p, n-i));  
        }
        return q;
    }

 

遞歸很好理解,如果不懂可以看上面的講解,遞歸的思路其實和回溯法是一樣的,遍歷所有解空間但這里和上面斐波拉契數列的不同之處在於,在每一層上都進行了一次最優解的選擇,q=Math.max(q, p[i-1]+cut(p, n-i));這個段語句就是最優解選擇,這里上一層的最優解與下一層的最優解相關。

②備忘錄版本

public static int cutMemo(int []p)
    {
        int []r=new int[p.length+1];
        for(int i=0;i<=p.length;i++)
            r[i]=-1;                        
        return cut(p, p.length, r);
    }
    public static int cut(int []p,int n,int []r)
    {
        int q=-1;
        if(r[n]>=0)
            return r[n];
        if(n==0)
            q=0;
        else {
            for(int i=1;i<=n;i++)
                q=Math.max(q, cut(p, n-i,r)+p[i-1]);
        }
        r[n]=q;

        return q;
    }




有了上面求斐波拉契數列的基礎,理解備忘錄方法也就不難了。備忘錄方法無非是在遞歸的時候記錄下已經調用過的子函數的值。這道鋼條切割問題的經典之處在於自底向上的動態規划問題的處理,理解了這個也就理解了動態規划的精髓。

③自底向上的動態規划

public static int buttom_up_cut(int []p)
    {
        int []r=new int[p.length+1];
        for(int i=1;i<=p.length;i++)
        {
            int q=-1;
            //①
            for(int j=1;j<=i;j++)
                q=Math.max(q, p[j-1]+r[i-j]);
            r[i]=q;
        }
        return r[p.length];
    }


自底向上的動態規划問題中最重要的是理解注釋①處的循環,這里外面的循環是求r[1],r[2]……,里面的循環是求出r[1],r[2]……的最優解,也就是說r[i]中保存的是鋼條長度為i時划分的最優解,這里面涉及到了最優子結構問題,也就是一個問題取最優解的時候,它的子問題也一定要取得最優解。下面是長度為4的鋼條划分的結構圖。我就偷懶截了個圖。

 


這里寫圖片描述
動態規划原理

雖然已經用動態規划方法解決了上面兩個問題,但是大家可能還跟我一樣並不知道什么時候要用到動態規划。總結一下上面的斐波拉契數列和鋼條切割問題,發現兩個問題都涉及到了重疊子問題,和最優子結構。

①最優子結構

用動態規划求解最優化問題的第一步就是刻畫最優解的結構,如果一個問題的解結構包含其子問題的最優解,就稱此問題具有最優子結構性質。因此,某個問題是否適合應用動態規划算法,它是否具有最優子結構性質是一個很好的線索。使用動態規划算法時,用子問題的最優解來構造原問題的最優解。因此必須考查最優解中用到的所有子問題。

②重疊子問題

在斐波拉契數列和鋼條切割結構圖中,可以看到大量的重疊子問題,比如說在求fib(6)的時候,fib(2)被調用了5次,在求cut(4)的時候cut(0)被調用了4次。如果使用遞歸算法的時候會反復的求解相同的子問題,不停的調用函數,而不是生成新的子問題。如果遞歸算法反復求解相同的子問題,就稱為具有重疊子問題(overlapping subproblems)性質。在動態規划算法中使用數組來保存子問題的解,這樣子問題多次求解的時候可以直接查表不用調用函數遞歸。
動態規划的經典模型
線性模型

線性模型的是動態規划中最常用的模型,上文講到的鋼條切割問題就是經典的線性模型,這里的線性指的是狀態的排布是呈線性的。【例題1】是一個經典的面試題,我們將它作為線性模型的敲門磚。

【例題1】在一個夜黑風高的晚上,有n(n <= 50)個小朋友在橋的這邊,現在他們需要過橋,但是由於橋很窄,每次只允許不大於兩人通過,他們只有一個手電筒,所以每次過橋的兩個人需要把手電筒帶回來,i號小朋友過橋的時間為T[i],兩個人過橋的總時間為二者中時間長者。問所有小朋友過橋的總時間最短是多少。

 


這里寫圖片描述

每次過橋的時候最多兩個人,如果橋這邊還有人,那么還得回來一個人(送手電筒),也就是說N個人過橋的次數為2*N-3(倒推,當橋這邊只剩兩個人時只需要一次,三個人的情況為來回一次后加上兩個人的情況…)。有一個人需要來回跑,將手電筒送回來(也許不是同一個人,realy?!)這個回來的時間是沒辦法省去的,並且回來的次數也是確定的,為N-2,如果是我,我會選擇讓跑的最快的人來干這件事情,但是我錯了…如果總是跑得最快的人跑回來的話,那么他在每次別人過橋的時候一定得跟過去,於是就變成就是很簡單的問題了,花費的總時間:

T = minPTime * (N-2) + (totalSum-minPTime)

來看一組數據 四個人過橋花費的時間分別為 1 2 5 10,按照上面的公式答案是19,但是實際答案應該是17。

具體步驟是這樣的:

第一步:1和2過去,花費時間2,然后1回來(花費時間1);

第二歩:3和4過去,花費時間10,然后2回來(花費時間2);

第三部:1和2過去,花費時間2,總耗時17。

所以之前的貪心想法是不對的。我們先將所有人按花費時間遞增進行排序,假設前i個人過河花費的最少時間為opt[i],那么考慮前i-1個人過河的情況,即河這邊還有1個人,河那邊有i-1個人,並且這時候手電筒肯定在對岸,所以opt[i] = opt[i-1] + a[1] + a[i] (讓花費時間最少的人把手電筒送過來,然后和第i個人一起過河)如果河這邊還有兩個人,一個是第i號,另外一個無所謂,河那邊有i-2個人,並且手電筒肯定在對岸,所以opt[i] = opt[i-2] + a[1] + a[i] + 2*a[2] (讓花費時間最少的人把電筒送過來,然后第i個人和另外一個人一起過河,由於花費時間最少的人在這邊,所以下一次送手電筒過來的一定是花費次少的,送過來后花費最少的和花費次少的一起過河,解決問題)
所以 opt[i] = min{opt[i-1] + a[1] + a[i] , opt[i-2] + a[1] + a[i] + 2*a[2] }
區間模型

區間模型的狀態表示一般為d[i][j],表示區間[i, j]上的最優解,然后通過狀態轉移計算出[i+1, j]或者[i, j+1]上的最優解,逐步擴大區間的范圍,最終求得[1, len]的最優解。

【例題2】給定一個長度為n(n <= 1000)的字符串A,求插入最少多少個字符使得它變成一個回文串。
典型的區間模型,回文串擁有很明顯的子結構特征,即當字符串X是一個回文串時,在X兩邊各添加一個字符’a’后,aXa仍然是一個回文串,我們用d[i][j]來表示A[i…j]這個子串變成回文串所需要添加的最少的字符數,那么對於A[i] == A[j]的情況,很明顯有 d[i][j] = d[i+1][j-1] (這里需要明確一點,當i+1 > j-1時也是有意義的,它代表的是空串,空串也是一個回文串,所以這種情況下d[i+1][j-1] = 0);當A[i] != A[j]時,我們將它變成更小的子問題求解,我們有兩種決策:

1、在A[j]后面添加一個字符A[i];

2、在A[i]前面添加一個字符A[j];

根據兩種決策列出狀態轉移方程為:

d[i][j] = min{ d[i+1][j], d[i][j-1] } + 1; (每次狀態轉移,區間長度增加1)

空間復雜度O(n^2),時間復雜度O(n^2), 下文會提到將空間復雜度降為O(n)的優化算法。
背包模型

背包問題是動態規划中一個最典型的問題之一。由於網上有非常詳盡的背包講解,這里只將常用部分抽出來。

【例題3】有N種物品(每種物品1件)和一個容量為V的背包。放入第 i 種物品耗費的空間是Ci,得到的價值是Wi。求解將哪些物品裝入背包可使價值總和最大。f[i][v]表示前i種物品恰好放入一個容量為v的背包可以獲得的最大價值。決策為第i個物品在前i-1個物品放置完畢后,是選擇放還是不放,狀態轉移方程為:

f[i][v] = max{ f[i-1][v], f[i-1][v – Ci] +Wi }

時間復雜度O(VN),空間復雜度O(VN) (空間復雜度可利用滾動數組進行優化達到O(V) )。
動態規划題集整理

1、最長單調子序列
Constructing Roads In JG Kingdom★★☆☆☆
Stock Exchange ★★☆☆☆

2、最大M子段和
Max Sum ★☆☆☆☆
最長公共子串 ★★☆☆☆

3、線性模型
Skiing ★☆☆☆☆
總結

弄懂動態規划問題的基本原理和動態規划問題的幾個常見的模型,對於解決大部分的問題已經足夠了。希望能對大家有所幫助,轉載請標明出處http://write.blog.csdn.net/mdeditor#!postId=75193592,創作實在不容易,這篇博客花了我將近一個星期的時間。
參考文獻

1.算法導論
---------------------
作者:HankingHu
來源:CSDN
原文:https://blog.csdn.net/u013309870/article/details/75193592
版權聲明:本文為博主原創文章,轉載請附上博文鏈接!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM