算法归纳3-最值问题/穷举优化-记忆化搜索-动态规划(模板)-树形dp
1,适用范围及模板
- 记忆化搜索与动态规划等价吗?
- 自己的理解:
- 记忆化搜索其实应该总结到归纳2-递归那里,是递归的一部分,但是记忆化搜索和动态规划能够解决的问题范围接近(最值问题),就在这里进行了总结;并且动态规划效率比记忆化搜索要高,算是一种效率上的进阶。
- 记忆化搜索(递归+备忘录形式)=备忘录(递归+备忘录形式)=递归树剪枝(递归+备忘录形式)、动态规划(迭代+dp数组)的思想本质都是相同的:使用缓存避免大量重复计算,从而降低计算时间。
- 可以认为记忆化搜索是递归求最值的最佳实践(相比BFS适用性更广),动态规划是迭代求最值的最佳实践,两者都是提高穷举性能的手段;同时,递归和迭代都对应于计算机程序三种基本结构中的循环结构,记忆化搜索和动态规划正是递归和迭代在最值问题上等价的实际体现。
- BFS、记忆化搜索(递归系列),动态规划(迭代系列)都是在穷举所有的可能性,通过比较所有可能性得到最值结果(似乎除了使用数学方法进行求解,计算机也就是通过穷举来解决问题了);只是穷举的具体方法以及对缓存的利用方法不同,但是正是这种不同导致方法的适用范围和效率也不同。
- BFS适合可以转化为求图上最短距离的最值问题
- 记忆化搜索和动态规划适合所有最值问题,而且动态规划比记忆化搜索的效率更高
- 穷举的常用方式:递归、DFS、回溯,其中,回溯适合求所有可能排列组合结果的问题
- 记忆化搜索:
- 记忆化搜索思维框架:递归暴力搜索->借助缓存(到这一步时间复杂度就可以了)
- 记忆化搜索需要用到备忘录缓存中间结果,因此递归的时候:
- 先查备忘录,备忘录里有结果就返回,没有结果再继续递归;
- 递归过程中得到结果也要先存储到备忘录中,再返回。
- 动态规划:
- 动态规划解题套路框架
- 动态规划问题的一般形式就是求最值。(最长递增子序列,最小编辑距离)
- 动态规划的特点:通过缓存中间值(dp数组)大幅缩短计算时间。
- 动态规划的穷举有其特别之处,这类问题存在重叠子问题(指一个递归问题里包括的子问题尽管非常多,但不同子问题非常少,少量的子问题被重复解决非常多次),如果暴力穷举的话效率会极其低下,所以需要备忘录或者DP table来优化穷举过程,避免不必要的计算(没有重叠子问题,加了备忘录也是白加,因为所有的计算条件都是新的,在备忘录里根本不会有,这样就只是在耗内存🐕)(有重叠子问题,加备忘录才有意义)。
- 动态规划问题一定会具备最优子结构(指对于一个给定的问题,当该问题可以由其子问题的最优解获得时,则该问题具有“最优子结构”性质),才能通过子问题的最值得到原问题的最值(才能由base case推导到最终结果)。
- 虽然动态规划的核心思想就是穷举求最值,但是问题可以千变万化,穷举所有可行解其实并不是一件容易的事,只有列出正确的状态转移方程(指本阶段的状态是上一阶段状态和上一阶段决策的结果,若给定了第K阶段的状态Sk以及决策uk(Sk),则第K+1阶段的状态Sk+1也就完全确定),才能正确地穷举(状态转移方程和最优子结构相关)。
- 重叠子问题、最优子结构、状态转移方程就是动态规划三要素,其中写出状态转移方程是最困难的(状态转移时求最值的函数)。
- 动态规划思维框架:核心在于列出正确的状态转移方程,思考以下几个点:
- 1-确定 base case,对dp数组进行初始化
- 2-确定「状态」,也就是原问题和子问题中会变化的变量(该变量对应base case的含义,dp数组的索引和值分别代表什么)
- 3-确定「选择」,也就是导致「状态」产生变化的行为
- 4-明确 dp 函数/数组的定义,想明白数组的索引代表什么(状态),而依据索引得到的数组返回值又是什么(返回值对应base case的含义)
- 5-确定状态转移方程,依据当前状态、选择、dp数组、状态转移方程,得到下一状态及需要存储到dp数组中的值
- 其中1,2,4本质上是相同的,关键就是思考:
- 状态(dp数组的索引和值分别代表什么)
- 选择(从一个状态到另一个状态有哪些选择)
- 状态转移方程(一次求最值过程中需要考虑的情况与选择数相当)
- 动态规划不如叫状态转移🐕
- 有最优子结构、列出正确的状态转移方程就可以用动态规划,之后如果有重叠子问题就可以用备忘录使得时间复杂度进一步降低。
- 思维步骤:
- dpArray:状态(根据状态开几维数组,要不要每种选择开一个维度)-存的值-base
- 选择与可能性:状态有哪些选择,会遇到哪些可能性,分别如何进行状态转移(状态转移的结果应该可比较)
- 遍历方向
- 最终结果
- 动态规划模板:
//自底向上的迭代模式 # 第1步:基于base case进行dp数组初始化 dp[0][0][...] = base # 第2步:进行状态转移 for 状态1 in 状态1的所有取值: for 状态2 in 状态2的所有取值: for ... for 选择1 in 选择1的所有取值: for ... dp[状态1][状态2][...] = 求最值(选择1,选择2...) # 第2步-V2:进行状态转移 for 状态1 in 状态1的所有取值{ for 状态2 in 状态2的所有取值{ if(情况1){ for 选择 in 选择的所有取值1(一般展开): dp[状态] = 求最值函数1(做选择后的结果列表) //求最值函数是根据情况进行变化的,不同情况中可以做的选择也会不同 }else if(情况2){ for 选择 in 选择的所有取值2(一般展开): dp[状态] = 求最值函数2(做选择后的结果列表) } ... else{ for 选择 in 选择的所有取值n(一般展开): dp[状态] = 求最值函数n(做选择后的结果列表) } } }
//自顶向下的递归模式 private *** dp(***){ //1,结束条件 if(){ return ; } //2,查表 if(cache.keySet().contains()){ return cache.get(); } //3,做选择以及状态转移得到结果 for(){ res = ... } //4,存表 cache.put(, res); //5,返回结果 return res; }
- 记忆化搜索和动态规划的区别:
- 形式不同:记忆化搜索-递归+备忘录,动态规划-迭代+dp数组
- 方向不同:记忆化搜索自顶向下(由target出发),动态规划自底向上(由base case出发)
- 正是由于方向的不同,记忆化搜索过程中会遇到计算时需要,但备忘录中暂时没有的值,需要递归求解并对备忘录进行填充;而动态规划在进行过程中运算所需要的值在有些情况下一定在dp数组中且不会发生变化,所以不需要递归而采用迭代形式,并且消耗时间更短(没有栈帧的开辟和回收)
- 但是有时dp数组相同索引的值会由于数组总长度等条件的不同而发生变化,此时可以借助备忘录,如果使用备忘录需要组合确定备忘录的索引,例子:思路1使用备忘录及创建索引的原因;特点:有多个状态变量时,可以借助备忘录,此时需要创建唯一索引,并使用递归形式(用上递归也就没有状态数组的事了-递归本身就是一个数组,只用状态变量)。
- 此时和回溯形式很接近,都有对选择的遍历,只是状态转移为求最值,回溯为列举所有情况。
- 有多个状态变量时,也可以使用多维状态数组,状态数组的维度和使用备忘录时创建唯一索引所使用的变量个数相同,并且各维度索引含义就是各变量,此时就可以使用嵌套迭代的形式,但是有时又因为多重索引中有些索引的选择范围难以确定、base-case难以写全、索引纬度过高,导致多重索引不占优势。
- 动态规划有两种代码形式:自顶向下的递归模式;自底向上的迭代模式;迭代模式有明确的dp数组
- 例题:322. 零钱兑换
//方法1-BFS-迭代形式-图中找最短路径,顶点为amount,边为coins
import java.util.LinkedList;
import java.util.HashSet;
class Solution {
private LinkedList<Integer> cache = new LinkedList<>(); //记录amount变化中的节点
private HashSet<Integer> visited = new HashSet<>(); //缓存哪些遇见过,备忘录,剪枝的关键
private int[] coins; //节点之间的边
public int coinChange(int[] coins, int amount) {
this.coins = coins;
cache.add(amount);
visited.add(amount);
return bfs();
}
private int bfs(){
//图的层序遍历(与树的层序遍历基本相同,只是多了用visited防止走回头路)
int res = 0;
while(cache.size()!=0){
int presentSize = cache.size();
for(int i=0; i<presentSize; i++){
//遍历某一层的所有节点
int presentAmount = cache.removeFirst();
if(presentAmount==0){
return res;
}
for(int coin: coins){
//遍历层中某一节点所有边
if(presentAmount>=coin){
if(!visited.contains(presentAmount-coin)){ /*剪枝核心步骤*/
cache.add(presentAmount-coin);
visited.add(presentAmount-coin);
}
}
}
}
res++;
}
return -1;
}
}
//方法2-记忆化搜索-递归形式+备忘录
import java.util.HashMap;
class Solution {
private int[] coins;
private HashMap<Integer, Integer> cache = new HashMap<>();
public int coinChange(int[] coins, int amount) {
this.coins = coins;
/*核心步骤1:备忘录的初始化*/
cache.put(0, 0);
for(int coin: coins){
cache.put(coin, 1);
}
return memorySearch(amount);
}
private int memorySearch(int amount){
if(cache.keySet().contains(amount)){
/*核心步骤2:有备忘录先查备忘录*/
return cache.get(amount);
}
int res = Integer.MAX_VALUE;
boolean flag = false;
for(int coin: coins){
if(amount-coin>=0){
int middleRes = memorySearch(amount-coin);
if(middleRes!=-1){
res = Math.min(res, middleRes+1);
flag = true;
}
}
}
/*核心步骤3:返回前先保存备忘录*/
if(flag){
cache.put(amount, res);
}else{
res = -1;
cache.put(amount, res);
}
return res;
}
}
/*
========================================================
去除备忘录的暴力搜索形式
========================================================
*/
import java.util.HashMap;
class Solution {
private int[] coins;
public int coinChange(int[] coins, int amount) {
this.coins = coins;
return violentSearch(amount);
}
private int violentSearch(int amount){
int res = Integer.MAX_VALUE;
boolean flag = false;
for(int coin: coins){
if(amount == coin){
return 1;
}else if(amount-coin>0){
int middleRes = violentSearch(amount-coin);
if(middleRes!=-1){
res = Math.min(res, middleRes+1);
flag = true;
}
}
}
if(flag){
return res;
}else{
return -1;
}
}
}
//方法3-动态规划
class Solution {
public int coinChange(int[] coins, int amount) {
int[] dpArray = new int[amount+1]; //索引表示总金额,数组的值表示最少的硬币个数
//基于base case进行dp数组初始化
dpArray[0] = 0;
for(int coin:coins){
if(coin==amount){
return 1;
}else if(coin<amount){
dpArray[coin]=1;
}
}
//状态转移
for(int i=1; i<=amount; i++){
//状态遍历,此时i即为当前状态
if(dpArray[i]==0){
int res = Integer.MAX_VALUE;
boolean flag = false;
for(int coin: coins){
//选择遍历
if(coin<i && dpArray[i-coin]!=-1){
res = Math.min(res, dpArray[i-coin]+1);
flag = true;
}
}
if(flag){
dpArray[i]=res;
}else{
dpArray[i]=-1;
}
}
}
return dpArray[amount];
}
}
2-应用
2-1-树状dp-换根动态规划
https://leetcode-cn.com/problems/minimum-height-trees/solution/c-huan-gen-by-vclip-sa84/
https://leetcode-cn.com/problems/minimum-height-trees/solution/by-ac_oier-7xio/
2-2-二分查找+动态规划
https://leetcode-cn.com/problems/russian-doll-envelopes/solution/by-wa-pian-d-h8kb/
https://labuladong.gitee.io/algo/3/24/69/
2-3-背包问题
2-3-1-常见的背包问题
- 1,组合问题:
- 0-1背包
- 完全背包
- 考虑组合顺序
- 2,True、False问题:
- 0-1背包
- 139. 单词拆分
- 3,最大最小问题:
2-3-2-解决对应问题的三组核心公式(动态转移方程):
- 组合问题公式
dp[i] = dp[i] + dp[i-num]
示例代码
//初始化1-nums中所有的数字都为大于零的数
int[][] dpArray = new int[nums.length+1][target]; //dpArray[i][j]表示使用前i个物品,装满j的空间有多少装法
for(int i=0; i<=nums.length; i++){
dpArray[i][0] = 1; //装满0的空间,不装就是了,什么都不装就是一种装法
}
//初始化2-nums中所有的数字为大于等于零的数
dpArray[0][0] = 1; //j=0的数值还需要遍历已得到
//根据nums物品是否可重用又分为0-1背包和完全背包
//如果是0-1背包,即数组中的元素不可重复使用(比如子集划分,问一组数字是分为和相同的两部分的分法有多少种)
//如果是完全背包,即数组中的元素可重复使用
//0-1背包
for(int i=1; i<=nums.length; i++){
for(int j=1; j<=target; j++){
if(j>=nums[i-1]){
//此时可装可不装,
dpArray[i][j] = dpArray[i-1][j] + dpArray[i-1][j-nums[i-1]]; //注意这里是dpArray[i-1][j-nums[i-1]],也就是用过了不能再用
}else{
//只能不装
dpArray[i][j] = dpArray[i-1][j];
}
}
}
//完全背包
for(int i=1; i<=nums.length; i++){
for(int j=1; j<=target; j++){
if(j>=nums[i-1]){
//此时可装可不装,
dpArray[i][j] = dpArray[i-1][j] + dpArray[i][j-nums[i-1]]; //注意这里是dpArray[i][j-nums[i-1]],也就是用过了可以再用
}else{
//只能不装
dpArray[i][j] = dpArray[i-1][j];
}
}
}
//如果需要考虑组合的顺序,二维的没有想明白,一维的感觉倒是很清晰
int[] dpArray = new int[target+1]; //dpArray[i]表示空间为i-1时使用所有的nums有多少种组合方法
dpArray[0] = 1;
for(int i=1; i<=target; i++){
for(int num: nums){
if(i>=num){
dpArray[i]+=dpArray[i-num];
}
}
}
- True、False问题公式
dp[i] = dp[i] or dp[i-num]
- 最大最小问题公式
dp[i] = min(dp[i], dp[i-num]+1)或者dp[i] = max(dp[i], dp[i-num]+1)
2-3-3-背包问题的判定
背包问题具备的特征:给定一个target,target可以是数字也可以是字符串,再给定一个数组nums,nums中装的可能是数字,也可能是字符串,问:能否使用nums中的元素做各种排列组合得到target。
2-3-4- 参考链接
2-4-路径问题
- 如果是求路径和,正着遍历
64. 最小路径和 - 如果是求出发点数值满足的条件,倒着遍历
174. 地下城游戏
N1-基本DP模型总结
行动是治愈恐惧的良药,而犹豫拖延将不断滋养恐惧。