动态规划

171 阅读5分钟

一、动态规划算法思想

image.png

image.png

  • 最优子结构:小规模问题的解可以组合成为大规模问题的解
  • 子问题划分不独立,有重合

二、硬币选择问题

问题描述:有指定面值的硬币,给一个数值,求出至少需要几枚硬币才能组成这一数值。

分治算法解决硬币选择问题:重复计算了很多子问题

dp[i]:组成i元钱至少需要dp[i]枚硬币

int get_min_coins(int n) {
    if (n < 1) {
        return 0;
    }
    else if (n == 1 || n == 3 || n == 5) {
        return 1;
    }
    else {
        int num1 = get_min_coins(n - 1) + 1;
        int num2 = get_min_coins(n - 3) + 1;
        int num3 = get_min_coins(n - 5) + 1;
        return min({ num1, num2, num3 });
    }
}
// 动态规划算法求解
/*
    dp[0] = 0
    dp[1] = 1 + dp[1-1] = 1 + dp[0] = 1
    dp[2] = 1 + dp[2-1] = 1 + dp[1] = 2

    dp[3] = 1 + dp[3-1] = 1 + dp[2] = 3
    dp[3] = 1 + dp[3-3] = 1 + dp[0] = 1
    
    dp[4] = 1 + dp[4-1] = 1 + dp[3] = 2
    dp[4] = 1 + dp[4-3] = 1 + dp[1] = 2
*/
const int coins[3] = { 1,3,5 };

int get_min_coins2(int n) {
    int* dp = new int[n + 1]();
    dp[0] = 0;
    for (int i = 1; i <= n; i++) {
        // 全部使用1元硬币
        dp[i] = i;
        for (int coin : coins) {
            if (coin <= i && 1 + dp[i - coin] < dp[i]) {
                // 如果使用当前面值为coin的硬币后,所用的硬币数量小于使用上一枚硬币的数量,则更新
                dp[i] = 1 + dp[i - coin];
            }
        }
    }
    int ans = dp[n];
    delete[] dp;
    return ans;
}

三、斐波纳契数列

分治法

int get_fibonacci1(int n) {
    if (n < 0) {
        return -1;
    }
    if (n == 1 || n == 2) {
        return 1;
    }
    // 大量子问题重复求解
    return get_fibonacci1(n - 1) + get_fibonacci1(n - 2);
}

动态规划

int get_fibonacci2(int n) {
    if (n < 1) {
        return -1;
    }
    int* dp = new int[n + 1]();
    for (int i = 1; i <= n; i++) {
        dp[i] = 1;
    }
    for (int i = 3; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }
    int ans = dp[n];
    delete[] dp;
    return ans;
}

三、最大子数组和

image.png

dp[i]记录以i号元素结尾的子数组的和(不一定从首元素开始)

/*
    dp[0] = nums[0]
    dp[1] = max(nums[1], nums[1] + dp[0])
    dp[2] = max(nums[2], nums[2] + dp[1])
    ...
    同时需要记录dp的最大值
*/

int maxSubArray(vector<int>& nums) {
    int* dp = new int[nums.size()];
    dp[0] = nums[0];
    int max_val = dp[0];
    for(int i = 1; i < nums.size(); i++){
        // if(dp[i-1] < 0){
        //     dp[i] = nums[i];
        // }else{
        //     dp[i] = dp[i-1] + nums[i];
        // }
        dp[i] = max(dp[i-1] + nums[i], nums[i]);  // 如果当前的和小于当前元素的值,说明上一次的子数组和dp[i]<0,需要将当前的和dp[i]设置成当前元素的值,子数组重新开始计算
        max_val = max(dp[i], max_val);
    }
    delete[] dp;
    return max_val;
}

四、最长递增子序列

image.png

状态:dp[i]表示以i号元素结尾的最长非降子序列的长度(不一定以0号元素开头,可以以0~i任意一个元素开头)

案例1:[1,3,6,7,9,4,10,5,6] 得到的dp为[1 2 3 4 5 3 6 4 5]
案例2:[4,3,6,7,9,4,10,5,6] 得到的dp为[1 1 2 3 4 2 5 3 4]

