算法基礎-->貪心和動態規劃

本篇博文將詳細總結貪心和動態規劃部分,貪心和動態規劃是非常難以理解和掌握的,但是在筆試面試中經常遇到,關鍵還是要理解和掌握其思想,然後就是多刷刷相關一些算法題就不難了。本篇將會大篇幅總結其算法思想。

貪心和動態規劃思想

馬爾科夫模型

對於 Ai+1 ,只需考察前一個狀態 Ai , 即可完成整個推理過程,它的特點是狀態 Ai 只由 Ai1 確定,與狀態 A1Ai2 無關,在圖論中,常常稱之爲 馬爾科夫模型

這裏寫圖片描述

相應的,對於 Ai+1 ,需考察前 i 個狀態集A1,A2Ai1,Ai 纔可完成整個推理過程,往往稱之爲高階馬爾科夫模型

這裏寫圖片描述

高階馬爾科夫模型的推理,叫做“動態規劃”,而馬爾科夫模型的推理,對應“貪心法”

無後效性

  • 計算A[i] 時只讀取A[0i1] ,不修改——歷史
  • 計算A[i] 時不需要A[i+1n1] 的值——未來

理解貪心,動態規劃:

動態規劃:

  可以如下理解動態規劃:計算 A[i+1] 只需要知道 A[0i] 的值,無需知道 A[0i] 是通過何種途徑計算得到的——只需知道它們當前的狀態值本身即可。如果將 A[0i] 的全體作爲一個整體,則可以認爲動態規劃法是馬爾科夫過程,而非高階馬爾科夫過程。

貪心:

   根據實際問題,選取一種度量標準。然後按照這種標準對 n 個輸入排序,並按序一次輸入一個量。如果輸入和當前已構成在這種量度意義下的部分最優解加在一起不能產生一個可行解,則不把此輸入加到這部分解中。否則,將當前輸入合併到部分解中從而得到包含當前輸入的新的部分解。

  這一處理過程一直持續到 n 個輸入都被考慮完畢,則記入最優解集合中的輸入子集構成這種量度意義下的問題的最優解。 這種能夠得到某種量度意義下的最優解的分級處理方法稱爲貪心方法。

字符串迴文劃分問題

問題描述

給定一個字符串 str ,將 str 劃分成若干子串,使得每一個子串都是迴文串。計算 str 的所有可能的劃分。

單個字符構成的字符串,顯然是迴文串;所以,這個的劃分一定是存在的。

  如:s=aab ,返回

aab
aab

方法一:深度優先搜索

思考:若當前計算得到了 str[0i1] 的所有劃分,可否添加 str[ij] ,得到更大的劃分呢?顯然,若str[ij] 是迴文串,則可以添加。

剪枝:在每一步都可以判斷中間結果是否爲合法結果。

  • 回溯+剪枝——如果某一次發現劃分不合法,立刻對該分支限界。
  • 一個長度爲 n 的字符串,最多有 n1 個位置可以截斷,每個位置有兩種選擇,因此時間複雜度爲O(2n1)=O(2n)

在利用 DFS 解決這個問題時,我們還需要解決一個小問題,如何判斷一個子串 str[i,i+1,...,j]0<=i<n,i<=j<n 是否迴文?

  • 線性探索:jin1 遍歷即可,從字符串 str[i,i+1,...,j] 兩端開始比較,然後得出是否對稱迴文。

  • 事先緩存所有 str[i,i+1,..,j] 是迴文串的那些記錄,用二維布爾數組 p[n][n]true/false 表示 str[i,i+1,...,j] 是否是迴文串。

  • 它本身是個小的動態規劃:如果已知 str[i+1,...,j1] 是迴文串,那麼判斷 str[i,i+1,...,j] 是否是迴文串,只需要判斷 str[i]==str[j] 就可以了。

// 判斷str[i,j]迴文與否
void CalcSubstringPalindrome(const char* str, int size, vector<vector<bool>>& p)
{
    int i, j;
    for (i = 0; i < size; i++)
        p[i][i] = true;//單個字符肯定是迴文串
    for (i = size - 2; i >= 0; i--)
    {
        p[i][i + 1] == (str[i] == str[i + 1]);//得出字符串內每兩個相鄰字符迴文與否,也就是得出初始狀態
        for (j = i + 2; j < size; j++)//以i子串左端並且在內循環i固定,j爲子串右端,並且j不斷向外擴展,
        //遞進的判斷str[i,j]迴文與否
        {
            if ((str[i] == str[j]) && p[i + 1][j - 1])
                p[i][j] = true;
        }
    }
}


