深刻淺出理解動態規劃 | 交疊子問題與最優子結構

    在現實生活中,有一類活動的過程,因爲它的特殊性,可將過程分紅若干個互相聯繫的階段,在它的每一階段都須要做出決策,從而使整個過程達到最好的活動效果。所以各個階段決策的選取不能任意肯定,它依賴於當前面臨的狀態,又影響之後的發展。當各個階段決策肯定後,就組成一個決策序列,於是也就肯定了整個過程的一條活動路線.這種把一個問題看做是一個先後關聯具備鏈狀結構的多階段過程就稱爲多階段決策過程,這種問題稱爲多階段決策問題。在多階段決策問題中,各個階段採起的決策,通常來講是與時間有關的,決策依賴於當前狀態,又隨即引發狀態的轉移,一個決策序列就是在變化的狀態中產生出來的,故有「動態」的含義,稱這種解決多階段決策最優化的過程爲動態規劃方法。算法

本期內容:動態規劃

    動態規劃是一種將複雜問題分解成不少子問題並將子問題的求解結果存儲起來避免重複求解的一種算法。編程

    動態規劃算法是經過拆分問題,定義問題狀態和狀態之間的關係,使得問題可以以遞推(或者說分治)的方式去解決。動態規劃算法的基本思想與分治法相似,也是將待求解的問題分解爲若干個子問題(階段),按順序求解子階段,前一子問題的解,爲後一子問題的求解提供了有用的信息。在求解任一子問題時,列出各類可能的局部解,經過決策保留那些有可能達到最優的局部解,丟棄其餘局部解。依次解決各子問題,最後一個子問題就是初始問題的解。數組

    動態規劃(dynamic programming)是一種算法設計技術,在20世紀50年代由一位卓越的美國數學家 Richard Bellman所發明的。
    一個問題可以使用動態規劃算法求解時具備的兩個主要性質:微信

  • 第一,交疊子問題 ide

        動態規劃算法的關鍵在於解決冗餘,這是動態規劃算法的根本目的。動態規劃實質上是一種以空間換時間的技術,它在實現的過程當中,不得不存儲產生過程當中的各類狀態,因此它的空間複雜度要大於其餘的算法。選擇動態規劃算法是由於動態規劃算法在空間上能夠承受,而搜索算法在時間上卻沒法承受,因此咱們舍空間而取時間函數

  • 第二,最優子結構(最優化原理)學習

        最優化原理可這樣闡述:一個最優化策略具備這樣的性質,不論過去狀態和決策如何,對前面的決策所造成的狀態而言,餘下的諸決策必須構成最優策略。簡而言之,一個最優化策略的子策略老是最優的。一個問題知足最優化原理又稱其具備最優子結構性質。優化

    本期經過比較遞歸法、記憶化搜索算法和打表算法的時間複雜度,討論動態規劃的主要性質--交疊的子問題。設計

一、動態規劃問題中的術語

階段: 把所給求解問題的過程恰當地分紅若干個相互聯繫的階段,以便於求解,過程不一樣,階段數就可能不一樣.描述階段的變量稱爲階段變量。在多數狀況下,階段變量是離散的,用k表示。此外,也有階段變量是連續的情形。若是過程能夠在任什麼時候刻做出決策,且在任意兩個不一樣的時刻之間容許有無窮多個決策時,階段變量就是連續的。code

狀態: 狀態表示每一個階段開始面臨的天然情況或客觀條件,它不以人們的主觀意志爲轉移,也稱爲不可控因素。在上面的例子中狀態就是某階段的出發位置,它既是該階段某路的起點,同時又是前一階段某支路的終點。

無後效性: 咱們要求狀態具備下面的性質:若是給定某一階段的狀態,則在這一階段之後過程的發展不受這階段之前各段狀態的影響,全部各階段都肯定時,整個過程也就肯定了。換句話說,過程的每一次實現能夠用一個狀態序列表示,在前面的例子中每階段的狀態是該線路的始點,肯定了這些點的序列,整個線路也就徹底肯定。從某一階段之後的線路開始,當這段的始點給定時,不受之前線路(所經過的點)的影響。狀態的這個性質意味着過程的歷史只能經過當前的狀態去影響它的將來的發展,這個性質稱爲無後效性 。

