23/9/8-23/9/9刷题记录之动态规划

本文详细介绍了动态规划的基本概念、解题步骤,以及通过实例分析了斐波那契数、爬楼梯、最小花费爬楼梯、不同路径问题、不同路径II、整数拆分和二叉搜索树的动态规划解决方案,展示了如何确定dp数组、状态转移公式和优化空间复杂度。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

参考

动态规划-代码随想录

一、基础

1. 什么是动态规划

动态规划中每一个状态一定是由上一个状态推导出来的,和贪心不同(贪心是没有从状态推导的,而是从局部直接选择最优的)

2. 解题步骤

  1. 确定dp数组(dp table)以及其下标的含义
  2. 确定递推公式
  3. dp数组如何初始化
  4. 确定遍历顺序
  5. 举例推导dp数组

3. 如何debug

  • 这道题目我举例推导状态转移公式了么?
  • 我打印dp数组的日志了么?
  • 打印出来了dp数组和我想的一样么?

二、基础题目

1. 509. 斐波那契数【简单】

  • 时间复杂度:O(n)
  • 空间复杂度:O(n)

按照动态规划的思路理解:

  1. 确定dp数组以及下标的含义,dp[i]的定义为:第i个数的斐波那契数值是dp[i]
  2. 确定递推公式,状态转移方程 dp[i] = dp[i - 1] + dp[i - 2]
  3. dp数组如何初始化,
  4. 确定遍历顺序。从递归公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,dp[i]是依赖 dp[i - 1] 和 dp[i - 2],那么遍历的顺序一定是从前到后遍历的
  5. 举例推导dp数组
class Solution {
    public int fib(int n) {
        if (n <2) return n;
        int[] dp = new int[n+1];
        dp[0] = 0;
        dp[1] = 1;
        dp[n] = dp[0] + dp[1];
        for (int i = 2 ;i < n+1; i++){
            dp[i] = dp[i-1] + dp[i- 2];
        }
        return dp[n];
    }
}

2. 70. 爬楼梯【简单】

  1. 确定dp数组以及下标的含义,dp[i]的定义为:第i个数的楼梯数值是dp[i]  爬到第i层楼梯,有dp[i]种方法
  2. 确定递推公式,dp[i] = dp[i - 1] + dp[i - 2] 
  3. dp数组如何初始化,不考虑dp[0]如何初始化,只初始化dp[1] = 1,dp[2] = 2,然后从i = 3开始递推,这样才符合dp[i]的定义。
  4. 确定遍历顺序。从递推公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,遍历顺序一定是从前向后遍历的
  5. 举例推导dp数组,类似斐波那契数列,但是dp[0]在本题没意义
  • 时间复杂度:O(n)
  • 空间复杂度:O(n)
//和斐波那契一样的
class Solution {
public:
    int climbStairs(int n) {
        if (n <= 1) return n; // 因为下面直接对dp[2]操作了,防止空指针
        vector<int> dp(n + 1);
        dp[1] = 1;
        dp[2] = 2;
        for (int i = 3; i <= n; i++) { // 注意i是从3开始的
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[n];
    }
};

优化后,其实只需要一个sum,而不需要存储每一步的结果

class Solution {
    public int climbStairs(int n) {
        if (n <2 ) return n; //必须特殊处理
        int[] dp = new int[3]; //不要设置成2,这样更清楚下标的意思
        int sum = 0;
        dp[1] = 1;
        dp[2] = 2;
        for(int i = 3;i < n+1 ;i++){
            sum = dp[1]+dp[2];
            dp[1]=dp[2];
            dp[2] = sum;
        }
        return dp[2]; //不要返回sum,循环结束后被释放了
    }
}

3. 746. 使用最小花费爬楼梯【简单】

  1.  确定dp下标的意思,到达第i台阶所花费的最少费用为dp[i]
  2. 确定递推公式,dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2])
  3. 初始化 dp[0] = 0,dp[1] = 0
  4. 遍历顺序:从前到后遍历cost数组
  5. 举例
  • 时间复杂度:O(n)
  • 空间复杂度:O(n)
class Solution {
    public int minCostClimbingStairs(int[] cost) {
        //确定dp下标的意思
        int[] dp = new int[cost.length +1];
        //初始化
        dp[0] = 0;
        dp[1] = 0;
        //遍历顺序
        for(int i = 2;i < cost.length+1;i++){
            //确定方程
            dp[i] = Math.min(dp[i-1]+cost[i-1],dp[i-2]+cost[i-2]);
        }
        return dp[cost.length];
    }
}

4. 62. 不同路径【中等】

  1. 确定dp数组(dp table)以及下标的含义:dp[i][j] :表示从(0 ,0)出发,到(i, j) 有dp[i][j]条不同的路径。
  2. 确定递推公式(这一步没考虑到):dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
  3. 初始化dp数组:首先dp[i][0]一定都是1,因为从(0, 0)的位置到(i, 0)的路径只有一条,那么dp[0][j]也同理。 这是初始化一行一列,而不是单独的单元格。循环来使得他初始化
  4. 确定遍历顺序:dp[i][j]都是从其上方和左方推导而来,那么从左到右一层一层遍历就可以了。
  5. 举例

四个循环

  • 时间复杂度:O(m × n)
  • 空间复杂度:O(m × n)
class Solution {
    public int uniquePaths(int m, int n) {
        int[][] dp = new int[m][n];
        //初始化
        for (int i = 0;i < m;i++){
            dp[i][0] = 1;
        }
        for(int j = 0;j < n;j++){
            dp[0][j] = 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]; //注意边界
    }
}

 5. 63. 不同路径 II【中等】