//以str[nStart]爲起點,不斷的判斷str[nSart,i]迴文與否,若是迴文加入solution
void FindSolution(const char* str, int size, int nStart, vector<vector<string>>& all, vector<string>& solution, const vector<vector<bool>>& p)
{
    if (nStart >= size)//表示當前方向遞歸深入到頭
    {
        all.push_back(solution);//將當前方向的所有迴文壓入到all
        return;
    }
    for (int i = nStart; i < size; i++)
    {
        if (p[nStart][i])
        {
            solution.push_back(string(str + nStart, str + i + 1));
            FindSolution(str, size, i + 1, all, solution, p);//沿着這個方向深入遞歸
            solution.pop_back();//回溯到當前初始狀態,選擇其他方向
        }
    }
}


void MinPalindrome3(const char* str, vector<vector<string>>& all)
{
    int size = (int)strlen(str);
    vector<vector<bool>> p(size, vector<bool>(size));
    CalcSubstringPalindrome(str, size, p);

    vector<string> solution;
    FindSolution(str, size, 0, all, solution, p);
}

方法二:動態規劃

如果已知:str[0i1] 的所有迴文劃分 φ(i) ,(這個 i 表示迴文長度, 顯然每個迴文是個 vector ,長度爲 i 的迴文有多個,故 φ 是個vector<vector<string>> 類型的,可以理解爲二維數組)如何求 str[0i] 的所有劃分呢? 如果子串 str[ji] 是迴文串,則將該子串和 φ(j1) 共同添加到 φ(i+1) 中(長度爲 j1 的每個迴文都添加上該回文子串)。

算法:

  1. 將集合 φ(i+1) 置空;
  2. 遍歷 j(0ji) ,若 str[j,j+1i] 是迴文串,則將 str[ji] 添加到 φ(j1) ,然後再把 φ(j1) 添加到 φ(i+1) 中;
  3. i0n ,依次調用上面兩步,最終返回 φ(n) 即爲所求。
//to 表示prefix[i],長度爲i的迴文集合;from表示prefix[j]長度爲j的迴文集合;sub表示要添加的迴文str[j,i]
void Add(vector<vector<string>>& to, const vector<vector<string>>& from, const string& sub)
{
    if (from.empty())//from 爲空時,直接將sub壓入to
    {
        to.push_back(vector<string>(1, sub));//vector<string>(1, sub):初始化vector,長度爲1,一個字符串sub
        return;
    }
    to.reserve(from.size());
    for (vector<vector<string>>::const_iterator it = from.begin(); it != from.end(); it++)//遍歷from裏面每個迴文
    {
        to.push_back(vector<string>());
        vector<string>& now = to.back();
        now.reserve(it->size() + 1);
        //將from某個個迴文裏面的每個字符依次壓入now,然後在末尾加上要添加的迴文子串sub
        for (vector<string>::const_iterator s = it->begin(); s != it->end(); s++)
            now.push_back(*s);
        now.push_back(sub);
    }
}

void MinPalindrome4(const char* str, vector<vector<string>>& all)
{
    int size = (int)strlen(str);
    vector<vector<bool>> p(size, vector<bool>(size));
    CalcSubstringPalindrome(str, size, p);

    vector<vector<string>>* prefix = new vector<vector<string>>[size];//注意這裏是vector<vector>* 相當於一個三維數組
    prefix[0].clear();
    int i, j;
    for (i = 1; i <= size; i++)
    {
        for (j = 0; j < i; j++)
        {
            if (p[j][i - 1])//prefix[i]表示長度爲i的迴文集合,這裏是指長度,那麼索引應該到i-1
            {
                Add((i == size) ? all : prefix[i], prefix[j], string(str + j, str + i));
            }
        }
    }
    delete[] prefix;
}

DFS和DP的深刻認識

  • 顯然DFSDP 好理解,而代碼上DP 更加簡潔。
  • DFS 的過程,是計算完成了 str[0i] 的切分,然後遞歸調用,繼續計算 str[i+1,i+2n1] 的過程;
  • DP 中,假定得到了 str[0i1] 的所有可能切分方案,如何擴展得到 str[0i] 的切分;
  • 上述兩種方法都可以從後向前計算得到對偶的分析。

從本質上說,二者是等價的:最終都搜索了一顆隱式樹

  • DFS 顯然是深度優先搜索的過程,而 DP 更像層序遍歷 的過程。
  • 如果只計算迴文劃分的最少數目,動態規劃更有優勢;如果計算所有迴文劃分,DFS 的空間複雜度比DP 略優。

利用貪心思想的幾個重要算法

最小生成樹MST

最小生成樹要求從一個帶權無向連通圖中選擇 n1 條邊並使這個圖仍然連通(也即得到了一棵生成樹),同時還要考慮使樹的權最小。爲了得到最小生成樹,人們設計了很多算法,最著名的有 Prim 算法和Kruskal 算法,這兩個算法都是貪心算法

