动态规划的一些思考和总结

学习《labuladong的算法秘籍》中的动态规划部分产生的一些思考和总结

一、核心原理

刷过一些题后,感觉动态规划最核心的就是 dp 数组 + 状态转移方程

【dp数组】是为了避免重复计算而带来的高复杂度

【状态转移方程】是为了将问题进行分解成更加简单的子结构

动态规划的核心思想就是:

1、自底向上(迭代):从最简子结构开始,不断向上计算,在计算过程中将对应子结构的值存在 dp数组 中,使得更复杂结构的问题在分解后可以直接找到答案。

2、自顶向下(递归) :从当前问题规模出发,通过递归不断分解问题直至 base case,计算出base case后,再从最深的递归函数开始不断返回,向上计算,最后求解出问题的答案。

要写出动态规划的解法,最重要的就是写出 状态转移方程。有了状态转移方程,就知道需要保存什么值,就可以推出dp数组的定义。

首先要明确 状态。这个状态是会影响问题规模的量。比如在背包问题中,很明显问题的规模主要和 物品的数量 以及 背包的容量 相关。因为如果这两个量的值很小,那么问题就会很简单,反之就会很复杂。

明确状态之后,就要寻找如何转移。转移的目的是降低问题的规模(通过改变状态)。那么在背包问题中,我们要求解的是 在当前给定数量(N)的物品 和 给定容量(W)的背包的前提下的某个问题,我们用 f(N, W)来表示。

我们想要找出来的是 f(N,W) 能够用某些更小规模的问题来表示(可以理解为数列中的递推公式),例如 f(N,W) = f(N-1,W) + f(N, W-1)

最后要去找到base case的值,例如f(0,W)或者f(N,0),因为所以的状态转移都是建立在base case的基础上的。

二、经典动态规划问题

这里主要谈两类问题,主要是按照状态转移方程的类型来分。

1、单参数

300.最长递增子序列

53.最大子序和

它们的状态转移方程都只有一个参数(dp数组都是一维的)

【300.最长递增子序列】中可以将 元素个数作为状态 即可写出状态转移方程:f(i) = max(f(i), f(j)+1) j = [i+1, ... n-1](具体一点,i表示前i个元素或者后i个元素,这里的状态转移方程用的后i个元素)

那么核心部分的代码也就很好写了

 int[] dp = new int[n];
 Arrays.fill(1);
 for(int i = n-2; i >= 0; i--){
     for(int j = i+1; j < n; j++){
         if(nums[i] > nums[j]) continue;
         dp[i] = Math.max(dp[i], dp);
     }
 }

2、双参数

1143.最长公共子序列

72.编辑距离

10.正则表达式匹配

这一类的问题涉及到两个状态,但本质上还是第一类问题,只是由于字符串/数组变成了两个,所以需要两个参数来记录状态。

以【10.正则表达式状态匹配】为例。存在 一个待匹配的字符串 str 和 一个模板串 pattern,那么状态就有两个: str 的长度和 pattern 的长度,那这个问题的解就可以表示为 f(N, M)

然后就要考虑如何转移状态化简问题的解。我们先任取两个值 ij,分别表示【只考虑str的前i个字符】以及【只考虑pattern的前j个字符】,这样我们要求解的就是f(str.length(), pattern.length())了。

由于f(i,j)之前的子问题肯定都已经计算好了,所以我们就只要考虑如何减小ij这两个参数。下面就涉及到问题的细节,在这个问题中,到状态转移到ij时,考虑pattern的第j个字符是什么?我们分为两种情况:'*'和不是'*',原因在于'*'是可以匹配多个的,是变化的。

  • 如果是'*':就要看前一个字符是否和str的第i个字符【相同】

    • 不相同:dp[i][j]转移到dp[i][j-2]

    • 相同:就要选择是否要去匹配str的第i个字符

      • 不去匹配:dp[i][j]转移到dp[i][j-2]

      • 要匹配:dp[i][j]转移到dp[i-1][j]

  • 如果不是'*':就看str的第i个字符和pattern的第j个字符是否相同

    • 相同:dp[i][j]转移到dp[i-1][j-1]

    • 不相同:dp[i][j] = false

【完整代码】如下:

public boolean isMatch(String s, String p) {
     int n = s.length();
     int m = p.length();
 ​
     boolean[][] dp = new boolean[n+1][m+1];
     dp[0][0] = true;
     for(int i = 0; i <= n; i++){ // 从0开始是为了看是否能够匹配空串;填上dp数组的最上面一行;就是确定base case
         for(int j = 1; j <= m; j++){
             if(p.charAt(j - 1) == '*'){
                 // 匹配0个
                 dp[i][j] = dp[i][j-2];
                 // 匹配1个
                 if(i == 0) continue;
                 if(s.charAt(i-1) == p.charAt(j-2) || p.charAt(j-2) == '.'){
                     dp[i][j] = dp[i][j] || dp[i-1][j];
                 }
             } else {
                 if(i == 0) continue;
                 if(s.charAt(i-1) == p.charAt(j-1) || p.charAt(j-1) == '.'){
                     dp[i][j] = dp[i-1][j-1];
                 }
             }
         }
     }
     return dp[n][m];
 }

三、背包问题

1、类型

1.1、0-1背包问题

【问题描述】

