动态规划专题

基础知识

动态规划,英文:Dynamic Programming,简称DP,如果某一问题有很多重叠子问题,使用动态规划是最有效的。所以动态规划中每一个状态一定是由上一个状态推导出来的,这一点就区分于贪心,贪心没有状态推导,而是从局部直接选最优的。

动态规划算法通常用于求解具有某种最优性质的问题。在这类问题中,可能会有许多可行解。每一个解都对应于一个值,我们希望找到具有最优值的解。

动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。

与分治法不同的是,分治方法通常将问题划分为互不相交的子问题,递归的求解子问题,再将它们的解组合起来,求出原问题的解;而适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的;若用分治法来解这类问题,则分解得到的子问题数目太多,有些子问题会被重复计算很多次;而动态规划对每一个子问题只求解一次,将其解保存在一个表格里面,且不管该子问题以后是否被用到,只要它被计算过,就应该将其结果填入表中,从而无需每次求解一个子问题时都重新计算,避免了不必要的计算工作,这就是动态规划法的基本思路。具体的动态规划算法多种多样,但它们具有相同的填表格式。

动态规划的应用场景:
动态规划方法一般用来求解最优化问题。这类问题可以有很多可行解,每个解都有一个值,我们希望找到具有最优值的解,我们称这样的解为问题的一个最优解,而不是最优解,因为可能有多个解都达到最优值。
解决动态规划问题一般分为四步:
1、定义一个状态,这是一个最优解的结构特征
2、进行状态递推,得到递推公式 ,也就是状态转移方程
3、进行初始化
4、返回结果

解题技巧

1、常用思路:从集合的角度(以集合为单位,由一个集合得到另一个集合,得到的集合的属性就是结果)考虑动态规划(DP)问题;
2、DP问题也是求某一个集合的最大值/最小值/总个数,我们需要寻找一种状态去代表某一类东西;
3、闫氏DP思考方式
动态规划专题_第1张图片
拿到DP问题首先要考虑状态表示f[i]是什么【务必明确】(这个实际是很难设计的,靠经验);
然后考虑状态转移要怎么进行(求状态转移方程):通常先考虑最后一步f[i]的情况,确定集合划分的方式;再分别推出不同子集的状态转移方式,具体的见72,91,,300等。

4、当状态转移方程中涉及到f[i-1]、f[i-2]等时,一般状态表示f[i]采用从下标1开始表示,使f[0]作为边界,可以避免处理边界的问题(i=0,i=1时f[i-1]、f[i-2]就越界了,需要额外加if判断),但需注意f[0]应该初始化成什么要考虑清楚,例如91题,198题;

5、状态表示不一定只用一个集合,可以有多个集合,视问题而定,如198题;

7、优化,例如长度为n的数组中只用到前一位的数据(i-1)可以优化为使用变量存储(i-1);nxm维数组中的数据只依赖于当前层(i)和上一层(i-1)可以优化为二维2xm作为滚动数组;如120题,518题。

8、优化,如果状态表示f[i,j]计算时需要枚举一遍,这样复杂度较高,此时可以观察相邻两项的计算表达式是否十分相似,如果相似就可以稍作修改利用上前一项,从而不再需要枚举,实现优化。如518题,10题。

9、什么样的题适合用动态规划?
最值型动态规划,比如求最大,最小值是多少
计数型动态规划,比如换硬币,有多少种换法
坐标型动态规划,比如在m*n矩阵求最值型,计数型,一般是二维矩阵
区间型动态规划,比如在区间中求最值

题目练习

53. 最大子数组和

题目链接

给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

子数组 是数组中的一个连续部分。
动态规划专题_第2张图片
进阶:如果你已经实现复杂度为 O(n) 的解法,尝试使用更为精妙的 分治法 求解。

题目分析

动态规划
暴力枚举非常耗费时间,所以得想办法对它进行优化.那怎么进行优化呢?其实是没有必要对所有的子数组的和进行枚举计算的,可以用一个数(可以成为标志位或者变量或者状态位)来代表一类数。

比如在暴力枚举中, 就是每一次枚举每一个nums[i] 时,从[i,nums.length-1] 这个区间中求最大和,然后记录到一个变量res中,然后继续nums[i+1] 的枚举,接着和res 的数进行比较,从而得到最大子序列和。

可以把这个核心思想给抽出来: 每次枚举num[i] 时,记录[0,i]的最大和。那动态规划的样貌就出来了:用一个数去代表一类数的属性(最大和)结果。