Prim算法

Prim 算法是解決最小生成樹的常用算法。它採取貪心策略,從指定的頂點開始尋找最小權值的鄰接點。圖 G=<V,E> ,初始時 S=V0 ,把與 V0 相鄰接,且邊的權值最小的頂點加入到 S 。不斷地把 S 中的頂點與 VS 中頂點的最小權值邊加入(不可能形成環),直到所有頂點都已加入到 S 中。

實例:

這裏寫圖片描述

Prim 過程,假定從 V0 開始:


這裏寫圖片描述

實現代碼:

#include<stdio.h>
#include <stdlib.h>
#include<iostream>
#include<vector>
#define MAXINT 6
using namespace std;

//聲明一個二維數組,C[i][j]存儲的是點i到點j的邊的權值,如果不可達,則用1000表示
//藉此二維數組來表示一個連通帶權圖
int c[MAXINT][MAXINT] = { { 1000, 6, 1, 5, 1000, 1000 }, { 6, 1000, 5, 1000, 3, 1000 }, { 1, 5, 1000, 5, 6, 4 }, { 5, 1000, 5, 1000, 1000, 2 }, { 1000, 3, 6, 1000, 1000, 6 }, { 1000, 1000, 4, 2, 6, 1000 } };
void Prim(int n)
{
    int lowcost[MAXINT];//lowcost[i]表示V-S中的點i到達S的最小權值
    int closest[MAXINT];//closest[i]表示V-S中的點i到達S的最小權值S中對應的點
    bool s[MAXINT];//bool型變量的S數組表示i是否已經包括在S中
    int i, k;
    s[0] = true;//從第一個結點開始尋找,擴展
    for (i = 1; i <= n; i++)//簡單初始化,這個時候S爲{0},V-S爲{1,2,3,4,5}
    {
        lowcost[i] = c[0][i];//這個時候S中只有0
        closest[i] = 0;//現在所有的點對應的已經在S中的最近的點是1
        s[i] = false;
    }
    cout << "0->";
    for (i = 0; i<n; i++)//執行n次,也即是向S中添加所有的n個結點
    {
        int min = 1000;//最小值,設大一點的值,後面用來記錄lowcost數組中的最小值
        int j = 1;
        for (k = 1; k <= n; k++)//尋找lowcost中的最小值,並且找出此時V-S中對應的點j
        {
            if ((lowcost[k]<min) && (!s[k]))
            {
                min = lowcost[k]; j = k;
            }
        }
        cout << j << " " << "->";
        s[j] = true;//添加點j到集合S中
        for (k = 1; k <= n; k++)//因爲新加入了j點,需要更新V-S到S的最小權值,只需要與剛加進來的c[j][k]比較即可
        {
            if ((c[j][k]<lowcost[k]) && (!s[k])){ lowcost[k] = c[j][k]; closest[k] = j; }
        }
    }
}
int main()
{
    Prim(MAXINT - 1);
    return 0;
}

Kruskal算法

Kruskal 算法:將邊按照權值遞增排序,每次選擇權值最小並且不構成環的邊,重複 n1 次。

實例:


這裏寫圖片描述

在實現kruskal 時,我們需要了解一下並查集,請看該鏈接 並查集詳解

基於 kruskal 算法的特點,我們存儲一個圖的方式將不會使用鄰接表或者鄰接矩陣,而是直接存儲邊,具體的數據結構如下所示,重載小於操作符的目的是爲了方便對邊進行排序。

實現代碼:

#include <iostream>  
#include <vector>  
#include <algorithm>  
#include <fstream>  
using namespace std;  

