DP算法(動態規划算法)


前幾天做leetcode的算法題很多題都提到了動態規划算法,那么什么是動態規划算法,它是什么樣的思想,適用於什么場景,就是我們今天的主題。

首先我們提出所有與動態規划有關的算法文章中都會提出的觀點: 將一個問題拆成幾個子問題,分別求解這些子問題,即可推斷出大問題的解

什么都不了解的話看到這句話是懵逼的,我們也先略過,等看完整篇文章再回過頭來看一看這個觀點。

下面正式開始。

首先我們來看這個譯名,動態規划算法。這里的動態指代的是遞推的思想,算法在實現的過程中是動態延伸的,而不是提前控制的。規划指的是需要我們給出動態延伸的方法和方向。

這兩個就是算法的問題點。

我們來看個例子(例子來源:https://www.zhihu.com/question/23995189特別推薦這篇回答,非常的簡單且詳細)。

  假設您是個土豪,身上帶了足夠的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,最后的代價(用掉的鈔票總數)是多少呢?
  明顯  ,它的意義是:利用11來湊出15,付出的代價等於f(4)加上自己這一張鈔票。現在我們暫時不管f(4)怎么求出來。
  依次類推,馬上可以知道:如果我們用5來湊出15,cost就是  。
  那么,現在w=15的時候,我們該取那種鈔票呢?當然是各種方案中,cost值最低的那一個!
  - 取11: cost=f[4]+1=4+1=5
  - 取5:  cost=f[10]+1=2+1=3
  - 取1:  cost=f[14]+1=4+1=5
  顯而易見,cost值最低的是取5的方案。我們通過上面三個式子,做出了正確的決策

我們來看看這其中規划的展現部分,規划展現在情況分類上,我們只有三種錢幣,所以會要求實現f[n]中的n要大於某一個規定值才是可取的,要10元時候我們不可以取出11元,所以在這部分要進行單獨判斷。

再說動態部分,顯然,f[4],f[10],f[14]是我們需要單獨去計算的,那么如何計算呢,就需要他們動態的去遞推下一步如何計算。

來看看這個式子:f(n)=min{f(n-1),f(n-5),f(n-11)}+1

顯然f(n)直接由后三個值決定,我們只要算出這三個值即可,而這三個值又由同樣式的更小值決定,只要得出更小值,甚至最小值,我們就可以推導出f(n)。

我們以O(n)的復雜度解決了這個問題。現在回過頭來,我們看看它的原理:
  - f(n)只與f(n-1)f(n-5)f(n-11)的值相關。
  -  我們只關心  的值,不關心是怎么湊出w的。 
     這兩個事實,保證了我們做法的正確性。它比起貪心策略,會分別算出取1、5、11的代價,從而做出一個正確決策,這樣就避免掉了“鼠目寸光”!
     它與暴力的區別在哪里?我們的暴力枚舉了“使用的硬幣”,然而這屬於冗余信息。我們要的是答案,根本不關心這個答案是怎么湊出來的。譬如,要求出f(15),只需要知道f(14),f(10),f(4)的值。其他信息並不需要。我們舍棄了冗余信息。我們只記錄了對解決問題有幫助的信息——f(n).  我們能這樣干,取決於問題的性質:求出f(n),只需要知道幾個更小的f(c)。我們將求解f(c)稱作求解f(n)的“子問題”。

實際上是一種冗余信息的反向篩查算法,是暴力枚舉的簡化。

來看看使用DP算法的要求。

【無后效性】 
 一旦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解決呢? 
 能將大問題拆成幾個小問題,且滿足無后效性、最優子結構性質。

這也同時揭示了我一開始寫的那句話,大問題由小問題累積而成,是必要的。

【DP三連】  
設計DP算法,往往可以遵循DP三連:  
我是誰?  ——設計狀態,表示局面  
我從哪里來?  我要到哪里去?  ——設計轉移  
設計狀態是DP的基礎。接下來的設計轉移,有兩種方式:一種是考慮我從哪里來(本文之前提到的兩個例子,都是在考慮“我從哪里來”);另一種是考慮我到哪里去,這常見於求出f(x)之后,更新能從x走到的一些解。這種DP也是不少的,我們以后會遇到。  
總而言之,“我從哪里來”和“我要到哪里去”只需要考慮清楚其中一個,就能設計出狀態轉移方程,從而寫代碼求解問題。前者又稱pull型的轉移,后者又稱push型的轉移。

實際的例子我用前兩天的幾道動態規划的算法題來舉例。

一道一道來:

1.Delete Operation for Two Strings

java:

class Solution {
    public int minDistance(String s1, String s2) {
        char[] cs1 = s1.toCharArray(), cs2 = s2.toCharArray();
        int n = s1.length(), m = s2.length();
        int[][] f = new int[n + 1][m + 1];
        for (int i = 0; i <= n; i++) f[i][0] = i;
        for (int j = 0; j <= m; j++) f[0][j] = j;
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= m; j++) {
                f[i][j] = Math.min(f[i - 1][j] + 1, f[i][j - 1] + 1);
                if (cs1[i - 1] == cs2[j - 1]) f[i][j] = Math.min(f[i][j], f[i - 1][j - 1]);
            }
        }
        return f[n][m];
    }
}

 

