算法笔记 万物皆可DP——动态规划常见类型 HERODING的算法之路

万物皆可DP

  • 前言
  • 1. 动态规划解题思路
    • 1.1 解题思路
    • 1.2 问题特点
  • 2. 背包问题
    • 2.1 01背包问题
    • 2.2 完全背包问题
    • 2.3 多重背包问题
  • 3. 字符串问题
    • 3.1 最长公共子序列
    • 3.2 分割回文串II
  • 4. 股票问题
  • 5. 总结

前言

如果说搜索算法占据了机试算法题的半壁江山,那么动态规划DP就占据了机试算法题的八分江山,可能有些夸张,但是在做题的时候明显可以感觉得到,万物皆可DP不是天方夜谭,什么组合的个数,最长匹配长度,最少的个数,凡是跟最优解有关的(无论最多还是最少)都可以用的上DP,所以之前的DFS、BFS中的最优解问题,亦可以用DP去解决,而且对于组合数的回溯问题,在n超过一定长度的情况下,只能用DP去解决。
动态规划是求解决策过程最优化的过程,是一种空间换时间的方法(这也就是它能够解决O(2^n)问题的核心,从而放弃DFS的使用),本质其实类似分治思想,把待求解的问题分解成多个子问题,不同子问题之间是相互关联的,前面计算过的子问题可以提供给后面子问题使用。
那DP这么强大,为什么常常让coder望而却步呢?因为它实在是太灵活了,且因题而异,DP的核心——状态转移方程,是只有根据题目的意思才能够构建出来。要说有无模板,也是有的,但是都是依据做题经验总结出来的,如果不能剖析问题的本质,往往就无法构建出来状态转移方程,所以在这里我根据自己的经验通过举例子列举几种DP常见类型,希望能给自己和读者在阅读时有所思考和启发,那么就开始叭~


1. 动态规划解题思路

1.1 解题思路

首先抛砖引玉,祭出可能是最简单的动态规划问题,斐波那契数列问题,该问题经常用递归的方式解决(会有很多重复计算,递归只是因为简单),递归的代码如下:

class Solution {
public:
    int fib(int n) {
        if(n == 0){
            return 0;
        }
        if(n == 1){
            return 1;
        }
        return fib(n - 1) + fib(n - 2); 
    }
};

但是出现的明显问题是重复运算,从最后一行的fib(n-1) + fib(n-2)可以看出,fib(n-1)里面的最后一行是fib(n-2)+fib(n-3),显然fib(n-2)又重复算了一次,这样的情况还有很多,就不一一列举了,这就是递归的弊端。但是动态规划可以很好地解决该问题,动态规划的代码如下:

class Solution {
public:
    int fib(int n) {
        if(n == 0 || n == 1) {
            return n;
        }
        vector<int> dp(n + 1);
        dp[0] = 0;
        dp[1] = 1;
        int a = 0, b = 1;
        for(int i = 2; i <= n; i ++) {
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[n];
    }
};

只是开辟了一个一维数组,就不需要重复进行大量计算了(计算结果保存在数组中,直接调用),此外,再仔细观察发现,每次遍历只用了dp[i - 1]和 dp[i - 2],也就是说可以用两个变量存储dp[i - 1]和 dp[i - 2],每一轮遍历更新一下即可,代码如下:

class Solution {
public:
    int fib(int n) {
        if(n == 0 || n == 1) {
            return n;
        }
        int a = 0, b = 1;
        for(int i = 2; i <= n; i ++) {
            int temp = b;
            b = a + b;
            a = temp;
        }
        return b;
    }
};

这样又进一步节省了空间复杂度!这就是滚动数组的方法,在降低空间复杂度上是一个不错的选择。
接下来介绍一般的动态规划解题思路:

  1. 找到合适的数据结构存储中间值,比如斐波那契数列中的定义的dp[n]或者a和b;
  2. 找到状态转移方程,这一般根据题目的要求设计;
  3. 初始化方法,根据题目所给的条件对定义的dp数组初始化,一般都是在边界位置进行;
  4. 运算顺序,这里举一个例子,假设dp[i][j]记录从i到j是否回文,实际上我们只用到了上三角的部分,有状态转换方程:dp[i][j]=dp[i+1][j-1] && str[i]==str[j],对于i来说,i是根据i+1得来的,所以i是从后往前遍历,对j来说,j是根据j -1来的,所以j从前往后遍历。

1.2 问题特点

动态规划的题目分为两大类,一种是求最优解类,典型问题是背包问题,另一种就是计数类,比如这里的统计方案数的问题,它们都存在一定的递推性质。
能采用动态规划解决的问题,一般要具有三个性质:

  1. 最优化原理:如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构,即满足最优化原理。
  2. 无后效性:即某阶段状态一旦确定,就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响以前的状态,只与当前状态有关。
  3. 有重叠子问题:即子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次使用到。(该性质并不是动态规划适用的必要条件,但是如果没有这条性质,动态规划算法同其他算法相比就不具备优势)

理解上述内容后,就开始我们真题的演练吧!

2. 背包问题

背包问题是最经典的动态规划问题,至少有一半的DP题都可以借鉴背包问题的思想,变成背包问题的变种形式。背包问题不是某一个题目,而是一系列题目,包括01背包问题,完全背包问题,多重背包问题等,这是个循序渐进的过程,认真阅读一定会有所收获的,包括日后回首的自己。

2.1 01背包问题

这里选取的是蓝桥杯练习题上的01背包问题,给定N个物品,每个物品有一个重量W和一个价值V,你有一个能装M重量的背包,问怎么装使得所装价值最大,每个物品只有一个。
如果你看过我上一篇对DFS的总结,你一定会眼前一亮,这不就是heroding所说的一维数据结构的DFS吗,遍历所有满足题意的组合,找到最大的结果不就行了。确实,背包问题完全适合DFS的解法,至少从理论上是可以的,实际上如果n过大,肯定会内存爆炸,直接超时。。。这就是为什么我说,解决这类题型,动态规划比DFS好,所以仔细看完这篇解读,你又有了一个解题利器!
01背包问题,物品只有一个(不重复),即对每一个物品只有选择和不选两种选择,分析题目所给要素:

  • N件商品;
  • 总容量M;
  • 质量数组weight;
  • 价值数组value;

我们按照1.1给的解题思路去面对这道题目,步骤如下:

  1. 找到合适的数据结构存储
    分析题目,有选择的物品,以及有限的重量,那么首先定义的数据结构是二维int型数组dp[i][j],i,j分别代表遍历到第i个物品时背包中的重量为j;
  2. 找到状态转移方程
    这里是整个dp最关键的部分,对于每一个物品,有选择和不选择两种状态,如果不选择,那么背包中的价值不变,跳到下一个物品,即dp[i][j] = dp[i - 1][j];如果选择的话,就需要更新一下背包中的重量,以及价值,dp[i][j] = dp[i - 1][j - weight[i]] + value[i];
  3. 初始化
    初始化根据自定义的dp数组和状态转移方程去理解,背包为0时,不管遍历到哪个都是0(放不进去),所以dp[i][0] = 0;如果一个都不遍历,那么不管背包容量多大都没有东西,即dp[0][j] = 0;
  4. 运算顺序
    在本题中,i和j都是通过之前的状态得到的,所以都应该从小到大遍历。

好了,那么让我们上手一道一模一样的题目来练习一下吧,这是一道北大的复试机试题目,本质上就是01背包问题,代码如下:

#include
#include
#include
#include

using namespace std;

int main() {
    int C, N;
    while(scanf("%d%d", &C, &N) != EOF) {
        vector<int> prices(N + 1);
        vector<int> values(N + 1);
        for(int i = 1; i <= N; i ++) {
            cin >> prices[i] >> values[i];
        }
        // 定义dp数组+初始化
        vector<vector<int>> dp(N + 1, vector<int>(C + 1, 0));
        for(int i = 1; i <= N; i ++) {
            for(int j = 0; j <= C; j ++) {
            	// 状态转移方程
                if(j >= prices[i]) {
                    dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - prices[i]] + values[i]);
                } else {
                    dp[i][j] = dp[i - 1][j];
                }
            }
        }
        cout << dp[N][C] << endl;
    }
    return 0;
}

