🐵回溯、动态规划
00 min
Oct 21, 2023
Oct 25, 2023
type
status
date
slug
summary
tags
category
icon
password
  • 组合问题
回溯 == 递归
回溯法就是暴力搜索,并不是什么高效的算法,最多再剪枝一下。
组合无序,排列有序
回溯法解决的是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度就构成了树的深度。
递归可以用于解决多层嵌套循环的问题
回溯放在递归函数里,这不是右值吗?
一个集合来求组合的话,就需要startIndex
如果是多个集合取组合,各个集合之间相互不影响,那么就不用startInde(相当于一个集合里可以重复选择)
有重复元素,但同一元素不能重复选取(最基本的),最后的集合不能重复
元素在同一个组合内是可以重复的,怎么重复都没事,但两个组合不能相同。
所以我们要去重的是同一树层上的“使用过”,同一树枝上的都是一个组合里的元素,不用去重
notion image
用来记录同一树枝上的元素是否使用过。(避免同一元素重复选取)
来看看怎么使用used数组
notion image
在candidates[i] == candidates[i - 1]相同的情况下:
  • used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
  • used[i - 1] == false,说明同一树层candidates[i - 1]使用过(因为前面有一样的元素,但你这一分支没用过,说明别人在用了,你用不了)
当前取的 candidates[i] 是从 candidates[i - 1] 回溯而来的
notion image
used[i-1]==false,表明恢复现场过了
used[i - 1] == true,说明是进入下一层递归
切割问题 == 组合问题
去重需要排序
如果把 子集问题、组合问题、分割问题都抽象为一棵树的话,那么组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点!
求排列问题的时候,就要从0开始,因为集合是有序的,{1, 2} 和{2, 1}是两个集合,
般来说动态规划中,状态的转移可以理解成图论中的一条有向边(u → v),我们以 u 作为已求出来的状态,v 为需要求的状态,那么填表法便是将目光放在 v 上,去寻找已知的 u 来更新;而刷表法则是将目光放在 u 上,利用已知的 u 来去更新可抵达的状态 v
具体来说填表法会写成这样 :dp[i] = dp[i - j] + w 从1到n
而刷表法会写成这样: dp[i + j] = dp[i] + w 从n到1
填表法的好处不多说,毕竟平时用得最多的便是填表法
而刷表法的好处便是:一般对于一道题中,若是在求本状态时,寻找需要利用到的状态比较困难时,则可以考虑用刷表法
多维度状态数组超内存的情况,一般来说这种的特点是,有一维可以利用其它几维间接推出来
 

关于寻找具体方案?

对于这种题目,求最后的答案时可以像寻常般进行dp,而反过头来求出答案来源的具体方案我一般是dfs往回找,时间负责度也不高
对于求解对字典序有要求的具体方案,我会反过来dp,然后正过来dfs找具体方案
当然,也看过别的求解具体方案的方式:多开一个数组记录转移的路线,然后像是遍历图一样找路线
区间DP:
一般来说转移都是从小区间转移到大区间,固一般是先枚举小区间再枚举大区间的即 dp[l][r] <- dp[l + 1][r - 1]
环形的区间dp,一般的处理方式是拆环成链,开两倍的空间
 
那层抽象的膜千万不能捅破!!!要相信抽象
  • 线性DP
    • 单串 dp[i] 以nums[i]结尾
    • 双串 dp[i][j]
    • 背包
  • 区间DP
 
 
• dp[i][j]:表示第i轮的第j种状态 (j=1,2,...,K)
 
 
 

组合优化

组合优化是在一个有限的对象集中找出最优对象的一类问题。在很多组合优化的问题中,穷举搜索/枚举法是不可行的。组合优化的问题的特征是可行解的集是离散或者可以简化到离散的,并且目标是找到最优解。
 

NPC问题

多项式时间可解的NP问题?
背包问题、打家劫舍(相邻不能选)、股票问题、子序列问题、路径
  • 矩阵:边上一般单独处理一下
 

 
动态规划 递归 算法本质上都属于分治算法
  • 它们都是将原问题分解为子问题进行求解
  • 子问题独立的情况我们使用递归算法解决即可;但如果子问题重叠计算,我们可以使用DP优化

回溯

多重循环到回溯
单纯固定的循环嵌套,表达能力有限
如果子问题和原问题是相似的,从原问题到子问题的过程适合用递归解决;并且通过递归可以达到自动表达多重循环的效果
回溯(递归,dfs)复杂度是指数级别的,dfs遍历二叉树,遍历中可以根据题目限制做一些剪枝
复杂度 == 状态个数 * 计算单个状态的复杂度
💡
两种思路: 1.选或不选——括号生成 2.枚举选哪个——分割回文子串
  • 子集型回溯
  • 组合型回溯
  • TODO:排列型回溯