struct Edge  
{  
    int u; //邊連接的一個頂點編號  
    int v; //邊連接另一個頂點編號  
    int w; //邊的權值  
    friend bool operator<(const Edge& E1, const Edge& E2)  
    {  
        return E1.w < E2.w;  
    }  
};  
//創建並查集,uset[i]存放結點i的根結點,初始時結點i的根結點即爲自身
void MakeSet(vector<int>& uset, int n)  
{  
    uset.assign(n, 0);  
    for (int i = 0; i < n; i++)
        uset[i] = i;  
}  
//查找當前元素所在集合的代表元  
int FindSet(vector<int>& uset, int u)  
{  
    int i = u;  
    while (uset[i] != i) i = uset[i];  
    return i;  
}  
void Kruskal(const vector<Edge>& edges, int n)  
{  
    vector<int> uset;  
    vector<Edge> SpanTree;  
    int Cost = 0, e1, e2;  
    MakeSet(uset, n);  
    for (int i = 0; i < edges.size(); i++) //按權值從小到大的順序取邊  
    {  
        e1 = FindSet(uset, edges[i].u);  
        e2 = FindSet(uset, edges[i].v);  
        if (e1 != e2) //若當前邊連接的兩個結點在不同集合中,選取該邊併合並這兩個集合,如果相等連接則成環  
        {  
            SpanTree.push_back(edges[i]);  
            Cost += edges[i].w;  
            uset[e1] = e2; //合併當前邊連接的兩個頂點所在集合  
        }  
    }  
    cout << "Result:\n";  
    cout << "Cost: " << Cost << endl;  
    cout << "Edges:\n";  
    for (int j = 0; j < SpanTree.size(); j++)  
        cout << SpanTree[j].u << " " << SpanTree[j].v << " " << SpanTree[j].w << endl;  
    cout << endl;  
}  
int main()  
{  

    int n, m;  
    cin >> n >> m;
    vector<Edge> edges;  
    edges.assign(m, Edge());  
    for (int i = 0; i < m; i++)
        cin >> edges[i].u >> edges[i].v >> edges[i].w;  
    sort(edges.begin(), edges.end()); //排序之後,可以以邊權值從小到大的順序選取邊  
    Kruskal(edges, n);  
    system("pause");  
    return 0;  
}  

Dijkstra最短路徑算法

該算法爲單源點最短路徑算法,要求邊的權值爲正數。在圖 G(V,E) 中,假定源點爲 v0 ,結點集合記 V ,將結點集合分爲兩部分,分別爲集合 S ,集合 VS

  1. S 爲已經找到的從v0 出發的最短路徑的終點集合,它的初始狀態爲空集,那麼從v0 出發到圖中其餘各頂點(終點)viviVS) ,記arcs[i][j] 爲結點 vi 直接到達 結點 vj 的距離。記d[j]源點 v0 到達結點 vj最短距離。初始時:d[j]=arcs[0][j]

  2. 選擇 vj ,使得 d[j]=minj(d[i]viVS)vj 就是當前求得的一條從 v0 出發的最短路徑的終點。令 S=Sj

  3. 修改從 v0 出發到集合 VS 上任一頂點vk 可達的最短路徑長度。如果 d[j]+arcs[j][k]<d[k] , 則修改 d[k] 爲:d[k]=d[j]+arcs[j][k]

  4. 以上2,3 步驟重複 n1 次。

在網上找了個Dijkstra 圖例過程:


這裏寫圖片描述

實現代碼:

#include<stdio.h>
#include <stdlib.h>
#include<iostream>
#include<vector>
#include<set>
#include<queue>
using namespace std;

const int  MAXINT = 32767;
const int MAXNUM = 10;//結點總數
int d[MAXNUM];//單源點到其他結點的最短路徑
int prev[MAXNUM];//記錄前驅結點

int arcs[MAXNUM][MAXNUM];//鄰接矩陣,arcs[i][j]也即是兩結點(vi,vj)之間的直接距離

void Dijkstra(int v0, int* prev)//源點 v0
{
    bool S[MAXNUM];// 判斷是否已存入該點到S集合中
    int n = MAXNUM;
    for (int i = 1; i <= n; ++i)
    {
        d[i] = arcs[v0][i];//初始時最短距離爲直接距離
        S[i] = false;// 初始都未用過該點
        if (d[i] == MAXINT)
            prev[i] = -1;
        else
            prev[i] = v0;
    }
    d[v0] = 0;
    S[v0] = true;//S集合中加入v0
    for (int i = 2; i <= n; i++)
    {
        int mindist = MAXINT;
        int u = v0; // 找出當前未使用的點j的dist[j]最小值
        for (int j = 1; j <= n; ++j)
            if ((!S[j]) && d[j] < mindist)
            {
                u = j; // u爲在V-S中到源點v0的最短距離對應的結點
                mindist = d[j];
            }
        S[u] = true;//將u加入到S中

        //更新其他結點到單源點的最短距離,查看其他結點經過u到單源點的距離會不會比之前單元點的直接距離要短
        for (int j = 1; j <= n; j++)
            if ((!S[j]) && arcs[u][j]<MAXINT)
            {
                if (d[u] + arcs[u][j] < d[j])//在通過新加入的u點路徑找到離v0點更短的路徑  
                {
                    d[j] = d[u] + arcs[u][j]; //更新dist 
                    prev[j] = u; //記錄前驅頂點 
                }
            }
    }
}

總結

Prim算法與貪心

  Prim 算法中,計算 VS 中每個結點S 的最小權值,也就是最小距離,然後把距離最小的那個結點加入到 S 中,並且在 VS 中剔除該結點。那麼計算 VS 中每個結點的最短距離成爲問題的關鍵所在。顯然只有在加入新的最短結點到 S 中時,VS 中每個結點的最短距離纔會可能發生改變,我們定義把距離最短的那個結點加入到 S 時中稱爲狀態,那麼該狀態結果只取決於上一個狀態。這是一種典型的貪心思想。

