有了四步解题法模板,再也不害怕动态规划!
(进阶版)有了四步解题法模板,再也不害怕动态规划!
(再进阶版)有了四步解题法模板,再也不害怕动态规划!
1. 一般动态规划
1.1 概念
用一句话解释动态规划就是 “记住你之前做过的事”,如果更准确些,其实是 “记住你之前得到的答案”。
我举个大家工作中经常遇到的例子。
在软件开发中,大家经常会遇到一些系统配置的问题,配置不对,系统就会报错,这个时候一般都会去 Google
或者是查阅相关的文档,花了一定的时间将配置修改好。过了一段时间,去到另一个系统,遇到类似的问题,这个时候已经记不清之前修改过的配置文件长什么样,这个时候有两种方案,一种方案还是去
Google 或者查阅文档,另一种方案是借鉴之前修改过的配置,第一种做法其实是万金油,因为你遇到的任何问题其实都可以去
Google,去查阅相关文件找答案,但是这会花费一定的时间,相比之下,第二种方案肯定会更加地节约时间。
但是这个方案是有条件的,条件如下:
- 之前的问题和当前的问题有着关联性,换句话说,之前问题得到的答案可以帮助解决当前问题
- 需要记录之前问题的答案
当然在这个例子中,可以看到的是,上面这两个条件均满足,大可去到之前配置过的文件中,将配置拷贝过来,然后做些细微的调整即可解决当前问题,节约了大量的时间。
不知道你是否从这些描述中发现,对于一个动态规划问题,我们只需要从两个方面考虑,那就是 找出问题之间的联系,以及 记录答案,这里的难点其实是找出问题之间的联系,记录答案只是顺带的事情,利用一些简单的数据结构就可以做到。
1.2 算法步骤与流程
一般解决动态规划问题,分为四个步骤,分别是
- 问题拆解,找到问题之间的具体联系
- 状态定义
- 递推方程推导
- 实现
1.3 相关关联算法
1.4 例题
class Solution {
public int minimumTotal(List<List<Integer>> triangle) {
for(int i = triangle.size()-1; i > 0 ; i--){
for(int j = 0; j < i ; j++){
triangle.get(i-1).set(j, triangle.get(i-1).get(j) + Math.min(triangle.get(i).get(j), triangle.get(i).get(j+1)));
}
}
return triangle.get(0).get(0);
}
}
class Solution {
public int maxSubArray(int[] nums) {
if (nums == null || nums.length == 0) return 0;
int n = nums.length;
int r = nums[0];
for (int i = 1 ; i < n ; i++){
nums[i] = Math.max(nums[i-1] , 0) + nums[i];
r = Math.max(nums[i] , r);
}
return r;
}
}
class Solution {
public int climbStairs(int n) {
if (n == 1) {
return 1;
}
int[] dp = new int[n + 1]; // 多开一位,考虑起始位置
dp[0] = 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];
}
}
对于此问题,当前状态只依赖于前两种状态,完全没必要使用数组存储所有的状态。只需要用两个临时变量记录前两种状态即可。时间复杂度O(n) -> O(1)。
class Solution {
public int climbStairs(int n) {
if (n < 3) {
return n;
}
int a = 1, b = 2, max = 0;
for (int i = 3; i <= n; ++i) {
max = a + b;
a = b;
b = max;
}
return max;
}
}
class Solution {
public int coinChange(int[] coins, int amount) {
int dp[] = new int[amount + 1];
Arrays.fill(dp, amount + 1 ); // 不能用Integer.MAX_VALUE,因为Integer.MAX_VALUE+1会变成负数
dp[0] = 0;
for(int i = 1; i <= amount; ++i){ // 不能提前在for循环里给数组每个无法凑成amount的dp[amount]返回-1,因为会影响后面的Math.min。直接在最后判断dp[amount]即可。
for(int num : coins){
if (num > i) continue;
dp[i] = Math.min(dp[i] , dp[i - num] + 1);
}
}
return dp[amount] > amount ? -1 : dp[amount];
}
}
2. 矩阵(坐标)类动态规划
2.1 概念
上一次 解释了动态规划的一些基本特性和解题思路,也说了动态规划其实就是记住之前问题的答案,然后利用之前问题的答案来分析并解决当前问题,这里面有两个非常重要的步骤,就是 拆解问题 和 定义状态。
这次来针对具体的一类动态规划问题,矩阵类动态规划问题,来看看针对这一类问题的思路和注意点。
矩阵类动态规划,也可以叫做坐标类动态规划,一般这类问题都会给你一个矩阵,矩阵里面有着一些信息,然后你需要根据这些信息求解问题。
其实 矩阵可以看作是图的一种,怎么说?你可以把整个矩阵当成一个图,矩阵里面的每个位置上的元素当成是图上的节点,然后每个节点的邻居就是其相邻的上下左右的位置,我们遍历矩阵其实就是遍历图,在遍历的过程中会有一些临时的状态,也就是子问题的答案,我们记录这些答案,从而推得我们最后想要的答案。
1.2 算法步骤与流程
一般来说,在思考这类动态规划问题的时候,我们只需要思考当前位置的状态,然后试着去看当前位置和它邻居的递进关系,从而得出我们想要的递推方程,这一类动态规划问题,相对来说比较简单,我们通过几道例题来熟悉一下。
1.3 相关关联算法
1.4 例题
class Solution {
public int uniquePaths(int m, int n) {
int a[][] = new int[m][n];
for(int i = 0; i < m; i++){
a[i][0] = 1;
}
for(int j = 0; j < n; j++){
a[0][j] = 1;
}
for(int i = 1; i < m; i++){
for(int j = 1; j < n; j++){
a[i][j] = a[i][j-1] + a[i-1][j];
}
}
return a[m-1][n-1];
}
}
class Solution {
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
int m = obstacleGrid.length;
int n = obstacleGrid[0].length;
obstacleGrid[0][0] = obstacleGrid[0][0] == 1 ? 0 : 1;
for(int i = 1; i < m; i++){
obstacleGrid[i][0] = obstacleGrid[i][0] == 1 ? 0 : obstacleGrid[i-1][0];
}
for(int j = 1; j < n; j++){
obstacleGrid[0][j] = obstacleGrid[0][j] == 1 ? 0 : obstacleGrid[0][j-1];
}
for(int i = 1; i < m; i++){
for(int j = 1; j < n; j++){
obstacleGrid[i][j] = (obstacleGrid[i][j] == 1) ? 0 : (obstacleGrid[i][j-1] + obstacleGrid[i-1][j]);
}
}
return obstacleGrid[m-1][n-1];
}
}
980. 不同路径 III
// 还没写出来
class Solution {
public int minPathSum(int[][] grid) {
int m = grid.length;
int n = grid[0].length;
for(int i = 1; i < m; i++){
grid[i][0] = grid[i-1][0] + grid[i][0];
}
for(int j = 1; j < n; j++){
grid[0][j] = grid[0][j-1] + grid[0][j];
}
for(int i = 1; i < m; i++){
for(int j = 1; j < n; j++){
grid[i][j] = Math.min(grid[i][j-1], grid[i-1][j]) + grid[i][j];
}
}
return grid[m-1][n-1];
}
}
class Solution {
public int maximalSquare(char[][] matrix) {
int m = matrix.length;
int n = matrix[0].length;
int a[][] = new int[m][n];
int max = 0;
for (int i = 0; i < m; i++){
for (int j = 0; j < n; j++){
if(Character.getNumericValue(matrix[i][j]) == 1) max = 1;
a[i][j] = Character.getNumericValue(matrix[i][j]);
}
}
for (int i = 1; i < m; i++){
for (int j = 1; j < n; j++){
a[i][j] = a[i][j] == 0 ? 0 : Math.min(a[i-1][j-1], Math.min(a[i][j-1], a[i-1][j])) + 1;
if(a[i][j] > max) max = a[i][j];
}
}
return max * max;
}
}
class Solution {
public int numSubmat(int[][] mat) {
int rows = mat.length;
int cols = mat[0].length;
int tmp = 0;
int nums = 0;
for(int i = 0; i < rows; i++)
for(int j = 1; j < cols; j++){
if(mat[i][j-1]!=0&&mat[i][j]!=0) mat[i][j] = mat[i][j-1] + 1;
}
for(int j = 0; j < cols; j++)
for(int i = 0; i < rows; i++){
tmp = mat[i][j];
for(int k = i-1; k >= 0; k--){
tmp = Math.min(mat[k][j], tmp);
nums += tmp;
}
nums += mat[i][j];
}
return nums;
}
}
经典动态规划:0-1 背包问题
416. 分割等和子集(子集背包问题)
给一个可装载重量为 sum / 2 的背包和 N 个物品,每个物品的重量为 nums[i]。现在让你装物品,是否存在一种装法,能够恰好将背包装满?这就是一个背包问题的变形。
class Solution {
public boolean canPartition(int[] nums) {
int cnt = 0;
int maxNum = 0;
for(int num : nums){
cnt += num;
maxNum = Math.max(num, maxNum);
}
if(cnt % 2 == 1 || cnt / 2 < maxNum) return false;
cnt /= 2;
boolean[][] dp = new boolean[nums.length][cnt+1];
for(int i = 0; i < nums.length; ++i){
dp[i][0] = true;
}
for(int i = 1; i < nums.length; ++i){
for(int j = 1; j <= cnt; ++j){
if(dp[i-1][j]){ //前面i-1个已经能刚刚把背包装满了,后面不用装了,肯定能满
dp[i][j] = true;
continue;
}
if(j - nums[i] >= 0) dp[i][j] = dp[i-1][j - nums[i]];
dp[i][j] |= dp[i-1][j];
}
}
return dp[nums.length-1][cnt];
}
}
优化成一维dp数组的形式。可以发现在计算dp[i][j]的过程中,每一行的 dp 值都只与上一行的 dp值有关,因此只需要一个一维数组dp[]即可降低空间复杂度。i=1时,计算dp。i=2的时候再计算dp,此时用到的值其实是i=1时候的值。但注意第二层的循环我们需要从大到小计算。dp[j] |= dp[j - nums[i]];因为j - nums[i] <= j,从小到大的话,要计算dp[j](i = 2时候的值),dp[j - nums[i]]已经是i = 2时候的值了,应该要是i=1。而从大到小则不会。
class Solution {
public boolean canPartition(int[] nums) {
int cnt = 0;
int maxNum = 0;
for(int num : nums){
cnt += num;
maxNum = Math.max(num, maxNum);
}
if(cnt % 2 == 1 || cnt / 2 < maxNum) return false;
cnt /= 2;
boolean[] dp = new boolean[cnt+1];
dp[0] = true;
for(int i = 1; i < nums.length; ++i){
for(int j = cnt; j >= nums[i]; --j){ // j < nums[i] 时,dp[j]就是前一时刻的值
dp[j] |= dp[j - nums[i]];
}
}
return dp[cnt];
}
}
518. 零钱兑换 II(类完全背包问题)
**相对爬楼梯,这个问题是无序的。相对01背包,这个是可重复选择的。**此题不同于爬楼梯问题,爬楼梯是区分顺序的(1-2与2-1不同,是两种组合)。此题不区分顺序(1-2与2-1相同,是一种组合)。这种不区分顺序的题,我们需要指定它的顺序,以保证不重复,如1必须在2的前面(不一定升序,只要有顺序接即可)。所以类似此题的情况,我们把数组coin放在外层循环即可。
二维:
int change(int amount, int[] coins) {
int n = coins.length;
int[][] dp = amount int[n + 1][amount + 1];
// base case
for (int i = 0; i <= n; i++)
dp[i][0] = 1;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= amount; j++)
if (j - coins[i-1] >= 0)
dp[i][j] = dp[i - 1][j]
+ dp[i][j - coins[i-1]]; //因为可重复,所以此处为i,不是i-1
else
dp[i][j] = dp[i - 1][j];
}
return dp[n][amount];
}
而且,我们通过观察可以发现,dp 数组的转移只和 dp[i][…] 和 dp[i-1][…] 有关,所以可以压缩状态,进一步降低算法的空间复杂度。这一次里面的那个循环就得从小到大了。dp[i - 1][j]就是上一次的值,从小到大和从大到小都无所谓。dp[i][j - coins[i-1]]的i是当前值,j - coins[i-1]必须得先计算,所以得从小到大。
class Solution {
public int change(int amount, int[] coins) {
int[] dp = new int[amount + 1];
dp[0] = 1;
for (int coin : coins) {
for (int i = coin; i <= amount; i++) {
dp[i] += dp[i - coin];
}
}
return dp[amount];
}
}
3. 序列类类动态规划
3.1 概念
前面我们分析了矩阵类动态规划,说到这类动态规划通常在一个矩阵中进行,我们只需要考虑当前位置的信息即可,分析并定义状态的时候,也只需要分析当前位置和其相邻位置的关系,通常这样做就可以达到拆解问题的目的。
这次再来看一类动态规划问题,序列类动态规划问题,这类动态规划问题较为普遍,分析难度相比之前也略有提升,通常问题的输入参数会涉及数组或是字符串。
在开始之前,先解释一下子数组(子串)和子序列的区别,你可以看看下面这个例子:
输入数组:[1,2,3,4,5,6,7,8,9]
子数组:[2,3,4], [5,6,7], [6,7,8,9], …
子序列:[1,5,9], [2,3,6], [1,8,9], [7,8,9], …
可以看到的是,子数组必须是数组中的一个连续的区间,而子序列并没有这样一个要求。
相比矩阵类动态规划,序列类动态规划最大的不同在于,对于第 i 个位置的状态分析,它不仅仅需要考虑当前位置的状态,还需要考虑前面 i – 1 个位置的状态,这样的分析思路其实可以从子序列的性质中得出。
3.2 算法步骤与流程
对于这类问题的问题拆解,有时并不是那么好发现问题与子问题之间的联系,但是通常来说思考的方向其实在于寻找当前状态和之前所有状态的关系,我们通过几个非常经典的动态规划问题来一起看看。
3.3 相关关联算法
3.4 例题
class Solution {
public int lengthOfLIS(int[] nums) {
int n = nums.length;
int[] a = new int[n];
int max = 1;
for (int i = 0; i < n; i++){
a[i] = 1;
for (int j = 0; j < i; j++){
if (nums[i] > nums[j]) a[i] = Math.max(a[j] + 1, a[i]);
}
if(a[i] > max) max = a[i];
}
return max;
}
}
198. 打家劫舍
有时候不需要在前面单独判断0和1再return。写一个初始值后,for循环可以处理0和1的情况。
class Solution {
public int rob(int[] nums) {
int max = 0;
for(int i = 0; i < nums.length; i++){
if(i == 2){
nums[2] = nums[0] + nums[2];
}
else if(i > 2){
nums[i] = Math.max(nums[i-2], nums[i-3]) + nums[i];
}
max = Math.max(nums[i], max);
}
return max;
}
}
213. 打家劫舍 II
前面那道题目的 follow up,问的是如果这些房子的排列方式是一个圆圈,其余要求不变,问该如何处理。
房子排列方式是一个圆圈意味着之前的最后一个房子和第一个房子之间产生了联系,这里有一个小技巧就是我们线性考虑 [0, n – 1) 和 [1, n ),然后求二者的最大值。
其实这么做的目的很明显,把第一个房子和最后一个房子分开来考虑。因为最大值的方案里,要么第一个房子不偷,要么最后一个房子不偷。完成此题我们可以直接使用上面的实现代码计算两次。但是因为上面那个方法我们是使用原有提供的nums数组存储状态,所以第一次计算完后,nums数组已变样,影响第二次计算。因此我们需要拷贝一个nums2数组,为第二次计算做准备。
这里有一个边界条件就是,当只有一个房子的时候,我们直接输出结果即可。
class Solution {
public int rob(int[] nums) {
int n = nums.length;
if(n == 1) return nums[0];
int[] nums2 = new int[nums.length];
for(int i = 0; i < nums.length; i++){
nums2[i] = nums[i];
}
int max = Math.max(rob2(nums, 0, n-1), rob2(nums2, 1, n));
return max;
}
public int rob2(int[] nums,int begin, int end) {
int max = 0;
for(int i = begin; i < end; i++){
if(i == begin + 2){
nums[begin + 2] = nums[begin] + nums[begin + 2];
}
else if(i > begin + 2){
nums[i] = Math.max(nums[i-2], nums[i-3]) + nums[i];
}
max = Math.max(nums[i], max);
}
return max;
}
}
假如有一排房子,共 n 个,每个房子可以被粉刷成 k 种颜色中的一种,你需要粉刷所有的房子并且使其相邻的两个房子颜色不能相同。
当然,因为市场上不同颜色油漆的价格不同,所以房子粉刷成不同颜色的花费成本也是不同的。每个房子粉刷成不同颜色的花费是以一个 n x k 的矩阵来表示的。
例如,costs[0][0] 表示第 0 号房子粉刷成 0 号颜色的成本花费;costs[1][2] 表示第 1 号房子粉刷成 2 号颜色的成本花费,以此类推。请你计算出粉刷完所有房子最少的花费成本。
public int minCostII(int[][] costs) {
int house = costs.length;
int color = costs[0].length;
int min;
int rsMin = Integer.MAX_VALUE;
for (int i = 0; i < house; ++i) {
for (int j = 0; j < color; ++j) {
if(i!=0){
min = Integer.MAX_VALUE;
for (int k = 0; k < color; ++k) {
if (k != j) { // 比较前后两个数组中非同一索引的元素
min = Math.min(cost[i][j] + cost[i-1][k], min);
}
}
cost[i][j] = min;
}
if(i == house -1 ){ // 找最后一个数组中的最大值
rsMin = Math.min(cost[i][j], rsMin);
}
}
}
return rsMin;
}