递推公式:dp[i] = max(1, 1+dp[j]) 表示要么是当前这个元素组成一个非降子序列,要么是和前面某个序列组成非降子序列。

在dp中记录的非降子序列长度中最后一个值arr[j]是最大的,若arr[j] <= arr[i],说明当前的i号元素可以与dp[j]中所计算的非降子序列组成一个新的非降子序列(dp[i] = 1 + dp[j]),

其实当前这个nums[i]可能和前面很多dp[j]记录的序列都构成非降子序列,需要用满足arr[i] >= arr[j]这一条件的最大的dp[j]来更新当前的dp[i]

int get_LIS(vector<int>& nums) {
   const int n = nums.size();
   int* dp = new int[n];
   dp[0] = 1;
   int max_len = 1;

   for (int i = 1; i < n; i++) {
       // 当nums[i]比nums[0] ~ nums[i-1]元素都小的时候,max_len_before_i = 0
       int max_len_before_i = 0;
       for (int j = 0; j < i; j++) {
           if (nums[j] < nums[i]) {
               max_len_before_i = max(max_len_before_i, dp[j]);
           }
       }
       dp[i] = max(1, max_len_before_i + 1);
       max_len = max(max_len, dp[i]);
   }
   delete[] dp;
   return max_len;
}

注意双重for循环的方式无法得到最优解,比如:[3,4,1,8,6,7,10]从3开始往后遍历,得到的递增序列是[3,4,8,10],而不是更长的[3,4,6,7,10]

五、1143. 最长公共子序列

image.png

int LCS(const string& str1, int i, const string& str2, int j) {
    cnt++;
    if (i < 0 || j < 0) {
        return 0;
    }
    if (str1[i] == str2[j]) {
        return LCS(str1, i - 1, str2, j - 1) + 1;
    }
    return max(LCS(str1, i, str2, j - 1), LCS(str1, i - 1, str2, j));
}

int longestCommonSubsequence(string text1, string text2) {
    return LCS(text1, text1.size() - 1, text2, text2.size() - 1);
}

用一个Leetcode提交超时的案例说明,分治算法存在大量的计算冗余

image.png

经过本地测试,发现LCS函数执行次数为:918932708

使用递归形式的动态规划算法,对于已经求解过的子问题直接查表,LCS函数执行次数减少为:272

// dp[i][j]:表示str1[0~i]和str2[0~j]最长公共子序列的长度
int** dp = nullptr;

int LCS(string str1, int i, string str2, int j) {
    if (i < 0 || j < 0) {
        return 0;
    }
    if (dp[i][j] >= 0) {
        // 子问题被求解过了,直接查表,返回当前问题的结果
        return dp[i][j];
    }
    if (str1[i] == str2[j]) {
        dp[i][j] = LCS(str1, i - 1, str2, j - 1) + 1;
        return dp[i][j];
    }

    dp[i][j] = max(LCS(str1, i, str2, j - 1), LCS(str1, i - 1, str2, j));
    return dp[i][j];
}

int longestCommonSubsequence(string str1, string str2) {
    int m = str1.size();
    int n = str2.size();
    dp = new int* [m];
    for (int i = 0; i < m; i++) {
        dp[i] = new int[n];
        for (int j = 0; j < n; j++) {
            dp[i][j] = -1;
        }
    }

    int ans = LCS(str1, m - 1, str2, n - 1);

    for (int i = 0; i < m; i++) {
        delete[] dp[i];
    }
    delete[] dp;
    dp = nullptr;
    return ans;
}

LCS函数中添加path数组记录搜索过程

int LCS(string str1, int i, string str2, int j) {
    if (i < 0 || j < 0) {
        return 0;
    }
    if (dp[i][j] >= 0) {
        // 子问题被求解过了
        return dp[i][j];
    }
    cnt++;
    if (str1[i] == str2[j]) {
        path[i][j] = 1;  // 表示往左上角搜索
        dp[i][j] = LCS(str1, i - 1, str2, j - 1) + 1;
        return dp[i][j];
    }

    int len1 = LCS(str1, i, str2, j - 1);
    int len2 = LCS(str1, i - 1, str2, j);
    if (len1 >= len2) {
        path[i][j] = 2;  // 表示往左边搜索
        dp[i][j] = len1;
    }
    else {
        path[i][j] = 3;
        dp[i][j] = len2; // 表示往上面搜索
    }
    return dp[i][j];
}