決策: 一個階段的狀態給定之後,從該狀態演變到下一階段某個狀態的一種選擇(行動)稱爲決策。在最優控制中,也稱爲控制。在許多問題中,決策能夠天然而然地表示爲一個數或一組數。不一樣的決策對應着不一樣的數值。描述決策的變量稱決策變量,因狀態知足無後效性,故在每一個階段選擇決策時只需考慮當前的狀態而無須考慮過程的歷史。

決策變量的範圍稱爲容許決策集合 。

策略: 由每一個階段的決策組成的序列稱爲策略。對於每個實際的多階段決策過程,可供選取的策略有必定的範圍限制,這個範圍稱爲容許策略集合

二、交疊子問題(或重疊子問題)

    同分治法(Divide and Conquer)同樣,動態規劃也是將子問題的求解結果進行合併,其主要用在當子問題須要一次又一次地重複求解時,將子問題的求解結果存儲到一張表中(稱爲動態規劃表)以避免重複計算。所以當沒有公共的(交疊的、重疊的)子問題時動態規劃算法並不適用,由於沒有必要將一個再也不須要的結果存儲起來。例如,二分搜索(折半查找)就不具備重疊的子問題性質。

    咱們如下面的遞歸求解斐波那契數列的問題爲例子,就會發現有不少子問題一次又一次地被重複求解。

/*求解斐波那契數列的遞歸算法 */
int fib(int n) {
    if (n <= 1)
        return n;
    return fib(n - 1) + fib(n - 2);
}

    下圖是求解fib(5)的遞歸樹:

在這裏插入圖片描述

    從上面的遞歸樹咱們能夠發現fib(3)被調用了2次。若是咱們在第1次計算fib(3)時將fib(3)的結果存儲起來,這樣咱們在第2次調用fib(3)時就可使用先前存儲的值,而不須要再次計算fib(3)了。下面是兩種存儲fib(3)值的方法,這兩種方法均可以重複使用存儲的值:

     一、記憶化搜索方法(自頂向下)

    說明:所謂頂就是咱們要求解的問題,這裏就是fib(n)。

    採用這種方法,只需對遞歸程序進行一點小小的修改,即在計算某個值時,先查詢一個表。這個表可使用數組來實現,初始時把數組的值所有初始爲NIL(好比-1或0等值,這個值是計算過程當中不會出現的那些值)。任什麼時候候當咱們須要求解一個子問題時,咱們首先查詢這個表,若是這個表中有咱們預先對該子問題求解的結果,則咱們直接返回表中的這個值,不然咱們就對子問題進行計算,並把計算結果存入這個表中,以便在後續計算中能夠重複使用。

    下面的程序是求解第n個斐波那契數的記憶化搜索版本:

/* 求解第n個斐波那契數的記憶化搜索程序 */
#include<stdio.h>
#define NIL -1
#define MAX 100

int lookup[MAX]; /* 用數組實現的查找表 */

/* 將查找表初始化爲NIL */
void _initialize() {
    int i;
    for (i = 0; i < MAX; i++)
        lookup[i] = NIL;
}

/* 求解第n個斐波那契數 */
int fib(int n) {
    if (lookup[n] == NIL) {/* 若是爲NIL,代表第n項沒有求解過 */
        if (n <= 1)
            lookup[n] = n;  /* 求解第n項,並把求解結果存入查找表 */
        else
            lookup[n] = fib(n - 1) + fib(n - 2);
    }
    return lookup[n]; /* 若是不爲NIL,代表第n項求解過,直接返回 */
}

int main() {
    int n = 40;
    _initialize();
    printf("Fibonacci number is %d ", fib(n));
    return 0;
}

    二、打表法(自底向上)

    用打表法求解一個問題時,使用自底向上的方式進行計算並返回表格中的最後一項。例如,一樣是計算第n個斐波那契數,首先計算fib(0),而後計算fib(1),再計算fib(2),計算fib(3),直到fib(n)。所以,咱們採用的是自底向上的方式逐一創建子問題的求解結果表的。

    下面是打表法求解第n個斐波那契數的程序。(所謂打表法,就是把計算結果製成表格,而後打印結果,簡稱打表法,也稱制表法。)