上闫氏DP思考方式:
动态规划专题_第3张图片
确定状态表示:可以设定转态表示f[i]为:从[0,i] 区间内的所有组合。其属性为所有组合的的最大值,即f[i] = [0,i] 区间内的所有组合的最大值。而本题中对于f[i]可以分为两大类f[i-1]和0,如上图;

划分集合:首先考虑最后一步f[i],可以分为前面没有数(即从当前数开始计算和)和前面有数两大类,从而得到状态转移方程为:f[i] = max(f[i-1], 0) + nums[i];

得到结果为 ans=max(f(i)),0≤i

分治法
动态规划专题_第4张图片
考虑区间 [l, r] 内的答案如何计算,令 mid = (l + r) / 2,则该区间的答案由三部分取最大值,分别是:
(1). 区间 [l, mid - 1] 内的答案(递归计算)。
(2). 区间 [mid + 1, r] 内的答案(递归计算)。
(3). 跨越 mid 的连续子序列的答案。而这一部分的计算只需要从 mid 开始向 l 找连续的最大值,以及从 mid开始向 r 找最大值(重复计算了nums[mid]减去一个即可)即可,在线性时间内可以完成。

其实就是一棵树的前序遍历,对比左子树的最大值,当前树的最大值,以及右子树的最大值,三者取最大即可。

参考1
参考2

代码实现

DP

class Solution {
    //DP  时间复杂度:O(n),n为nums的长度,空间复杂度:O(1)
    public int maxSubArray(int[] nums) {
        int[] dp = new int[nums.length];  //f[i]
        dp[0] = nums[0];  //初始化 由状态转移方程可知 f[0] = nums[0]
        int ans = dp[0];  //初始化结果

        //求f[i]
        for(int i = 1; i < nums.length; i++){
            //状态转移方程:f[i] = max(f[i-1], 0) + nums[i],
            dp[i] = Math.max(dp[i-1], 0) + nums[i];
            //f[i]中的最大值就是答案
            ans = Math.max(ans, dp[i]);
        }
        return ans;
    }
}

分治法:

class Solution {
    //分治法  时间复杂度O(nlogn)  空间复杂度为O(logn)
    public int maxSubArray(int[] nums) {
        return getMax(nums, 0, nums.length - 1);
    }

    private int getMax(int[] nums, int start, int end){
    	//递归终止条件
        if(start > end) return Integer.MIN_VALUE;
        if(start == end) return nums[start];

        //取中点mid,以中点分三部分:中点左边,中点右边,包括中点区域
        int mid = (start + end) >> 1;
        //3、计算出穿过中点的最大值
        int leftMax = nums[mid], rightMax = nums[mid], leftSum = 0, rightSum = 0;
        //从中点往左遍历的最大值
        for(int i = mid; i >= start; i--){
            leftSum += nums[i];
            leftMax = Math.max(leftMax, leftSum);
        }
        //从中点往后遍历的最大值
        for(int i = mid; i <= end; i++){
            rightSum += nums[i];
            rightMax = Math.max(rightMax, rightSum);
        }
        //重复计算了nums[mid]故减去一个
        int ans = leftMax + rightMax - nums[mid];


        //计算区间1、[l, mid - 1] 和 区间2、[mid + 1, r] 内的答案
        int leftSubMax = getMax(nums, start, mid - 1);
        int rightSubMax = getMax(nums, mid + 1, end);

        //最终的最大和就是在上述三个答案中取最大值
        return Math.max(Math.max(leftSubMax, rightSubMax), ans);

    }
}

120. 三角形最小路径和

题目链接

给定一个三角形 triangle ,找出自顶向下的最小路径和。

每一步只能移动到下一行中相邻的结点上。相邻的结点 在这里指的是 下标 与 上一层结点下标 相同或者等于 上一层结点下标 + 1 的两个结点。也就是说,如果正位于当前行的下标 i ,那么下一步可以移动到下一行的下标 i 或 i + 1 。
动态规划专题_第5张图片

题目分析

经典DP问题

先读题,可知,共有2^(n-1)条路径,求最小路径和
动态规划专题_第6张图片
上闫氏DP:
确定状态表示:如图
划分集合:首先考虑最后一步f[i,j],可以分为从左上下来和右上下来两大类
动态规划专题_第7张图片
需要明确的是:

  • 如2-3-5 分别对应坐标是(0,0)(1,0)(2,1);2-4-5分别对应坐标是(0,0)(0,1)(2,1),因此左上下来去掉nums[i,j]是f[i-1,j-1],右上下来去掉nums是f[i-1,j]。
  • 当从 左上/右上 下来只有一个存在时取存在的作为f[i,j]
  • 当从 左上/右上 下来都存在时取最小的那个为f[i,j]

