解决动态规划问题
本文记录做题思路,以待后续复习!
使用「动态规划」解决的问题,需要我们掌握动态规划问题设计状态的技巧 (无后效性),并且需要知道如何推导状态转移方程,最后再去优化空间。
例如 leetCode 热题100 第53 最大子数组:
问题描述:
给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。 子数组是数组中的一个连续部分。
本题重点:
关键 1:理解题意;
关键 2:如何定义子问题(如何定义状态);
关键3:无后效性。
关键 1:理解题意:
题目要我们找出和最大的连续子数组的值是多少,「连续」是关键字,连续很重要,不是子序列。题目只要求返回结果,不要求得到最大的连续子数组是哪一个。这样的问题通常可以使用「动态规划」解决。
关键 2:如何定义子问题(如何定义状态):
设计状态思路: 把不确定的因素确定下来,进而把子问题定义清楚,把子问题定义得简单,并且能够确定子问题的结果,在之后的计算中不会再受其他因素的影响改变了结果,导致不确定性。
动态规划的思想通过解决了一个一个简单的问题,进而把简单的问题的解组成了复杂的问题的解。
我们不知道最大和的连续子数组一定会选哪一个数,那么我们可以求出所有经过输入数组的某一个数的连续子数组的最大和。
例如,示例 1 输入数组是 [-2,1,-3,4,-1,2,1,-5,4] ,我们可以求出以下子问题:
子问题 1:经过 −2 的连续子数组的最大和是多少;
子问题 2:经过 1 的连续子数组的最大和是多少;
子问题 3:经过 −3 的连续子数组的最大和是多少;
子问题 4:经过 4 的连续子数组的最大和是多少;
子问题 5:经过 −1 的连续子数组的最大和是多少;
子问题 6:经过 2 的连续子数组的最大和是多少;
子问题 7:经过 1 的连续子数组的最大和是多少;
子问题 8:经过 −5 的连续子数组的最大和是多少;
子问题 9:经过 4 的连续子数组的最大和是多少。
一共 9 个子问题,这些子问题之间的联系并没有那么好看出来,这是因为子问题的描述还有不确定的地方(有后效性)。
例如「子问题 3」:经过 −3 的连续子数组的最大和是多少。
「经过 −3 的连续子数组」我们任意举出几个:
[-2,1,-3,4] ,−3 是这个连续子数组的第 3 个元素;
[1,-3,4,-1] ,−3 是这个连续子数组的第 2 个元素;
……
我们不确定的是:−3 是连续子数组的第几个元素。那么我们就把 −3 定义成连续子数组的最后一个元素。在新的定义下,我们列出子问题如下:
子问题 1:以 −2 结尾的连续子数组的最大和是多少;
子问题 2:以 1 结尾的连续子数组的最大和是多少;
子问题 3:以 −3 结尾的连续子数组的最大和是多少;
子问题 4:以 4 结尾的连续子数组的最大和是多少;
子问题 5:以 −1 结尾的连续子数组的最大和是多少;
子问题 6:以 2 结尾的连续子数组的最大和是多少;
子问题 7:以 1 结尾的连续子数组的最大和是多少;
子问题 8:以 −5 结尾的连续子数组的最大和是多少;
子问题 9:以 4 结尾的连续子数组的最大和是多少。
我们加上了「结尾的」,这些子问题之间就有了联系。我们单独看子问题 1 和子问题 2:
子问题 1:以 −2 结尾的连续子数组的最大和是多少; 以 −2 结尾的连续子数组是 [-2],因此最大和就是 −2。
子问题 2:以 1 结尾的连续子数组的最大和是多少; 以 1 结尾的连续子数组有 [-2,1] 和 [1] ,其中 [-2,1]
就是在「子问题 1」的后面加上 1 得到。−2+1=−1<1 ,因此「子问题 2」 的答案是 1。
如果编号为 i 的子问题的结果是负数或者 0 ,那么编号为 i + 1 的子问题就可以把编号为 i 的子问题的结果舍弃掉(这里 i 为整数,最小值为 1 ,最大值为 8),这是因为:
一个数 a 加上负数的结果比 a 更小;
一个数 a 加上 000 的结果不会比 a 更大;
而子问题的定义必须以一个数结尾,因此如果子问题 i 的结果是负数或者 0,那么子问题 i + 1 的答案就是以 nums[i] 结尾的那个数。
因为我们把子问题定义的更清楚,子问题之间的联系就容易观察到。
接下来我们按照编写动态规划题解的步骤,把「状态定义」「状态转移方程」「初始化」「输出」「是否可以空间优化」全都写出来。
1. 定义状态(定义子问题)
dp[i]:表示以 nums[i] 结尾 的 连续 子数组的最大和。
说明:「结尾」和「连续」 是关键字。
2. 状态转移方程(描述子问题之间的联系)
根据状态的定义,由于 nums[i] 一定会被选取,并且以 nums[i] 结尾的连续子数组与以 nums[i - 1] 结尾的连续子数组只相差一个元素 nums[i] 。
假设数组 nums 的值全都严格大于 0,那么一定有 dp[i] = dp[i - 1] + nums[i]。
可是 dp[i - 1] 有可能是负数,于是分类讨论:
如果 dp[i - 1] > 0,那么可以把 nums[i] 直接接在 dp[i - 1] 表示的那个数组的后面,得到和更大的连续子数组;
如果 dp[i - 1] <= 0,那么 nums[i] 加上前面的数 dp[i - 1] 以后值不会变大。于是 dp[i]
「另起炉灶」,此时单独的一个 nums[i] 的值,就是 dp[i]。
以上两种情况的最大值就是 dp[i] 的值,写出如下状态转移方程:
记为「状态转移方程 1」。
状态转移方程还可以这样写,反正求的是最大值,也不用分类讨论了,就这两种情况,取最大即可,因此还可以写出状态转移方程如下:
记为「状态转移方程 2」。
求解动态规划的问题经常要分类讨论,这是因为动态规划的问题本来就有「最优子结构」的特点,即大问题的最优解通常由小问题的最优解得到。因此我们在设计子问题的时候,就需要把求解出所有子问题的结果,进而选出原问题的最优解。
思考初始值
dp[0] 根据定义,只有 1 个数,一定以 nums[0] 结尾,因此 dp[0] = nums[0]。
思考输出
简单的动态规划问题,很有可能问的问题就可以设计成为子问题,复杂的动态规划问题就没有那么容易看出子问题应该如何设计了,这需要一定的解决问题的经验。
这个问题的输出是把所有的 dp[0]、dp[1]、……、dp[n - 1] 都看一遍,取最大值。 同样的情况也适用于「力扣」第 300 题:「最长上升子序列」。
代码:
class Solution {
public:
int maxSubArray(vector<int>& nums) {
vector<int> dp(nums.size());
dp[0] = nums[0];
int maxSum = dp[0];
for (int i = 1; i < nums.size(); i++) {
dp[i] = max(nums[i], dp[i - 1] + nums[i]);
if (dp[i] > maxSum)
maxSum = dp[i];
}
return maxSum;
}
};
关键3:无后效性:
为了保证计算子问题能够按照顺序、不重复地进行,动态规划要求已经求解的子问题不受后续阶段的影响。这个条件也被叫做「无后效性」。换言之,动态规划对状态空间的遍历构成一张有向无环图,遍历就是该有向无环图的一个拓扑序。有向无环图中的节点对应问题中的「状态」,图中的边则对应状态之间的「转移」,转移的选取就是动态规划中的「决策」。
总结:
「动态规划」的解法,理解题意非常重要。其次,我们在做「动态规划」的问题的时候,需要经常思考为什么想到需要这样定义状态。