/* 打表法 */
#include<stdio.h>
int fib(int n) {
    int f[n + 1];
    int i;
    f[0] = 0;
    f[1] = 1;
    for (i = 2; i <= n; i++)
        f[i] = f[i - 1] + f[i - 2];

    return f[n];
}

int main() {
    int n = 9;
    printf("Fibonacci number is %d ", fib(n));
    return 0;
}

    打表法和記憶化搜索法都是把子問題的求解結果存入表格。在記憶化搜索方法中,咱們只是在須要時往查詢表中添加記錄,而在打表法中,從第1項記錄開始,全部計算結果一項一項地添加到表中。與打表法不一樣,記憶化搜索方法無需將全部計算結果添加到查詢表中。

    人們每每從時間複雜度和空間複雜度兩個方面來衡量某個算法的優劣性,但在實際生活中,若是對某個算法的要求不是特別高,咱們通常只考慮算法的時間複雜度。下面經過比較遞歸法、記憶化搜索方法、打表法在求解第n項斐波那契數時的時間開銷來分析算法的優劣性。

遞歸方法:

#include<stdio.h>
#include<time.h>

/* 求解斐波那契數列的遞歸算法 */
int fib(int n) {
    if (n <= 1)
        return n;
    return fib(n - 1) + fib(n - 2);
}

int main() {
    int n = 40;
    clock_t begin, end;
    double time_spent;
    begin = clock();    /* 開始時間 */
    printf("Fibonacci number is %d\n", fib(n));
    end = clock();      /* 結束時間 */
    time_spent = (double)(end - begin) / CLOCKS_PER_SEC;
    printf("Time Taken %lf\n", time_spent);
    return 0;
}

運行結果:
在這裏插入圖片描述
注意:上面的時間在不一樣的機器上是不一樣的

記憶化搜索方法:

/* 求解第n個斐波那契數的記憶化搜索程序 */
#include<stdio.h>
#include<time.h>
#define NIL -1
#define MAX 100

int lookup[MAX]; /* 用數組實現的查找表 */

/* 將查找表初始化爲NIL */
void _initialize() {
    int i;
    for (i = 0; i < MAX; i++)
        lookup[i] = NIL;
}

/* 求解第n個斐波那契數 */
int fib(int n) {
    if (lookup[n] == NIL) {/* 若是爲NIL,代表第n項沒有求解過 */
        if (n <= 1)
            lookup[n] = n;  /* 求解第n項,並把求解結果存入查找表 */
        else
            lookup[n] = fib(n - 1) + fib(n - 2);
    }
    return lookup[n]; /* 若是不爲NIL,代表第n項求解過,直接返回 */
}

int main() {
    int n = 40;
    clock_t begin, end;
    double time_spent;
    _initialize();
    begin = clock();    /* 開始時間 */
    printf("Fibonacci number is %d\n", fib(n));
    end = clock();      /* 結束時間 */
    time_spent = (double)(end - begin) / CLOCKS_PER_SEC;
    printf("Time Taken %lf\n", time_spent);
    return 0;
}

運行結果:
在這裏插入圖片描述

注意:上面的時間在不一樣的機器上是不一樣的

打表法:

#include<stdio.h>
#include<time.h>

/* 打表法 */
#include<stdio.h>
int fib(int n) {
    int f[n + 1];
    int i;
    f[0] = 0;
    f[1] = 1;
    for (i = 2; i <= n; i++)
        f[i] = f[i - 1] + f[i - 2];

    return f[n];
}

int main() {
    int n = 40;
    clock_t begin, end;
    double time_spent;
    begin = clock();    /* 開始時間 */
    printf("Fibonacci number is %d\n", fib(n));
    end = clock();      /* 結束時間 */
    time_spent = (double)(end - begin) / CLOCKS_PER_SEC;
    printf("Time Taken %lf\n", time_spent);
    return 0;
}

運行結果:
在這裏插入圖片描述