Kruskal算法與貪心

  對邊的權值進行從小到大的排序,依次加入小的權值邊,且不能形成環,我們把邊的集合 E 分成兩部分,一部分S ,另一部分US 表示已經加入的邊集合,U 表示候選的邊集合,我們需要對U 集合裏面的邊權值進行從小到大的排序,U 中的每個邊都要其對應的排名,每次向S 中加入U 中排名最高的邊也就是取決於其排名,我們定義每次加入一條邊爲一個狀態,那麼當前的狀態只是取決於上一次的狀態,上一次向S 中加入了哪條邊,則在U 中剔除該邊,則剩餘的邊排名就發生改變,需要更新。

Dijkstra算法與貪心

  只需要對Prim 算法稍作變化就能得到 Dijkstra 算法,故與貪心的聯繫參考上面的 Prim

以上幾種算法的狀態轉移示意圖如下,是一個馬爾科夫過程:

這裏寫圖片描述
可以看到,在從AiAi+1 的擴展過程中,上述三個算法都沒有使用 A[0i1] 的值。

最長遞增子序列LIS

在字符串部分我們詳解過這個問題,利用的是最長公共子序列解的,現在我們嘗試利用動態規劃解。

以序列 1,4,6,2,8,9,7 爲例。

前綴分析

1 結尾的遞增序列[1] ,長度爲 1 ;以 4 結尾的遞增序列 [1,4] ,長度爲 2 ;以 6 結尾的遞增序列 [1,4,6] ,長度爲3 ;同理可得下面表格:

這裏寫圖片描述

顯然以 9 結尾的遞增序列 1,4,6,8,9 長度最長爲 5

LIS記號

長度爲 N 的數組記爲 A=[a0,a1,a2...an1]

A 的前i 個字符構成的前綴串Ai=a0,a1,a2...ai1ai 結尾的最長遞增子序列記做 Li ,其長度記爲b[i]

假定已經計算得到了b[0,1,i1] ,如何計算b[i] 呢 ?
已知L0,L1Li1 的前提下,如何求Li ?

求解LIS

根據定義, Li 必須以ai 結尾; 如果將ai 分別綴到L0,L1Li1 後面,是否允許呢?如果 aiaj ,則可以將ai 綴到Lj 的後面,得到比Lj 更長的字符串。

從而:b[i]={max(b[j])+1,0jiajai}

  1. 計算b[i] :遍歷在i 之前的所有位置j ,找出滿足條件ajai 的最大的 b[j]+1
  2. 計算得到 b[0n1] 後,遍歷所有的b[i] ,找出最大值即爲最大遞增子序列的長度。

時間複雜度爲O(N2)

實現代碼

#include<stdio.h>
#include <stdlib.h>
#include<iostream>
#include<vector>
#include<set>
#include<queue>
#include<algorithm>
using namespace std;

int LIS(const int *p, int length, int *pre, int& nIndex)
{
    int* longest = new int[length];//longest[i]表示以p[i]結尾的遞增序列長度
    int i, j;
    for (i = 0; i < length; i++)
    {
        longest[i] = 1;//初始時以每個字符結尾的遞增序列長度都爲1
        pre[i] = -1;
    }
    int nLst = 1;//最長的遞增子序列長度
    nIndex = 0;
    for (i = 1; i < length; i++)
    {
        for (j = 0; j < i; j++)
        {
            if (p[j] <= p[i])
            {
                if (longest[i] < longest[j] + 1)
                {
                    longest[i] = longest[j] + 1;
                    pre[i] = j;//記錄前驅
                }
            }
        }
        if (nLst < longest[i])//記錄所有的遞增子序列裏面最長的長度
        {
            nLst = longest[i];
            nIndex = i;//nIndex記錄最長遞增子序列最後一個結點
        }
    }
    delete[] longest;
    return nLst;
}

void GetLIS(const int* array, const int* pre, int nIndex, vector<int>& lis)
{
    while (nIndex>=0)//nIndex爲最長遞增子序列最後一個結點
    {
        lis.push_back(array[nIndex]);
        nIndex = pre[nIndex];
    }
    reverse(lis.begin(), lis.end());//逆向輸出
}

void Print(int *p, int size)
{
    for (int i = 0; i < size; i++)
        cout << p[i]<<" ";
    cout << endl;
}

int main()
{
    int array[] = { 1, 4, 5, 6, 2, 3, 8, 9, 10, 11, 12, 12, 1 };
    int size = sizeof(array) / sizeof(int);
    int* pre = new int[size];
    int nIndex;
    int max = LIS(array, size, pre, nIndex);
    vector<int> lis;
    GetLIS(array, pre, nIndex, lis);
    delete[] pre;
    cout << max << endl;
    Print(&lis.front(), (int)lis.size());
    return 0;
}