image.png 用path数组获取最终的最长公共子序列

void back_strace(string str, int i, int j) {
    if (i < 0 || j < 0 || path[i][j] == 0) {
        return;
    }
    else if (path[i][j] == 1) {
        back_strace(str, i - 1, j - 1);  // 先递
        cout << str[i];                  // 归的时候打印
    }
    else if (path[i][j] == 2) {
        back_strace(str, i, j - 1);
    }
    else {
        back_strace(str, i - 1, j);
    }
}

非递归的dp算法:填表从左上角填到右下角

  • 当前字符相等就用左上角的值+1得到当前dp值(当前字符相等表示,此时的最优值等于还没有比较当前字符的值+1)
  • 当前字符不相等就用左边和上边的较大值更新当前dp(当前字符不相等表示,此时的最优值等于两种情况的其中一种)

image.png

// dp[i][j]:表示str1[0~i]和str2[0~j]最长公共子序列的长度
int longestCommonSubsequence(string str1, string str2) {
    int m = str1.size();
    int n = str2.size();
    vector<vector<int>> dp(m, vector<int>(n, 0));
    for(int i = 0; i < m; i++){
        for(int j = 0; j < n; j++){
            if(str1[i] == str2[j]){
                if(i == 0 || j == 0){
                    dp[i][j] = 1;
                }else{
                    dp[i][j] = 1 + dp[i-1][j-1];
                }
            }else{
                if(i == 0 && j == 0){
                    dp[i][j] = 0;
                }else if(i == 0 && j != 0){
                    // 当前字符不相等,且i==0,j!=0从左边得到当前的最优值
                    dp[i][j] = dp[i][j-1];   
                }else if(j == 0 && i != 0){
                    // 当前字符不相等,且i!=0,j==0从上面得到当前的最优值
                    dp[i][j] = dp[i-1][j];
                }else{
                    // 当前字符不相等,那么当前的最优值就等于两种情况中最大的那个
                    dp[i][j] = max(dp[i][j-1], dp[i-1][j]);
                }
            }
        }
    }
    return dp[m-1][n-1];
}

六、01背包问题

两个要素:最优子结构、划分的子问题重叠

dp[i][j]:物品范围是0 ~ i,背包容量是j,所能装入的最大价值

  1. 首先只考虑一个物品,就是w0 ,此时i == 0

    • 若wi > j,说明这个物品不能放入背包,dp[i][j] = 0

    • 若wi <= j,说明这个物品可以放入背包,dp[i][j] = vi

  2. 考虑多个物品,物品可选范围为0 ~ i

    • 若wi > j,说明这个物品i不能放入背包,dp[i][j] = dp[i-1][j]

    • 若wi <= j,说明这个物品可以放入背包,dp[i][j] = max(dp[i-1][j], vi + dp[i-1][j-wi])

int maxval_in_bag(const vector<int>& w, const vector<int>& v, int c) {
    int m = w.size();  // 物品数量,i的范围是[0,m-1]
    vector<vector<int>> dp(m, vector<int>(c + 1, 0)); // dp[i][j]:物品范围是0 ~ i,背包容量是j,所能装入的最大价值

    // 初始化,只有一个物品的时候,容量为j,dp的值
    for (int j = 1; j <= c; j++) {
        if (w[0] > j) {
            dp[0][j] = 0;
        }else{
            dp[0][j] = v[0];
        }
    }

    for (int i = 1; i < m; i++) {
        // 外层循环表示可选物品的范围不断增大
        for (int j = 1; j <= c; j++) {
            // 内层循环表示容量不断增大的时候,装当前0~i物品
            if (w[i] > j) {
                dp[i][j] = dp[i - 1][j];
            }
            else {
                dp[i][j] = max(dp[i - 1][j], v[i] + dp[i - 1][j - w[i]]);
            }
        }
    }
    for (int i = 0; i < m; i++) {
        for (int j = 1; j <= c; j++) {
            cout << dp[i][j] << " ";
        }
        cout << endl;
    }
    return dp[m - 1][c];
}