public class towString {
    String s1 = "ba";
    String s2 = "ac";
    int x =minDistance(s1,s2);

    public int  minDistance(String s1,String s2){
        char[] cs1 = s1.toCharArray();
        char[] cs2 = s2.toCharArray();
        //拆分為字符數組
        int n = s1.length();
        int m = s2.length();
        int[][] f = new int[n+1][m+1];//+1其實是為了空出“哨兵”,也就是00位,不加在后面剪了也一樣
        for(int i =0;i<=n;i++) {
            f[i][0] = i ;
            System.out.println("i="+i);
        }

        for(int j =0;j<=m;j++) {
            f[0][j] = j ;
            System.out.println("j="+j);
        }//雖然我們都知道里面是什么,但是還是輸出一下;

        for(int j =1;j<=m;j++){//注意這里從1開始
            for(int i=1;i<=n;i++){
                System.out.println("f[i-1][j] = "+f[i-1][j]);
                System.out.println("f[i][j-1] = "+f[i][j-1]);
                //輸出一下

                f[i][j] = Math.min(f[i-1][j]+1,f[i][j-1]+1);
                System.out.println("f[i][j]="+f[i][j]);
                System.out.println("*****************");

                System.out.println("cs1[i-1]="+cs1[i-1]);
                System.out.println("cs2[j-1]="+cs2[j-1]);
                if(cs1[i-1] == cs2[j-1]){
                    System.out.println("相同");
                    f[i][j] = Math.min(f[i][j],f[i-1][j-1]);
                }
                System.out.println("f[i][j]="+f[i][j]);
            }
        }
        for(int i =0;i<=n;i++){
            for (int j =0 ;j<=m;j++){
                System.out.print(f[i][j]+"  ");
            }
            System.out.println("\n");
        }
        return f[n][m];
    }
}

這里的特殊點在於需要每一步都比較,也就是[i-1][j]與[j][i-1],而不是[i][j];

5就不說了,是標准的最長公共子序列問題,上一道問題的縮減版,注意的是由於要求最長長度,所以用的是max。

6.Regular Expression Matching

class Solution {
    public boolean isMatch(String s, String p) {
        int m = s.length();
        int n = p.length();

        boolean[][] f = new boolean[m + 1][n + 1];
        f[0][0] = true;
        
        for (int i = 0; i <= m; ++i) {
            for (int j = 1; j <= n; ++j) {
                if (p.charAt(j - 1) == '*') {
                    f[i][j] = f[i][j - 2];
                    if (matches(s, p, i, j - 1)) {
                        f[i][j] = f[i][j] || f[i - 1][j];
                    }
                } else {
                    if (matches(s, p, i, j)) {
                        f[i][j] = f[i - 1][j - 1];
                    }
                }
            }
        }
        return f[m][n];
    }

    public boolean matches(String s, String p, int i, int j) {
        if (i == 0) {
            return false;
        }
        if (p.charAt(j - 1) == '.') {
            return true;
        }
        return s.charAt(i - 1) == p.charAt(j - 1);
    }
}

這道題考慮到一個問題點就是在特殊情況下的判斷問題,我們拋開matches函數再看這道題的話,流程是一樣的,創建,問題拆分,返回。

一般難點在於問題拆分部分,想要節省空間可以從創建部分下手,部分情況下可以降低數組維度。

以上。

(其實比較難的題,我自己拆分問題很多情況下也拆不好hhhhh,看看題解吧。)


免責聲明!

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



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