一篇博文便涉水:《为什么你学不过动态规划?告别动态规划,谈谈我的经验》
从中摘出来的中心思想方法:
第一步:【定 “义”】定义数组元素的含义
第二步:【状态转移方程】找出数组元素之间的关系式
第三步:【初始化】找出初始值。
文中讲解了3道例题,依次为:
注: 有时候用动态规划解决问题不如指针的方法来得好,如题目392. 判断子序列
双指针方法几乎不多占内存,而基于数组的动态规划反而需要一部分内存;但是二者的执行时间相同。
从另一方面来说,有些题目可以对空间进行优化,即不使用额外dp数组,通过分析,可以在不影响未来值计算的情况下,就地记录信息。
以下均是基于数组空间的DP解法,在后续可以对空间进行优化,达到 O ( 1 ) O(1) O(1)
大佬的经验
1、首先是思维,基本上可行解或者最优解的依赖关系是有先后的,也就是说,当前状态只会影响未来的状态而不会影响过去的状态,或者说当前的状态只由之前的状态决定,那基本上用动态规划没跑了。
2、另一个就是空间问题,除了状态依存关系之外,还有一个特点就是重叠子问题,说白了,过去的某个状态会决定未来的多个状态,或者说当前的状态会影响多个未来状态,那就把当前状态存下来,省的以后又求一次它不香?当然,我觉得吧,这一点只是动态规划的加分项,只有状态依存满足上面的关系,管你有没有重复子问题,我都可以用动态规划。
3、接2,滚动数组不用说了吧,就是并不用把之前的所有状态都存下来,看准状态转移,很多题目当前状态只跟上一个层有关,那只存一层状态它不香吗?
(作者:jinlinpang)
附:LeetCode 动态规划入口
想到了一次的正序遍历,但是结果求出的是非连续序列的最大和:
dp[i] = max(max(dp[i - 1], nums[i]), dp[i - 1] + nums[i]);
官方题解:
在整个数组或在固定大小的滑动窗口中找到总和或最大值或最小值的问题可以通过动态规划(DP)在线性时间内解决。
有两种标准 DP 方法适用于数组:
- 常数空间,沿数组移动并在原数组修改。
- 线性空间,首先沿 left->right 方向移动,然后再沿 right->left 方向移动。 合并结果。
- 我们在这里使用第一种方法,因为可以修改数组跟踪当前位置的最大和。
- 下一步是在知道当前位置的最大和后更新全局最大和。
简而言之,就是检测max(nums[i], nums[i - 1] + nums[i])
:
max(nums[i], nums[i + 1])
上图的第二行
current max sum
指的是局部连续子区间内的最大和,最后一行是全局的最大和
定义数组元素含义
定义dp[numsSize]
记录局部最大和,dp[i]
表示当前局部最大和
定义状态转移方程
要取最大值,则要看 是加了之后变大,还是原来就比较大,肯定不能是加了一个数之后反而变小了。
dp[i] = max(nums[i], dp[i - 1] + nums[i]);
初始化
dp[0] = nums[0];
int max(int a, int b){
return a > b ? a : b;
}
int maxSubArray(int* nums, int numsSize){
if(numsSize == 0)
return 0;
int dp[numsSize];
dp[0] = nums[0];
for(int i = 1; i < numsSize; i++){
dp[i] = max(nums[i], dp[i - 1] + nums[i]);
}
for(int i = 1; i < numsSize; i++){ //局部区间从后向前检测
dp[i] = max(dp[i], dp[i - 1]);
}
return dp[numsSize - 1];
}
时间复杂度: O ( n ) O(n) O(n)
空间复杂度: O ( n ) O(n) O(n)
//另一种思路,非负数才值得留下
int max(int a, int b){
return a > b ? a : b;
}
int maxSubArray(int* nums, int numsSize){
int res = nums[0], sum = 0;
for(int i = 0; i < numsSize; i++){
if(sum > 0)
sum += nums[i];
else
sum = nums[i];
res = max(res, sum);
}
return res;
}
解题思路:
1.定义数组元素含义
dp[i]
来表示能买入的最低价的下标profit[i]
来表示当前值与目前最低价之差【收益】2.定义状态转移方程
if(prices[i] <= prices[dp[i - 1]]){
dp[i] = i;
profit[i] = 0;
} else {
dp[i] = dp[i - 1];
profit[i] = prices[i] - prices[dp[i]];
}
profit
中最大的那个数3.初始化
4.最终代码
int maxProfit(int* prices, int pricesSize){
if(pricesSize <= 1)
return 0;
int dp[pricesSize], profit[pricesSize];
dp[0] = 0; //dp[i]记录最低价的下标
profit[0] = 0;
for(int i = 1; i < pricesSize; i++){
if(prices[i] <= prices[dp[i - 1]]){
dp[i] = i;
profit[i] = 0;
} else {
dp[i] = dp[i - 1];
profit[i] = prices[i] - prices[dp[i]];
}
}
int m = profit[0];
for(int i = 1; i < pricesSize; i++){
if(m < profit[i]){
m = profit[i];
}
}
return m;
}
时间复杂度: O ( n ) O(n) O(n)
空间复杂度: O ( n ) O(n) O(n)
这道题和上一题相同点在于都有访问限制条件,基于这一点就可以方便书写递推表达式
· 同型题目:《面试题 17.16. 按摩师》
1.定义数组元素含义
dp[i]
为当前获得的最高金额2.定义状态转移方程
dp[i] = max(dp[i - 1], dp[i - 2] + num[i - 1])
【图片参考:画解算法】3.初始化
4.代码
int max(int a, int b){
return a > b ? a : b;
}
int rob(int* nums, int numsSize){
if(numsSize == 0)
return 0;
int dp[numsSize + 1];
dp[0] = 0;
dp[1] = nums[0];
for(int i = 2; i <= numsSize; i++){
dp[i] = max(dp[i - 1], dp[i - 2] + nums[i - 1]);
}
return dp[numsSize];
}
时间复杂度: O ( n ) O(n) O(n)
- 当时自己写的时候走了很多弯路,没有让dp[0]错位,使得代码变得很复杂。
- 因为要对dp数组进行排序,每趟都要找dp[0, i - 2]中最大的一个数,时间复杂度达到了 O ( n 2 ) O(n^2) O(n2)
- 现在明白了dp[2] = max(dp[0] + dp[2], dp[1])的意义:在初始情况下,选择nums[0]和nums[1]中较大的那个
代码如下:
...
int rob(int* nums, int numsSize){
...
dp[0] = nums[0];
dp[1] = nums[1];
for(int i = 2; i < numsSize; i++){
int m = 0;
for(int j = 0; j <= i - 2; j++){ //找dp[0, i - 2]中最大的一个数
m = max_num(m, dp[j]);
}
dp[i] = m + nums[i];
}
int max = dp[0];
for(int i = 1; i < numsSize; i++){
if(max < dp[i])
max = dp[i];
}
return max;
}
后来仔细研究了一下,发现 dp[1] = max(nums[0], nums[1])
,这样就能在后续的dp[i](i > 2)遍历中不断使用到前面的max值(dp[2]除外)
int max(int a, int b){
return a > b ? a : b;
}
int rob(int* nums, int numsSize){
if(numsSize == 0)
return 0;
else if(numsSize == 1){
return nums[0];
} else if(numsSize == 2){
return max(nums[0], nums[1]);
}
int dp[numsSize];
dp[0] = nums[0];
dp[1] = max(nums[0], nums[1]);
for(int i = 2; i < numsSize; i++){
dp[i] = max(dp[i - 1], dp[i - 2] + nums[i]);
}
return dp[numsSize - 1];
}
如果一个数列至少有三个元素,并且任意两个相邻元素之差相同,则称该数列为等差数列。
数组 A 包含 N 个数,且索引从0开始。数组 A 的一个子数组划分为数组 (P, Q),P 与 Q 是整数且满足 0<=P如果满足以下条件,则称子数组(P, Q)为等差数组:
元素 A[P], A[p + 1], …, A[Q - 1], A[Q] 是等差的。并且 P + 1 < Q 。
函数要返回数组 A 中所有为等差数组的子数组个数。
如:A = [1, 2, 3, 4]
返回: 3, A 中有三个子等差数组: [1, 2, 3], [2, 3, 4] 以及自身 [1, 2, 3, 4]。
解题思路:
1.定义数组元素含义
dp[i] 表示以 A[i] 为结尾的等差递增子区间的个数。
2.定义状态转移方程
等差数列:A[i] - A[i-1] == A[i-1] - A[i-2]
当
A[i] - A[i-1] == A[i-1] - A[i-2]
,那么[A[i-2], A[i-1], A[i]]
构成一个等差递增子区间。而且在以A[i-1]
为结尾的递增子区间的后面再加上一个A[i]
,一样可以构成新的递增子区间。dp[2] = 1 [0, 1, 2] dp[3] = dp[2] + 1 = 2 [0, 1, 2, 3], // [0, 1, 2] 之后加一个 3 [1, 2, 3] // 新的递增子区间 dp[4] = dp[3] + 1 = 3 [0, 1, 2, 3, 4], // [0, 1, 2, 3] 之后加一个 4 [1, 2, 3, 4], // [1, 2, 3] 之后加一个 4 [2, 3, 4] // 新的递增子区间
综上,在 A[i] - A[i-1] == A[i-1] - A[i-2] 时,dp[i] = dp[i-1] + 1。
if(A[i] - A[i - 1] == A[i - 1] - A[i - 2]){
dp[i] = dp[i - 1] + 1;
}
3.初始化
因为递增子区间不一定以最后一个元素为结尾,可以是任意一个元素结尾,因此需要返回 dp 数组累加的结果。
4.最终代码
int numberOfArithmeticSlices(int* A, int ASize){
if(ASize <= 2)
return 0;
int dp[ASize];
dp[0] = 0;
dp[1] = 0;
for(int i = 2; i < ASize; i++){
if(A[i] - A[i - 1] == A[i - 1] - A[i - 2]){ //是等差数列,累加
dp[i] = dp[i - 1] + 1;
} else { //否则,重新计数
dp[i] = 0;
}
}
int total = 0;
for(int i = 0; i < ASize; i++){
total += dp[i];
}
return total;
}
时间复杂度: O ( n ) O(n) O(n)
空间复杂度: O ( n ) O(n) O(n)
给定一个无序的整数数组,找到其中最长上升子序列的长度。
输入: [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。
说明:
可能会有多种最长上升子序列的组合,你只需要输出对应的长度即可。
你算法的时间复杂度应该为 O ( n 2 ) O(n^2) O(n2) 。
解题思路:
1.定义数组元素含义
2.定义状态转换方程
dp[n] = max{ dp[i] + 1 | Si < Sn && i < n}
因为在求 dp[n] 时可能无法找到一个满足条件的递增子序列,此时 {Sn} 就构成了递增子序列,需要对前面的求解方程做修改,令 dp[n] 最小为 1,即:dp[n] = max{1, dp[i] + 1 | Si < Sn && i < n}
对于一个长度为 N N N 的序列,最长递增子序列并不一定会以 S N S_N SN 为结尾,因此 dp[N] 不是序列的最长递增子序列的长度,需要遍历 dp 数组找出最大值才是所要的结果,max{ dp[i] | 1 <= i <= N} 即为所求。
3.最终代码
int max(int a, int b){
return a > b ? a : b;
}
int lengthOfLIS(int* nums, int numsSize){
if(numsSize < 2)
return numsSize;
int dp[numsSize], m; //m用于记录序列长度最大值
for(int i = 0; i < numsSize; i++){
m = 1;
for(int j = 0; j < i; j++){ //遍历nums[0, i),与nums[i]比较,若是递增,则记录
if(nums[i] > nums[j]){
m = max(dp[j] + 1, m);
}
}
dp[i] = m;
}
m = dp[0];
for(int i = 1; i < numsSize; i++){
if(dp[i] > m)
m = dp[i];
}
return m;
}
时间复杂度: O ( n 2 ) O(n^2) O(n2)
空间复杂度: O ( n ) O(n) O(n)
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。
现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?
网格中的障碍物和空位置分别用1
和0
来表示。
说明: m 和 n 的值均不超过 100。
解题思路:
这道题和62题相比,多了一个“障碍物的要求”,实质是:遍历到obstacleGrid[i][j] == 1
时,
dp[i...m-1][0] = 0
,dp[0][j...n-1] = 0
dp[i][j] = 0
1.定义数组元素含义
dp[i][j]
表示走到(i, j)处总共的路径数,则返回值为dp[m - 1][n - 1]
2.定义状态转移方程
3.初始化
if(obstacleGrid[i][0] != 1 && flag == 0)
dp[i][0] = 1;
else {
flag = 1;
dp[i][0] = 0; //第一列,遇到障碍物的下方都不能走
}
if(obstacleGrid[0][j] != 1 && flag == 0)
dp[0][j] = 1;
else {
flag = 1;
dp[0][j] = 0; //第一行,遇到障碍物的右方都不能走
}
4.最终代码
int uniquePathsWithObstacles(int** obstacleGrid, int obstacleGridSize, int* obstacleGridColSize){
int m = obstacleGridSize, n = *obstacleGridColSize;
if(m <= 0 || n <= 0)
return 0;
long dp[m][n], flag = 0; //flag用于判断第一行或第一列是否遇到障碍物
for(int i = 0; i < m; i++){
if(obstacleGrid[i][0] != 1 && flag == 0)
dp[i][0] = 1;
else {
flag = 1;
dp[i][0] = 0; //第一列,遇到障碍物的下方都不能走
}
}
flag = 0; //初始化flag
for(int j = 0; j < n; j++){
if(obstacleGrid[0][j] != 1 && flag == 0)
dp[0][j] = 1;
else {
dp[0][j] = 0; //第一行,遇到障碍物的右方都不能走
flag = 1;
}
}
for(int i = 1; i < m; i++){
for(int j = 1; j < n; j++){
if(obstacleGrid[i][j] == 1)
dp[i][j] = 0;
else {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
}
return dp[m - 1][n - 1];
}
时间复杂度: O ( n 2 ) O(n^2) O(n2)
空间复杂度: O ( n ) O(n) O(n)