动态规划

666 阅读19分钟

1. 针对动态规划的一系列提问

1.1 什么是动态规划?

动态规划(Dynamic Programming)是运筹学的一个分支,是求解决策过程(Decision Process)最优化的数学方法。
它将现实中的问题看作一个前后关联的多阶段过程,每个阶段都需要作出决策,从而使整个过程达到最好的效果。

1.2 动态规划能够解决什么问题?

动态规划能够解决在约束条件下,求最优解的问题。

1.3 动态规划的工作原理是什么?

利用子问题求解整个问题。
与分治算法子问题各自独立不同,动态规划的子问题之间有联系,可以利用已解决的子问题求解未解决的子问题。
因此可以用一个表来记录所有已解决的子问题的答案。动态规划算法的实现过程就是一个填表的过程。

1.4 动态规划从什么开始?

从表格开始。

1.5 动态规划的每个表格和表格内容分别代表什么?

每个表格代表一个子问题。
每个表格内容代表子问题对应的最优解。

1.6 动态规划的最后一个表格一定是求解的结果么?

不是,参看最长公共子串。

1.7 动态规划的难点是什么

  1. 找出划分子问题的方法。
  2. 确定表格的行和列。
  3. 找出计算表格内容的公式。

2. 背包问题

假定你是一个小偷,背着一个可以装4磅东西的背包。你可以盗窃如下3件商品,为了使盗窃的商品价值最高,你该选择哪些商品。

商品名称商品价值商品重量
音响3000美金4磅
笔记本电脑2000美金3磅
吉他1500美金1磅

2.1 背包问题的子问题是什么

子问题是一个放入更少的物品的更小的背包。

2.2 背包问题的表格和表格的内容是什么

表格的行表示向背包放入的物品。
表格的列表示背包的重量,背包的重量必须包括放入背包物品所有可能的重量。
网格表示当前能放入背包的商品的最大价值

2.3 图解背包问题

第一行向背包放入的是音响。音响无法放入1,2,3磅的背包,所以1,2,3磅背包无法放入任何物品,价值为0。音响能放入4磅的背包,所以4磅的背包能放入价值3000美金的物品。

1磅2磅3磅4磅
音响0003000美金

第二行向背包放入的是笔记本, 已放入背包的是音响。音响,笔记本无法均放入1,2磅的背包,所以1,2磅背包无法放入任何物品,价值为0。音响不能放入3磅的背包,笔记本能放入3磅的背包,所以3磅背包能放入价值为2000美金的物品。笔记本能放入4磅的背包,还剩一磅的小背包,当前没有能放入1磅小背包的物品。所以只放笔记本,4磅背包的能放入价值为2000美金的物品。但是音响放入4磅的背包,就能放价值3000美金的物品。因此4磅背包当前能放价值3000美金的物品。
1磅2磅3磅4磅
音响0003000美金
笔记本002000美金3000美金

第三行向背包放入的是吉他,已放入背包的是音响和笔记本。吉他能放入1,2磅的背包,且音响和笔记本均无法放入,所以1,2磅背包能放入价值为1500美金的物品。吉他和笔记本均可放入3磅的背包,但是吉他放入后剩2磅的空间,没有可以放入的其它物品,3磅背包只放入吉他,放入物品的价值是1500美金,比只放入笔记本的价值低,因此3磅背包放入笔记本价值最大,为2000美元。4磅背包放入吉他后还剩一个3磅的小背包,可以放入笔记本,总价值为3500美元,比只放入音响价值高。因此4磅背包放入吉他和笔记本能达到价值最大3500美金。

1磅2磅3磅4磅
音响0003000美金
笔记本002000美金3000美金
吉他1500美金1500美金2000美金3500美金

2.4 背包问题的表格计算公式是什么

根据2.3的填表过程可以得出如下推导:

  1. 如果向背包放入的物品能放入背包,计算将物品放入背包后,剩余的小背包能放入的物品的价值和物品价值的总和。再将这个价值与不放入这个物品背包能够放入的物品的最大价值进行比较,谁大,谁就作为当前背包能放入物品的最大价值。
  2. 如果向背包放入的物品不能放入背包,就是不放入这个物品背包能够放入的物品的最大价值。