给定一个可装载重量为W的背包和N个物品,每个物品有重量和价值两个属性,其中第i个物品的重量为wt[i],价值为val[i],请问用这个背包最多可以装多少价值的物品?

1.2、完全背包问题

518.零钱兑换Ⅱ

给定不同面额的硬币和一个总金额,计算出可以凑成总金额的硬币组合数,假设每一种面额的硬币有无限个。

转换为背包问题,就是

有一个最大容量为 amount 的背包,有一系列物品 coins,物品的重量为 coins[i],每个物品的数量无限,请问有多少种方法,能够恰好把背包装满?

由于每个物品的数量是无限的,所以被称为【完全背包问题

1.3、子集背包问题

416.分割等和子集

给定一个只包含正整数的非空数组。是否可以将这个数组分割成两个子集,使得这两个子集的元素和相等。

其实类似于0-1背包问题

有一堆物品 nums,物品重量为 nums[i],物品重量之和为 sum。有一个容量为 sum / 2 的背包,是否存在一种方法能够恰好装满这个背包?

2、分析

由于都可以转换为情景相似的背包问题,所以就可以放在一起分析。

这里我们挑完全背包问题作为例子,【518.零钱兑换Ⅱ】

  1. 明确状态:N:前N个硬币;W:总金额

  2. 明确转移:需要求解的是 f(N, W),即给定前N个硬币恰好可以组成W金额的组合数。这么定义了之后,我们就可以很轻易的写出最简单问题的答案:f(0, W) = 0和f(N, 0) = 1

  3. 如何转移:对于 f(N,W),我们考虑第N个硬币P。如果我们在组合过程中不使用P,那么问题就被转化为了 f(N-1,W)。如果我们在组合过程中要使用P,那么问题就被转化为了f(N, W-wt[P])。

  4. 所以我们可以得出状态转移方程:f(N,W) = f(N-1,W)+f(N,W-wt[P])

写出状态转移方程之后,我们就可以知道如何定义 dp数组 了。

int[][] dp = new int[][];
 // dp[i][j]: 前i个硬币组合出j金额的种数

我们就可以用dp数组来表示状态转移方程

int[][] dp = new int[][]; // dp[i][j]: 前i个硬币组合出j金额的种数

[3]有一个比较难理解的点,就是为什么使用了P,问题被转化成了f(N, W-wt[P]),而不是f(N-1, W-wt[P])

可能会感觉是 N-1,毕竟第N个硬币已经用过了,但是也可能W-wt[P]金额组合中也用了第N个硬币,所以不能确定硬币数,只能确定金额数。

3、代码和改进

代码

【518.零钱兑换Ⅱ】完整代码如下:

public int change(int amount, int[] coins) {
     int n = coins.length;
     int[][] dp = new int[n+1][amount+1];
     for(int i = 0; i <= n; i++) dp[i][0] = 1;
     // 目标就是求 dp[n][amount]
     for(int i = 1; i <= n; i++){
         for(int j = 1; j <= amount; j++){
             if(j < coins[i-1]){
                 dp[i][j] = dp[i-1][j];
             } else {
                 dp[i][j] = dp[i-1][j] + dp[i][j - coins[i-1]];
             }
         }
     }
     return dp[n][amount];
 }

状态压缩

最后,还要再探讨一下状态压缩问题,即可以使用一维dp数组,使得空间复杂度从O(NW)降为O(W)

这里主要选取【518.零钱兑换Ⅱ】和【416.分割等和子集】两个问题

首先写出它们的状态转移方程

 // 518.零钱兑换Ⅱ
 dp[i][j] = dp[i-1][j] + dp[i][j - coins[i-1]];
 // 416.分割等和子集
 dp[i][j] = dp[i-1][j] + dp[i-1][j - nums[i-1]];

可以发现,dp[i][j]只和dp[i][...]以及dp[i-1][...]有关系。

改成一维数组后

 int[] dp = new int[amount];

在第i轮迭代开始之前,dp数组中的值就等价于之前的 dp[i-1][...]

所以只要控制遍历顺序(正向或逆向),使得在遍历dp[j]时,dp[j-?]的值已经更新(dp[i][j-?])或者没有更新(dp[i-1][j-?]),即可实现状态的转移。

最后附上使用一维数组解决的代码

注意两段代码的第二层循环,就是通过正向遍历和逆向遍历实现在计算dp[j]时dp[j-?]的已更新和未更新

 // 518.零钱兑换Ⅱ
 public int change(int amount, int[] coins) {
     int n = coins.length;
     int[] dp = new int[amount+1];
     dp[0] = 1;
     // 目标就是求 dp[amount]
     for(int i = 0; i < n; i++){
         for(int j = 1; j <= amount; j++){
             if(j < coins[i]) continue;
             dp[j] = dp[j] + dp[j - coins[i]];
         }
     }
     return dp[amount];
 }
 // 416.分割等和子集
 public boolean canPartition(int[] nums) {
     // 前置判断
     int sum = 0; int n = nums.length;
     if (n == 1) return false;
     for (int i : nums) sum += i;
     if(sum % 2 == 1) return false;
     int target = sum / 2; // 背包中的目标值
 ​
     boolean[] dp = new boolean[target+1];
     dp[0] = true;
     for(int i = 0; i < n; i++){
         for(int j = target; j >= 1; j--){
             if(j < nums[i]) continue;
             dp[j] = dp[j] || dp[j - nums[i]];
         }
     }
     return dp[target];
 }

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值