相较于组合,排列中的元素是有顺序的
01背包算一种子集型回溯
 
如果子集的长度没有限制,那么每一步都是一个答案,需要记录
  1. 站在答案的角度看,每个节点的选择都是一个答案
notion image
  1. 站在输入的角度看,就是当前元素选 / 不选,叶子就是答案
notion image
回溯函数也就是递归函数
既然回溯法并不高效为什么还要用它呢?
因为没得选,一些问题能暴力搜出来就不错了,撑死了再剪枝一下,还没有更高效的解法。
 
  • 组合问题:N个数里面按一定规则找出k个数的集合
  • 切割问题:一个字符串按一定规则有几种切割方式
  • 子集问题:一个N个数的集合里有多少符合条件的子集
  • 排列问题:N个数按一定规则全排列,有几种排列方式
  • 棋盘问题:N皇后,解数独等等
 
 
notion image
也可以写成函数对象
 
递归来做层叠嵌套(可以理解是开k层for循环),每一次的递归中嵌套一个for循环,那么递归就可以用于解决嵌套循环无法确定几层的问题了
递归和循环都是在重复执行同一份代码,但是递归的核心是有嵌套关系,本次的计算结果需要返回给上一级问题;而循环是每次迭代都针对一个循环外的变量操作
 
回溯法中递归函数参数很难一次性确定下来,一般先写逻辑,需要啥参数了,填什么参数。
 
所谓去重,其实就是使用过的元素不能重复选取
都知道组合问题可以抽象为树形结构,那么“使用过”在这个树形结构上是有两个维度的,一个维度是同一树枝上使用过,一个维度是同一树层上使用过。没有理解这两个层面上的“使用过” 是造成大家没有彻底理解去重的根本原因。
那么问题来了,我们是要同一树层上使用过,还是同一树枝上使用过呢?
回看一下题目,元素在同一个组合内是可以重复的,怎么重复都没事,但两个组合不能相同。
所以我们要去重的是同一树层上的“使用过”,同一树枝上的都是一个组合里的元素,不用去重
强调一下,树层去重的话,需要对数组排序!
notion image

动态规划 DP

💡
状态定义?状态转移方程?
找出状态与哪些局面有关
思考问题的方式:
1.选或不选——括号生成 2.枚举选哪个——分割回文子串
 

DP萌新三步

  1. 思考回溯怎么写
    1. 入参和返回值
    2. 递归到哪里(非边界,就是怎么递推)
    3. 递归边界和入口
  1. 改成记忆化搜索
  1. 1:1 翻译成递推:把dfs改成数组f,递归改
一般先从问题的第一个或最后一个开始思考,因为它们受到的约束最小
 
  1. 确定dp数组以及下标的含义(一般是用来确定子问题)
    1. dp xx代表target
  1. 确定递推公式(如何依赖子问题的)
  1. dp数组如何初始化(用于确保在一开始推导时依赖的数值正确,对于没有的状态,我们要去避免依赖,或者给一个不影响最终答案的初始值)
  1. 确定遍历顺序(依赖前面还是依赖后面)
  1. 举例推导dp数组(这一步主要用来验证)
需要注意的是,在推导dp[i]的时候,一定要时刻想着dp[i]的定义,否则容易跑偏。

回溯三问:

  1. 当前操作? 枚举 i 个房子选 / 不选
  1. 子问题? 从 i 个房子中得到的最大金额
  1. 当前问题? 分类讨论:
    1. 不选:从 i - 1 个房子中得到的最大金额
    2. 选:从 i - 2 个房子中得到的最大金额
DP从暴力解法出发,抛弃了冗余的信息,比如我们只关心最终的数量,而不关心方案的具体组成;同时能够避免计算子问题
相比于暴力,DP是枚举有希望成为答案的解(这相当于剪枝),解空间也更小
区分于贪心,贪心没有状态推导,而是从局部直接选最优的,一般比较难证明
  • 无后效性:已经求解的子问题,不会受到选择的影响
  • 最优子结构:原问题的最优解可以由子问题的最优解推导而来
递归——自顶向下
DP——自底向上
💡
这些种种优点,最核心的就是因为DP知道这个问题怎么从基础开始推导
代码处理:
  • 边界条件特殊处理、正确初始化
  • 避免下标越界
子序列的本质还是选/不选
几个方面,就几个维度
多个阶段过程转化为一系列单阶段问题,然后利用各个阶段之间的关系,逐个求解
递推(转移):未知 ← 已知

树形DP

树是天然的原问题和子问题结构,我们就考虑选或不选、选哪个,由子问题推出原问题

树的直径

遍历二叉树,计算最长链(返回给父节点使用),同时计算直径(每个节点都可能拐弯,都看一遍)
在当前节点「拐弯」的直径 = 左子树的最长链 + 右子树的最长链 + 1
return给父节点以当前节点为root的最长链 = max(左子树的最长链,右子树的最长链)+ 1
路径上的点数 == 路径上的边数 + 1