又因为,第i层只用到了第i−1层的数据,可以用两个滚动数组来优化,也就是f[n][n] -> f[2][n],这里给出这版代码

还可以再进一步优化,即直接原地修改,空间复杂度为O(1),这里就不给出了。

还有就是这是自顶向下的解法,自底向上的解法会更快,参见

代码实现

class Solution {
    //DP 进一步优化 时间复杂度O(n^2) 空间复杂度O(2n)=O(n)
    public int minimumTotal(List<List<Integer>> triangle) {
        int n = triangle.size();
        //只需要f[i-1][]中的元素,因此可以降n为2
        int[][] f = new int[2][n];    //f[i,j]表示到第i行j列元素的最小路径和
        f[0][0] = triangle.get(0).get(0); //初始化
        
        //计算f[i][j]
        for(int i = 1; i < n; i++){   
            for(int j = 0; j <= i; j++){
                //i & 1 : i=0时->0; i=1时->1; 2->0; 3->1;...交替
                //&运算符优先级低于算术运算符
				
				//这里是因为,j=0时只能从右边下来,但又要对比左右两边
                f[i & 1][j] = Integer.MAX_VALUE; 
                //状态转移方程:从左上下来 f[i-1,j-1]+nums[i,j] 从右上下来f[i-1,j]+nums[i,j] 
                //j>0才可以从左边更新(第一个数不能从左上过来)
                if(j > 0) f[i & 1][j] = Math.min(f[i & 1][j], f[i - 1 & 1][j - 1] + triangle.get(i).get(j));
                //j
                if(j < i) f[i & 1][j] = Math.min(f[i & 1][j], f[i - 1 & 1][j] + triangle.get(i).get(j));
            }
        }

        //枚举最后一行的所有状态取最小值,也就是自顶向下的最小路径和
        int result = f[n - 1 & 1][0];
        for(int i = 1; i < n; ++i){
            result = Math.min(result, f[n - 1 & 1][i]);
        }

        return result;
    }
}

63. 不同路径 II

题目链接

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记星星)。

现在考虑网格中有障碍物(石头)。那么从左上角到右下角将会有多少条不同的路径?

网格中的障碍物和空位置分别用 1 和 0 来表示。

动态规划专题_第8张图片
动态规划专题_第9张图片

题目分析

经典DP问题,上闫氏DP:
确定状态表示:如图
划分集合:首先考虑最后一步f[i,j],可以分为从上面下来和从左边过来两大类
动态规划专题_第10张图片
相似的还有 LeetCode 62. 不同路径;
而 LeetCode 980 . 不同路径III 就不能用DP,需要暴力搜索。

代码实现

class Solution {
    //DP O(nm),其中m为网格的行数,n为网格的列数,只需要遍历所有网格一次。空间复杂度:O(m)
    public int uniquePathsWithObstacles(int[][] obstacleGrid) {
        int m = obstacleGrid.length;  
        int n = obstacleGrid[0].length;
        //f[i,j]表示所有从起点走到[i,j]的路径数
        int[][] f = new int[m + 10][n + 10];  //+10是为了避免处理越界情况
        //必须初始化
        f[0][0] = 1;//初始化 
        
        for(int i = 0; i < m; i++){
            for(int j = 0; j < n; j++){
                //如果是障碍物,则跳过,所有路径归零
                if(obstacleGrid[i][j] == 1){
                    f[i][j] = 0;
                    continue;
                }
                //从上面下来,状态转移方程:f[i][j] += f[i-1][j] 须保证当前行不是第1行且上面不是障碍物
                if(i - 1 >= 0 && obstacleGrid[i - 1][j] == 0) f[i][j] += f[i-1][j];
                //从左边过来 状态转移方程:f[i][j] += f[i-1][j] 须保证当前列不是第1列且左边不是障碍物
                if(j > 0 && obstacleGrid[i][j - 1] == 0) f[i][j] += f[i][j - 1];
            }
        }
        //f[m - 1][n - 1]就是所求的总不同路径数
        return f[m - 1][n - 1];
    }
}

91 解码方法

题目链接
动态规划专题_第11张图片
动态规划专题_第12张图片
动态规划专题_第13张图片

题目分析

DP问题
确定状态表示:如图,f[i]表示由前i个数字解码得到的所有字符串,属性为数量
划分集合:首先考虑最后一步f[i],所得字符串最后1个字母可以分为由1个数字解码和2个数字解码。
动态规划专题_第14张图片
注意:本题中采用下标从1开始的操作,因为DP问题从下标1开始往往会更方便,使f[0]作为边界,f[1]->f[n]表示1个数字的不同解码方案数—>n个数字的不同解码方案数,可以避免处理边界的问题