矩陣乘積

問題描述

根據矩陣相乘的定義來計算C=A×B ,需要mns 次乘法。

三個矩陣A、B、C的階分別是a0×a1,a1×a2,a2×a3 ,從而(A×B)×CA×(B×C) 的乘法次數是a0a1a2+a0a2a3a1a2a3+a0a1a3 ,二者一般情況是不相等的。那麼如何使得計算量最小呢?

問題分析

可以利用矩陣乘法的結合律 來降低乘法的計算量。

給定n 個矩陣A1,A2,,An ,其中AiAi+1 是可乘的,i=12n1 考察該n 個矩陣的連乘積:A1×A2×A3×An ,確定計算矩陣連乘積的計算次序,使得依此次序計算矩陣連乘積需要的乘法次數最少。

  1. 將矩陣連乘積記爲A[i:j] ,這裏ij ,顯然,若i==j ,則A[i:j]A[i] 本身。

  2. 考察計算A[i:j] 的最優計算次序。設這個計算次序在矩陣AkAk+1 之間將矩陣鏈斷開,ik<j ,則其相應的完全加括號方式爲:

    (Ai,Ai+1...Ak)(Ak+1,Ak+2,...,Aj)
  3. 計算量:A[i:k] 的計算量加上A[k+1:j] 的計算量,再加上A[i:k]A[k+1:j] 相乘的計算量。

最優子結構

特徵:計算A[i:j] 的最優次序所包含的計算矩陣子鏈 A[i:k]A[k+1:j] 的次序也是最優的。即要全局最優,子結構也需要最優。

矩陣連乘計算次序問題的最優解包含着其子問題的最優解。這種性質稱爲最優子結構性質

最優子結構性質是可以使用動態規劃算法求解的顯著特徵

狀態轉移方程

  • 設計算A[i:j](1ijn) 所需要的最少數乘次數爲m[i,j] ,則原問題的最優值爲m[1,n]

  • Ai 的維度爲 pi1pi

  • i==j 時,A[i:j]Ai 本身,因此,m[i,i]=0(i=1,2,,n)

  • i<j 時有:


    這裏寫圖片描述

  • k 遍歷i,j ,找到一個使得計算量最小的k,也即是:


    這裏寫圖片描述

i 不斷從1 擴展到 nji 擴展,隨着問題規模越來越大,總能求出 m[1,n]

實現代碼

#include<stdio.h>
#include <stdlib.h>
#include<iostream>
#include<vector>
#include<set>
#include<queue>
#include<algorithm>
using namespace std;

//p[0,..,n]存儲n+1個數,其中(p[i-1],p[i])是矩陣i的階
//s[i][j]記錄了矩陣i連乘到矩陣j應該在哪斷開;m[i][j]記錄了矩陣i連乘到矩陣j最小計算量
void MatrixMultiply(int* p, int n, int** m, int** s)
{
    int r, i, j, k, t;
    for (i = 1; i <= n; i++)
        m[i][i] = 0;
    //r個連續矩陣相乘,r不斷擴展,不斷計算任意兩點之間最優斷開點,最小計算量
    for (r = 2; r <= n; r++)
    {
        for (i = 1; i <= n - r + 1; i++)
        {
            j = i + r - 1;
            m[i][j] = m[i][i]+m[i + 1][j] + p[i - 1] * p[i] * p[j];//初始值,第一項m[i][i]=0
            s[i][j] = i;//初始在i處斷開
            for (k = i + 1; k < j; k++)
            {
                t = m[i][k] + m[k + 1][j] + p[i - 1] * p[k]*p[j];
                if (t < m[i][j])
                {
                    m[i][j] = t;//(i,j)最小計算量
                    s[i][j] = k;//記錄(i,j)中最優斷開點
                }
            }
        }
    }
}

找零錢問題

問題描述

給定某不超過100 萬元的現金總額,兌換成數量不限的100502010521 的組合,共有多少種組合 呢?注意這裏問的是多少種組合

問題分析

此問題涉及兩個類別:面額和總額

  1. 如果面額都是1 元的,則無論總額多少,可行的組合數顯然都爲1
  2. 如果面額多一種,則組合數有什麼變化呢?

定義dp[i][j] :使用面額小於等於 i 的錢幣,湊成j 元錢,共有多少種組合方法。

  1. dp[100][500]=dp[50][500]+dp[100][400]
    dp[50][500]50 以下的面額的組成500 是一種組合方式,這裏面就包括了dp[20][500]dp[10][500]
    dp[100][400] :表示首先拿出100 的面額,剩餘的 400 用小於等於100 的面額組合。
    上述兩種組合方式沒有包含關係,兩種組合合在一起組成所有的組合方式。

  2. dp[i][j]=dp[ismall][j]+dp[i][ji]
    如果把 i 看成數組下標,則有:dp[i][j]=dp[i1][j]+dp[i][jdom[i]]

