【笔记】动态规划总结 2.0

本文是一篇关于动态规划算法的详细总结,涵盖了动态规划的基础概念、解题步骤以及一系列经典问题的解析,包括斐波那契数、背包问题、买卖股票、子序列和回文串等。作者强调了动态规划的本质是数学归纳法,通过“五部曲”解题法来帮助读者理解并解决相关问题。
摘要由CSDN通过智能技术生成

【笔记】动态规划刷题总结 2.0

个人心得

问题本质

动态规划名字看起来高大上,感觉是种很复杂的算法,令人“望文生畏”,其实一句话概括,就是 数学归纳法,推公式 。动态规划问题的每一个状态是由上一个状态通过 状态转移方程 推导得出(对于存在很多状态的问题,需要画状态图辅助推导出正确的状态转移方程,类似《编译原理》的自动状态机)。动态规划是一种 “聪明的穷举” ,所谓具备 “最优子结构” 和存在 “重叠子问题” 。本质上来说,动态规划是通过引入dp数组这种 “空间换时间” 的方法来降低暴力算法的时间复杂度。

解题步骤

参考Carl的方法,牢牢把握“五部曲”:

  1. 确定dp数组以及下标的含义

  2. 确定递推公式

  3. dp数组如何初始化

  4. 确定遍历顺序

  5. 举例推导dp数组

解释说明:

  • 根据题目选择二维数组还是滚动数组(一维),状态数决定二维dp数组的列数
  • 2先于3是因为不同的状态转移方程需要不同的初始化方法
  • 深刻理解1,才能初始化好3
  • 遍历顺序主要集中于背包问题:用滚动数组解决01背包时,注意先物品再背包且背包倒序遍历(防止重复)。完全背包正序、倒序均可,若求排列问题只能先遍历背包,若求组合问题只能先遍历物品
  • 如果存在错误,打印dp数组日志来验证每一步过程,下附“灵魂三问”,解决好这三个问题debug将不再困难:
    • 这道题目我举例推导状态转移公式了么?
    • 我打印dp数组的日志了么?
    • 打印出来了dp数组和我想的一样么?

一、基础题目,用于熟悉“五部曲”解题

509.斐波那契数

// 时间、空间复杂度O(N)
class Solution {
   
public:
    int fib(int n) {
   
        if (n == 0 || n == 1) return n;
        vector<int> dp(n + 1, 0);
        dp[0] = 0;
        dp[1] = 1;
        for (int i = 2; i <= n ; i++) {
   
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[n];
    }
};

// 当然可以发现,只需要维护两个数值就可以了,不需要记录整个序列。下面写法空间复杂度O(1)
class Solution {
   
public:
    int fib(int N) {
   
        if (N <= 1) return N;
        int dp[2];
        dp[0] = 0;
        dp[1] = 1;
        for (int i = 2; i <= N; i++) {
   
            int sum = dp[0] + dp[1];
            dp[0] = dp[1];
            dp[1] = sum;
        }
        return dp[1];
    }
};

70.爬楼梯

class Solution {
   
public:
    int climbStairs(int n) {
   
        if (n < 3) return n;
        vector<int> dp(n + 1);
        // 本题考虑关于dp[0]的初始化没意义
        // i从3开始,直接跳过dp[0]
        dp[1] = 1;
        dp[2] = 2;
        for (int i = 3; i <= n; i++) {
   
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[n];
    }
};
// 还可以优化空间复杂度
// 【扩展】完全背包:每次走m下,几种方法到n层台阶

746.使用最小花费爬楼梯

class Solution {
   
public:
    int minCostClimbingStairs(vector<int>& cost) {
   
        // 初始化包含了将dp[0],dp[1]设为0
        // 此题并非跳到最后一级台阶截止,而是要跳出所有楼梯,dp要多一个
        vector<int> dp(cost.size() + 1, 0);
        for (int i = 2; i <= cost.size(); i++) {
   
            dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
        }
        return dp[cost.size()];
    }
};
// 还可以优化空间复杂度O(1)

62.不同路径

class Solution {
   
public:
    int uniquePaths(int m, int n) {
   
        vector<vector<int>> dp(m, vector(n, 0));
        // 第一行,第一列的所有元素均只有一种到达方式
        for (int i = 0; i < m; i++) dp[i][0] = 1;
        for (int j = 0; j < n; j++) dp[0][j] = 1;
        // 从(1,1)开始
        for (int i = 1; i < m; i++) {
   
            for (int j = 1; j < n; j++) {
   
                dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
            }
        }
        return dp[m - 1][n - 1];
    }
};

62.不同路径II

class Solution {
   
public:
    int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
   