一般树:

  • 维护列表,先遍历完,然后排序列表并取最值操作
  • 直接动态维护最值,在遍历中同时更新
如果邻居包含父节点,就把父节点加入dfs的args,在逻辑内单独判断就行

建图

题目:给你一个二维整数数组 edges ,其中 edges[i] = [ai, bi] 表示节点 ai 和 bi 之间有一条 无向 边。
邻接表(也是用二维表示):
  • 列表(知道节点个数n)
  • 哈希表(没必要,一般都知道节点个数n)

最大独立集

节点不相邻
对于节点i如果去考虑儿子的儿子,情况太复杂。直接让每个子节点(i-1)维护两个状态(选/不选),就不用去考虑i-2了
notion image

树上最大支配集

 
notion image
notion image
notion image
蓝色 = min(左蓝,左黄,左红) + min(右蓝,右黄,右红) + 1
 
notion image
notion image
notion image
优化:
notion image
每个节点都return min(蓝,红),如果真的不幸,选的都是红色节点(蓝-红<0),那就选一个最大的负数
红色 = 黄色 + max(0,min(各个节点的差值))
只要有一个节点选了蓝色(大于红色),就只会加上0
C++用tuple一次返回多个值,可以用[]接收

区间DP

线性DP:从数组的前缀/后缀上转移
区间DP:把问题缩小到中间的区间上,而不仅仅是前缀/后缀
选左端点还是选右边端点
一般是dfs(0,n-1) f[0][n-1]

状态DP

买卖股票问题 先画出状态机,这类问题不是简单的单向递推
状态:前i天后现有的利润
转移:各种操作会有不同的收益,并且会改变接下去的状态
含冷冻期:
TODO:最后有恰好/至少交易k次
TODO:写的比代码随想录好

子序列

子序列就是数组的一个子集,所以我们可以用子集型回溯来解决 选/不选? 选哪个?

最长递增子序列

notion image
对于选哪个,我们只需要知道当前数字的下标,更好写
notion image
j是需要枚举的

和下一题的联系

把原数组sort后,把两者取一个最长公共子序列,就是最长递增子序列

DP的优化技巧

notion image
 
只有定义成最小值,我们才有机会更新长度;维护更小的最小值,可以增大更新长度的可能性
按照这样的定义,并没有子问题,所以不能算DP,反而算是贪心
贪心的证明:
  • 反证法
  • 数学归纳法
    • notion image

最长公共子序列

  • 不需要考虑只选一个(只会更差)
  • 不需要考虑都不选(已包含,相当于只选一个的子问题)
notion image
 
单个数组或者字符串要用动态规划时,可以把动态规划 dp[i] 定义为 nums[0:i] 中想要求的结果;当两个数组或者字符串要用动态规划时,可以把动态规划定义成两维的 dp[i][j] ,其含义是在 A[0:i] 与 B[0:j] 之间匹配得到的想要的结果。

递归搜索 + 记忆化

递推

滚动数组(两个数组)

一个数组

notion image
 
因为左上的状态会被之前的计算覆盖掉
所以用一个临时变量pre存起来
 

编辑距离

股票问题

一个维度用来表示一个状态
任意笔 - 2笔 - k笔

背包问题

  • 01背包
  • 完全背包
  • 多重背包
notion image
背包问题(Knapsack problem)是一种组合优化NP完全(NP-Complete,NPC)问题。
问题可以描述为:给定一组物品,每种物品都有自己的重量价格,在限定的总重量内,我们如何选择,才能使得物品的总价格最高。
一共有N件物品,第i件物品的重量为w[i],价值为v[i]。在总重量不超过背包承载上限W的情况下,能够装入背包的最大价值是多少?
NPC问题是没有多项式时间复杂度的解法的,但是利用动态规划,我们可以以伪多项式时间复杂度求解背包问题。
先遍历物品还是先遍历背包重量呢?
要看转移方程,如果是从左上角方向转移而来(包括正上方向),不影响
但一般先遍历物品再遍历背包这个顺序更好理解。
推导公式估计看一遍就记下来了,但难就难在如何初始化和遍历顺序上

01背包

如果采用暴力穷举的方式,每件物品都存在装入和不装入两种情况,所以总的时间复杂度是
而使用动态规划可以将复杂度降至。我们的目标是书包内物品的总价值,而变量是物品和书包的限重,所以我们可定义状态:
那么dp[i][j]有两种情况:
  1. 不装入第i件(这里不是前i件)物品,即dp[i−1][j]
  1. 装入第i件物品(前提是能装下),即dp[i−1][j−w[i]] + v[i]
即状态转移方程为:

优化