注意:上面的時間在不一樣的機器上是不一樣的

    經過比較三種方法所花費的時間,很明顯遞歸方法比記憶化搜索方法和打表法這兩種採用動態規劃方法所花費的時間都大不少。

三、最優子結構

    對於一個給定的問題,當該問題能夠由其子問題的最優解得到時,則該問題具備「最優子結構」性質。

    例如,「最短路徑」問題具備以下的「最優子結構」性質:

    若是一個結點x在從起點u到終點v的最短路徑上,則從u到v的最短路徑由從u到x的最短路徑和從x到v的最短路徑構成。像Floyd-Warshall(弗洛伊德—沃舍爾)和Bellman-Ford(貝爾曼—福特)算法就是典型的動態規劃的例子。

    另外,「最長路徑」問題不具備「最優子結構」性質。咱們這裏所說的最長路徑是兩個節點之間的最長簡單路徑(路徑沒有環),由CLRS(Thomas H. Cormen,Charles E. Leiserson,Ronald L. Rivest,Clifford Stein)編寫的《算法導論》(Introduction to Algorithms)這本書中給出了下面的無權圖。

在這裏插入圖片描述

    從q到t有兩條最長的路徑:q→r→t與q→s→t。與最短路徑不一樣,這些最長路徑沒有「最優子結構」性質。例如,最長路徑q→r→t不是由q 到r的最長路徑和r到t的最長路徑構成的,由於從q到r的最長路徑是 q→s→t→r,從r到t的最長路徑是r→q→s→t。

經典例題:數字三角形

題目描述:
    下圖給出了一個數字三角形,從三角形的頂部到底部有不少條不一樣的路徑,對於每條路徑,把路徑上面的數加起來能夠獲得一個和,你的任務就是找到最大的和。

在這裏插入圖片描述

注意:路徑上的每一步只能從一個數走到下一層上和它最近的左邊的那個數或者右邊的那個數。

輸入:

    輸入一個正整數N (1 < N <= 100),給出三角形的行數,下面的N行給出數字三角形,數字三角形上的數的範圍都在0和100之間。

輸出:

    輸出最大的和。

樣例輸入:

5
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5

樣例輸出:

30

解題思路:

    動態規劃一般用來求最優解。能用動態規劃解決的求最優解問題,必須知足最優解的每一個局部解也都是最優的。以上題爲例,最佳路徑中的每一個數字到底部的那一段路徑,都是從該數字出發到底部的最佳路徑。

    實際上,遞歸的思想在編程時未必要實現爲遞歸函數。在上面的例子中,有遞推公式:
在這裏插入圖片描述

    不須要寫遞歸函數,從最後一行的元素開始向上逐行遞推,就能求得最終 dp[1][1]的值。程序以下:

#include<stdio.h>
#include<string.h>

#define MAX_NUM 1000
int D[MAX_NUM + 10][MAX_NUM + 10];  /* 存儲數字三角形 */
int N;                                    /* 數字三角形的行數 */
int dp[MAX_NUM + 10][MAX_NUM + 10]; /* 狀態數組 */

int max(int x, int y) {
    return x > y ? x : y;
}

int main() {
    int i, j;

    scanf("%d", &N);

    memset(dp, 0, sizeof(dp));/* 狀態數組所有初始化爲0 */

    for (i = 1; i <= N; ++i)
        for (j = 1; j <= i; ++j)
            scanf("%d", &D[i][j]); /* 輸入數字三角形 */

    for (j = 1; j <= N; j++) { /* 處理最底層一行 */
        dp[N][j] = D[N][j]; /* 最底層一行狀態數組的值即爲該數字自己 */
    }

    for (i = N - 1; i >= 1; i--) { /* 從倒數第二層開始直至最頂層 */
        for (j = 1; j <= i; j++) {
            dp[i][j] = max(dp[i + 1][j], dp[i + 1][j + 1]) + D[i][j];
        }
    }

    printf("%d\n", dp[1][1]);       /* 頂點(1,1)即爲最大值 */s

    return 0;
}

    以下圖所示,方框裏的數字是能取得的最大值。相信你們看完這個圖,對動態規劃的理解不那麼困難了。