观察一下代码可以发现,仍然有可以优化的地方,就是在状态转移方程那里,dp[i]只与dp[i - 1]有关系,所以可以将二维dp数组化简为一维,如下所示:

		//...
		vector<int> dp(C + 1, 0);
        for(int i = 1; i <= N; i ++) {
            for(int j = C; j >= prices[i]; j --) {
                dp[j] = max(dp[j], dp[j - prices[i]] + values[i]);
            }
        }
       //...

那么这里要注意几点,一个对于j要从后往前进行,因为正向进行的话会把i-1的值修改了,换言之,就是每次更新dp[j]必须是用i - 1的dp[]更新的,只有倒着进行才不会出现上述情况,如果还不理解这里举个简单的例子,比如dp[1]刚刚进行更新,结果更新dp[2]的时候需要使用dp[1],但是此时的dp[1]不是i - 1时候的dp[1]了,结果就导致结果完全错误。还有个需要注意的是在二维数组中,如果j < prices[i]需要进行dp[i][j] = dp[i - 1][j];但是在一维dp数组中不需要,因为dp[j] = dp[j]没有什么意义。
这里留下两道练习题,一个是北大机试题目采药问题,另一个是清华大学复试上机题最小邮票数,都是不错的01背包问题的练习题目,其中最小邮票数取的是min,所以在初始化要以一个尽可能大的值初始化。

2.2 完全背包问题

这是有关01背包问题的拓展,此时每件物品不只可以取一个,而是可以选择多件,在这样的条件下使得背包中物品价值总和最大。
同样,设置二维数组dp[][],令dp[i][j]表示前i个物品装进容量为j的背包能够获得的最大价值,通过设置这么一个二维数据,数组dp[n][m]的值就是完全背包问题的解。
和01背包一样,只考虑第i件物品时,可将情况分为是否放入dii件物品两种:

  1. 对于容量为j的背包,如果不放入第i件物品,那么这个额问题和01背包问题一样,即dp[i][j] = dp[i - 1][j]
  2. 对于容量为j的背包,如果放入第i件物品,那么放入之前背包的容量就变成j - weight[i],并得到这个物品的价值value[i],由于第i个物品是可以无限获取的,所以状态转移方程变成:dp[i][j] = dp[i][j - weight[i]] + value[i];
    所以最后的状态转移方程为:dp[i][j] = max(dp[i - 1][j], dp[i][j - weight[i]] + value[i]);

此外由于dp[i][j]只和dp[i - 1],dp[i][j]有关,可以进一步优化,状态转移方程为:
                        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
关键在于遍历的顺序,i还是正向遍历,但是j和01背包不同,这里需要让dp[j - weight[i]]已经完成了本次的更新修改。这就需要在每次更新中,正序遍历所有的j的值,因为只有这样才能保证完成正确的状态转移。
好了,那就开始题目的练习与讲解吧,这里我首先挑选了一道最基础的完全背包问题,LeetCode 418 零钱兑换II,这里给出的就是给定的硬币个数有无限个,代码如下:

class Solution {
public:
    int change(int amount, vector<int>& coins) {
        // dp[i]表示总金额为i时组合个数
        vector<int> dp(amount + 1);
        dp[0] = 1;
        // 遍历每一种硬币
        for(auto& coin : coins) {
            // 遍历从1——amount的所有情况
            for(int i = 1; i <= amount; i ++) {
                if(i >= coin) {
                    dp[i] = dp[i] + dp[i - coin];
                }
            }
        }
        return dp[amount];
    }
};


/*作者:heroding
链接:https://leetcode-cn.com/problems/coin-change-2/solution/cdong-tai-gui-hua-by-heroding-gg0q/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。*/

可以看到代码中已经是优化后的结果,下面再来一道经典的完全平方数问题,这里不是求最大值,而是求最小的组合,代码如下:

class Solution {
public:
    int numSquares(int n) {
        // 定义动态规划数组
        int INF = 1e8;
        vector<int> dp(n + 1, INF);
        dp[0] = 0;
        for(int i = 1; i * i <= n; i ++) {
            for(int j = i * i; j <= n; j ++) {
                dp[j] = min(dp[j], dp[j - i * i] + 1);
            }
        }
        return dp[n];
    }
};

