!dynamic-programming

什么是动态规划

动态规划,英文:Dynamic Programming,简称 DP,如果某一问题有很多重叠子问题,使用动态规划是最有效的。

所以动态规划中每一个状态一定是由上一个状态推导出来的,这一点就区分于贪心,贪心没有状态推导,而是从局部直接选最优的,

关于贪心算法,你该了解这些! (opens new window) 中我举了一个背包问题的例子。

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

动态规划中 dp[j] 是由 dp[j-weight[i]] 推导出来的,然后取 max(dp[j], dp[j - weight[i]] + value[i])

但如果是贪心呢,每次拿物品选一个最大的或者最小的就完事了,和上一个状态没有关系。

所以贪心解决不了动态规划的问题。

其实大家也不用死扣动规和贪心的理论区别,后面做做题目自然就知道了

而且很多讲解动态规划的文章都会讲最优子结构啊和重叠子问题啊这些,这些东西都是教科书的上定义,晦涩难懂而且不实用。

大家知道动规是由前一个状态推导出来的,而贪心是局部直接选最优的,对于刷题来说就够用了。

严格定义

无后效性

一旦 f(n) 确定,“我们如何凑出 f(n)”就再也用不着了。

要求出 f(15),只需要知道 f(14),f(10),f(4) 的值,而 f(14),f(10),f(4) 是如何算出来的,对之后的问题没有影响。

“未来与过去无关”,这就是无后效性。

严格定义:如果给定某一阶段的状态,则在这一阶段以后过程的发展不受这阶段以前各段状态的影响

最优子结构

回顾我们对 f(n) 的定义:我们记“凑出 n 所需的最少钞票数量”为 f(n).

f(n) 的定义就已经蕴含了“最优”。利用 w=14,10,4 的最优解,我们即可算出 w=15 的最优解。

大问题的最优解可以由小问题的最优解推出,这个性质叫做“最优子结构性质”。

如何判断一个问题能否使用 DP 解决呢

能将大问题拆成几个小问题,且满足无后效性、最优子结构性质。

DP 为什么会快?

无论是 DP 还是暴力,我们的算法都是在可能解空间内,寻找最优解

来看钞票问题。暴力做法是枚举所有的可能解,这是最大的可能解空间。 DP 是枚举有希望成为答案的解。这个空间比暴力的小得多。

也就是说:DP 自带剪枝。

DP 舍弃了一大堆不可能成为最优解的答案。譬如:

15 = 5+5+1+1+1+1+1 从来没有考虑过,因为这不可能成为最优解。

从而我们可以得到 DP 的核心思想:尽量缩小可能解空间。

在暴力算法中,可能解空间往往是指数级的大小;如果我们采用 DP,那么有可能把解空间的大小降到 多项式级

一般来说,解空间越小,寻找解就越快。这样就完成了优化。

解题步骤

思路一

状态转移公式(递推公式)是很重要,但动规不仅仅只有递推公式。

对于动态规划问题,我将拆解为如下五步曲,这五步都搞清楚了,才能说把动态规划真的掌握了

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

一些同学可能想为什么要先确定递推公式,然后在考虑初始化呢?

因为一些情况是递推公式决定了 dp 数组要如何初始化

思路二

动态规划的的四个解题步骤是:

要分析题目, 找出子问题, 但是很多时候分析题目能读懂就不错了, 感觉很难找出子问题与子问题之间的联系

这里定义子问题其实就是定义了 dp[i]

思路三

设计状态是 DP 的基础。接下来的设计转移,有两种方式:

总而言之,“我从哪里来”和“我要到哪里去”只需要考虑清楚其中一个,就能设计出状态转移方程,从而写代码求解问题。前者又称 pull 型的转移,后者又称 push 型的转移。

思路四

从记忆化递归的去优化? 递归是自顶向下的, case 越来越少. dp 是自底向上的, case 基于前面的 子问题. 两种相反的思路, 要如何从递归的暴力法里面悟出 dp 的状态转移呢?

关键是递归暴力会有很多重复的子问题, 这些重复的子问题正是 dp 优化的地方

动态规划应该如何 Debug

相信动规的题目,很大部分同学都是这样做的。

看一下题解,感觉看懂了,然后照葫芦画瓢,如果能正好画对了,万事大吉,一旦要是没通过,就怎么改都通过不了,对 dp 数组的初始化,递推公式,遍历顺序,处于一种黑盒的理解状态。

找问题的最好方式就是把 dp 数组打印出来,看看究竟是不是按照自己思路推导的!

一些同学对于 dp 的学习是黑盒的状态,就是不清楚 dp 数组的含义,不懂为什么这么初始化,递推公式背下来了,遍历顺序靠习惯就是这么写的,然后一鼓作气写出代码,如果代码能通过万事大吉,通过不了的话就凭感觉改一改。

做动规的题目,写代码之前一定要把状态转移在 dp 数组的上具体情况模拟一遍,心中有数,确定最后推出的是想要的结果