在這裏插入圖片描述

    實際上,由於dp[i][j]的值在用來計算出dp[i-1][j]後已經無用,因此能夠將計算出的dp[i-1][j]的值直接存放在dp[i][j]的位置。這樣,計算出 dp[N-1][1]替換原來的 dp[N][1],計算出 dp[N-1][2]替換原來的dp[N][2]......計算出 dp[N-1][N-1]替換原來的 dp[N][N-1],dp數組實際上只用最後一行,就可以存放上面程序中本該存放在dp[N-1]那一行的所有結果。同理,再一行行向上遞推,dp數組只須要最後一行就能夠存放所有中間計算結果,最終的結果(本該是dp[1][1])也能夠存放在dp[N][1])。所以,實際上dp不須要是二維數組,一維數組就足夠了。

    改寫後的程序以下:

#include<stdio.h>
#include<string.h>

#define MAX_NUM 1000
int D[MAX_NUM + 10][MAX_NUM + 10];  /* 存儲數字三角形 */
int N;    /* 數字三角形的行數 */
int *dp; /* 狀態數組 */

int max(int x, int y) {
    return x > y ? x : y;
}

int main() {
    int i, j;

    scanf("%d", &N);

    for (i = 1; i <= N; ++i)
        for (j = 1; j <= i; ++j)
            scanf("%d", &D[i][j]); /* 輸入數字三角形 */

    dp = D[N];  /* dp指向第N行 */

    for (i = N - 1; i >= 1; i--) { /* 從倒數第二層開始直至最頂層 */
        for (j = 1; j <= i; j++) {
            dp[j] = max(dp[j], dp[j + 1]) + D[i][j];
        }
    }

    printf("%d\n", dp[1]);       /* (1,1)即爲最大值 */

    return 0;
}

    這種用一維數組取代二維數組進行遞推、節省空間的技巧叫「滾動數組」。上面的程序雖然節省了空間,可是沒有下降時間複雜度,時間複雜度依然是O(N^2)的,從程序使用了兩重循環就能夠看出。

四、總結

    許多求最優解的問題能夠用動態規劃來解決。用動態規劃解題,首先要把原問題分解爲若干個子問題,這一點和前面的遞歸方法相似。區別在於,單純的遞歸每每會致使子問題被重複計算,而用動態規劃的方法,子問題的解一旦求出就會被保存,因此每一個子問題只需求解一次。

    子問題常常和原問題形式類似,有時甚至徹底同樣,只不過規模從原來的n變成n-1, 或從原來的n×m變成n×(m-1)。找到子問題,就意味着找到了將整個問題逐漸分解的辦法,由於子問題能夠用相同的思路一直分解下去,直到最底層規模最小的子問題能夠一目瞭然地看出解(像上面數字三角形的遞推公式中,當i=N時,解就能夠直接獲得)。每一層子問題的解決會致使上一層子問題的解決,逐層向上,就會致使最終整個問題的解決。若是從最底層的子問題開始,自底向上地推導出一個個子問題的解,那麼編程時就不須要寫遞歸函數了。

五、文章推薦

推薦一:深刻淺出理解動態規劃(一) | 交疊子問題,文章內容:動態規劃--交疊子問題(記憶化搜索算法、打表法求解第n個斐波那契數)。

推薦二:深刻淺出理解動態規劃(二) | 最優子結構,文章內容:動態規劃--最優子結構(經典例題:數字三角形求解)。

六、公衆號推薦(資源加油站)

瞭解更多資源請關注我的公衆號:C you again,你將收穫如下資源

微信掃碼關注公衆號,查看更多IT文章

做者: C you again,從事軟件開發 努力在IT搬磚路上的技術小白
公衆號:C you again】,分享計算機類畢業設計源碼、IT技術文章、遊戲源碼、網頁模板、程序人生等等。公衆號回覆 【粉絲】進博主技術羣,與大佬交流,領取乾貨學習資料
關於轉載:歡迎轉載博主文章,轉載時代表出處
求贊環節:創做不易,記得 點贊+評論+轉發 謝謝你一路支持

相關文章
相關標籤/搜索