C[i][w]=max(C[i1][w当前物品的重量]+当前物品的价值,C[i1][w])C[i][w] = max(C[i-1][w-当前物品的重量]+当前物品的价值, C[i-1][w])

C[i][w]:表示向能放入w磅物品的背包放入从0i对应的物品能达到的最大价值C[i][w]:表示向能放入w磅物品的背包放入从0到i对应的物品能达到的最大价值
C[i1][w]:表示向能放入w磅物品的背包放入从0i1对应的物品能达到的最大价值C[i-1][w]:表示向能放入w磅物品的背包放入从0到i-1对应的物品能达到的最大价值

2.5 背包问题的代码怎么写

背包问题完整代码链接

    //创建表格,行是物品,列是包能放入的物品的重量
    int weightCount = packCapacity + 1;
    int** dpArray = new int*[itemCount]();
    for (int i = 0; i < itemCount; ++i) {
        dpArray[i] = new int[weightCount];
    }

    // 填充表格
    for (int i = 0; i < itemCount; ++i)
    {
        // 记录放入背包物品的重量和价值
        int curWeight = items[i].weight;
        int curValue = items[i].value;
        for (int w = minWeight; w < weightCount; ++w)
        {
            // 记录不放入当前物品的情况下,放入背包物品能够达到的最大价值
            int preTotalValue= 0;

            if (i > 0) {
                preTotalValue = dpArray[i - 1][w];
            }
            
            // 记录放入当前物品的情况下,放入背包物品能够达到的最大价值
            int curTotalValue = 0;

            // 如果当前物品能够放入背包,记录下物品的价值
            if (w >= curWeight) {
                curTotalValue = curValue;
            }
            // 如果放入当前物品后背包还能放入其它物品,且确实还有其它物品,加上剩余的小背包能够放入物品的最大价值
            if ( w > curWeight && i > 0 ) {
                curTotalValue += dpArray[i-1][w - curWeight];
            }
      
            // 找出放入当前物品和不放入当前物品情况下,放入背包的物品能够达到的最大价值
            int maxTotalValue = preTotalValue;

            if (maxTotalValue < curTotalValue) {
                maxTotalValue = curTotalValue;
            }

            // 记录下放入当前物品后,放w磅物品的背包能够放入物品的最大价值
            dpArray[i][w] = maxTotalValue;

        }    
    }

    //记录下最终的最大价值
    int maxValue = dpArray[itemCount - 1][weightCount - 1];

3. 最长公共子序列问题

最长公共子序列是指两个字符串去掉不相同的字符后,剩余的相同字符组成的序列

image.png

3.1 最长公共子序列问题的子问题是什么

两个字符串的子串的公共子序列

3.2 最长公共子序列问题的表格和表格的内容是什么

表格的行和列分别是查找最长公共子序列的字符串。
表格的内容是子串的最长公共子序列的长度。

3.3 图解最长公共子序列问题

第一行第一列的两个子串分别是“F”与“F”,因此最大公共子序列长度为1。
第一行第二列的两个子串分别是“F”与“FO”, “F”与“O”不相等,因此取“F”与“F”的最大公共子序列长度1。
第一行第三列的两个子串分别是“F”与“FOS”, “F”与“S”不相等,因此取“F”与“FO”的最大公共子序列长度1。
第一行第四列的两个子串分别是“F”与“FOSH”, “F”与“H”不相等,因此取“F”与“FOS”的最大公共子序列长度1。

字符串FOSH
F1111

第二行第一列的两个子串分别是“FI”与“F”,“I”与“F”不相等,因此取“F”与“F”的最大公共子序列长度1。
第二行第二列的两个子串分别是“FI”与“FO”, “I”与“O”不相等,因此取“FI”与“F”的最大公共子序列长度1或者“F”与“FO”的最长公共子序列长度1中较大的,也就是1。
第二行第三列的两个子串分别是“FI”与“FOS”, “I”与“S”不相等,因此取“FI”与“FO”的最大公共子序列长度1或者“F”与“FOS”的最长公共子序列长度1中较大的,也就是1。
第二行第四列的两个子串分别是“FI”与“FOSH”, “I”与“H”不相等,因此取“F”与“FOSH”的最大公共子序列长度1或者“FI”与“FOS”的最长公共子序列长度1中较大的,也就是1。