由上述状态转移方程可知,dp[i][j]的值只与dp[i-1][0,...,j-1]有关
所以可以采用滚动数组对空间进行优化(即去掉dp的第一维)
需要注意的是,为了防止上一层循环的dp[0,...,j-1]被覆盖,循环的时候 j 只能逆向枚举
另一个角度思考:(不如注释,但也是一层很重要的意思
从后往前循环,每次取得状态不会和之前取得状态重合,这样每种物品就只取一次了。
举一个例子:物品0的重量weight[0] = 1,价值value[0] = 15
如果正序遍历
dp[1] = dp[1 - weight[0]] + value[0] = 15
dp[2] = dp[2 - weight[0]] + value[0] = 30
此时dp[2]就已经是30了,意味着物品0,被放入了两次,所以不能正序遍历。
为什么倒序遍历,就可以保证物品只放入一次呢?
倒序就是先算dp[2]
dp[2] = dp[2 - weight[0]] + value[0] = 15 (dp数组已经都初始化为0)
dp[1] = dp[1 - weight[0]] + value[0] = 15
 
代码中是先遍历物品嵌套遍历背包容量,那可不可以先遍历背包容量嵌套遍历物品呢?
滚不起来,dp[i−1][j−w[i]] i-1统一,但是用j−w[i] 不统一
 
notion image
 

动态规划的核心思想

避免重复计算:第i件物品装入或者不装入而获得的最大价值完全可以由前面i-1件物品的最大价值决定,暴力枚举忽略了这个事实。

完全背包

每种物品有无限多个
我们的目标和变量和01背包没有区别,所以我们可定义与01背包问题几乎完全相同的状态:
dp[i][j]也有两种情况:
  1. 不装入第i种物品,即dp[i−1][j],同01背包;
  1. 装入第i种物品,此时和01背包不太一样,因为每种物品有无限个(但注意书包限重是有限的),所以此时不应该转移到dp[i−1][j−w[i]]而应该转移到dp[i][j−w[i]],即装入第i种商品后还可以再继续装入第种商品
与01背包问题唯一不同就是max第二项不是dp[i-1]而是dp[i]

优化

优化后不同点在于这里的 j 只能正向枚举而01背包只能逆向枚举

多重背包

物品的数量为n[i]
状态定义还是一样:
从装入第 i 种物品多少件出发:装入第i种物品0件、1件、...n[i]件(还要满足不超过限重)
转移方程:

优化

转换成01背包

01背包问题是最基本的背包问题,我们可以考虑把背包问题转化为01背包问题来解:将一种物品转换成若干件只能装入0件或者1件的01背包中的物品。
最简单的想法是,考虑到第 i 种物品最多装入 W/w[i] 件,于是可以把第 i 种物品转化为 W/w[i] 件物品,费用及价值均不变,然后求解这个01背包问题。
更高效的转化方法是采用二进制的思想
把第 i 种物品拆成重量为 、价值为的若干件物品,其中 k 取遍满足的非负整数。这是因为不管最优策略选几件第 i 种物品,总可以表示成若干个刚才这些物品的和。这样就将转换后的物品数目降成了对数级别
将第i种物品分成了件物品,将原问题转化为了复杂度为的01背包问题
(例:13 = 1 + 4 + 8)

变种

恰好装满

如果没有恰好装满背包的限制,我们将dp全部初始化成0就可以了。因为任何容量的背包都有一个合法解“什么都不装”,这个解的价值为0,所以初始时状态的值也就全部为0了。
如果有恰好装满的限制,那只应该将dp[0,...,N][0]初始为0,其它dp值均初始化为-inf,因为此时只有容量为0的背包可以在什么也不装情况下被“恰好装满”,其它容量的背包初始均没有合法的解,应该被初始化为-inf

求方案数

装满背包或将背包装至某一指定容量的方案总数
在状态转移方程中进行统计

二维背包

前面讨论的背包容量都是一个量:重量。二维背包问题是指每个背包有两个限制条件(比如重量和体积限制),选择物品必须要满足这两个条件。

具体方案

一般而言,背包问题是要求一个最优值,如果要求输出这个最优值的方案,可以参照一般动态规划问题输出方案的方法:记录下每个状态的最优值是由哪一个策略推出来的,这样便可根据这条策略找到上一个状态,从上一个状态接着向前推即可。
以01背包为例,我们可以再用一个数组G[i][j]来记录方案,设 G[i][j] = 0表示计算 dp[i][j] 的值时是采用了max中的前一项(也即dp[i−1][j]),G[i][j] = 1 表示采用了方程的后一项。即分别表示了两种策略: 未装入第 i 个物品及装了第 i 个物品。其实我们也可以直接从求好的dp[i][j]反推方案:若 dp[i][j] = dp[i−1][j] 说明未选第i个物品,反之说明选了。
 
 

Comments