动态规划初级试炼场
Posted 小小算法
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了动态规划初级试炼场相关的知识,希望对你有一定的参考价值。
动态规划初级试炼场
友情提示:
本文中列举的部分例题,并不一定只能用动态规划求解,但文中只会给出问题的动态规划解法。
这篇文章首先会介绍一下下动态规划的一般解题思路,然后会按照这个思路讲解四个 上的经典动态规划题目,相信你会有所收获的!
动态规划解题一般分为三步:
-
表示状态
每一种状态都是使用数组的维数来表示的。
一般我们会先使用数组的第一维来表示阶段,然后再根据需要通过增加数组维数,来添加其它状态。
-
找出状态转移方程
状态转移指的是,当前阶段的状态如何通过之前计算过的阶段状态得到。
-
初始化边界
初始化边角状态,以及第一个阶段的状态。
简短的介绍结束了,下面将迎来我们的第一个问题。
53. 最大子序和[1]
给定一个整数数组 ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),并返回其最大和。
示例:
输入: [-2,1,-3,4,-1,2,1,-5,4],
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。
思路分析
首先是对这个问题进行阶段划分。我们可以把这个长度为 的数组划分为 个阶段,例如第 个阶段代表前 个元素的最大子序和,我们用 来表示 。
使用数组的第一维来表示阶段:
表示第 个阶段,也就是前 个元素的最大子序和。
表示第 个阶段,也就是前 个元素的最大子序和。
......
仅仅表示了阶段 ,我们还是很难看出 和 之间的转移关系。因此我们需要再寻找对转移策略有影响的其它状态,我们发现 到 的转移策略与它们的结尾元素是否被选择有关,因此再添加一维来表示对应阶段结尾元素的选取状态。具体情况请往下看。
-
状态表示
表示前 个元素中,不选择第 个元素时的最大子序和
表示前 个元素中,选择第 个元素时的最大子序和
-
状态转移
-
边界处理
代码
int maxSubArray(vector<int>& nums) {
int dp[100010][2];
int len = nums.size();
dp[0][0] = INT_MIN;
dp[0][1] = nums[0];
for (int i = 1; i < len; i ++) {
dp[i][0] = max(dp[i-1][0], dp[i-1][1]);
dp[i][1] = max(dp[i-1][1] + nums[i], nums[i]);
}
return max(dp[len-1][0], dp[len-1][1]);
}
另一种思路
在这我们换一种状态表示的方法,帮助大家开阔思路。我们通过动态规划的方式,计算出以每个元素作为结尾的最大子序和,然后在这些结果中选取最大的一个,作为最终的结果。
-
状态表示
表示以第 个元素作为结尾时的最大子序和
-
状态转移
-
边界处理
代码
int maxSubArray(vector<int>& nums) {
int dp[100010];
dp[0] = nums[0];
for (int i = 1; i < nums.size(); i ++) {
dp[i] = max(nums[i], nums[i] + dp[i-1]);
}
int ret = INT_MIN;
for (int i = 0; i < nums.size(); i ++) {
ret = max(ret, dp[i]);
}
return ret;
}
188. 买卖股票的最佳时机 IV[2]
给定一个数组,它的第 个元素是一支给定的股票在第 天的价格。设计一个算法来计算你所能获取的最大利润。你最多可以完成 笔交易。
注意: 你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
示例 1:
输入: [2,4,1], k = 2
输出: 2
解释: 在第 1 天 (股票价格 = 2) 的时候买入,
在第 2 天 (股票价格 = 4) 的时候卖出,
这笔交易所能获得利润 = 4-2 = 2 。
思路分析
同样还是先划分阶段, 表示我们通过前 天的买卖获得的最大利润, 可以由 转移得到。但状态表示不足,我们无法得到它们之间的转移策略,因此我们需要再添加状态。
题目对交易次数有限制,因此我们需要添加一个状态,来表示对应阶段剩余的交易次数。 题目要求不能同时参与多笔交易,因此我们需要再添加一个状态,来表示对应阶段是否已经参与了交易,也就是是否持有股票。
-
状态表示
表示第 天剩余的交易次数为 ,并且未持有股票时获得的最大利润
表示第 天剩余的交易次数为 ,并且持有股票时获得的最大利润
-
状态转移
第 天未持有股票,有两种情况:
第 天未持有股票 第 天持有股票,但第 天卖出(卖出记为一次交易,所以前 天的剩余交易次数为 )
第 天持有股票,也有两种情况:
第 天就持有股票 第 天未持有股票,但第 天买入(买入不算完成一次交易)
-
边界处理
//剩余次数最多为 k,因此 k+1 天不能持有股票
for (int i = 0; i < 1005; i ++) {
dp[i][k+1][1] = INT_MIN;
}
//第 1 天未持有股票,获利为 0
//第 1 天持有股票,也就是在第一天买入,获利为-prices[0]
for (int j = k; j >= 0; j --) {
dp[0][j][0] = 0;
dp[0][j][1] = -prices[0];
}
未经优化的 DP 代码(不能通过)
int maxProfit(int k, vector<int>& prices) {
int len = prices.size();
if (len == 0) {return 0;}
int dp[1005][105][2];
for (int i = 0; i < 1005; i ++) {
dp[i][k+1][1] = INT_MIN;
}
for (int j = k; j >= 0; j --) {
dp[0][j][0] = 0;
dp[0][j][1] = -prices[0];
}
for (int i = 1; i < len; i ++) {
for (int j = k; j >= 0; j --) {
dp[i][j][1] = max(dp[i-1][j][1], dp[i-1][j][0] - prices[i]);
dp[i][j][0] = max(dp[i-1][j][0], dp[i-1][j+1][1] + prices[i]);
}
}
return dp[len-1][0][0];
}
上面的这个代码是不能通过的,因为有的测试用例 非常大,而我们的时间复杂度和空间复杂度都和 有关。
下面我将给出优化后的代码,但并不详细讲解优化。解释两点:
是交易次数,交易一次需要花费两天(买入再卖出),因此如果题目给出的 ,那就变成了 122. 买卖股票的最佳时机 II [3] 这个问题,那我们就可以用贪心来解决,时间复杂度为 . 用于表示阶段的第一维可以优化掉(直接删掉就可以了),从而数组变成了两维。
优化之后的代码(可通过)
int maxProfit(int k, vector<int>& prices) {
int len = prices.size();
if (k > len / 2) {
int ret = 0;
for (int i = 0; i < len-1; i ++) {
if (prices[i+1] > prices[i]) {
ret += prices[i+1] - prices[i];
}
}
return ret;
}
int dp[10005][2];
dp[k+1][1] = INT_MIN;
for (int j = k; j >= 0; j --) {
dp[j][0] = 0;
dp[j][1] = -prices[0];
}
for (int i = 1; i < len; i ++) {
for (int j = k; j >= 0; j --) {
dp[j][1] = max(dp[j][1], dp[j][0] - prices[i]);
dp[j][0] = max(dp[j][0], dp[j+1][1] + prices[i]);
}
}
return dp[0][0];
}
5383. 给 N x 3 网格图涂色的方案数[4]
你有一个 n x 3
的网格图
,你需要用 红,黄,绿 三种颜色之一给每一个格子上色,且确保相邻格子颜色不同(也就是有相同水平边或者垂直边的格子颜色不同)。
给你网格图的行数 。请你返回给 涂色的方案数。由于答案可能会非常大,请你返回答案对 取余的结果。
示例 1:
输入:n = 1
输出:12
解释:总共有 12 种可行的方法:
注意,这张图片代表的仅仅是一行中三个单元格的所有可能涂色方案。
思路分析
首先划分阶段, 表示前 行单元格的涂色方案总数, 可以由 转移得到。但状态表示不足,我们无法得到它们之间的转移策略,因此我们需要再添加状态。
题目要求相邻格子颜色不能相同,因此我们必须添加一维,来表示 末尾行的三个格子的涂色方案。
例如,
末尾(第
行)的三个格子涂色为 红 黄 绿
,那
末尾(第
行)的三个格子就不能涂色为 红 黄 绿
或者 红 绿 红
等,因为这些格子的颜色会与第
行相邻格子的颜色出现相同的情况。
到这,我们其实还不明确每个阶段末尾那行的状态有哪些,也就是涂色方案有哪些,但我们知道它们是由红黄蓝三种颜色形成的排列组合,因此我们可能把它们的所有可能方案枚举出来,保存在数组 all
中。这里需要注意两点:
我们用 三个数字,分别表示 红 黄 绿 三种颜色。 题目要求相邻格子的颜色不能相同,因此我们需要在枚举的过程中添加一个判断,作为筛选。
vector<vector<int>> all;
for (int i = 0; i < 3; i ++) {
for (int j = 0; j < 3; j ++) {
for (int k = 0; k < 3; k ++) {
// 第1个格子和第2个相邻或者第2和第3响铃
if (i == j || j == k) {
continue;
}
all.push_back({i, j, k});
}
}
}
-
状态表示
表示前 行格子中,第 行格子涂色方案为 的所有方案总数。
其中 表示的方案是
all[j]
中的颜色组合。
-
状态转移
f(n)(j) = 0;
for (int k = 0; k < len; k ++) {
// isValid函数用于判断这两个状态是否存在相邻同色的元素
if (isValid(all[j], all[k])) {
f(n)(j) += f(n-1)(k);
}
}
-
边界处理
第一行的三个格子的涂色方案可以为 all
中的任意涂色方案
for (int i = 0; i < all.size(); i ++) {
f(0)(i) = 1;
}
代码
bool isValid(vector<int>& a, vector<int>& b) {
for (int i = 0; i < 3; i ++) {
if (a[i] == b[i]) {
return false;
}
}
return true;
}
int numOfWays(int n) {
int mod = 1000000007;
vector<vector<int>> all;
for (int i = 0; i < 3; i ++) {
for (int j = 0; j < 3; j ++) {
for (int k = 0; k < 3; k ++) {
if (i == j || j == k) {
continue;
}
all.push_back({i, j, k});
}
}
}
int len = all.size();
int dp[5005][12];
for (int i = 0; i < len; i ++) {
dp[0][i] = 1;
}
for (int i = 1; i < n; i ++) {
for (int j = 0; j < len; j ++) {
dp[i][j] = 0;
for (int k = 0; k < len; k ++) {
if (isValid(all[j], all[k])) {
dp[i][j] = (dp[i][j] + dp[i-1][k]) % mod;
}
}
}
}
// 最后的结果为最后一个阶段的所有状态之和
int ret = 0;
for (int i = 0; i < len; i ++) {
ret = (ret + dp[n-1][i]) % mod;
}
return ret;
}
72. 编辑距离[5]
给你两个单词 和 ,请你计算出将 转换成 所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
-
插入一个字符 -
删除一个字符 -
替换一个字符
示例 1:
输入:word1 = "horse", word2 = "ros"
输出:3
解释:
horse -> rorse (将 'h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')
思路分析
先划分阶段。这是双序列型动态规划问题, 表示第一个序列的前 个元素和第二个序列的前 个元素共同形成的一个状态。 这个阶段的状态可以由哪些阶段转换而来呢?
我们最终是将 转换为 ,我们分析其中任意的一个阶段 。 表示,处理到了 的第 个字符, 的第 个字符,这时 , , 都已经处理好了。
接下来我们就只需要判断 和 是否相等,以及在经过插入、删除和替换操作后,所获得的结果中选择最优的一个。
-
状态表示
表示处理到 的第 个字符和 的第 个字符时的最少操作次数。
-
状态转移
-
边界处理
初始化 和 为空串的情况.
for (int i = 0; i <= len1; i ++) {
f(i)(0) = i;
}
for (int j = 0; j <= len2; j ++) {
f(0)(j) = j;
}
代码
int minDistance(string word1, string word2) {
int dp[1001][1001];
int len1 = word1.size();
int len2 = word2.size();
for (int i = 0; i <= len1; i ++) {
dp[i][0] = i;
}
for (int j = 0; j <= len2; j ++) {
dp[0][j] = j;
}
for (int i = 1; i <= len1; i ++) {
for (int j = 1; j <= len2; j ++) {
if (word1[i-1] == word2[j-1]) {
dp[i][j] = dp[i-1][j-1];
} else{
int temp[] = {dp[i][j-1], dp[i-1][j], dp[i-1][j-1]};
dp[i][j] = (*min_element(temp, temp+3)) + 1;
}
}
}
return dp[len1][len2];
}
最后
动态规划的关键在于状态的表示,只要状态表示出来了,转移方程和边界处理也就能够比较容易的推出。在表示状态的时候一般先表示阶段,然后再看是否还需要添加其它的状态。
有时仅表示出阶段就足以解题,我们就不需要在添加其它状态,例如最后一个例题:72. 编辑距离[6]
有时表示的状态种类需要达到三维,甚至更多,例如第二个例题:188. 买卖股票的最佳时机 IV[7]
有时一种状态里面又包含大量的信息,需要我们先枚举出该状态的所有值,然后对每个值分别进行转移,例如倒数第二个例题:5383. 给 N x 3 网格图涂色的方案数[8]
尽管动态规划题型千变万化,但解题步骤是不变的,只要多多练习就可以熟练掌握的。
我也只是个菜鸡,只能总结这么多了,一起加油。
参考资料
53. 最大子序和: https://leetcode-cn.com/problems/maximum-subarray/
[2]188. 买卖股票的最佳时机 IV: https://leetcode-cn.com/problems/best-time-to-buy-and-sell-stock-iv/
[3]122. 买卖股票的最佳时机 II: https://leetcode-cn.com/problems/best-time-to-buy-and-sell-stock-ii/
[4]5383. 给 N x 3 网格图涂色的方案数: https://leetcode-cn.com/problems/number-of-ways-to-paint-n-x-3-grid/
[5]72. 编辑距离: https://leetcode-cn.com/problems/edit-distance/
以上是关于动态规划初级试炼场的主要内容,如果未能解决你的问题,请参考以下文章