遞推公式

  1. 使用dom[]=1,2,5,10,20,50,100 表示基本面額,i 的意義從面額變成面額下標,則: dp[i][j]=dp[i1][j]+dp[i][jdom[i]]

  2. 從而有:


    這裏寫圖片描述

  3. 初始條件( 注意都爲1,而不是0):

    這裏寫圖片描述

   按照上面的狀態轉移方差我們可以從初始狀態一直推導到終止狀態dp[6][100w]

實現代碼

#include<stdio.h>
#include <stdlib.h>
#include<iostream>
#include<vector>
#include<set>
#include<queue>
#include<algorithm>
using namespace std;

int Charge(int value, const int* denomination, int size)
{
    int i;//i是下標
    int** dp = new int*[size];//dp[i][j]:用i面額以下的組合成j元
    for (i = 0; i < size; i++)
        dp[i] = new int[value + 1];

    int j;
    for (j = 0; j <= value; j++)//i=0表示用面額1元的
        dp[0][j] = 1;//這個時候只有一種組合方式

    for (i = 1; i < size; i++)//先用面額小的,再用面額大的
    {
        dp[i][0] = 1;//添加任何一種面額,都是一種組合
        for (j = 1; j <= value; j++)//先組合小的,然後擴展,在小的基礎上一直組合到dp[size-1][value]
        {
            if (j >= denomination[i])
                dp[i][j] = dp[i-1][j]+dp[i][j-denomination[i]];
            else
                dp[i][j] = dp[i - 1][j];
        }
    }

    int time = dp[size - 1][value];
    //清理內存
    for (i = 0; i < size; i++)
        delete[] dp[i];
    return time;
}

int main()
{
    int denomination[] = { 1, 2, 5, 10, 20, 50, 100 };
    int size = sizeof(denomination) / sizeof(int);
    int value = 200;
    int c = Charge(value, denomination, size);
    cout << c << endl;
    return 0;
}

滾動數組

將狀態轉移方程去掉第一維,很容易使用滾動數組,降低空間使用量。

原狀態轉移方程:

這裏寫圖片描述

滾動數組版本的狀態轉移方程:

這裏寫圖片描述

這個 last[j] 就是原始狀態轉移方程中的dp[i1][j]dp[j] 就是dp[i][j]

#include<stdio.h>
#include <stdlib.h>
#include<iostream>
#include<algorithm>
using namespace std;

int Charge2(int value, const int* denomination, int size)
{
    int i;//i是下標
    int* dp = new int[value+1];//dp[i][j]:用i面額以下的組合成j元
    int* last = new int[value + 1];

    int j;
    for (j = 0; j <= value; j++)//只用面額1元的
    {
        dp[j] = 1;
        last[j] = 1;
    }
    for (i = 1; i < size; i++)
    {
        for (j = 1; j <= value; j++)
        {
            if (j >= denomination[i])
                dp[j] = last[j] + dp[j - denomination[i]];
        }
        memcpy(last, dp, sizeof(int)*(value + 1));//相當於dp[i-1][j] 賦值給last
    }
    int times = dp[value];
    delete[] last;
    delete[] dp;
    return times;
}

int main()
{
    int denomination[] = { 1, 2, 5, 10, 20, 50, 100 };
    int size = sizeof(denomination) / sizeof(int);
    int value = 200;
    int c = Charge2(value, denomination, size);
    cout << c << endl;
    return 0;
}

在動態規劃的問題中,如果不求具體解的內容,而只是求解的數目,往往可以使用滾動數組的方式降低空間使用量(甚至空間複雜度),由於滾動數組減少了維度,甚至代碼會更簡單,但是代碼會更加難以理解。

走棋盤/格子取數

問題描述

給定 m×n 的矩陣,每個位置是有一個非負整數的權值,從左上角開始,每次只能朝右和下走,走到右下角,求總和最小的權值。

這裏寫圖片描述

狀態轉移方程

走的方向決定了同一個格子不會經過兩次。

  • 若當前位於(x,y) 處,它來自於哪些格子呢?
  • dp[xy] 表示從起點走到座標爲(x,y) 的方格的最小權值。
  • dp[0,0]=a[0,0] , 第一行(列)累積
  • dp[x,y]=min(dp[x1,y]+a[x,y],dp[x,y1]+a[x,y])
  • 即:dp[x,y]=min(dp[x1,y],dp[x,y1])+a[x,y]

狀態轉移方程:

dp(i,0)=ik=0chess[k][0]dp(0,j)=jk=0chess[0][k]dp(i,j)=min(dp(i1,j),dp(i,j1))+chess[i][j]

   在上邊界時只能向右走,在左邊界時只能向下走。

滾動數組去除第一維:

{dp(j)=ik=0chess[0][k]dp(0,j)=min(dp(j),dp(j1))+chess[i][j]

實現代碼

#include<stdio.h>
#include <stdlib.h>
#include<iostream>
#include<vector>
#include<algorithm>
using namespace std;

int MinPath(vector<vector<int>> &chess, int M, int N)
{
    vector<int> pathLength(N);
    int i, j;

    //初始化
    pathLength[0] = chess[0][0];
    for (j = 1; j < N; j++)
        pathLength[j] = pathLength[j - 1] + chess[0][j];
    //依次計算每行
    for (i = 1; i < M; i++)
    {
        pathLength[0] += chess[i][0];
        for (j = 1; j < N; j++)
        {
            if (pathLength[j - 1] < pathLength[j])
                pathLength[j] = pathLength[j - 1] + chess[i][j];
            else
                pathLength[j] += chess[i][j];
        }
    }
    return pathLength[N - 1];
}

int main()
{
    const int M = 10;
    const int N = 8;
    vector<vector<int>> chess(M, vector<int>(N));

    //初始化棋盤(隨機給定)
    int i, j;
    for (i = 0; i < M; i++)
    {
        for (j = 0; j < N; j++)
            chess[i][j] = rand() % 100;
    }
    cout << MinPath(chess, M, N) << endl;
    return 0;
}

帶陷阱的走棋盤問題

問題分析

8×6 的矩陣中,每次只能向上或向右移動一格,並且不能經過P 。試計算從A 移動到B 一共有多少種走法。

這裏寫圖片描述

狀態轉移方程

  1. dp[i][j] 表示從起點到(i,j) 的路徑條數。
  2. 只能從左邊或者上邊進入一個格子。
  3. 如果 (i,j) 被佔用,dp[i][j]=0
  4. 如果(i,j) 不被佔用,dp[i][j]=dp[i1][j]+dp[i][j1]

故狀態轉移方程:

dp[i][0]=dp[0][j]=1dp[i][j]=0dp[i][j]=dp[i1][j]+dp[i][j1](i=0||j=0)(i,j)(i,j)

一共要走m+n2 步,其中(m1) 步向右,(n1) 步向下。組合數C(m+n2,m1)=C(m+n2,n1)

問題解決

這裏寫圖片描述

  我們把 A 點看着起點 (0,0) ,計算起點A 到終點 B 的所有路徑allPath ,然後再計算 Ap 的所有路徑path1 ,再計算pB 的所有路徑paht2 ,那麼paht1path2 即是從起點 A 到終點B 的所有經過點 p 的路徑數,allPathpaht1paht2 即爲避開點pA 到點B 的所有路徑。

實現代碼

#include<stdio.h>
#include <stdlib.h>
#include<iostream>
#include<vector>
#include<algorithm>
using namespace std;

int MinPath(vector<vector<int>> dp,int M, int N)
{
    int i, j;
    //在左邊界和下邊界上的點路徑都只有1
    for (i = 0; i < M+1; i++)
        dp[i][0] = 1;
    for (j = 0; j < N+1; j++)
        dp[0][j] = 1; 

    for (i = 1; i < M+1; i++)
    {
        for (j = 1; j < N+1; j++)
        {
            dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
        }
    }
    return dp[M][N];
}

int main()
{
    const int M = 6;
    const int N = 8;
    vector<vector<int>> dp(M, vector<int>(N));
    int x = 3;
    int y = 5;
    int allPath = MinPath(dp,M-1, N-1);//從起點到終點的所有路徑
    int path1 = MinPath(dp,x, y);//起點到佔用點所有路徑
    int path2 = MinPath(dp, M-x-1, N-y-1);//從佔用點到終點的所有路徑
    int path = allPath - path1*path2;//在所有路徑中除去經過佔用點路徑數
    cout << path << endl;
    return 0;
}

動態規劃總結

這裏寫圖片描述

動態規劃是方法論,是解決一大類問題的通用思路。事實上,很多內容都可以歸結爲動態規劃的思想。

何時可以考慮使用動態規劃:

  • 初始規模下能夠方便的得出結論
    空串、長度爲0的數組、自身等’

  • 能夠得到問題規模增大導致的變化
    遞推式——狀態轉移方程

在實踐中往往忽略無後效性

哪些題目不適合用動態規劃?

  • 狀態轉移方程 的推導,往往陷入局部而忽略全局。在重視動態規劃的同時,別忘了從總體把握問題。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章