代码实现

class Solution {
    //DP  时间复杂度:O(n),其中n是字符串s的长度。空间复杂度:O(n)
    public int numDecodings(String s) {
        int n = s.length();
        //f[i]表示由前i个数字解码得到的字符串数(所有方案)
        //通常DP问题从下标1开始会更方便,f[0]作为边界,f[1]->f[n],从而避免处理边界的问题,例如下方f[i-1] i=0时就会越界,需要额外加if判断
        int[] f = new int[n + 1]; //这里还可以优化,使用三个变量分别存储f[i]f[i-1]f[i-2],空间复杂度为O(1)  
        f[0] = 1;

        //注意f下标时从1开始,因此s中对应的索引为i-1
        for(int i = 1; i <= n; i++){
            //所得字符串最后一个字母由个位数解码  状态转移方程:f[i] += f[i - 1] 须保证最后一个数字不为0(0不对应任何字母)
            if(s.charAt(i - 1) != '0') f[i] += f[i - 1];
            //所得字符串最后一个字母由两位数解码  状态转移方程:f[i] += f[i - 2] 首先须保证至少有两个数
            if(i >= 2){
                //然后须保证构成的两位数在[10,26]区间内
                int num = (s.charAt(i - 2) - '0') * 10 + s.charAt(i - 1) - '0';
                if(num >= 10 && num <= 26) f[i] += f[i - 2];
            }
        }
        //f[n]为所求的解码方法的总数
        return f[n];
    }
}

198. 打家劫舍*****

题目链接

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。

动态规划专题_第15张图片

题目分析

这题告诉我们状态表示不一定只有一个,可以有多个

确定状态表示:

因为只用一个状态表示fi]很难确定当前数能不能选,因为不知道上一个数有没有选

因此用两个状态表示
在这里插入图片描述
划分集合

首先考虑最后一步f[i],可以分为当前数不选和当前数选两大类,因此可以推出状态转移方程为:
在这里插入图片描述
当然也可以使用一个状态表示f[i],如下:f[i]表示从前i个数选,获取到的金额的最大值。
动态规划专题_第16张图片
类似的还有 打家劫舍II;
树形DP:打家劫舍III

代码实现

class Solution {
    //DP 本题使用两个状态表示 时间复杂度:O(n),空间复杂度为O(n)
    public int rob(int[] nums) {
        int n = nums.length;
        //f和g从下标1开始表示,避免处理越界情况 例如下方f[i-1]i=0时就会越界,需要加if判断
        int[] f = new int[n + 1];  //f[i]表示在前i个数中选,所有不选当前数nums[i]的最大值
        int[] g = new int[n + 1];  //g[i]表示在前i个数中选,所有选当前数nums[i]的最大值
        //f[0],g[0]作为边界,根据题目,应初始化为0
        f[0] = 0;
        g[0] = 0;
        
        for(int i = 1; i <= n; i++){
            //不选当前数  状态转移方程 f[i] = max(f[i - 1], g[i - 1])
            f[i] = Math.max(f[i - 1], g[i - 1]);
            //选当前数  状态转移方程:g[i] = f[i - 1] + nums[i];
            g[i] = f[i - 1] + nums[i - 1];  //因为f,g下标从1开始表示,因此对应nums中要i-1
        }
        //最终大的那个就是所求结果
        return Math.max(f[n], g[n]);
    }
}
class Solution {
    //DP 只用一个状态表示  时间复杂度为O(n) 空间复杂度为O(n)
    public int rob(int[] nums) {
        int n = nums.length;
        //f[i]表示从前i个数选的最大值(即得到的集合,属性为最大值)
        int[] f = new int[n + 10];   //可以优化使用三个变量存储f[i]f[i-2]f[i-1] 空间复杂度为O(1)
        //初始化f[0]为0,f[1]为nums[0] 
        f[0] = 0;
        f[1] = nums[0];

        //因为涉及到f[i-2],所以i从2开始,避免处理越界,对应nums中就得i-1
        for(int i = 2; i <= n; i++){
            //状态转移方程,选当前数f[i]=nums[i]+f[i-2] 不选当前数f[i]=f[i-1]  取最大值
            f[i] = Math.max(nums[i - 1] + f[i - 2], f[i - 1]);
        }

        return f[n];
    }
}

300. 最长递增子序列*****

题目链接

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列

动态规划专题_第17张图片
动态规划专题_第18张图片

题目分析

经典DP问题

确定状态表示:如图