字符串FOSH
F1111
I1111

第三行第一列的两个子串分别是“FIS”与“F”,“S”与“F”不相等,因此取“FI”与“F”的最大公共子序列长度1。
第三行第二列的两个子串分别是“FIS”与“FO”, “S”与“O”不相等,因此取“FIS”与“F”的最大公共子序列长度1或者“FI”与“FO”的最长公共子序列长度1中较大的,也就是1。
第三行第三列的两个子串分别是“FIS”与“FOS”, “S”与“S”是相等,因此取“FI”与“FO”的最大公共子序列长度1再加上相等的S的长度1,即长度为2。
第三行第四列的两个子串分别是“FIS”与“FOSH”, “S”与“H”不相等,因此取“FI”与“FOSH”的最大公共子序列长度1或者“FIS”与“FOS”的最长公共子序列长度2中较大的,也就是2。

字符串FOSH
F1111
I1111
S1122

第四行第一列的两个子串分别是“FISH”与“F”,“H”与“F”不相等,因此取“FIS”与“F”的最大公共子序列长度1。
第四行第二列的两个子串分别是“FISH”与“FO”, “H”与“O”不相等,因此取“FI”与“F”的最大公共子序列长度1或者“F”与“FO”的最长公共子序列长度1中较大的,也就是1。
第四行第三列的两个子串分别是“FISH”与“FOS”, “H”与“S”不相等,因此取“FISH”与“FO”的最大公共子序列长度1或者“FIS”与“FOS”的最长公共子序列长度2中较大的,也就是2。
第四行第四列的两个子串分别是“FISH”与“FOSH”, “H”与“H”是相等,因此取“FIS”与“FOS”的最大公共子序列长度2加上相等的H的长度1,也就是3。

字符串FOSH
F1111
I1111
S1122
H1123

3.4 最长公共子序列问题的表格计算公式是什么

根据3.3的填表过程可以得出如下推导:

  1. 如果两个子串的最后两个字符相等,则最大公共子序列的长度就是去掉相等字符后剩下的子串的最大公共子序列长度加1。
  2. 如果两个子串的最后两个字符不相等,则最大公共子序列的长度去掉任一个不相等的最后一个字符而后剩下的子串的最大公共子序列长度最大的那个值。
如果两个字符串分别为WordAWordB如果两个字符串分别为Word_A和Word_B
如果WordA[i]==WordB[j],C[i][j]=1+C[i1][j1]如果Word_A[i] == Word_B[j], C[i][j] = 1 + C[i-1][j-1]
如果WordA[i]!=WordB[j],C[i][j]=max(C[i1][j],C[i][j1])如果Word_A[i] != Word_B[j], C[i][j] = max(C[i-1][j], C[i][j-1])

3.5 最长公共子序列问题的代码怎么写

最长公共子序列完整代码链接

 // 创建表格,横轴是字符串A,纵轴是字符串B
    int **dpArray = new int*[lengthA];
    for (int i = 0; i < lengthA; ++i) {
        dpArray[i] = new int[lengthB];
    }

    // 填充表格
    for (int i = 0; i < lengthA; ++i)
    {
        int aInserted = false;
        for (int j = 0; j < lengthB; ++j)
        {
            // 字符串A的i位置的字符和字符串B的j位置的字符相等
            if (wordA.at(i) == wordB.at(j)) {
                // 记录下相等的公共子序列长度为1
                dpArray[i][j] = 1;
                // 如果去掉相等的字符后,还有剩余的子串,加上子串的最长公共子序列
                if (i > 0 && j > 0) {
                    dpArray[i][j] += dpArray[i - 1][j - 1];
                }
                //判断当前的字符是否已经插入,未插入,则将这个字符插入公共子序列
                if (!aInserted) {
                    sequence->push_back(wordA.at(i));
                    aInserted = true;
                }
                
            }
            else {
                // 字符串A的i位置的字符和字符串B的j位置的字符不相等
                dpArray[i][j] = 0;
                // 如果去掉字符串A的i位置的字符,字符串A还有子串,记录下剩下的字符串A子串与字符串B的子串的最长公共子序列长度
                if (i > 0) {
                    dpArray[i][j] = dpArray[i - 1][j];
                }

                // 如果去掉字符串B的j位置的字符,字符B还有子串,比较剩下的字符串B子串与字符串A的子串的最长公共子序列长度
                // 与之前去掉字符串A的i位置的字符后的最长公共子序列长度,选取较大的最大公共子序列长度
                if (j > 0 && dpArray[i][j] < dpArray[i][j - 1]) {
                    dpArray[i][j] = dpArray[i][j - 1];
                }
            }

        }    
    }

    // 表格的最后一格就是字符串A和字符串B的最长公共子序列的长度
    int maxLength = dpArray[lengthA - 1][lengthB - 1];