可以看到和01背包问题的求最小值方法一样的,首先都要先初始化为一个足够大的值,然后再套用模板进行。

2.3 多重背包问题

多重背包问题算是比较少见的背包问题了,它的本质是01背包和完全背包问题的折中,每种物体至多只能取k件,此时的数据结构多了一个k[i],专门记录不同物体的个数,多重背包可以转换为01背包问题,只不过还有更加便捷的方法去解决,将原数量为k的物品拆分为若干组,将每组视为一件物品,其价值和重量为该组中所有物品的总和。每组物品的数量为20,21,22……2c-1,k-2c+1,其中c是使得k - 2c + 1 >= 0的最大正数,这相当于二进制的拆分,这样大大降低了背包问题的时间复杂度。
下面是一道来自HDU OJ题,可惜网址现在不给访问,题目是珍惜现在、感恩生活,题目大意是你有资金n元,市场有m种大米,每种大米袋装价格不等,在有限的资金最够构面多少千克粮食。代码如下:

#include

using namespace std;

const int MAXN = 10000;

int dp[MAXN];	// dp数组 
int v[MAXN];	// 物品价值
int w[MAXN];	// 物品质量 
int k[MAXN];	// 物品数目 
int value[MAXN];	// 分解后物品价值 
int weight[MAXN]; 	// 分解后物品质量 

int main() {
	int caseNumber;
	cin >> caseNumber;
	while(caseNumber --) {
		int n, m;
		cin >> n >> m;
		int number = 0;	// 实时记录当前分组的个数 
		for(int i = 0; i < n; i ++) {
			cin >> w[i] >> v[i] >> k[i];
			for(int j = 1; j <= k[i]; j <<= 1) {// 按2的幂取 1,2,4....
				value[number] = j * v[i];
				weight[number] = j * w[i];
				number ++;
				k[i] -= j;
			}
			if(k[i] > 0) {
				value[number] = k[i] * v[i];
				weight[number] = k[i] * w[i];
				number;
			}
		}
		for(int i = 0; i <= m; i ++) {
			dp[i] = 0;
		} 
		for(int i = 0; i < number; i ++) {
			for(int j = m; j >= weight[i]; j --) {
				dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
			}
		}
		cout << dp[m] << endl;
	} 
	return 0;
}

3. 字符串问题

以上都是与数字有关的动态规划题目,其实很多与字符串相关的题目也可以用到动态规划的方法,比如最长匹配,回文串个数,这里我也是简单举几个例子帮助大家理解。

3.1 最长公共子序列

本题是LeetCode 1143 最长公共子序列,非常经典的一道关于字符串匹配的动态规划题,dp[i][j]表示第一个字符串的1—i序列与第二个字符串的1—j序列最长匹配的长度,代码如下:

class Solution {
public:
    int longestCommonSubsequence(string text1, string text2) {
        int n = text1.length(), m = text2.length();
        // 动态规划数组
        vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
        for(int i = 1; i < n + 1; i ++) {
            for(int j = 1;j < m + 1; j ++) {
                // 如果当前位置相等
                if(text1[i - 1]  == text2[j - 1]) {
                    dp[i][j] = dp[i - 1][j - 1] + 1;
                } else {
                    // 不相等就选取最长的
                    dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
                }
            }
        }
        return dp[n][m];
    }
};

/*作者:heroding
链接:https://leetcode-cn.com/problems/longest-common-subsequence/solution/cdong-tai-gui-hua-by-heroding-nbxv/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。*/

这道题就是常说的,看着简单实现困难的那类问题,代码不长,不到20行,核心代码部分也就四五行,但是真正能想到用这样的思路去解决才是最困难的地方,所以还是希望读者自己手敲一遍,自己感受往往会有意想不到的收获。

3.2 分割回文串II

第二道字符串题目是LeetCode 132 分割回文串II,这道题是双重动态规划的巧用,首先第一次动态规划是为了标记所有的回文串,第二次动态规划是为了统计所有的回文串(尽可能长的字符串),相信经过我的描述,代码已经在各位脑海中浮现了,代码如下:

class Solution {
public:
    int minCut(string s) {
        int len = s.size();
        vector<vector<bool>> judge(len, vector<bool>(len, true));
        for (int i = len - 1; i >= 0; --i) {
            for (int j = i + 1; j < len; ++j) {
                // 动态规划思想,i到j是否为回文串取决于i与j是否相等或者s[i]、s[j]相等,[i + 1]到[j - 1]范围内满足回文
                judge[i][j] = (s[i] == s[j]) && judge[i + 1][j - 1];
            }
        }
        vector<int> times(len, INT_MAX);
        for(int i = 0; i < len; i ++) {
            if(judge[0][i]) {
                times[i] = 0;
            } else {
                for(int j = 0; j < i; j ++) {
                    // 如果j + 1 到 i 是回文串
                    if(judge[j + 1][i]) {
                        times[i] = min(times[i], times[j] + 1);
                    }
                }
            }
        }
        return times[len - 1];
    }
};



/*作者:heroding
链接:https://leetcode-cn.com/problems/palindrome-partitioning-ii/solution/liang-ci-dpcxiang-jie-by-heroding-48zi/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。*/

而且观察代码可以发现,第二次的动态规划,实际就是背包问题中求min的过程,状态转移方程是 times[i] = min(times[i], times[j] + 1),即0——i分割的次数,如果judge[0][i]为True,说明不用分割,不为True就从0到i找回文的位置,如果j + 1 到 i 是回文串,就进行状态转移,选择不分割和分割的最小值。

4. 股票问题

股票问题是经典的组合动态规划方法,比如股票的sale和buy这两种状态就是相互进行转换的,而且同一时期只能有一个状态,首先看LeetCode 123 买卖股票的最佳时机III,因为最多可以交易两次,所以可以 设定四种状态,分别是buy1,buy2,sell1,sell2,四个数据结构代表的意义如下:

  1. buy1: 在该天第一次买入股票可获得的最大收益;
  2. sell1: 在该天第一次卖出股票可获得的最大收益;
  3. buy2: 在该天第二次买入股票可获得的最大收益;
  4. sell2: 在该天第二次卖出股票可获得的最大收益;

最后返回的一定是sell2(最后一种状态),那么这四种状态怎么更新呢?buy1肯定挑最便宜买入的那天买入,sell1等于上次卖出和这次卖出的最大值,buy2等于上次买过后的钱和上次卖的钱减去买的股的最大值,sell2是上次卖出和这次卖出的最大值,代码如下:

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int buy1 = INT_MIN;
        int sell1 = 0;
        int buy2 = INT_MIN;
        int sell2 = 0;
        for(int p : prices) {
            buy1 = max(buy1, -p);
            sell1 = max(sell1, buy1 + p);
            buy2 = max(buy2, sell1 - p);
            sell2 = max(sell2, buy2 + p); 
        }
        return sell2;
    }
};

股票问题第二个就是当买卖为k次的情况,那么四种状态是远远不够了,不然干脆定义成两个数组,思路都是一样的,只不过这次对于每一天,要遍历k次,状态转移方程为buy[i] = max(buy[i], sell[i - 1] - ele);sell[i] = max(sell[i], buy[i] + ele);代码如下:

class Solution {
public:
    int maxProfit(int k, vector<int>& prices) {
        vector<int> sell(k + 1, 0);
        vector<int> buy(k + 1, INT_MIN);
        for(auto &ele : prices)
        {
            for(int i = 1; i < k + 1; i ++)
            {
                buy[i] = max(buy[i], sell[i - 1] - ele);
                sell[i] = max(sell[i], buy[i] + ele);
            }
        }
        return sell[k];
    }
};


5. 总结

码了一天的动态规划,说到底对我本人来说,也就是背包问题理解的比较透彻了,字符串问题还是没能够总结出一定的方法,希望在日后的完善过程中可以总结出规律,读者如果没有太足够的耐心,只用看背包问题部分即可;或者觉得某一个部分讲解的不够好或者没说明白,请私信或者评论区指出,我会尽快解答和改正,无论如何,今天算是我收获满满的一天,也希望会是在看的各位有所感悟的一次阅读体验!下一篇算法笔记,可能会是并查集(图论题目利器),或者是二分法,这两种方法都是某些常见问题的利器,敬请期待!

你可能感兴趣的:(笔记,c++,动态规划,数据结构,程序设计,算法)