划分集合:首先考虑最后一步f[i],倒数第二个数可以是第0到i-1个位数,特殊时,倒数第2个数不存在,因此要划分为i类。
动态规划专题_第19张图片
相似的有 LeetCode 673. Number of Longest Increasing Subsequence

进阶涉及到贪心算法,贪心 + 二分查找,时间复杂度为O(logn),参见官方题解或这里,这里暂时不提。
类似的有:AcWing 896. 最长上升子序列 II

代码实现

class Solution {
    //DP  时间复杂度为O(n^2),遍历每个数,每个数又有n种状态,空间复杂度O(n)
    public int lengthOfLIS(int[] nums) {
        int n = nums.length;
        //f[i]表示以第i个数结尾的上升子序列的最大长度
        int[] f = new int[n];
        //存储结果
        int res = 0;

        for(int i = 0; i < n; i++){
            //特殊时,倒数第二个数不存在,即i=0
            f[i] = 1;
            //当倒数第二个数是第j个数时,j可能为0->i-1,取使得f[i]最大的那个
            for(int j = 0; j < i; j++){
                //状态转移方程 f[i]=f[j]+1 须保证nums[i]>nums[j]才能构成上升序列
                if(nums[j] < nums[i]) f[i] = Math.max(f[i], f[j] + 1);
            }
            res = Math.max(res, f[i]);
        }

        return res;
    }
}

72. 编辑距离*****

题目链接

给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
插入一个字符
删除一个字符
替换一个字符

动态规划专题_第20张图片

题目分析

经典DP
确定状态表示f[i,j]所有将word1的前i个字母变成word2的前j个字母的方案的最小操作次数。

划分集合:首先考虑最后一步f[i,j],可以分为插入,删除,替换三大类;

进行状态转移
对于最后一步为插入操作,说明word1的前i个字母已经和word2的前j-1个字母相等,再插入word2[j],两个字符串就相等,因此可以推出状态转移方程f[i,j] = f[i,j-1] + 1

对于最后一步为删除操作,说明word1的前i-1个字母已经和word2的前j个字母相等,再删除word1[i],两个字符串就相等,因此可以推出状态转移方程f[i,j] = f[i-1,j] + 1

对于最后一步为替换(或者说修改)操作,又可以分为两类:一是不需要替换(修改),说明word1的前i个字母已经和word2的前j个字母相等,即两个字符串已经相等,因此可以推出状态转移方程f[i,j] = f[i-1,j-1];二是需要替换(修改),说明word1的前i-1个字母已经和word2的前j-1个字母相同,需要将word1的第i个字母替换为word[j],两字符串就相等,因此可以推出状态转移方程f[i,j] = f[i-1,j-1] + 1

因此,可以知道最终结果就是这四大类中的最小值。流程图如下:
动态规划专题_第21张图片
代码参见

代码实现

class Solution {
    //DP  时间复杂度为O(mn),其中m,n为字符串的长度,空间复杂度 :O(mn)
    public int minDistance(String word1, String word2) {
        int n = word1.length();
        int m = word2.length();
        //f[i,j]表示将word1的前i个字母变成word2的前j个字母的所有方案中的最少操作次数。
        int[][] f = new int[n + 1][m + 1];

        //初始化f[i][0]和f[0][i]
        //若word1长度为i,word2长度为0,则需要进行i次删除操作
        for(int i = 0; i <= n; i++) f[i][0] = i;
        //若word1长度为0,word2长度为i,则需要进行i次插入操作
        for(int i = 0; i <= m; i++) f[0][i] = i;  

        //涉及到f[i-1,j-1],因此下标从1开始,对应字符串中就要i-1
        for(int i = 1; i <= n; i++){
            for(int j = 1; j <= m; j++){
                //最后一步为删除:f[i,j]=f[i-1][j]+1  最后一步为插入:f[i,j]=f[i,j-1]+1
                f[i][j] = Math.min(f[i - 1][j] + 1, f[i][j - 1] + 1);
                //最后一步为替换(或者说修改)
                if(word1.charAt(i - 1) == word2.charAt(j - 1)){ 
                    //如果word1的第i个字母已经和word2第j个字母相等,无需替换,f[i,j]=f[i-1,j-1]
                    f[i][j] = Math.min(f[i][j], f[i -1][j - 1]);
                }else{
                    //如果word1的第i-1个字母已经和word2第j-1个字母相等,需要替换word1[i], f[i,j]=f[i-1,j-1]+1
                    f[i][j] = Math.min(f[i][j], f[i - 1][j - 1] + 1);
                }
            }
        }
        //最后一步为四种情况的结果取最小的那个
        return f[n][m];
    }
}

518. 零钱兑换 II *****

题目链接