4. 最长公共子串问题

最长公共子串是指两个字符串相同的最长任意个连续的字符组成的子序列。

image.png

4.1 最长公共子串问题的子问题是什么

两个字符串的子串的公共子串

4.2 最长公共子串问题的表格和表格的内容是什么

表格的行和列分别是查找最长公共子串的字符串。
表格的内容是横纵轴字符相等时,计算连续相等的字符的长度。横纵轴字符不相等时,连续的相等的字符长度断开,设置为0。

4.3 图解最长公共子串问题

第一行第一列的两个子串分别是“F”与“F”,因此最大公共子串的长度为1。
第一行第二列的两个子串分别是“F”与“FO”, “F”与“O”不相等,相等子串不再连续,取0。
第一行第三列的两个子串分别是“F”与“FOS”, “F”与“S”不相等,相等子串不再连续,取0。
第一行第四列的两个子串分别是“F”与“FOSH”, “F”与“H”不相等,相等子串不再连续,取0。

字符串FOSH
F1000

第二行第一列的两个子串分别是“FO”与“F”,“O”与“F”不相等,相等子串不再连续,取0。
第二行第二列的两个子串分别是“FO”与“FO”, “O”与“O”相等,因此取“F”与“F”的到“F”和“F”所在位置的连续的子串长度1加上相等的字符长度1,也就是2。
第二行第三列的两个子串分别是“FO”与“FOS”, “O”与“S”不相等,相等子串不再连续,取0。
第二行第四列的两个子串分别是“FO”与“FOSH”, “O”与“H”不相等,相等子串不再连续,取0。

字符串FOSH
F1000
O0200

第三行第一列的两个子串分别是“FOR”与“F”,“R”与“F”不相等,相等子串不再连续,取0。
第三行第二列的两个子串分别是“FOR”与“FO”, “R”与“O”不相等,相等子串不再连续,取0。
第三行第三列的两个子串分别是“FOR”与“FOS”, “R”与“S”不相等,相等子串不再连续,取0。
第三行第四列的两个子串分别是“FOR”与“FOSH”, “R”与“H”不相等,相等子串不再连续,取0。

字符串FOSH
F1000
O0200
R0000

第四行第一列的两个子串分别是“FORH”与“F”,“H”与“F”不相等,相等子串不再连续,取0。
第四行第二列的两个子串分别是“FORH”与“FO”, “H”与“O”不相等,相等子串不再连续,取0。
第四行第三列的两个子串分别是“FORH”与“FOS”, “H”与“S”不相等,相等子串不再连续,取0。
第四行第四列的两个子串分别是“FORH”与“FOSH”, “H”与“H”相等,因此取“FOR”与“FOS”的到“R”和“S”所在位置连续的相等的子串长度0加上相等的字符H的长度1,即长度1。

字符串FOSH
F1000
O0200
R0000
H0001

4.4 最长公共子串问题的表格计算公式是什么

根据4.3的填表过程可以得出如下推导:

  1. 如果两个子串的最后两个字符相等,则最大公共子序列的长度就是去掉相等字符后剩下的子串的最大公共子序列长度加1。
  2. 如果两个子串的最后两个字符不相等,则最大公共子序列的长度去掉任一个不相等的最后一个字符而后剩下的子串的最大公共子序列长度最大的那个值。