我的思路:

  1. 确定dp下标的含义:(0,0)表示从0行0列出发,有障碍物
  2. 确认递推公式:dp[i][j] = dp[i-1][j] + dp[i][j-1]
  3. 初始化 如果障碍物的位置不为1, dp[0][j] = 1,dp[i][0] = 1
  4. 确定遍历顺序,向右边向下
  5. 举例

注意代码的写法。不一定要引入if

  • 时间复杂度:O(n × m),n、m 分别为obstacleGrid 长度和宽度
  • 空间复杂度:O(n × m)
class Solution {
    public int uniquePathsWithObstacles(int[][] obstacleGrid) {
        int m = obstacleGrid.length;
        int n = obstacleGrid[0].length; // 列
        int[][] dp = new int[m][n];
        //特殊情况处理:起点或终点有障碍物
        if(obstacleGrid[0][0] == 1 || obstacleGrid[m-1][n-1] == 1){
            return 0;
        }
        //初始化第一行
        for(int i = 0;i < m && obstacleGrid[i][0] == 0;i++){
            dp[i][0] = 1;
        }
        //初始化第一列
        for(int j = 0;j < n && obstacleGrid[0][j] == 0;j++){
            dp[0][j] = 1;
        }
        for(int i = 1;i < m;i++){
            for(int j = 1;j < n;j++){
                dp[i][j] = (obstacleGrid[i][j]== 0)? dp[i-1][j]+ dp[i][j-1] : 0;
            }
        }
        return dp[m-1][n-1];
    }
}

6. 343. 整数拆分【中等】

 想不出来

  1. 确定dp数组以及其下标的含义:dp[i]分拆数字i,可以得到的最大乘积为dp[i]。
  2. 确定递推公式:

    一个是j * (i - j) 直接相乘。

    一个是j * dp[i - j],相当于是拆分(i - j),j * (i - j) 是单纯的把整数拆分为两个数相乘,而j * dp[i - j]是拆分成两个以及两个以上的个数相乘。dp[i] = max({dp[i], (i - j) * j, dp[i - j] * j});

  3. dp的初始化,只初始化i=2, 0,1的时候没有意义

  4. 确定遍历顺序:从前向后

class Solution {
    public int integerBreak(int n) {
        int[] dp = new int[n+1]; //注意边界
        //初始化
        dp[2] = 1;
        for (int i = 3; i<= n;i++){
            for(int j = 1;j < i-1;j++){
                dp[i] = Math.max(dp[i], Math.max((i-j)*j,dp[i-j]*j));
            }
        }
        return dp[n];
    }
}

优化j的循环条件

  • 时间复杂度:O(n^2)
  • 空间复杂度:O(n)
class Solution {
    public int integerBreak(int n) {
        int[] dp = new int[n+1];
        //初始化
        dp[2] = 1;
        for (int i = 3; i<= n;i++){
            for(int j = 1;j <= i/2;j++){
                dp[i] = Math.max(dp[i], Math.max((i-j)*j,dp[i-j]*j));
            }
        }
        return dp[n];
    }
}

7. 96. 不同的二叉搜索树【中等】

  1.  确认dp数组以及其下标的含义:dp[i] : 1到i为节点组成的二叉搜索树的个数为dp[i]
  2. 确认递推公式: dp[i] += dp[以j为头结点左子树节点数量] * dp[以j为头结点右子树节点数量]。j相当于是头结点的元素,从1遍历到i为止。dp[i] += dp[j - 1] * dp[i - j]。j-1 为j为头结点左子树节点数量,i-j 为以j为头结点右子树节点数量。外部循环从2遍历到n,表示节点的数量,内部循环从1遍历到i,表示根节点的位置。

    对于根节点j,左子树的节点个数为j-1,右子树的节点个数为i-j的原因如下:

    在一个二叉搜索树中,左子树上的节点的值都小于根节点的值,右子树上的节点的值都大于根节点的值。

    假设我们有i个节点,其中根节点的位置是j。那么在根节点的左侧,有j-1个节点,它们可以作为左子树的节点。同样地,在根节点的右侧,有i-j个节点,它们可以作为右子树的节点。

    因此,对于第i个节点,我们需要计算以每个位置j为根节点时,左子树节点的个数是j-1,右子树节点的个数是i-j。

    通过对每个位置j的遍历,我们可以累加左子树和右子树的组合方式,从而得到以第i个节点为根节点的二叉搜索树数量。

  3. dp的初始化:dp[0]=1
  4. 确定遍历顺序:节点数为i的状态是依靠 i之前节点数的状态。那么遍历i里面每一个数作为头结点的状态,用j来遍历。
  5. 举例
  • 时间复杂度:O(n^2)
  • 空间复杂度:O(n)

 

class Solution {
    public int numTrees(int n) {
        //初始化 dp 数组
        int[] dp = new int[n + 1];
        //初始化0个节点和1个节点的情况
        dp[0] = 1;
        dp[1] = 1;
        //总结点数
        for (int i = 2; i <= n; i++) {
            //找根节点
            for (int j = 1; j <= i; j++) {
                //对于第i个节点,需要考虑1作为根节点直到i作为根节点的情况,所以需要累加
                //一共i个节点,对于根节点j时,左子树的节点个数为j-1,右子树的节点个数为i-j
                dp[i] += dp[j - 1] * dp[i - j];
            }
        }
        return dp[n];
    }
}

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

花花橙子

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

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

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

打赏作者

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

抵扣说明:

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

余额充值