给你一个整数数组 coins 表示不同面额的硬币,另给一个整数 amount 表示总金额。

请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0 。
假设每一种面额的硬币有无限个。
题目数据保证结果符合 32 位带符号整数。

动态规划专题_第22张图片
动态规划专题_第23张图片

题目分析 还有点没想明白

经典DP中的 完全背包问题:什么是背包问题

动态规划专题_第24张图片
01背包
有 N 件物品和一个容量是 V 的背包。每件物品只能使用一次
第 i 件物品的体积是 vi,价值是 wi。
求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
输出最大价值。

完全背包
有 N 种物品和一个容量是 V 的背包,每种物品都有无限件可用。
第 i 种物品的体积是 vi,价值是 wi。
求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
输出最大价值。

多重背包
有 N 种物品和一个容量是 V 的背包。
第 i 种物品最多有 si 件,每件体积是 vi,价值是 wi。
求解将哪些物品装入背包,可使物品体积总和不超过背包容量,且价值总和最大。
输出最大价值。

掌握以上三个即可

对于本题,分析如下:

确定状态表示:f[i,j] 所有由前i种硬币凑出的总金额等于j的方案数量

划分集合:首先考虑最后一步f[i,j],可以分为第i个物品选0个,选1个,,,选k个(设选k+1个体积就超过j)
动态规划专题_第25张图片

进行状态转移

对于上述集合,可以考虑k的取值:

  • k = 0时,f[i,j]=f[i-1,j]
  • k > 0时,由于包含k个第i种硬币,因此可以去掉这k个第i种硬币,再计算状态,即求得f[i-1,j-k*coins[i]],而不管最后一步k取什么,方案总数f[i,j] = f[i-1,j-k*coins[i]]

因此,设coins[i] = v,有:
f[i,j] = f[i-1,j] + f[i-1,j-v] + f[i-1,j-2v] + ...+ f[i-1, j - kv]
这种解法时间复杂度为O(n^3),状态表示是二维的,每维计算需要O(n),遍历所有硬币一次O(n);

进一步优化:
考虑f[i,j-v],表示由前i种硬币凑成了j-v的金额,往前推,存在f[i, j-v] = f[i-1, j-v] + f[i-1,j-2v] + .... + f[i-1, j-kv]
因此上式可以优化为f[i,j] = f[i-1,j] + f[i,j-v]

再进一步优化:
由于f[i,j]只与f[i-1,j]f[i,j-v]有关,可以发现f[i]依赖于上一层(i-1)和当前层(i),根据这个特性可以用滚动数组(f[2][j])来优化;
而对于f[j]可以发现其依赖于自己(j)和前面的元素(j-v),根据这个特性就可以优化滚动数组为一维数组,因为f[i,j-v]就是在i这一层计算得到的,而j可以从小到大枚举(例如i=1这一层中,会先计算得到f[j-v],计算f[j]时使用的就是i=1这一层中的f[j-v],与i=0那一层没有关系),因此用不到其它层的数据,i的存在没有意义,去掉。
这样状态转移方程就优化为f[j] += f[j-v],此时f[j]表示金额之和等于j的硬币组合数。式子表示,如果存在一种硬币组合的金额和等于j−v,则在该硬币组合中增加一个面额为v的硬币,就可得到一种金额和等于j的硬币组合,从而f[j]就可以+1,有f[j-v]个这样的组合,f[j]就可以+f[j-v]

代码实现

class Solution {
    //DP   完全背包问题  时间复杂度O(n) 空间复杂度O(amount)=O(1) 
    public int change(int amount, int[] coins) {
        int n = coins.length;
        //f[j]表示金额之和等于j的硬币组合数。
        int[] f = new int[amount + 1];
        //初始化
        f[0] = 1;

        //遍历硬币
        for(int i = 0; i < n; i++){
            //先确定i,再从小到大依次枚举j,计算由前i种硬币凑成金额j的方案数,
            for(int j = coins[i]; j <= amount; j++){
                //如果存在金额和为j-coins[i],那么再加一个coin[i],就可以就得f[j]的一种新组合
                //由此可推出状态转移方程 f[j]+=f[j-coins[i]]
                f[j] = f[j] + f[j - coins[i]];
            }
        }
         return f[amount];
    }
}

664. 奇怪的打印机 ***** 再思考一下

题目链接

有台奇怪的打印机有以下两个特殊要求:

打印机每次只能打印由 同一个字符 组成的序列。
每次可以在从起始到结束的任意位置打印新字符,并且会覆盖掉原来已有的字符。
给你一个字符串 s ,你的任务是计算这个打印机打印它需要的最少打印次数
动态规划专题_第26张图片