然后再写代码,如果代码没通过就打印 dp 数组,看看是不是和自己预先推导的哪里不一样。

如果打印出来和自己预先模拟推导是一样的,那么就是自己的递归公式、初始化或者遍历顺序有问题了。

如果和自己预先模拟推导的不一样,那么就是代码实现细节有问题。

这也是我为什么在动规五步曲里强调推导 dp 数组的重要性。

如果这灵魂三问自己都做到了,基本上这道题目也就解决了,或者更清晰的知道自己究竟是哪一点不明白,是状态转移不明白,还是实现代码不知道该怎么写,还是不理解遍历 dp 数组的顺序。

动态规划的优化

如果只需要依赖前面的固定个数的状态, 就可以做空间复杂度上的优化, 从 N 优化到常数级. 参考斐波那契数列

509. 斐波那契数

  1. 定义 “状态”
  2. 找到 “状态转移方程”
  3. 初始化,内循环的才是行的长度,外循环是列的高度

如果列比较难初始化,可以在内部增加判断,如果 j<weights[i]

  1. i 是竖着的那个,j 是横着的那个

    let dp = new Array(len1) // 列
    dp[-1] = new Array(len2).fill(0) // 负1行
    dp[-1][-1] = 0
    for (let i = 0; i < len1; i++) { // 行
      dp[i] = new Array(len2)
      dp[i][-1] = 0
    }
    
    // 二维费用问题的初始化
    // dp代表的是可以获得的经验
    const dp = new Array(costs.length)
    // 三维数组的初始化,只需要二维
    dp[-1] = []
    for (let i = 0; i < costs.length; i++) {
      dp[i] = []
      dp[i][-1] = new Array(kill).fill(0)
      for (let k = 0; k <= kill; k++) {
        dp[i][k] = new Array(patience+1).fill(0)
        dp[-1][k] = new Array(patience+1).fill(0)
      }
    }
    
    // 从负1开始即可
    for (let i = -1; i < costs.length; i++) {
      dp[i] = []
      for (let k = -1; k <= kill; k++) {
        dp[i][k] = new Array(patience+1).fill(0)
      }
    }
    
    let lineA =  new Array(len2).fill(0) // 负1行
    lineA[-1] = 0
    let lineB = new Array(len2) // 第一行
    lineB[-1] = 0
    let referLine = lineA
    let currentLine = lineB
    

递归与 DP

参考 397. 整数替换 这种形式的递归处理, 都可以理解为动态规划

记忆化搜索 + 递归

动态规划

动态规划 + 滚动数组优化空间复杂度

矩阵快速幂

通项公式

背包问题, 也可以使用记忆化搜索 + 递归解决

因为递归 + 回溯, 其实是暴力法, 所以理论上来说是可以解决所有的问题的, 所有的动态规划问题都可以通过 回溯/递归 + 记忆化搜索解决, 问题是时间复杂度, 还是有可能不通过

要做好记忆化搜索, 很多时候就是递归的思路了, 因为很多时候只能返回子问题的答案. 父问题的答案是基于子问题去实现的, 其实就是动态规划的状态转移方程

为了更好的记忆化, 也是从子问题开始递归效率更好

115. 不同的子序列

File difficulty etags date-created
343. 整数拆分 medium 2023-07-10-Mon, 5:41:34 pm
509. 斐波那契数 easy 2023-07-10-Mon, 9:40:29 am
63. 不同路径 II medium 2023-07-10-Mon, 4:46:49 pm
62. 不同路径 medium 2023-07-10-Mon, 4:27:58 pm
70. 爬楼梯 easy 2023-07-10-Mon, 12:40:02 pm
746. 使用最小花费爬楼梯 easy 2023-07-10-Mon, 2:36:24 pm
95. 不同的二叉搜索树 II medium 2023-06-11-Sun, 3:37:01 pm

背包问题

knapsack-problem

容器问题

买橙子 - 无法找零

题目

分析

  function buyOrange(n){
    // 只有两种袋子
    const bags = [6,8]
    const dp = new Array(bags.length)
    dp[-1] = new Array(n+1).fill(0)
    for (let i = 0; i < bags.length; i++) {
      dp[i] = new Array(n+1).fill(0)
    }
  
  
    for (let i = 0; i < bags.length; i++) {
      for (let j = 0; j <= n; j++) { // 可以取等于
        if(j<bags[i]){
          dp[i][j] = 0
        }else{
          const ref = dp[i-1][j],
                pri = dp[i][j-bags[i]]+1
          dp[i][j] = Math.max(ref,pri)
          // 无法找零,无法装袋的情况?
          // 如果不是 6 和 8 的组合数,就没有办法装袋
        }
      }
    }
    // 定义状态为橘子数?那怎么返回袋数?
    console.log(dp)
    return dp[bags.length-1][n]
  }
  
  let n = 20
  console.log(buyOrange(n))

贪心算法

递推型

斐波那契是不是也算是递推型的问题呢?

所有的 dp 都是由子问题求解而来的, 是不是都可以算作递推呢?