如果两个字符串分别为WordAWordB如果两个字符串分别为Word_A和Word_B
如果WordA[i]==WordB[j],C[i][j]=1+C[i1][j1]如果Word_A[i] == Word_B[j], C[i][j] = 1 + C[i-1][j-1]
如果WordA[i]!=WordB[j],C[i][j]=0如果Word_A[i] != Word_B[j], C[i][j] = 0

4.5 最长公共子串问题的代码怎么写

最长公共子串完整代码链接

   //用于记录最长的公共子串长度
    int curMaxLen = 0;

    //填充表格
    for (int i = 0; i < lengthA; ++i)
    {
        for (int j = 0; j < lengthB; ++j)
        {
            //字符串A的第i个字符和字符串B的第j个字符相等
            if (wordA.at(i) == wordB.at(j)) {
                //记录下两个相等字符的长度1
                dpArray[i][j] = 1;
                //去掉相等字符,如果还有剩余子串,加上连续相等子串的长度
                if (i > 0 && j > 0) {
                    dpArray[i][j] += dpArray[i - 1][j - 1];
                }

                // 如果记录的最长公共子串长度小于当前的公共子串长度
                // 清理调记录的公共子串的集合,将当前的公共子串记到公共子串集合中
                if (curMaxLen < dpArray[i][j]) {
                    curMaxLen = dpArray[i][j];
                    subStringSet->clear();
                    subStringSet->insert(wordA.substr(i + 1 - curMaxLen, curMaxLen));
                }
                // 如果记录的最长公共子串长度等于当前的公共子串长度
                // 将当前公共子串加入到公共子串集合中
                else if (curMaxLen == dpArray[i][j]) {
                    subStringSet->insert(wordA.substr(i + 1 - curMaxLen, curMaxLen));
                }
            }
            // 连续的公共子串断开,长度记0
            else {
                dpArray[i][j] = 0;
            }

        }    
    }

5. 棋子问题

假设我们有一个 n 乘以 n 的矩阵 w[n][n]。矩阵存储的都是正整数。棋子起始位置在左上角,终止位置在右下角。我们将棋子从左上角移动到右下角。每次只能向右或者向下移动一位。从左上角到右下角,会有很多不同的路径可以走。我们把每条路径经过的数字加起来看作路径的长度。那从左上角移动到右下角的最短路径长度是多少呢?

image.png

5.1 棋子问题的子问题是什么

到更接近起点的终点的最短路径长度。

5.2 最长棋子问题的表格和表格的内容是什么

表格的横轴和纵轴就是矩阵横轴和纵轴。
表格的内容就是起点到表格对应的终点的最短路径长度。

5.3 图解棋子问题

第一行第一列是起点(0,0)到(0,0)只能向右移动,最短路径长度是1。
第一行第二列是起点(0,0)到(0,1)只能向右移动,最短路径长度是1+3,也就是4。
第一行第三列是起点(0,0)到(0,2)只能从(0,1)到(0,2)向右移动,最短路径长度是4+5,也就是9。
第一行第四列是起点(0,0)到(0,3)只能从(0,2)到(0,3)向右移动,最短路径长度是9+9,也就是18。

0123
>>>
014918

第二行第一列是起点(0,0)到(1,0)只能向下移动,最短路径长度是1+2,也就是3。
第三行第一列是起点(0,0)到(2,0)只能从(1,0)到(2,0)向下移动,最短路径长度是3+5,也就是8。
第四行第一列是起点(0,0)到(3,0)只能从(2,0)到(3,0)向下移动,最短路径长度是8+6,也就是14。

0123
014918
1v3
2v8
3v14

第二行第二列是起点(0,0)到(1,1)可以从(0,1)向下移动到(1,1),其路径长度是4+1。也可以从(1,0)向右移动到(1,1),其路径是3+1。所有最短路径为4。
第二行第三列是起点(0,0)到(1,2)可以从(0,2)向下移动到(1,2),其路径长度是9+3。也可以从(1,1)向右移动到(1,2),其路径是4+3。所有最短路径为7。
第二行第四列是起点(0,0)到(1,3)可以从(0,3)向下移动到(1,3),其路径长度是18+4。也可以从(1,2)向右移动到(1,3),其路径是7+4。所有最短路径为11。

0123
014918
>>>
134711
28
314