题目分析

区间DP

动态规划专题_第27张图片
可以将打印字符看作给每个位置染色。

确定状态表示
集合:f[L,R]代表所有将[L,R]染成最终样子的方式的集合
属性:f[L,R]的值等于该集合的染成最终样子的最少次数

划分集合
如果想将整个区间染成最终的样子,那么最左侧一定要与最终的样子一致,这样就一定存在一种方案先染最左侧,因此可以以从最左侧开始染到某个位置作为划分集合的方式,其子集有如下情况:

  • f[L,L],即第一次只染最左侧1个端点就不染了,此时还需要染的最少次数为f[L + 1,R],因为L已经已经染过了,只需要染[L + 1,R]即可,因此总的操作次数最小值为f[L + 1,R] + 1
  • 而对于其他情况,假设第一次染是从L染到K(L+1 <= K <= R),K与最左侧L的颜色必然相同,否则就没有必要染到K(如果不同后面就会被覆盖,这样染到K就没意义了),此时染的次数最小值为f[L,K-1],即所有从L染到K-1的方式,因为K与最左侧L的颜色相同,可以顺带将K染上与最左侧一样的颜色,所以不需要额外加1;然后再染K+1到R,因此总的操作次数为f[L,K-1] + f[K+1, R]

只需要分别求出每一个子集的最小值,再取最小值就得到f[L,R]的最小值。

状态数量共n^2个,每个状态需要枚举一次,因此总的时间复杂度为O(n)

另一种思考方式是:参考 将f[L,R]划分子集,其子集有如下情况:

  • f[L,L]的集合,此时最少次数为f[L + 1,R]+1
  • f[L,L + 1]的集合,若s[L + 1] == s[L],则最少次数为f[L,L] + f[L + 2,R],否则最少次数为f[L,L + 1] + f[L + 2,R]
  • f[L,L + 2]的集合,若s[L + 2] == s[L],则最少次数为f[L,L + 1] + f[L + 3,R],否则最少次数为f[L,L + 2] + f[L + 3,R]
  • f[L,k]的集合,若s[k] == s[L],则最少次数为f[L,k - 1] + f[k + 1,R],否则最少次数为f[L,k] + f[k + 1,R],其中k属于[L + 1,R],注意:如果k = R 时,此处会出现f[R + 1,R]的情况,此值为0;如果s[k] != s[L]时,即使把该s[k]染成s[L]的颜色,到后面还是会被覆盖,因此f[L,k] + f[k + 1,R]一定会比其他情况大,所以可以舍去

代码实现

class Solution {
    //DP   时间复杂度:O(n^3),n是字符串的长度,空间复杂度:O(n^2)需要保存所有 n^2个状态。
    public int strangePrinter(String s) {
        int n = s.length();
        //f[L,R]的元素表示所有将[L,R]染成最终样子的最小操作次数
        //因为下面用到了f[L][K-1],因此+1避免处理边界
        int[][] f = new int[n + 1][n + 1];

        //所有要用到的状态都算出来,按照长度从小到大枚举
        for(int len = 1; len <= n; len++){
            //从小到大枚举L,实现按区间染色,直到染到最终的样子
            for(int L = 0; L + len - 1 < n; L++){
                int R = L + len - 1;
                //第一种情况,只染到L,状态转移方程为:f[L][R]=f[L + 1][R] + 1
                f[L][R] = f[L + 1][R] + 1;
                //其他情况,依次枚举K
                for(int K = L + 1; K <= R; K++){
                    if(s.charAt(K) == s.charAt(L)){
                        //当K和L颜色相等,状态转移方程为:f[L][R]=f[L][k - 1] + f[K + 1][R]
                        f[L][R] = Math.min(f[L][R], f[L][K - 1] + f[K + 1][R]);
                    }
                }
            }
        }
        return f[0][n - 1];
    }
}

10. 正则表达式匹配******

题目链接

给你一个字符串 s 和一个字符规律 p,请你来实现一个支持 ‘.’ 和 ‘*’ 的正则表达式匹配。

‘.’ 匹配任意单个字符
‘*’ 匹配零个或多个前面的那一个元素
所谓匹配,是要涵盖 整个 字符串 s的,而不是部分字符串。

动态规划专题_第28张图片

题目分析

本题难点在于,*匹配零个或多个前面的那一个元素,因此能匹配多少个字母是不确定的。

可以观察到,*与前面字符(一定存在)是一起出现,不能分开的。

动态规划专题_第29张图片
动态规划专题_第30张图片

确定状态表示
f[i,j]表示s的前i个字符和p的前j个字符是否匹配。

