基础动态规划
$Dynamic\ Programming$
动态规划分析:
主要思想:
类似于递推,将当前状态的属性(最大值 $max$,最小值 $min$,解的个数 $nums$)由上一个状态转移而来,从而继承上一个状态的属性,具有最优子结构属性,由此得出状态转移方程
dp的状态转移方程可以有两种思考方式:顺推和逆推
顺推即考虑当前状态能向后推出何状态
逆推即考虑当前状态由哪些状态转移而来
状态转移方程的推导方式
顺推:
1 | f[i + 1][j] = f[i][j]; |
值得注意的是,在顺推中 $f_{i, j}$ 表示未计算 $i, j$ 值的状态,因此顺推的目标解为 $f_{n + 1, k}$
逆推:
1 | f[i][j] = f[i - 1][j]; |
对于两种状态取 $max$ 即可
同时,该状态需要具有无后效性的特点,即当前状态的选择无法影响下一状态的决策
另外值得注意的是,我们需要对dp边界的初始值进行特判
动态规划问题的时间复杂度分析:
状态数量 * 转移计算量
背包问题:
给定 n
个物体,和一个容量是 v
的背包,每个物体的体积是vi
,权重是 wi
,,求如何装物品使得总权重最大
在背包问题中,f[i][j]
表示所有从前i
个物品中选并且总体积不超过j
的所有选法中 最大/最小 的总价值
经典背包问题特点
背包问题 | 特点 |
---|---|
01背包 | 每件物品最多只能用一次 |
完全背包 | 每件物品可以用无限次 |
多重背包 | 每件物品可以使用有限次 |
分组背包 | 将所有物品分为若干组 每组物品中最多只能选一个物品 |
01背包
朴素解:
1 | int n, m; |
在 dp 每一层的状态计算中不难发现,每一层第一维度的值都和上一层的值有关 f[i][j] = f[i - 1][j]
,第二维度的值只可能出现 j 或 j - v[i],那么可以利用滚动数组对空间进行优化:
1 | for (int i = 1; i <= n; i ++) |
完全背包
完全背包的状态计算:
类比于01背包将当前情况分为选(1)或不选(0),在完全背包中可以按照选多少个物品分类
对于各个集合中状态的表示,设当前物品选了 k
个,即有状态转移方程:f[i - 1][j - k * v[i]] + k * w[i]
将每个情况取最大值,即可求出当前情况的价值最大值
朴素解:
1 | int n, m; |
一维优化:
当对 f[i][j]
进行枚举时,进行的过程是这样的:
f[i, j] = max(f[i, j], f[i, j - v[i]] + w[i], f[i, j - 2 * v[i]] + 2 * w[i], ...)
在枚举 f[i][j]
时,我们已经完成了对 f[i, j - v[i]]
的枚举,当时进行的过程是这样的:
f[i, j - v[i]] = max(f[i][j - v[i]], f[i - 1][j - 2 * v[i]] + w[i], ...)
此时我们发现,计算 f[i][j]
的过程与计算 f[i][j - v[i]]
的过程非常相似,规律可以表述为:
max(f[i, j - v[i]] + w[i], f[i, j - 2 * v[i]] + 2 * w[i], ...) = f[i, j - v[i]] + w[i]
那么我们可以将状态转移方程优化为:
1 | for (int i = 1; i <=n; i ++) |
此时的状态转移方程为:f[i][j] = max(f[i][j], f[i][j - v[i] + w[i]])
而在01背包中的状态转移方程为:f[i][j] = max(f[i - 1][j], f[i - 1][j - v[i] + w[i]])
类比于01背包的一维优化,我们可以将完全背包优化为:
1 | for (int i = 1; i <=n; i ++) |
到此为止,我们已经将完全背包的时间复杂度优化到了 $O(n)$
多重背包
对于每个物品都有个数限制 s[i]
,只需枚举物品个数 $k$ 并使 $k$ 满足k <= s[i] && k * v[i] <= j
朴素版本:
1 | int n, m; |
多重背包的二进制优化 $O(w·n·logs)$:
基本原理:
假设s = 1023
,如按照朴素方式枚举,则需要枚举 1023 次。此时我们将 1 ~ 1023 分为十组,每组分别是:
2^0^, 2^1^, 2^2^, 2^3^, …, 2^9^
即:1, 2, 4, 8, …, 512
易证对于 [1, 1023] 区间中的每一个数字,都可以用以上 10 个数表示
当 $s$ 不为 2 的正整数次方时,假设 s = 200
,此时我们将 200 分为以下几组:
2^0^, 2^1^, 2^2^, 2^3^, 2^4^, 2^5^, 2^6^, 73
此时即可以用以上几组数据将 [1, 200] 区间中所有的数字表示出来
将 $s$ 推广为一般数字:
2^0^, 2^1^, 2^2^,…, 2^k^, s - 2^k^
由此可知,对于任意一个 $s$ ,都可以拆分为 log2s 个组, 只需要找到这 log2s 个组所有排列组合的最大值,即为这几个组的 01背包 问题
模板:
1 | const int N = 25000; |
分组背包
将所有物品分为若干组,每组物品中最多只能选一个物品,只需枚举第 $i$ 组中选第 $k$ 物品
可得状态转移方程:f[i][j] = max(f[i][j], f[i - 1][j - v[i][k]] + w[i][k])
(v[i][k]
表示第 $i$ 组物品中第 $k$ 个物品的体积,以此类推)
模板:
1 | const int N = 1e2 + 10; |
依赖背包
选 $A$ 物品需要购买 $B$ 物品,或选 $A$ 物品才可以买 $B, C$ 物品
基本的解决思路是,我们将两种绑定的物品转化为分组背包,举个例子,若买 $A$ 之后才可以买 $B$,会出现以下三种情况
- 选 $A$ 不选 $B$
- 选 $A$ 选 $B$
- 不选 $A$ 不选 $B$
对于每一种情况,进行 $dfs$ 深度搜索绑定为一个组,再按照分组背包的二进制解即可
我们可以发现,当有 $n$ 件物品依附于一件物品时,分出组的个数为 $2^n + 1$ 时间复杂度起飞 ~
当物品的绑定关系较为复杂时,可以使用树形DP或图上DP
线性动态规划
经典应用:
1. Acwing895-最长上升子序列
暴力版本 $O(n^2)$
集合:
可以用 f[i]
表示所有以第 $i$ 个数结尾的上升子序列,维护的属性是长度最大值 Max
;
- 那么如何将初始状态转移到
f[i]
呢?
我们可以依照 f[i]
子序列的倒数第二个数来分类: 0 表示当前子序列长度为 1,1 表示该子序列的倒数第二个数为总序列第 1 个数 ……以此类推,直到 i - 1
表示该子序列的倒数第二个数为总序列第 i - 1
个数。
- 如何判断该状态是否成立呢?
对于 f[i] = j
,只需判断 a[j] < a[i]
,如成立则存在该状态
因此得到的状态转移方程为:
f[i] = max(f[i], f[j] + 1)
- 该解法的时间复杂度?
状态数量 O(n) * 转移计算量 O(N) = $O(n^2)$
1 | int n; |
另外,也可以将选当前位置和不选当前位置单独分离出一个二维状态
若要记录最长上升子序列,则将主体部分改为:
1 | int g[N]; |
单调优化版:$O(n · logn)$
观察性质可以发现,对于
3 1 2 1 8 5 6
这个序列,当枚举以 8 为结尾的最长上升子序列时,下标为 1 的数字 1 无疑是比下标为 0 的数字 3 更优的选择 (涵盖的值域更大,更有机会取得最长上升子序列)
因此我们可以开一个数组 q[N]
存储长度为 $i$ 的上升子序列中结尾元素最小的情况中结尾元素的值。
- 该最小值是否随结尾值 $i$ 的增大而严格单调递增?
假设对于长度为 $k$ 的末尾最小值为 $n$ ,若 $n$ 小于或等于等于长度为 $k - 1$ 的末尾的最小值,则对于 $k - 1$ 必有一种更优的、最小值更小的情况,则该假设不成立
- 那么如何求出以
a[i]
结尾的最长上升子序列的长度的最大值呢?
对于预处理好的不同长度的上升子序列 q
,只需在该数组中利用二分查找找到不大于 a[i]
的最大的值 q[k]
,将 a[i]
接在 q[k]
后面即可。此时还要注意,由于 q[k]
是不大于 a[i]
的最大的情况,所以 q[k + 1]
的值一定会大于 a[i]
因此需要更新 q[k + 1]
由于对于每个点进行二分查找,因此该算法的时间复杂度是 $O(n · logn)$
模板:
1 | int n; |
2. Acwing897-最长公共子序列
集合:
使用 f[i][j]
表示所有 第一个字符串的前 $i$ 个字母构成的子序列 和 第二个字符串前 $j$ 个字母构成的子序列 的公共子序列长度
属性:
长度最大值
集合划分:
将全集依照 是否选择 a[i]
和 b[j]
分为四个子集:
00:
a[i]
与b[j]
均不选
01:a[i]
选而b[j]
不选
10:a[i]
不选而b[j]
不
11:a[i]
与b[j]
选
状态表示
对于第一种状态,可以表示为 f[i - 1][j - 1]
对于第二种状态,可以表示为 f[i - 1][j]
,但由于 f 数组表示的是子序列长度,所以并不能达到 “不包含 a[i]
而包含 b[j]
的情况” 。因此这种情况是包含第一种状态的,但由于所求属性为长度最大值,这种冲突并不会影响最终结果
同理,对于第三种状态,可以表示为 f[i][j - 1]
对于第四种状态,可以表示为 f[i - 1][j - 1]
因此, f 的状态计算只需取后三种情况的最大值即可
模板
1 | int n, m; |
区间dp
区间dp 就是将若干个集合合并为一个集合,每次合并付出的代价为集合元素个数,求将所有集合合并为一个集合所付出的最小代价
集合:
f[i][j]
表示将所有从第 i 个集合到第 j 个集合合并为一个集合的合并方式
属性
最小值
状态表示
我们可以发现,不论哪种合并方式,最后一步均为将两个集合合并为一个集合,因此可以以最后一次合并集合的分界线分类,即可以将全集分为 k - 1
个部分
由此,第三维度的 $k$ 仅需从 1 枚举到 j - 1
即可
时间复杂度分析
状态的二维转移需要 $O(n^2)$,枚举 $k$ 的时间为 $O(n)$,因此总时间复杂度为 $O(n^3)$
模板
整体思路:先从小到大枚举区间长度,再从前往后枚举区间左端点,最后枚举分割点的决策
1 | const int N = 310; |
计数类dp
完全背包解
状态表示
f[i][j]
表示从 1 ~ i
中选并且总和等于 $j$ 的所有选法集合
状态转移方程
f[i][j] = f[i - 1][j] + f[i][j - i]
类比于完全背包的一维优化,优化后的状态转移方程:
f[j] = f[j] + f[j - i]
1 | int n; |
其他解法
状态表示
f[i][j]
表示所有总和为 $i$ ,并且恰好表示为 $j$ 个数的和的方案数集合
状态计算
将全集按照所有拆分成的数字分为:
- 拆分成的数字中最小值为 1
- 拆分成的数字中最小值不为 1
由此则可以做到状态不重不漏地表示
- 那么如何表示这两种状态呢?
对于第一种情况,如果将其中的一个 1 删除,则可以从前面的状态转移到此状态且并不影响总方案数,则该状态表示为 f[i - 1][j - 1]
对于第二种情况,如果将所有数都减去 1 ,在能够从前面状态转移到此状态的前提下仍能保证集合中的数均为正整数,则该状态表示为 f[i - j][j]
则有:f[i][j] = f[i - 1][j - 1] + f[i - j][j]
值得注意的是,题目要求输出一个正整数 $n$ 的所有拆分方案,而以上的状态转移方程仅限于正整数 $i$ 拆分为 $j$ 个数,因此对于所有方案的计算,只需:
1 | for (int i = 1; i <= n; i ++) |
正解
1 | int n; |
数位$DP$
情况分析
进行分类讨论
- 将问题简化,寻找
1~n
中0~9
出现的次数,可以使用前缀和思想 - 假设是从
1~n
,x = 1
,n = abcdefg
- 求1在第四位上出现的次数
1 <= xxx1yyy <= abcdefg
xxx = 000 ~ abc - 1
,yyy = 000 ~ 999
, 共abc * 1000
种选法xxx = abc
时d < 1
,abc1yyy > abc0efg
, 共有0
种选法d = 1
,yyy = 000 ~ efg
, 共有efg + 1
种选法d > 1
,yyy = 000 ~ 999
, 共有1000
种选法
$dp$问题难点在状态表示和分情况讨论
边界情况
- 当枚举的数字出现在最高位上时,只存在第二种情况
- 当枚举数字为
0
时,前三位不能取000
状态压缩$dp$
核心思想是使用二进制数的每一位表示不同的情况或者数据
二进制操作
- 取出整数
n
在二进制表示下第k
位:(n >> k) & 1
- 取出整数
n
在二进制表示下第0 ~ k - 1
位(后k
位):n & ((1 << n) - 1)
- 把整数
n
在二进制表示下的第k
位取反:n ^ (1 << k)
- 对整数
n
在二进制表示下的第k
位赋值为1
:n | (1 << k)
- 对整数
n
在二进制表示下的第k
位赋值为0
:n & (~(1 << k))
例1
时间复杂度$O(4 * 10^7)$
- 当放入所有横向小方格之后,只剩一种情况来放置竖向的小方格,因此只需求摆放横向小方格的方案数
- 竖向来分析,
f[i][j]
代表现在要摆第i
列,j
使用二进制存储的是上一列种哪些列伸出横向小方格(0
代表没有伸出,1
代表伸出来了) - 在枚举到第
i
列时,分析第i - 1
列的j
(记作k
),需要满足(j & k) == 0
,即上一列伸出来的和这一列不冲突j | k
中不存在连续奇数个0
,所有剩余的连续格子都必须是偶数,因为剩下的只能放置竖向小方块
核心代码
1 | int n, m; |
例2
暴力思路
因为每个点走一次,所以遍历0 ~ n - 1
的全排列
时间复杂度$O(n! * n)$, 即$O(20! * 20)$
因此使用状态压缩DP求解
状态压缩DP思路
f[i][j]
表示所有从0
走到j
,走过的所有点是i
的所有路径 (属性:Min
)- 用
i
,一个二进制数的每一位表示每一个点有没有走过
分类依据 - 根据倒数第二个点的位置分类(从
0
到n - 1
) - 假设倒数第二个点是
k
,路径就是从$0 \rightarrow k \rightarrow j$,其中$k \rightarrow j$已知,只需求$0 \rightarrow k$的最小值即可 - 其中$0 \rightarrow k$的值应该是
f[i - j][k]
,其中j
代表点,不是直接运算 - 答案只需循环计算
min(f[i][j], f[i - j][k] + a[k][j])
核心代码
1 | //从0开始读入w[i][j] 此处省略 |
树形$DP$
因本题已经熟练掌握,暂时略去本题分析,以后找到其他题再进行分析
代码实现
1 |
|
记忆化搜索
优点:考虑代码复杂度,使代码复杂度降低,思路简单,便于调试
递归分析动态规划法
闫氏$DP$分析法
状态表示
- 集合:所有从
f[i][j]
开始滑的路径 - 属性:
Max
状态计算
- 向右滑:
f[i][j + 1] + 1
前提:h[i][j + 1] < h[i][j]
- 向左滑:
f[i][j - 1] + 1
前提:h[i][j - 1] < h[i][j]
- 向前滑:
f[i + 1][j] + 1
前提:h[i + 1][j] < h[i][j]
- 向后滑:
f[i - 1][j] + 1
前提:h[i - 1][j] < h[i][j]
使用递归解决$DP$问题的前提是图是拓扑图,不会出现环,本题有前提因此不会出现环
核心算法
1 | int dx[4] = {-1, 0, 1, 0}, dy[4] = {0, 1, 0, -1};//定义偏移量 |