        //如果在起点或终点出现了障碍,直接返回0
        if (obstacleGrid[obstacleGrid.size() - 1][obstacleGrid[0].size() - 1] == 1 || obstacleGrid[0][0] == 1) return 0;
        vector<vector<int>> dp(obstacleGrid.size(), vector<int>(obstacleGrid[0].size(), 0));
        for (int i = 0; i < obstacleGrid.size(); i++) {
   
            if (obstacleGrid[i][0] == 1)
                break;
            dp[i][0] = 1;
        }
        for (int j = 0; j < obstacleGrid[0].size(); j++) {
   
            if (obstacleGrid[0][j] == 1)
                break;
            dp[0][j] = 1;
        }
        for (int i = 1; i < obstacleGrid.size(); i++) {
   
            for (int j = 1; j < obstacleGrid[0].size(); j++) {
   
                if (obstacleGrid[i][j] == 0) {
   
                    dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
                }
            }
        }
        return dp[obstacleGrid.size() - 1][obstacleGrid[0].size() - 1];
    }
};

343.整数拆分(比较难想,注意理解)

class Solution {
   
public:
    int integerBreak(int n) {
   
        if (n == 2) return 1;
        vector<int> dp(n + 1, 0);
        dp[2] = 1;
        for (int i = 3; i <= n; i++) {
   
            // 因为拆分一个数 n 使之乘积最大,那么一定是拆分成m个近似相同的子数相乘才是最大的
            // 只不过我们不知道m究竟是多少而已,但可以明确的是m一定大于等于2,既然m大于等于2,也就是 最差也应该是拆成两个相同的 可能是最大值。
            // 那么 j 遍历,只需要遍历到 i/2 就可以,后面就没有必要遍历了,一定不是最大值。
            for (int j = 1; j <= i / 2; j++) {
   
                // 从1遍历j,然后有两种渠道得到dp[i]
                // 一个是j * (i - j) 直接相乘,一个是j * dp[i - j]
                // j * (i - j) 是单纯的把整数拆分为两个数相乘,而j * dp[i - j]是拆分成两个以及两个以上的个数相乘
                // j怎么就不拆分呢?j是从1开始遍历,拆分j的情况,在遍历j的过程中其实都计算过了
                dp[i] = max(dp[i], max(dp[i - j] * j, (i - j) * j));
            }
        }
        return dp[n];
    }
};

96.不同的二叉搜索树(很难想,注意理解)

class Solution {
   
public:
    int numTrees(int n) {
   
        if (n == 1) return n;
        vector<int> dp(n + 1, 0);
        dp[0] = 1;
        dp[1] = 1;
        for (int i = 2; i <= n; i++) {
   
            for (int j = 1; j <= i; j++) {
   
                //  dp[i] += dp[以j为头结点左子树节点数量] * dp[以j为头结点右子树节点数量]
                // j 相当于是头结点的元素,从1遍历到i为止
                dp[i] += dp[j - 1] * dp[i - j];
            }
        }
        return dp[n];
    }
};

二、背包问题(难点是遍历顺序,其次是递推公式)

引用自:代码随想录-背包总结篇

416.分割等和子集1

1、0-1背包理论基础

二维数组版本

有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。

1、确定dp数组以及下标的含义

使用二维数组,即dp[i] [j]表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少

2、确定递推公式

有两个方向推出来dp[i] [j],

