• [算法总结] 动态规划 (Dynamic Programming)


    本文组织结构如下:

    • 前言
    • 最长公共子序列(LCS)
    • 最长不下降子序列(LIS)
    • 最大连续子序列之和
    • 最长回文子串
    • 数塔问题
    • 背包问题(Knapsack-Problem)
    • 矩阵链相乘
    • 总结

    前言

    在学过的算法当中,DP给我的感觉是最难的了。借着本次写blog好好复习一下这个算法。

    众所周知,DP算法的关键点:

    • 抽象出问题的状态表示
    • 定义状态转移方程
    • 填表顺序

    最长公共子序列

    最长公共子序列(Longest Common Subsequence,LCS),顾名思义,是指在所有的子序列中最长的那一个,子序列要求都出现过并且出现顺序与母串保持一致。

    例如,给定字符串 ab:

    string a = "cnblog"
    string b = "belong";
    

    blog 都出现过,且字母顺序一致,那就一个公共子序列(在这里也是最长的公共子序列)。

    状态定义:

    dp[i, j] 表示 a[0,...,i] 与 b[0,...j] 的最长公共子序列的长度
    

    那么现在的目的就是求出:dp[alen, blen]

    状态转移方程:

            = 0                          if i=0 or j=0
    dp[i,j] = dp[i-1,j-1]+1              if a[i]=b[j]
            = max(dp[i-1,j], dp[i, j-1]) if a[i]!=b[j]
    

    观察可知,每一个 dp[i,j] 都是依赖于 “左边、上边、左上角” 的三个元素之一,所以对于数组 dp ,可以从前往后填写。

    DP算法一个关键的地方就是需要初始化边界。在此处,就是需要初始化 dp 数组的第 0 列,以及第 0 行。

    代码如下,其中 plat 数组用于求解最大的公共子序列是什么(回溯法+DFS,具体不展开细讲,其他blog写了很多)。

    int lcs(string &a, string &b)
    {
        string result = "";
        int alen = a.length();
        int blen = b.length();
        vector<vector<int>> dp(alen + 1, vector<int>(blen + 1, 0));
        vector<vector<char>> plat(alen + 1, vector<char>(blen + 1, 0));
        for (int i = 1; i <= alen; i++)
        {
            for (int j = 1; j <= blen; j++)
            {
                if (a[i - 1] == b[j - 1])  //此处为什么是 a[i-1] 和 b[j-1] 呢?
                    dp[i][j] = dp[i - 1][j - 1] + 1, plat[i][j] = '\';
                else
                {
                    if (dp[i - 1][j] >= dp[i][j - 1])
                        dp[i][j] = dp[i - 1][j], plat[i][j] = '|';
                    else
                        dp[i][j] = dp[i][j - 1], plat[i][j] = '-';
                }
            }
        }
    
        print(plat, alen, blen);
        return dp.back().back();
    }
    

    上面的代码中,需要特别留意和加以理解的地方就是内层循环中的 if 语句。

    可以使用下图来理解,实际上当 i=0 or j=0 时,表示的是字符串为空串,即 s="" 。但是在代码当中,字符串的下标是从 0 开始计数的。在上面的状态转移方程当中,dp[1,1] 实际上需要判断两个字符串的第一个字符是否相等,即 a[0] == b[0]

          B D C A B A
        0 1 2 3 4 5 6 
      0   0 0 0 0 0 0 
    A 1 0
    B 2 0
    C 3 0
    B 4 0
    D 5 0
    A 6 0
    B 7 0
    

    完整代码:

    #include <iostream>
    #include <cassert>
    #include <string>
    #include "leetcode.h"
    #include <vector>
    using namespace std;
    string a = "ABCBDAB", b = "BDCABA";
    // string a = "xyxxzxyzxy", b = "zxzyyzxxyxxz";
    void print(vector<vector<char>> &plat, int i, int j)
    {
        if (i <= 0 || j <= 0)
        {
            return;
        }
        if (plat[i][j] == '-')
        {
            print(plat, i, j - 1);
        }
        else if (plat[i][j] == '|')
        {
            print(plat, i - 1, j);
        }
        else if (plat[i][j] == '\')
        {
            print(plat, i - 1, j - 1);
            cout << a[i - 1];
        }
        else
        {
        }
    }
    int lcs(string &a, string &b)
    {
        string result = "";
        int alen = a.length();
        int blen = b.length();
        vector<vector<int>> dp(alen + 1, vector<int>(blen + 1, 0));
        vector<vector<char>> plat(alen + 1, vector<char>(blen + 1, 0));
        for (int i = 1; i <= alen; i++)
        {
            for (int j = 1; j <= blen; j++)
            {
                if (a[i - 1] == b[j - 1])
                    dp[i][j] = dp[i - 1][j - 1] + 1, plat[i][j] = '\';
                else
                {
                    if (dp[i - 1][j] >= dp[i][j - 1])
                        dp[i][j] = dp[i - 1][j], plat[i][j] = '|';
                    else
                        dp[i][j] = dp[i][j - 1], plat[i][j] = '-';
                }
            }
        }
    
        print(plat, alen, blen);
        return dp.back().back();
    }
    
    int main()
    {
        cout << lcs(a, b);
    }
    
    /*
            = 0                           if i=0 or j=0
    dp[i,j] = dp[i-1,j-1]+1               if a[i]=b[j]
            = max(dp[i-1,j], dp[i, j-1])  if a[i]!=b[j]
     */
    

    最长不下降子序列

    不下降子序列就是说:从一个数组当中选出若干个元素,按照下标顺序排列,该序列要求非降序排列。

    例如有数组:

    int a[] = {1, 2, 3, -9, 3, 9, 0, 11};
    

    那么不下降子序列可以是:

    1 2 3 3 9 11
    1 2 3
    ...
    

    求这些序列的最大长度。

    我们令 dp[i] 表示:a[i] 结尾的,最长不下降子序列的长度。

    那么对于 dp[i],可以有以下状态转移方程:

    dp[0] = 1
    dp[i] = 1
          = max(1, max(dp[j]+1)), 0<=j<=(i-1) and a[i]>=a[j]
    

    (其实这里的状态转移方程比代码还难理解,还是看代码好 = =)

    代码:

    int solve(int a[], int len)
    {
        vector<int> dp(len, 0);
        dp[0]=1;
        for (int i=1;i<len;i++)
        {
            dp[i] = 1;
            for (int j=0;j<i;j++)
            {
                if (a[i]>=a[j])
                    dp[i]=max(dp[i], dp[j]+1);
            }
        }
        int val = -1;
        for (auto x:dp)
            val=max(val,x);
        return val;
    }
    int main()
    {
        int a[] = {1, 2, 3, -9, 3, 9, 0, 11};
        cout << solve(a, 8);
    }
    

    最大连续子序列之和

    这又是一道最XX的题目。

    给定 A[1,...,n] ,求 ij , 1<=i<=j<=n,使得 sum(A[i,...,j]) 最大,输出这个最大和。

    比如

    -2 11 -4 13 -5 2
    

    最大的、连续的子序列就是:

    11 -4 13
    

    最大和是 20 。

    正常的思路是穷举每一个左端点和右端点,但是这样的复杂度是 O(n*n),其次每次对区间求和又需要 O(n) 的复杂度,只要数据量大,这种方法是不明智的。

    来看一下DP的解法。

    状态定义:

    dp[i]: 以 a[i] 结尾的,最大连续子序列的和。
    

    在这种情况下,在 i 位置,要么只取 a[i],要么取 a[i] 加上“前面”的。

    转移方程:

    dp[0] = a[0]
    dp[i] = max(a[i], dp[i-1]+a[i])
    

    代码如下:

    int solve(int a[], int len)
    {
        vector<int> dp(len, 0);
        dp[0] = a[0];
        for (int i=1;i<len;i++)
        {
            dp[i]=max(a[i], dp[i-1]+a[i]);
        }
        int val = -1;
        for (auto x:dp)
            val = max(val, x);
        return val;
    }
    int main()
    {
        int a[] = {-2, 11, -4, 13, -5, -2};
        cout << solve(a, 6) << endl;
    }
    /*
    常见错误:
    dp[i]是[0,i]的最大连续子序列之和
    dp[i+1] = max(dp[i], dp[i]+a[i])
    (这么定义没法保证连续)
    正解:
    dp[i]表示必须以a[i]结尾的连续序列最大和,为什么“必须”?(因为要求连续)
    那么:
    dp[i] = max(a[i], dp[i-1]+a[i])
     */
    

    最长回文子串

    子串要求是连续的。

    求出字符串S的所有子串中,最长的子串。

    还是直接说怎么用DP求解。

    定义状态:

    dp[i,j]表示:S[i,...,j] 是否为回文串。
    

    边界条件:

    dp[i,i] = true				//只有一个字符的字符串
    dp[i,i+1] = (s[i]==s[i+1])	
    

    状态转移方程:

    dp[i,j] = dp[i+1,j-1] && (s[i]==s[j])
    

    细心的你可能会发现,这次的 “填表” 不能从前往后填了,因为 dp[i, j] 依赖于它的左下角的元素。这次需要从 “左上” 到 “右下” 这样斜着填表。(先算左下的,后算右上的)

    图解说明一下为什么斜着填,假设 s = "abba", len = 4

    dp数组初始状态:
      a b b a
    a 1 
    b 0 1 
    b 0 0 1 
    a 0 0 0 1
    
    执行s[i]==s[i+1]:
      a b b a
    a 1 0 
    b 0 1 1 
    b 0 0 1 0
    a 0 0 0 1
    ==>
      a b b a
    a 1 0 0 
    b 0 1 1 0
    b 0 0 1 0
    a 0 0 0 1
    ==>
      a b b a
    a 1 0 0 1
    b 0 1 1 0
    b 0 0 1 0
    a 0 0 0 1
    
    

    代码:

    int solve(string &s)
    {
        int len = s.length();
        if (len == 0 || len == 1)
            return len;
        if (len == 2)
            return (s[0] == s[1]) + 1;
        vector<vector<bool>> dp(len, vector<bool>(len, 0));
        int maxlen = 1;
        for (int i = 0; i <= len - 2; i++)
        {
            dp[i][i] = 1, dp[i][i + 1] = (s[i] == s[i + 1]);
            if (dp[i][i + 1])
                maxlen = 2;
        }
        dp[len - 1][len - 1] = 1;
        int i = 0;
        int j = 2;
        int ti, tj;
        do
        {
            ti = i, tj = j;
            while (i < len && j < len)
            {
                dp[i][j] = dp[i + 1][j - 1] && (s[i] == s[j]);
                if (dp[i][j] == true)
                    maxlen = max(maxlen, j - i + 1);
                i++, j++;
            }
            i = 0, j = tj + 1;
        } while (!(j == len));
        return maxlen;
    }
    int main()
    {
        string s[] = {"PATZJUJZTACCBCC", "", "1", "12", "22", "232", "2332"};
        for (int i = 0; i < 7; i++)
            cout << solve(s[i]) << endl;
    }
    
    /*
    dp[i][j]表示str[i,j]是否为一个回文串,若是则1,若否则0
    那么:
            = 1                         if i=j
    dp[i,j] = (s[i]==s[j])              if i+1=j
            = dp[i+1,j-1]&&s[i]==s[j]   other
     */
    

    数塔问题

    给定如下的树塔 :

    level = 5
            5
           /  
          8    3
         /   /  
        12   7   16
       /  /   /      
      4   10   11   6
     /  /   /   / 
    9   5    3    9   4
    

    i 层有 i 个数字, 从第 1 层到第 n 层,每次只能向下走一个数字, 求解所有路径中, 和最大是多少?

    首先,我们使用一个二维数组去存储这个树塔,那么对于某个节点 a[i,j] ,它的左右子树如下:

    a[i][j]
       |      
    a[i+1][j]  a[i+1][j+1]
    

    定义状态:

    dp[i,j] 表示从(i,j)出发,到达底层的所有路径中得到的最大和
    

    显然,边界条件为:

    dp[level-1][j] = a[level-1][j], 0<=j<level
    

    状态转移函数:

    dp[i,j] = a[i,j] + max(dp[i+1,j], dp[i+1,j+1]), 0<=i<levl-1
    

    显然,填表的顺序是自底向上。

    完整代码:

    #include <iostream>
    #include <algorithm>
    #include <iomanip>
    #include <vector>
    #define MAXR 100
    #define MAXC 100
    using namespace std;
    int a[MAXR][MAXC] = {{0}};
    int solve(int level)
    {
        vector<vector<int>> dp(level + 1, vector<int>(level + 1, 0));
        auto &v = dp.back();
        for (int j = 1; j <= level; j++)
            v[j] = a[level][j];
        for (int i = level - 1; i >= 1; i--)
        {
            for (int j = 1; j <= i; j++)
            {
                dp[i][j] = max(dp[i + 1][j], dp[i + 1][j + 1]) + a[i][j];
            }
        }
        for (int i = 1; i <= level; i++)
        {
            for (int j = 1; j <= i; j++)
            {
                cout << setw(3) << dp[i][j];
            }
            cout << endl;
        }
        return dp[1][1];
    }
    void print(int level)
    {
        for (int i = 1; i <= level; i++)
        {
            for (int j = 1; j <= i; j++)
                cout << setw(4) << a[i][j];
            cout << endl;
        }
    }
    int main()
    {
        int level;
        cin >> level;
        for (int i = 1; i <= level; i++)
        {
            for (int j = 1; j <= i; j++)
                cin >> a[i][j];
        }
        cout << solve(level);
    }
    
    /*
            a[i][j]
           /    
    a[i+1][j]    a[i+1][j+1]
    
    
    dp[i,j] 表示从(i,j)出发,到达底层的所有路径中得到的最大和
    dp[1,1] = max(dp[2,1], dp[2,2])+a[1,1]
    dp[i,j] = max(dp[i+1,j], dp[i+1,i+1])+a[i,j]
    ...
    dp[r,c] = a[r,c]
     */
    
    /*
    Sample1:
    5
    5
    8 3
    12 7 16
    4 10 11 6
    9 5 3 9 4
     */
    

    0/1背包问题

    给定背包容量 C ,物品体积 volume[n],物品价值 value[n]每个物品只有一个

    求:在背包容量允许的情况下,装入背包物品的最大价值。

    定义状态:

    dp[i,j] 表示: 在背包容量为 j 的, 供选择物品为前 i 项, 装入背包的最大价值。

    状态转移方程:

    dp[i,j] = 0                                               if i=0 or j=0
            = dp[i-1,j]                                       if j < volume[i]
            = max(dp[i-1,j], dp[i-1,j-volume[i]] + value[i])  if j > volume[i]
    

    完整代码:

    #include <iostream>
    #include <vector>
    #include "leetcode.h"
    using namespace std;
    const int items = 4;
    const int C = 9;
    int volume[items + 1] = {-1, 2, 3, 4, 5};
    int values[items + 1] = {-1, 3, 4, 5, 7};
    vector<vector<int>> dp(items + 1, vector<int>(C + 1, 0));
    int solve()
    {
        for (int i = 1; i <= items; i++)
        {
            for (int j = 1; j <= C; j++)
            {
                if (j < volume[i])
                    dp[i][j] = dp[i - 1][j];
                else
                {
                    dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - volume[i]] + values[i]);
                }
            }
        }
        printvv(dp);
        return dp.back().back();
    }
    int main()
    {
    
        cout << solve() << endl;
    }
    

    矩阵链相乘

    如果两个矩阵可以相乘,那么必然满足:

    M1[a,b]  
    M2[b,c]
    

    此外, M1与M2相乘, 所计算的乘法次数为: a*b*c。所得到的矩阵为 M3[a,c]

    给定矩阵:

    M1 2*10
    M2 10*2
    M3 2*10
    

    计算:(M1M2)M3,乘法次数为:2*2*10+2*10*2 = 80

    计算:M1(M2M3),乘法次数为:10*10*2+2*10*10 = 400

    在这里,我们想找到一种计算顺序,使得计算:

    M1 M2 M3 ... Mn

    的乘法次数达到最小。

    首先,由于矩阵相乘必须满足:M1的列数等于M2的行数。

    我们采用下面的数据结构去存储 n 个矩阵的行数与列数。

    int nums[N+1]
    nums[i]   表示 Mi 的行数,
    nums[i+1] 表示 Mi 的列数.
    nums[n]   表示 M(n-1) 的列数
    

    然后,定义问题的状态:

    dp[i,j] 表示 Mi ... Mj 的最小乘法次数
    

    边界条件:

    dp[i,i] = 0
    

    下面推导状态转移方程,考虑整数 k (i<k<=j),将 M[i,j] 的乘法拆分为三步:

    • 计算 A = M[i,k-1] ,A的行列分别为:nums[i], nums[k]
    • 计算 B = M[k,j],B的行列分别为:nums[k], nums[j+1]
    • 计算 A*B

    由此可知,M[i,j]的乘法次数为:

    [i,k-1] + [k,j] + nums[i] * nums[j+1] * nums[k]
    

    因此 ,状态转移方程为:

    dp[i,j] = min(dp[i,k] + dp[k,j] + nums[i] * nums[j+1] * nums[k]),	i<k<=j
    

    看到这里,可能有点头大,不知道怎么去填 dp 表。其实是按“斜线”的顺序填入,与 最长回文子串 类似。

    下面来简单看一下过程,假设有五个矩阵( M0 M1 M2 M3 M4 )相乘,其行列存储如下:

    int nums[N + 1] = {5, 10, 4, 6, 10, 2};
    

    边界条件初始化:

    M 0 1 2 3 4
    0 0
    1 * 0
    2 * * 0
    3 * * * 0
    4 * * * * 0
    ==>
      M  0  1   2   3   4
      0  0 200 320  ?
      1  *   # ### 640 
      2  *   *   # 240 ###
      3  *   *   *   0 ###
      4  *   *   *   *   #
    

    以上述 dp[0,3] 为例,说明递推过程:

    dp[0,3] = min
                (
                  dp[0,0] + dp[1,3] + ...
                  dp[0,1] + dp[2,3] + ...
                  dp[0,2] + dp[3,3] + ...
                )
    

    其中,... 代表三个 nums[i] 相乘的常数项,可自行对照递推式代入。在这里,需要特别注意 dp[i,j]的依赖项(实际上是“同行”与“同列” 的所有元素)。

    完整代码:

    #include "leetcode.h"
    #include <cmath>
    #define N 5
    #define MAXINT 9999
    int nums[N + 1] = {5, 10, 4, 6, 10, 2};
    vector<vector<int>> dp(N, vector<int>(N, MAXINT));
    int solve()
    {
        for (int i = 0; i < N; i++)
            dp[i][i] = 0;
        for (int d = 1; d < N; d++)
        {
            for (int i = 0; i < (N - d); i++)
            {
                int j = i + d;
                for (int k = i + 1; k <= j; k++)
                {
                    dp[i][j] = min(dp[i][j], dp[i][k - 1] + dp[k][j] + nums[i] * nums[k] * nums[j + 1]);
                }
            }
        }
        for (auto &v : dp)
        {
            for (auto x : v)
            {
                cout << setw(4);
                if (x == MAXINT)
                    cout << '*';
                else
                {
                    cout << x;
                }
            }
            cout << endl;
        }
        return dp[0][N - 1];
    }
    int main()
    {
        cout << solve() << endl;
    }
    
    /*
    M0...M(n-1): nums[i]   表示 Mi 的行数,
                 nums[i+1] 表示 Mi 的列数.
                 nums[n]   表示 M(n-1) 的列数
     */
    /*
        dp[i,j] 表示 Mi ... Mj 的最小乘法次数
        边界: dp[i,i] = 0;
        递推: dp[i,j] = min(dp[i,k-1]+dp[k,j] + a[i]*a[k]*a[j+1]), i<k<=j
        ==>
        dp[0,n-1] = min(dp[0, k-1] + dp[k,n-1] + a[0]*a[k]*a[n]), 0<k<=n-1
     */
    

    总结

    没总结,有空再补充。

  • 相关阅读:
    Python反射函数
    类之特性
    ThinkPHP框架基础知识一
    smarty变量调节器与函数
    smarty模板及其应用
    php……流程
    php......权限管理
    php......文件上传
    php......注册审核
    php......留言板
  • 原文地址:https://www.cnblogs.com/sinkinben/p/11512710.html
Copyright © 2020-2023  润新知