DP笔记
DP笔记
一、动态规划总结
要使用动态规划需要哪些 条件?
- 最优子结构
- 子问题重叠
- 无后效性
1 和 2 中只需要满足一个,再加上 3,接下来就可以愉快地使用动态规划了。
动态规划把原问题划分为若干子问题,每个子问题的求解过程构成一个阶段,求解完前一个阶段再求解后一个阶段。根据无后效性,动态规划的求解过程构成一个有向无环图,求解的顺序就是该有向无环图的一个拓扑排序。
以下是处理动态规划问题的 基本要素:
-
确定状态、保存状态变量
即设计dp数组,保存当前状态。这个状态就是每个子问题的决策。常常:问什么就设什么。
-
划分阶段、设计决策方法
即设计状态转移方程。常常:根据相邻两个阶段的状态之间的关系来确定决策方法和状态转移方程。
-
处理边界
即设计一个终止条件或边界条件。
以下是处理动态规划问题的 基本步骤:
- 状态定义: 每个状态的决策,存放每个状态的变量。
- 状态转移方程: 当前状态与上一个状态之间的关系。
- 初始状态: 初始的状态或者边界条件。
二、线性DP
使用场景相对模糊:通常是有明显的子问题重叠。
状态有多个维度,每个维度都是线性划分的阶段,整体呈现递推的形式。
状态定义: 基本上是问什么设什么。答案即是dp数组的值。
状态转移方程: 分清楚阶段,找到子问题。通过子问题推出当前的状态。
边界条件: 一般比较清楚。(数据的边界)
重点: 先要分维度,再找到可以用于递推的子问题。这两个步骤要一起进行,数据范围可能会提示维度如何分,在此基础上设计出一种可以递推出答案的方法。最后按照维度循环得到答案。
例题:走楼梯,最长上升子序列,最长公共子序列。
三、背包问题
详见背包九讲。
四、区间DP
使用场景很明显:处理区间问题,得到某种最优答案。
区间dp的秘籍就是: i 到 j 的这个区间的所期望求的值可能是 i 到 k 的值和 k+1 到 j 的值通过某种方式合并得到。
很明显越大的区间要覆盖更小的区间所得到的值。
状态定义: dp[i][j]
表示:i 到 j 这个区间的答案。有的时候会在加上一维 k (0/1),表示某一段选或者不选。
状态转移方程: dp[i][j] = combine(dp[i][k], dp[k][j]); //k: i ~ j
combine () 是一种合并的方式,有的时候是取最大最小,有时是相加,还有可能是其他。
重点: 区间dp的第一层循环大多是遍历区间长度,接着循环起点(注意起点要合理,加上长度后不超过终点的范围),由循环得到的起点可以推出终点。最后再进行状态的转移。核心是找到合并两个区间的答案的方法。
例题:[P1880 [NOI1995] 石子合并]
dp[i][j]
表示:取 i ~ j 的最大得分
在 [i, j] 这一区间内,枚举下标k进行拆分。
合并 [i, k] 与 [k+1, j] 的区间,会加上 [i, j] 之间所有石子个数之和。
得到状态转移方程为 f[i][j]=max(f[i][j], f[i][k]+f[k+1][j]+sum[j]-sum[i-1]);
其中sum为前缀和
for (int len = 2; len <= n; ++len) { for (int i = 1; i <= n - len + 1; ++i) { int j = i + len - 1; dp[i][j] = INF; for (int k = i; k < j; ++k) dp[i][j] = max(dp[i][j], dp[i][k] + dp[k + 1][j] + sum[j] - sum[i - 1]); } }
粗略模板:
for 枚举区间长度 len { for 枚举起点 i(i+len 不超过范围){ 计算得到终点 j 初始化 dp[i][j]; for 枚举划分点 k in (i, j) 使用状态转移方程 } }
五、树形DP
使用场景很明显:存在树形结构,求某种最优解。
树形DP指在树型结构上实现的动态规划。树形结构有明显的层次性,动态规划是多阶段决策问题,正好对应。
状态定义: dp[i]
其中 i 一般是树上的节点编号,代表以该节点为根的子树的答案。有的时候会在加上一维 j (0/1),表示这一个节点的状态 (选或者不选) 。
状态转移方程: 树形DP一般自底向上回溯,将子树从小到大作为dp的阶段。遍历的时候一般采用深度优先遍历,递归求解每颗子树,回溯时从子结点向上进行状态转移。在当前结点的所有子树都求解完毕后,才可以求解当前结点。
重点: 树形dp的精髓就是回溯时的状态转移,通过深度优先搜索的方式,向下的过程中进行初始化操作,向上回溯的时候再转移状态。核心是找到节点的答案与它的子树的答案之间的关系。
例题:P8625 [蓝桥杯 2015 省 B] 生命之树,P1352 没有上司的舞会,P2016 战略游戏
用 表示第 个点的权值, 表示以u为根节点的子树的答案。
状态转移方程:
void dfs(int u,int fa){ dp[u] = a[u]; for(int v : tree[u]){ if(v == fa) continue; dfs(v, u); dp[u] += max(dp[v], 0ll); } }
状态 dp[u][0/1]
表示:在 节点放哨兵(0) / 不放哨兵(1)的情形下,以 为根节点的子树的答案
(加了一维 (0/1) 表示当前节点放不放哨兵。)
状态转移方程为:
dp[u][0] += dp[to][1]
dp[u][1] += min(dp[to][0], dp[to][1])
void dfs(int u,int fa){ int dp1 = 1; int dp0 = 0; for(int v : tree[u]){ if(v == fa) continue; dfs(v, u); dp1 += min(dp[v][0], dp[v][1]); dp0 += dp[v][1]; } dp[u][1] = dp1; dp[u][0] = dp0; }
粗略模板:
void dfs(int u, int fa) { 预处理 dp[u]; for(int v : tree[u]) { if(v == fa) continue; dfs(v, u); 使用状态转移方程; } }
换根DP(也算是树形的DP)
对于之前的树形DP,都是以一个固定的结点也就是1来作为根求解最优解d额动态规划思想。而换根DP,顾名思义就是根不一定只有1,这涉及到换根的操作。
最笨的方法是以每个点作为根分别进行之前的树形DP操作,但是实际上这样的操作基本上都是会超时的。
正确的方法是只求解一个根,并且你要看出来当选择其他点作为根(当然这个点肯定是先从原先被求解的那个根的孩子)时,和父亲的关系。通过这种方式来设计递归的操作,不断地递归找孩子,并且通过某种关系得到对应解。
六、状压DP
使用场景:最显著的看数据范围,一般表示状态的变量不超过20。当然得有明显的状态区分。
状压DP是利用计算机二进制的性质来描述状态的一种DP方式。
很多棋盘问题都运用到了状压,同时,状压也很经常和BFS及DP连用。
状态定义: dp[i][j]
表示:包含变量 i 的某种情形下,j (状压) 状态下的答案。i 有的时候会扩展到二维,即用两个变量来表示情形。但是统一的特征就是有一个 状压 j 表示当前状态。
状态转移方程: 先枚举前面的变量,相当于枚举情形。然后枚举状态。判断状态是否合法后,枚举上一个情形的状态,再判断状态是否合法并进行状态的转移。
重点: 状压dp的显著特征就是会有一个关键变量的数据范围很小 ( 1 ≤ K ≤ 9 )。通过变量表示情形不需要复杂,主要是方便遍历和状态的判断。核心就是某种情形下如何判断状态的合法,注意全面性不要漏判断。
例题:P1879 [USACO06NOV] Corn Fields G,P1896 [SCOI2005] 互不侵犯
P1879 [USACO06NOV] Corn Fields G
dp[i][j]
表示:在前 行中,在状态 下的最大方案数 (即答案)
状态转移方程:
dp[i][j] = (dp[i][j] + dp[i - 1][k]) % mod;
for (int i = 1; i <= n; i++) { for (int mask = 0; mask < (1 << m); mask++) { if (mask & (mask << 1)) continue; if (mask & (mask >> 1)) continue; if ((mask | a[i]) != a[i]) continue; for (int mask1 = 0; mask1 < (1 << m); mask1++) { if ((mask & mask1) != 0) continue; dp[i][mask] = (dp[i][mask] + dp[i - 1][mask1]) % mod; } } }
粗略模板:
for i for j { // 枚举情形 for mask in (0, maxn) { // 枚举状态 判断状态 mask 是否合法 for mask1 in (0, maxn) { // 枚举上一层状态 判断 mask1 是否合法 以及 两次状态之间是否合法 使用状态转移方程 } } }
七、数位DP
使用场景很明显:和数位有关的最优解问题。
数位DP一般用于统计 [l, r] 区间满足特定条件的元素个数。经常要和记忆化搜索放在一起使用。
状态定义: dp[i][j]
表示:[i , j] 这个区间中符合要求的数的个数 (即答案)。有的时候会在加上一维 k (0/1),表示某一种要求。
状态转移方程: 很特殊,常和记忆化搜索一起使用。从高位向低位枚举 '0',减少分叉出的情况个数。常常:用limit 表示上界的限制(上界不超过某个数字),lead 表示是否存在前导 ‘0’ 问题。在这些限制之下,用深度优先搜索枚举下一位。
重点: 记忆化搜索要注意枚举无限制时才可记忆化;有限制时,不可以记忆化,需要继续根据限制进行枚举。常用这些变量作为限制int dfs(int pos, int limit, int lead, int cnt)
。核心很模板,记住枚举的套路,设计统计答案的方式即可。
: 表示当前位置
:表示是否有上界限制
: 表示是否有前导 0 限制
: 记录答案
数组 在没有特殊限制的时候可以记忆化答案
int dfs(int pos, int limit, int lead, int cnt) { if (pos == 0) return (cnt >= 30); if (!limit && !lead && f[pos][cnt]) return f[pos][cnt]; int res = 0; int up = limit ? a[pos] : 1; for (int i = 0; i <= up; i++) { res += dfs(pos - 1, limit && (i == a[pos]), lead && (i == 0), cnt + (i == 0 ? (lead ? 0 : 1) : -1)); } if (!limit && !lead) f[pos][cnt] = res; return res; }
粗略模板:
int dfs(int pos, int limit, int lead, int cnt) { if (pos == 0) 返回统计答案; if 没有特殊限制并且已经有记忆值 直接返回记忆值; 计算上界 up for i in (0, up) { // i 是这一位要填的数字 使用状态转移方程; 继续深度优先搜索; } if 没有限制 记忆答案值; 返回统计答案; }
八、概率DP、期望DP
使用场景很常见:有关求解期望、概率等题目
一般求概率是正推,求期望是逆推
总体来说比较灵活
状态定义: 一般就设概率或者期望,加上几个维度来表示状态,进行转移。
状态转移方程:
概率 DP 总逃不开 设计dp转移,多半逃不出高斯消元(手动 和 写代码 两种)
期望 DP 一般来说有它固定的模式。
一种模式是直接 DP,定义状态为到终点期望,采用逆序计算得到答案。
一种模式是利用期望的线性性质,对贡献分别计算,这种模式一般要求我们求出每种代价的期望使用次数,而每种代价往往体现在 DP 的转移之中。
重点: 多手动计算,常常用到许多有关概率或期望的常见公式。还经常结合逆元
费马小定理:若a与m互质,且m为质数,则
另一种形式为
由此可知: 就是 在模 意义下的逆元。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 地球OL攻略 —— 某应届生求职总结
· 提示词工程——AI应用必不可少的技术
· Open-Sora 2.0 重磅开源!
· 周边上新:园子的第一款马克杯温暖上架