  • 不放物品i:由dp[i - 1] [j]推出,即背包容量为j,里面不放物品i的最大价值,此时dp[i] [j]就是dp[i - 1] [j]。(其实就是当物品i的重量大于背包j的重量时,物品i无法放进背包中,所以被背包内的价值依然和前面相同。)
  • 放物品i:由dp[i - 1] [j - weight[i]]推出,dp[i - 1] [j - weight[i]] 为背包容量为j - weight[i]的时候不放物品i的最大价值,那么dp[i - 1] [j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值

所以递归公式: dp[i] [j] = max(dp[i - 1] [j], dp[i - 1] [j - weight[i]] + value[i]);

3、dp数组如何初始化

首先从dp[i] [j]的定义出发,如果背包容量j为0的话,即dp[i] [0],无论是选取哪些物品,背包价值总和一定为0;那么很明显当 j < weight[0]的时候,dp[0] [j] 应该是 0,因为背包容量比编号0的物品重量还小,当j >= weight[0]时,dp[0] [j] 应该是value[0],因为背包容量放足够放编号0物品

动态规划-背包问题7

4、确定遍历顺序

递推数据由上、左上得来,先遍历物品、背包均可。为了好理解建议先遍历物品,即i在外循环j在内循环

5、举例推导dp数组

最终结果就是dp[2] [4]

动态规划-背包问题4

一维dp数组(滚动数组)

背包问题其实状态都是可以压缩的。可以发现如果把dp[i - 1]那一层拷贝到dp[i]上,表达式完全可以是:dp[i] [j] = max(dp[i] [j], dp[i] [j - weight[i]] + value[i]);与其把dp[i - 1]这一层拷贝到dp[i]上,不如只用一个一维数组了,只用dp[j](一维数组,也可以理解是一个滚动数组)。

这就是滚动数组的由来,需要满足的条件是上一层可以重复利用,直接拷贝到当前层。

【遍历顺序的讨论】

【为什么倒序遍历背包容量?】二维dp遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包是从大到小。为什么呢?倒序遍历是为了保证物品i只被放入一次!。但如果一旦正序遍历了,那么物品0就会被重复加入多次!

【为什么二维dp数组历的时候不用倒序?】

因为对于二维dp,dp[i] [j]都是通过上一层即dp[i - 1] [j]计算而来,本层的dp[i] [j]并不会被覆盖!

【为什么不能先遍历背包容量嵌套遍历物品呢?】

因为一维dp的写法,背包容量一定是要倒序遍历(原因上面已经讲了),如果遍历背包容量放在上一层,那么每个dp[j]就只会放入一个物品,即:背包里只放入了一个物品。倒序遍历的原因是,本质上还是一个对二维数组的遍历,并且右下角的值依赖上一层左上角的值,因此需要保证左边的值仍然是上一层的,从右向左覆盖。

动态规划-背包问题9

五部曲省略,直接上测试代码

void test_1_wei_bag_problem() {
   
    vector<int> weight = {
   1, 3, 4};
    vector<int> value = {
   15, 20, 30};
    int bagWeight = 4;

    // 初始化
    vector<int> dp(bagWeight + 1, 0);
    for(int i = 0; i < weight.size(); i++) {
    // 遍历物品
        for(int j = bagWeight; j >= weight[i]; j--) {
    // 遍历背包容量
            dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
        }
    }
    cout << dp[bagWeight] << endl;
}

int main() {
   
    test_1_wei_bag_problem();
}

2、递推公式分类总结

① 问能否能装满背包(或者最多装多少)

dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]); ,对应题目如下:

动态规划:416.分割等和子集

只有确定了如下四点,才能把0-1背包问题套到本题上来。

  • 背包的体积为sum / 2
  • 背包要放入的商品(集合里的元素)重量为 元素的数值,价值也为元素的数值
  • 背包如果正好装满,说明找到了总和为 sum / 2 的子集
  • 背包中每一个元素是不可重复放入
class Solution {
   
public:
    bool canPartition(vector<int>& nums) {
   
        int sum = 0;
        for (int num : nums)
            sum += num;
        if (sum % 2 == 1) return false;
        int target = sum / 2;
        // 题目中说:每个数组中的元素不会超过 100,数组的大小不会超过 200
        // 总和不会大于20000,背包最大只需要其中一半,所以dp大小设置为10001就可以了
        // 但我们已经计算出了实际上总和的一半,因此比一半大即可
        vector<int> dp(target + 1, 0);
        
        for (int i = 0; i < nums.size(); i++) {
   
            // 每一个元素一定是不可重复放入,所以从大到小遍历
            for (int j = target; j >= nums[i]; j--) {
   
                dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
            }
        }
        if (dp[target] == target) return true;
        return false;   
    }
};
动态规划:1049.最后一块石头的重量 II

本题其实就是尽量让石头分成重量相同的两堆,相撞之后剩下的石头最小,这样就化解成01背包问题了

class Solution {
   
public:
    int lastStoneWeightII(vector<int>& stones) {
   
        int sum = 0;
        for (int stone : stones)
            sum += stone;
        int half = sum / 2;
        vector<int> dp(half + 1, 0);
        for (int i = 0; i < stones.size(); i++) {
   
            for (int j = half; j >= stones[i]; j--) {
   
                dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
            }
        }
        // 与 416 仅返回值的处理不同
        // 此时dp[half]中存放最接近但一定小于等于一半重量的石头,另一堆重量是sum - dp[half]
        return sum - 2 * dp[half];
    }
};

② 问装满背包有几种方法:

dp[j] += dp[j - nums[i]] ,注意到有累加求和,对应题目如下:

动态规划:494.目标和
class Solution {
   
public:
    int findTargetSumWays(vector<int>& nums, int target) {
   
        // 本质是找两堆数,差为target,也可以剪枝回溯,但是carl这版的思路还需要在理解一下
        // 假设正数和为x,即将但还未添加负号的负数和为sum - x,目标和target = x - (sum - x)
        // 则 x = (target + sum) / 2,为背包大小
        int sum = accumulate(nums.begin(), nums.end(), 0);
        if (abs(target) ></
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值