根据dp数组,得到具体选择了哪些物品

image.png

void back_strace1(const vector<vector<int>>& dp, const vector<int>& w) {
    int n = dp.size();        // 物品的数量
    int c = dp[0].size() - 1; // 背包的容量
    int i = n - 1;            // 当前可选物品的范围为0~i
    int j = c;                // 当前背包容量为j
    
    
    while (i >= 0 && j > 0) {
        if (i == 0 && w[i] <= j) {
            // 处理最后一个物品,可以装入则打印,不能装入则物品遍历结束,退出循环
            cout << "0 ";
            break;
        }
        if (dp[i][j] == dp[i - 1][j]) {
            // 如果所选背包容量一样且物品范围不一样,但是价值一样的情况下,说明没有选择当前物品i
            i--;
        }
        else {
            // 选择了当前的物品i
            cout << i << " ";
            i--;         // 缩减物品范围
            j -= w[i];   // 缩减背包容量
        }
    }
    cout << endl;
}

void back_strace2(const vector<vector<int>>& dp, const vector<int>& w, int i, int j) {
    // 单独处理第0个物品
    if (i == 0 && j > 0) {
        if (w[i] <= j) {
            cout << i << " ";
        }
    }
    if (i > 0 && j > 0) {
        if (dp[i][j] == dp[i - 1][j]) {
            back_strace3(dp, w, i - 1, j);
        }
        else {
            back_strace3(dp, w, i - 1, j -= w[i]);
            cout << i << " ";
        }
    }
}

七、三角形最小路径和

image.png

分治法(超时)

int min_sum(vector<vector<int>>& tr, int i, int j){
    if(i == tr.size() - 1){
        return tr[i][j];
    }
    return min(tr[i][j] + min_sum(tr, i + 1, j), tr[i][j] + min_sum(tr, i + 1, j + 1));
}

int minimumTotal(vector<vector<int>>& triangle) {
    return min_sum(triangle, 0, 0);
}

动态规划

dp[i][j]:表示从[i][j]位置开始的三角形的最小路径和

int minimumTotal(vector<vector<int>>& tr) {
    int n = tr.size(); 
    vector<vector<int>> dp(n, vector<int>(n, 0));
    for(int j = 0; j < n; j++){
        dp[n - 1][j] = tr[n - 1][j];
    }
    for(int i = n - 2; i >= 0; i--){
        for(int j = 0; j < tr[i].size(); j++){
            dp[i][j] = tr[i][j] + min(dp[i+1][j], dp[i+1][j+1]);
        }
    }
    return dp[0][0];
}

八、使用最小花费爬楼梯

image.png

int minCostClimbingStairs(vector<int>& cost) {
    vector<int> dp(cost.size(), 0);
    dp[0] = 0;
    dp[1] = min(cost[0], cost[1]);
    for(int i = 2; i < cost.size(); i++){
        dp[i] = min(dp[i-2] + cost[i-1], dp[i-1] + cost[i]);
    }
    return dp[cost.size() - 1];
}

dp[i]:爬过i号楼梯花费的最少体力

image.png

九、总结

动态规划

通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。动态规划常常适用于有重叠子问题和最优子结构性质的问题。

基本思想

若要解一个给定问题,我们需要解其不同部分(即子问题),再合并子问题的解以得出原问题的解。 通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,从而减少计算量: 一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。 这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用。

分治法与动态规划

共同点 :二者都要求原问题具有最优子结构性质,都是将原问题分而治之,分解成若干个规模较小(小到很容易解决的程序)的子问题.然后将子问题的解合并,形成原问题的解.

不同点:分治法将分解后的子问题看成相互独立的,通过用递归来做。

动态规划将分解后的子问题理解为相互间有联系,有重叠部分,需要记忆,通常用迭代来做。

问题特征

最优子结构:当问题的最优解包含了其子问题的最优解时,称该问题具有最优子结构性质。

重叠子问题:在用递归算法自顶向下解问题时,每次产生的子问题并不总是新问题,有些子问题被反复计算多次。动态规划算法正是利用了这种子问题的重叠性质,对每一个子问题只解一次,而后将其解保存在一个表格中,在以后尽可能多地利用这些子问题的解。