划分集合
根据p[j]是否等于*来划分

状态转移方程

  • p[j] != '*",即p中第j个字符不为*时,状态转移方程:f[i,j] = f[i-1,j-1] && (s[i]和p[j]匹配)
  • p[j] = '*",此时需要枚举*匹配多少个子没有,此时状态转移方程为:f[i,j] = f[i,j-2]||f[i-1,j-2]&&(s[i]和p[j-1]匹配)||f[i-2,j-2] && (s[i-1:i]都和p[j-1]匹配)||...,其中f[i,j-2]表示*匹配0个字符,因为*和其前面的字符是一对,所以前一项是s的第i个字符与p的前j-2个字符是否匹配;f[i-1,j-2]&&(s[i]和p[j-1]匹配)表示*匹配1个字符;f[i-2,j-2] && (s[i-1:i]都和p[j-1]匹配)表示*匹配2个字符,即s的第i,i-1个字母都与p的第j-1个字符相等,所以前一项是s的第i-2个字符与p的前j-2个字符是否匹配;…
    这样就需要枚举一遍*匹配几个字符,这样时间复杂度就会 高一倍,变成O(n^3),因此我们需要寻找不枚举的方案去优化,这类似于完全背包问题的优化,需要观察不同状态之间的关系
    可以发现,f[i-1,j] = f[i-1,j]||f[i-1,j-2]&&(s[i-1]和p[j-1]匹配)||...与f[i,j]去掉首项后十分相似,只相差一项(s[i]和p[j-1]匹配),因此可以利用分配律思想,把||看作加高,把&&看作乘号,把乘号后的项提出来,就得到了f[i,j] =f[i,j-2]||(f[i-1,j]&&s[i]和p[j-1])匹配,这样就实现了优化,不需要枚举,时间复杂度变为O(n^2)

可以总结出:
如果状态表示f[i,j]计算时需要枚举一遍,这样复杂度较高,此时可以观察相邻两项的计算表达式是否十分相似,如果相似就可以稍作修改利用上前一项,从而不再需要枚举,实现优化。

参考

代码实现

class Solution {
    //DP   时间复杂度O(mn),m和n分别是字符串s和p的长度,需要计算出所有的状态,并且每个状态在进行转移时的时间复杂度为O(1),空间复杂度O(mn),即为存储所有状态使用的空间。
    public boolean isMatch(String s, String p) {
        int n = s.length(), m = p.length();

        //f[i,j]表示s的前i个字符和p的前j个字符是否匹配。
        //为了减少处理数组越界问题,将下标从1开始,
        boolean[][] f = new boolean[n + 1][m + 1];
        //对应字符串也应从1开始,加个无用字符串即可
        s = " " + s;
        p = " " + p;
        //初始化
        f[0][0] = true;

        //枚举s和p中的字符,判断是否匹配
        for(int i = 0; i <= n; i++){
            //因为下方用到j-1,j-2,为避免数组越界,j从1开始(j=0时是为" ",无影响;且p[j]=*时j必然不小于2)
            for(int j = 1; j <= m; j++){
                //如果下一位是*,那么当前位应该和下一位看作整体
                if(j + 1 <= m && p.charAt(j + 1) == '*') continue;
                
                if(p.charAt(j) != '*'){
                    //第一种情况,状态转移方程:f[i,j] = f[i-1,j-1] && (s[i]和p[j]匹配)
                    //s[i]和p[j]匹配存在两种情况,用flag表示
                    boolean flag = (p.charAt(j) == s.charAt(i) || (p.charAt(j) == '.'));
                    //必须先保证i不为0,否则会数组越界,如果i=0,对应s[0]为" ",因此f[0][j]必然为false
                    f[i][j] = i != 0 && f[i - 1][j - 1] && flag;
                }else{
                    //第一种情况,状态转移方程:f[i,j] = f[i,j-2]||(f[i-1,j]&&s[i]和p[j-1])匹配
                    //s[i]和p[j-1]匹配存在两种情况,用flag表示
                    boolean flag = (p.charAt(j - 1) == s.charAt(i)) || (p.charAt(j - 1) == '.');
                    //必须先保证i不为0,否则会数组越界  aa  a*
                    //如果i=0,对应s[0]为" ",||右边表达式必然为false,此时f[i][j]=f[i][j-2]
                    f[i][j] = f[i][j - 2] || (i != 0 && f[i - 1][j] && flag);   
                }
            }
        }
        return f[n][m];
    }
}

练习

剪绳子

题目链接

参考

你可能感兴趣的:(数据结构与算法,动态规划,算法,贪心算法)