【LeetCode 笔记】动态规划


前言

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

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

第一步:【定 “义”】定义数组元素的含义。“尝试题目问啥,就把啥定义成状态。”
第二步:【状态转移方程】找出数组元素之间的关系式
第三步:【初始化】找出初始值

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

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

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

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

以下均是基于数组空间的DP解法,在后续可以对空间进行优化,达到 O ( 1 ) O(1) O(1)

大佬的经验

1、首先是思维,基本上可行解或者最优解的依赖关系是有先后的,也就是说,当前状态只会影响未来的状态而不会影响过去的状态,或者说当前的状态只由之前的状态决定,那基本上用动态规划没跑了。
2、另一个就是空间问题,除了状态依存关系之外,还有一个特点就是重叠子问题,说白了,过去的某个状态会决定未来的多个状态,或者说当前的状态会影响多个未来状态,那就把当前状态存下来,省的以后又求一次它不香?当然,我觉得吧,这一点只是动态规划的加分项,只有状态依存满足上面的关系,管你有没有重复子问题,我都可以用动态规划。
3、接2,滚动数组不用说了吧,就是并不用把之前的所有状态都存下来,看准状态转移,很多题目当前状态只跟上一个层有关,那只存一层状态它不香吗?
(作者:jinlinpang)

附:LeetCode 动态规划入口


53. 最大子序和

题目描述 53
解题思路:

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

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

    dp[i] = max(nums[i], dp[i - 1] + nums[i]);
    
  3. 初始化
    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;
}

121. 买卖股票的最佳时机

解题思路:

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.初始化

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

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)


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.代码

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];
}

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],一样可以构成新的递增子区间。

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[0] = 0, dp[1] = 0;

因为递增子区间不一定以最后一个元素为结尾,可以是任意一个元素结尾,因此需要返回 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)


300. 最长上升子序列

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


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


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

解题思路:

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 N N 的序列,最长递增子序列并不一定会以 S N S_N SN 为结尾,因此 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.最终代码

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)


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 ) (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.初始化

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];

    for(int i = 0; i < m; i++){
        if(obstacleGrid[i][0] != 1)
            dp[i][0] = 1;
        else 
            dp[i][0] = 0;   //第一列,遇到障碍物的下方都不能走
    }
    
    for(int j = 0; j < n; j++){
        if(obstacleGrid[0][j] != 1)
            dp[0][j] = 1;
        else 
            dp[0][j] = 0;   //第一行,遇到障碍物的右方都不能走
            
    }

    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)


5. 最长回文子串

题目描述

  1. 定义元素含义
    dp[i][j] 为字符串s[i … j] 是否为回文串,是为1,否则为0
  2. 定义状态转移方程
    两端相等,且去掉首尾的串为回文串,则必为回文串;或长度为2的串两端相等,也必为回文串
    if(s[i] == s[j] && (i + 1 == j || dp[i + 1][j - 1]))
                dp[i][j] = 1;
    
  3. 初始化
    for(int i = 0; i < len; i++){
        for(int j = 0; j < len; j++){
            if(i == j)  dp[i][i] = 1;	//单个字符s[i]必为回文串
            else    dp[i][j] = 0;		//初始化
        }
    }
    
  4. 最终代码
//截取字符串
char *subStr(char *s, int start, int length){
    char *dst = malloc(sizeof(char) * (length + 1));
    for(int i = 0; i < length; i++){
        dst[i] = s[i + start];
    }
    dst[length] = '\0';
    return dst;
}

char * longestPalindrome(char * s){
    int len = strlen(s);
    if(len < 2)
        return s;		//单个字符或空字符串直接返回
    int **dp = malloc(sizeof(int*) * len);
    for(int i = 0; i < len; i++)
        dp[i] = malloc(sizeof(int) * len);
    int maxlength = 1, begin = 0;

    for(int i = 0; i < len; i++){
        for(int j = 0; j < len; j++){
            if(i == j)  dp[i][i] = 1;	//单个字符s[i]必为回文串
            else    dp[i][j] = 0;		//初始化
        }
    }
    for(int j = 0; j < len; j++){
        for(int i = 0; i < j; i++){
            if(s[i] == s[j] && (i + 1 == j || dp[i + 1][j - 1]))
                dp[i][j] = 1;	//两端相等,且去掉首尾的串为回文串,则必为回文串;或长度为2的串两端相等,也必为回文串
            if(dp[i][j] && j - i + 1 > maxlength){
                maxlength = j - i + 1;		//更新最大长度
                begin = i;
            }
        }
    }
    return subStr(s, begin, maxlength);
}

背包问题

416. 分割等和子集

题目描述

class Solution {
    public boolean canPartition(int[] nums) {
        int sum = 0, n = nums.length;
        for (int num : nums) {
            sum += num;
        }
        // 和为奇数
        if (sum % 2 == 1)   return false;
        sum >>= 1;

        // dp[i][j] = x 表示对于前 i 个物品,当前背包的容量为 j 时,若 x 为 true,则说明可以恰好将背包装满,若 x 为 false,则说明不能恰好将背包装满。
        boolean[][] dp = new boolean[n + 1][sum + 1];
        for (int i = 0; i <= n; i++) {
            dp[i][0] = true;
        }
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= sum; j++) {
                if (j - nums[i - 1] < 0) {
                    // 容量不足
                    dp[i][j] = dp[i - 1][j];
                } else {
                    // 装入或不装
                    dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i - 1]];
                }
            }
        }
        return dp[n][sum];
    }
}

322. 零钱兑换

题目描述

class Solution {
    public int coinChange(int[] coins, int amount) {
        // dp[i] 表示总金额为 i 时所需最少的硬币个数
        int[] dp = new int[amount + 1];
        Arrays.fill(dp, amount + 666);
        dp[0] = 0;
        for (int i = 1; i <= amount; i++) {
            for (int coin : coins) {
                // 金额不够,直接跳过
                if (i - coin < 0)  continue;
                // 金额足够,选或者不选
                dp[i] = Math.min(dp[i], dp[i - coin] + 1);
            }
        }
        return dp[amount] == amount + 666 ? -1 : dp[amount];
    }
}

相关阅读

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Beta Lemon

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值