Huge Lemon的博客

【LeetCode 笔记】动态规划

2020-04-08

转自CSDN博文《【LeetCode 笔记】动态规划》

前言

一篇博文便涉水:《为什么你学不过动态规划?告别动态规划,谈谈我的经验》

从中摘出来的中心思想方法:

第一步:定义数组元素的含义
第二步:找出数组元素之间的关系式【状态转移方程】
第三步:找出初始值

其中第二步极其类似于第二数学归纳法

文中讲解了3道例题,依次为:

注:有时候用动态规划解决问题不如指针的方法来得好,如题目392. 判断子序列
双指针方法几乎不多占内存,而基于数组的动态规划反而需要一部分内存;但是二者的执行时间相同。

有些题目可以对空间进行优化,即不使用额外dp数组,通过分析,可以在不影响未来值计算的情况下,就地记录信息。


53. 最大子序和

题目描述 53
解题思路:

想到了一次的正序遍历,但是结果求出的是非连续序列的最大和:

1
dp[i] = max(max(dp[i - 1], nums[i]), dp[i - 1] + nums[i]);

官方题解:

在整个数组或在固定大小的滑动窗口中找到总和或最大值或最小值的问题可以通过动态规划(DP)在线性时间内解决。
有两种标准 DP 方法适用于数组:

  1. 常数空间,沿数组移动并在原数组修改。
  2. 线性空间,首先沿 left->right 方向移动,然后再沿 right->left 方向移动。 合并结果。
  • 我们在这里使用第一种方法,因为可以修改数组跟踪当前位置的最大和
  • 下一步是在知道当前位置的最大和后更新全局最大和

简而言之,就是检测max(nums[i], nums[i - 1] + nums[i])

  • if(max <= nums[i]),则不作改变,i++;
  • if(max > nums[i]),则更新 nums[i] = nums[i - 1] + nums[i];
    这就是与非连续子数组最大的不同:遇到负数【间断点】,不保留之前的最大和【即实现了非连续】;且不管正数负数,只要前后之和大于当前值就进行更新
  • 最后一步检测max(nums[i], nums[i + 1])

上图的第二行current max sum指的是局部连续子区间内的最大和,最后一行是全局的最大和

  1. 定义数组元素含义
    定义dp[numsSize]记录局部最大和,dp[i]表示当前局部最大和

  2. 定义状态转移方程
    要取最大值,则要看 是加了之后变大,还是原来就比较大,肯定不能是加了一个数之后反而变小了。

    1
    dp[i] = max(nums[i], dp[i - 1] + nums[i]);
  3. 初始化
    dp[0] = nums[0];

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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(1)$

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//另一种思路,非负数才值得留下
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;
}

121. 买卖股票的最佳时机

解题思路:

1.定义数组元素含义

  • dp[i]来表示能买入的最低价的下标
  • profit[i]来表示当前值与目前最低价之差【收益】

2.定义状态转移方程

  • 如果当前价格小于前一个价格,则对买入价格进行更新
  • 否则,继续记录目前最低买入价下标,计算当前价格收益

    1
    2
    3
    4
    5
    6
    7
    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.初始化

  • dp[0] = 0; //prices数组第一个(prices[0])的下标
  • profit[0] = 0; //第一个没买没卖,收益为0

4.最终代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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(1)$


198. 打家劫舍

题目描述
解题思路:

这道题和上一题相同点在于都有访问限制条件,基于这一点就可以方便书写递推表达式
· 同型题目:《面试题 17.16. 按摩师》

1.定义数组元素含义

  • 定义dp[i]为当前获得的最高金额

2.定义状态转移方程

  • 表达式的化简参考官方题解
  • 根据上述化简结论(dp错位)可设dp[numsSize + 1],其中设dp[0]为1
  • dp[i] = max(dp[i - 1], dp[i - 2] + num[i - 1])【图片参考:画解算法
  • 由于不可以在相邻的房屋闯入,所以在当前位置 n 房屋可盗窃的最大值,要么就是 n-1 房屋可盗窃的最大值,要么就是 n-2 房屋可盗窃的最大值加上当前房屋的值,二者之间取最大值

3.初始化

  • dp[0] = 0;
  • dp[1] = nums[0];

4.代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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)$

  • 当时自己写的时候走了很多弯路,没有让dp[0]错位,使得代码变得很复杂。
  • 因为要对dp数组进行排序,每趟都要找dp[0, i - 2]中最大的一个数,时间复杂度达到了$O(n^2)$
  • 现在明白了dp[2] = max(dp[0] + dp[2], dp[1])的意义:在初始情况下,选择nums[0]和nums[1]中较大的那个

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
...
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]除外)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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];
}


413. 等差数列划分

如果一个数列至少有三个元素,并且任意两个相邻元素之差相同,则称该数列为等差数列。


数组 A 包含 N 个数,且索引从0开始。数组 A 的一个子数组划分为数组 (P, Q),P 与 Q 是整数且满足 0<=P<Q<N 。
如果满足以下条件,则称子数组(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],一样可以构成新的递增子区间。

1
2
3
4
5
6
7
8
9
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。

1
2
3
if(A[i] - A[i - 1] == A[i - 1] - A[i - 2]){
dp[i] = dp[i - 1] + 1;
}

3.初始化

  • 数列至少要三个元素
  • dp[0] = 0, dp[1] = 0;

因为递增子区间不一定以最后一个元素为结尾,可以是任意一个元素结尾,因此需要返回 dp 数组累加的结果。

4.最终代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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(1)$


300. 最长上升子序列

给定一个无序的整数数组,找到其中最长上升子序列的长度。


输入: [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。


说明:
可能会有多种最长上升子序列的组合,你只需要输出对应的长度即可。
你算法的时间复杂度应该为 $O(n^2)$ 。

解题思路:

1.定义数组元素含义

  • 定义一个数组 dp 存储最长递增子序列的长度
  • dp[n] 表示以 Sn 结尾的序列的最长递增子序列长度。

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$ 的序列,最长递增子序列并不一定会以 $S_N$ 为结尾,因此 dp[N] 不是序列的最长递增子序列的长度,需要遍历 dp 数组找出最大值才是所要的结果,max{ dp[i] | 1 <= i <= N} 即为所求。

    • 如:nums = [1,3,6,7,9,4,10,5,6] $\Rightarrow$ dp = {1,2,3,4,5,3,6,4,5},但dp[N]并不是最大值。

3.最终代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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(1)$


63. 不同路径 II

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。
现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?

网格中的障碍物和空位置分别用 10 来表示。
说明: m 和 n 的值均不超过 100。

解题思路:

这道题和62题相比,多了一个“障碍物的要求”,实质是:遍历到obstacleGrid[i][j] == 1时,

  • 若是第一行或第一列,则不能继续往右或往下走,即:dp[i...m-1][0] = 0dp[0][j...n-1] = 0
  • 其余情况只需要绕开:dp[i][j] = 0

1.定义数组元素含义

  • 定义dp[i][j]表示走到(i, j)处总共的路径数,则返回值为dp[m - 1][n - 1]

2.定义状态转移方程

  • 根据上面分析的结论,可以得到:
    • if(obstacleGrid[i][j] == 1) dp[i][j] == 0;
    • if(obstacleGrid[i][j] == 0) dp[i][j] = dp[i - 1][j] + dp[i][j - 1];

3.初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
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.最终代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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(1)$

使用支付宝打赏
使用微信打赏

若你觉得我的文章对你有帮助,欢迎点击上方按钮对我打赏