File difficulty etags date-created
1289. 下降路径最小和 II hard 2023-08-10-Thu, 9:01:51 am
1388. 3n 块披萨 hard 2023-08-18-Fri, 7:21:19 pm
198. 打家劫舍 medium 2023-07-14-Fri, 9:51:08 am
213. 打家劫舍 II medium 2023-07-14-Fri, 10:56:11 am
264. 丑数 II medium 2023-07-25-Tue, 9:35:56 am
313. 超级丑数 medium 2023-07-25-Tue, 9:38:47 am
337. 打家劫舍 III medium 2023-07-14-Fri, 11:53:56 am

多状态

本质也是递推型

剑指 Offer 63. 股票的最大利润

File difficulty etags date-created
121. 买卖股票的最佳时机 easy 2023-07-14-Fri, 3:55:55 pm
123. 买卖股票的最佳时机 III hard 2023-07-15-Sat, 11:28:51 am
122. 买卖股票的最佳时机 II medium 2023-07-06-Thu, 7:50:10 pm
188. 买卖股票的最佳时机 IV hard 2023-07-15-Sat, 7:52:03 pm
309. 最佳买卖股票时机含冷冻期 medium 2023-07-15-Sat, 7:25:06 pm

最佳收益

问题

分析

再分析

方案总数

问题

分析

实现

问题

分析

实现

问题

分析

实现

问题

分析

实现

问题

分析

实现

虾皮

方案总数总结

序列型

为什么不用考虑两个都不选的场景? 编辑记录的一个 b 站视频有讲解

File difficulty etags date-created
1035. 不相交的线 medium 2023-07-16-Sun, 7:54:32 pm
115. 不同的子序列 hard 2023-07-17-Mon, 3:23:50 pm
122. 买卖股票的最佳时机 II medium 2023-07-06-Thu, 7:50:10 pm
1749. 任意子数组和的绝对值的最大值 medium 2023-08-08-Tue, 10:25:55 am
300. 最长递增子序列 medium 2023-07-16-Sun, 10:13:53 am
376. 摆动序列 medium 2023-07-06-Thu, 10:37:33 am
392. 判断子序列 easy 2023-05-15-Mon, 8:38:50 pm
467. 环绕字符串中唯一的子字符串 medium 2023-05-15-Mon, 1:29:08 am
5. 最长回文子串 medium 2023-05-19-Fri, 5:01:31 pm
516. 最长回文子序列 medium 2023-07-18-Tue, 7:23:14 pm
524. 通过删除字母匹配到字典里最长单词 medium 2023-05-16-Tue, 11:35:07 am
53. 最大子数组和 medium 2023-07-06-Thu, 7:37:09 pm
583. 两个字符串的删除操作 medium 2023-07-17-Mon, 4:01:05 pm
647. 回文子串 medium 2023-07-18-Tue, 4:17:13 pm
718. 最长重复子数组 medium 2023-07-16-Sun, 1:05:51 pm

最长公共子序列 LCS

问题

回溯

分析

实现

复杂度分析

错误的尝试

记忆化回溯

分析

实现

转化为低阶动态规划

复杂度分析

高阶动态规划

分析

实现

复杂度分析

贪心算法 + 二分查找

对比

子串型\子段型

最长公共子串

分析

实现

滚动数组

一维数组

后缀树的方法

问题

说明:

回溯法

复杂度分析

记忆化回溯

使用宽度优先搜索

动态规划

对比

问题

分析

j = 0

j = 1

j = 2

输出结果

实现

题目

降维思想

最小值变种

矩阵型

矩阵乘积

1564546290940

1564546391268

1564546627231

1564546638324

1564546767373

区间型

状态压缩型

https://www.nowcoder.com/discuss/173333?type=all&order=time&pos=&page=1

https://blog.csdn.net/birdstorm_s/article/details/20546879

https://blog.csdn.net/stone_fall/article/details/88669064

https://blog.csdn.net/haut_ykc/article/details/79606213

https://blog.csdn.net/dengpan1889/article/details/101646361

https://blog.csdn.net/V5ZSQ/article/details/83244233

https://www.cnblogs.com/ACRykl/p/8597593.html

树型

leetCode96.不同的二叉树

问题

分析

实现

  /**
   * @param {number} n
   * @return {number}
   */
  var numTrees = function(n) {
    let G = new Array(n+1).fill(0)
    G[0]=1
    G[1]=1
    for (let i = 2; i <= n; i++) {
      for (let j = 1; j <=i; j++) {
        G[i] += G[j-1] * G[i-j]
      }
    }
    return G[n]
  };

leetCode95. 不同的二叉树 II

问题

分析

再分类

关于动规,还有 树形 DP(打家劫舍系列里有一道),数位 DP,区间 DP ,概率型 DP,博弈型 DP,状态压缩 dp 等等等,这些我就不去做讲解了,面试中出现的概率非常低。

leetCode264.丑数 II

leetCode313.超级丑数

问题

分析

方案总结