第三行第二列是起点(0,0)到(2,1)可以从(1,1)向下移动到(2,1),其路径长度是4+2。也可以从(2,0)向右移动到(2,1),其路径是8+2。所有最短路径为6。
第三行第三列是起点(0,0)到(2,2)可以从(1,2)向下移动到(2,2),其路径长度是7+6。也可以从(2,1)向右移动到(2,2),其路径是6+6。所有最短路径为12。
第三行第四列是起点(0,0)到(2,3)可以从(1,3)向下移动到(2,3),其路径长度是11+7。也可以从(2,2)向右移动到(2,3),其路径是12+7。所有最短路径为18。

0123
014918
134711
v>v
2861218
314

第四行第二列是起点(0,0)到(3,1)可以从(2,1)向下移动到(3,1),其路径长度是6+8。也可以从(3,0)向右移动到(3,1),其路径是14+8。所有最短路径为14。
第四行第三列是起点(0,0)到(3,2)可以从(2,2)向下移动到(3,2),其路径长度是12+4。也可以从(3,1)向右移动到(3,2),其路径是14+4。所有最短路径为16。
第四行第四列是起点(0,0)到(3,3)可以从(2,3)向下移动到(3,3),其路径长度是18+3。也可以从(3,2)向右移动到(3,3),其路径是16+3。所有最短路径为19。

0123
014918
134711
2861218
vv>
314141619

5.4 棋子问题的表格计算公式是什么

根据5.3的填表过程可以得出如下推导:

  1. 对于起始节点到起始节点
如果i=0j=0,最短路径长度C[i][j]=w[i][j]如果i = 0,j = 0,最短路径长度C[i][j] = w[i][j]
  1. 对于第一行,因为只能向右移动,因此只需要从左到右求路径长度即可。
如果i=0j>0,最短路径长度C[i][j]=C[i][j1]+w[i][j]如果i = 0,j > 0,最短路径长度C[i][j] = C[i][j-1] + w[i][j]
  1. 对于第一列,因为只能向下移动,因此字需要从上到下求路径长度即可。
如果i>0j=0,最短路径长度C[i][j]=C[i1][j]+w[i][j]如果i > 0,j = 0,最短路径长度C[i][j] = C[i-1][j] + w[i][j]
  1. 对于其它行列,可以向下或向右移动,因此选取向下或向右移动路径最短的作为最短路径。
如果i>0j>0,最短路径长度C[i][j]=min(C[i1][j],C[i][j1])+w[i][j]如果i > 0,j > 0,最短路径长度C[i][j] = min(C[i-1][j], C[i][j-1]) + w[i][j]

5.5 棋子问题的代码怎么写

棋子问题完整代码链接

    // 创建表格,横轴是棋盘的横轴,纵轴是棋盘的纵轴
    int **dpArray = new int*[rowCount]();

    for (size_t i = 0; i < rowCount; i++)
    {
        dpArray[i] = new int[columnCount]();
    }

    //填充表格

    //起始点到起始点的路径长度就是棋盘上该点的长度
    dpArray[0][0] = chess[0][0];
    //计算第一列的最短路径长度
    for (size_t r = 1; r < rowCount; r++)
    {
        dpArray[r][0] = dpArray[r-1][0] + chess[r][0];
    }
    //计算第一行的最短路径长度
    for (size_t c = 1; c < columnCount; c++)
    {
        dpArray[0][c] = dpArray[0][c-1] + chess[0][c];
    }
    
    // 计算其它行列的最短路径长度
    for (int r = 1; r < rowCount; ++r)
    {
        for (int c = 1; c < columnCount; ++c)
        {
            dpArray[r][c] = chess[r][c] + min( dpArray[r-1][c], dpArray[r][c-1]);
           
        }
    }

    // 到达终点的最短路径长度
    int shortestDistance = dpArray[rowCount - 1][columnCount - 1];

参考文献

  1. MBA智库百科 -- 动态规划
  2. 《算法图解: 像小说一样有趣的算法入门书》- 作者:[美] Aditya Bhargava
  3. 极客时间-数据结构与算法之美-王争 -- # 41 | 动态规划理论:一篇文章带你彻底搞懂最优子结构、无后效性和重复子问题