动态规划 整理笔记
p.s. 以下某些代码部分的缩进有些问题,不妨碍正常浏览,请忽略。
目录
- 朴素 dp
- 背包 dp
- 区间 dp
- 树形 dp
- 数位 dp
- 动态 dp
- 斜率优化 dp
- 状压 dp
- SOS dp(高维前缀和)
- 概率与期望 dp
朴素 dp
-
转化一下题意,它分情况说那么多,本质上就是保留下来的序列中,每一个节点要么比左右两个相邻的节点大,否则就比它们小。所以这题实际上是 最长不下降子序列 + 最长不上升子序列。
所以对于每个点
,定义两个状态, ,分别表示比相邻两个大和小。根据最长不下降子序列的做法,这题的转移方程也就呼之欲出了:( )if(a[i]>a[i-1])f[i][0]=f[i-1][1]+1; else f[i][0]=f[i-1][0]; if(a[i]<a[i-1])f[i][1]=f[i-1][0]+1; else f[i][1]=f[i-1][1];
-
没想到这道题就个 RMQ + 简单 dp。从小到大遍历
到 ,对于每个确定的区间右端点 ,记 表示 中最少能分成几段。那么显然, 。当然,前提是 可被分为若干段。附 code.在上面的代码中可以看到具体实现。这里有个优化:为什么每次枚举到一个新的右端点,不需要从头再选一次
值最小的左端点 呢?这时候要注意到一个很重要的性质:对于一个区间 ,若将 这个节点也加进来,它要么对这个区间的极差没有影响,要么只会将这个极差变得更大。所以,若是一个左端点 ,满足区间 的极差大于 ,那么区间 的极差也必定大于 (其他情况同理)。同时,左端点 越往右, 只会不变或更大。综上,我们只需要继续上一个枚举的
的左端点的位置往后枚举新的左端点,并且取最左边的合法左端点即可。
-
题面:一个字符串A的子串被定义成从A中顺次选出若干个字符构成的串。如A=“cdaad",顺次选1,3,5个字符就构成子串"cad",现给定两个字符串,求它们的最长公共子串。
代码:
string a, b; int la, lb,f[maxn][maxn]; int main(){ cin >> a >> b; la = a.length(), lb = b.length(); rep(i, 0, la - 1) rep(j, 0, lb - 1){ f[i][j] = max(i ? f[i - 1][j] : 0, j ? f[i][j - 1] : 0); if(a[i] == b[j]) f[i][j] = max(f[i][j], ((i and j) ? f[i - 1][j - 1] : 0) + 1); } cout << f[la - 1][lb - 1]; return 0; }
-
首先得到组成的最后一个数的最小值是多少。记
表示前 个数字组成的数中,最后一个数最小时,它的左端点在哪里。这样一来,我们得到了最后一个数的左下标。然后,在左下标不动的情况下,我们从后往前推,进行又一次动态规划,这时候,记 表示当 为一个数的左端点时,右端点下标的最大值。最后输出答案时,就根据 数组一直跳,输出即可。(请忽略这及其恶心的马蜂:rep(i, 1, n - 1) per(j, i - 1, 0) if(cmp(f1[j], j, j + 1, i)){ f1[i] = j + 1; break;} f2[f1[n - 1]] = n - 1, k = f1[n - 1] - 1; while(s[k] == '0') f2[k] = n - 1, k -= 1; per(i, k, 0) per(j, f1[n - 1], i + 1) if(cmp(i, j - 1, j, f2[j])){ f2[i] = j - 1; break;} int r = 0, l; while(true){ l = r, r = f2[r]; rep(i, l, r) cout << s[i]; r += 1; if(r == n) break; cout << ",";}
-
P5017 [NOIP2018 普及组] 摆渡车:典中典中典。
初始状态不难设计,不过根据自己硬刚的经历来看:设计状态的时候,尽量不要将一些自己不能百分百保证正确的结论套入状态,简而言之“不要想太多”,就设计一个简单的状态让它“顺其自然”地处理就行(请感性理解)。
然后就是优化(附一神仙 dp 优化方法合集)。本题中涉及到的优化有:剪去无用转移和剪去无用状态,更多地,利用单调性质,使用斜率优化。
-
P1412 经营与开发:经典倒序推 dp 例题。
非常巧妙,既然正序无法取消后效性,那就倒序转移状态,十分简单。
-
记录一下此题的思考过程。
-
状态设计:
既然在五花八门的状态设计中摸不着头脑,就找题面的输出要求:
一行两个整数,
和 。 输出答案。所以我们自然就会考虑将
记为前 个数的排列中,有 个<
的方案数。 -
转移方程:
额考场上就卡在这一步。没思路,不妨就来考虑要从哪些状态转移到当前状态。对于当前状态 ,我们显然会从 和 两个状态转移过来。因为对于一个数 ,在它插入已有序列时,它比该序列任意一个数都大。故于 而言,能且仅能贡献一个<
或不贡献<
。所以,我们接下来只需要考虑在哪些位置插入 会有(无)贡献,并统计这些位置的总数,转移方程就出来了。某题解解析如下://dp[i][j]可增加dp[i-1][j]*( j + 1 )%mod种 // <号个数 序列前端 //dp[i][j]可增加dp[i-1][j-1]*( (i-1) - (j-1) -1 + 1 -> i-j )%mod个 // 数字个数 <号个数 符号个数为数字个数-1 序列末尾 大于号个数+序列末尾 //即dp[i][j]=(dp[i-1][j-1]*(i-j)%mod+dp[i-1][j]*(j+1)%mod)%mod;
-
背包 dp
-
与寻常的背包 dp 不同,它没有精确的上限。这里就需要用到一个小技巧,也就是把超过所需量的值变为所需量进行统计,但注意两个所需量不要混淆。精华代码:
memset(f, 0x7f, sizeof f); f[0][0] = 0; rep(i, 1, n) per(p, T + t[i], t[i]) per(q, A + a[i], a[i]) { f[p][q] = min(f[p][q], f[p - t[i]][q - a[i]] + w[i]); int x = min(p, T), y = min(q, A);//注意要分开处理 if(x != p or y != q) f[x][y] = min(f[x][y], f[p][q]); }cout << f[T][A];
-
非常有意思的一道最长不下降子序列题。
区间 dp
- P1063 [NOIP2006 提高组] 能量项链:注意断环成链时的技巧。
- Problem C: 凸多边形的三角剖分:题面看起来无从下手,但是本质上是常规区间 dp(带破环成链),
表示节点 到 的最小值,则 ,画画图就可以推出。另外注意 初始值要是一个极大值(例如 )。
-
此题重点在于高精度(运算符重载),其余 dp 部分并不是很难。(
但是我没想到,所以讲讲。)发现对于每一行,每次取完数之后,余下的数都在一整个区间内。这就是我们使用区间 dp 的原因,每次从大区间转移到一个小区间,由于没有“空区间”这一说法,故最后枚举单个元素得这一行的最大值。附 code.
-
很重要的一点是不要被题面中树的形态不确定而被吓到了。实际上,我们可以先从中序遍历入手,发现一棵子树是连续若干个节点的集合。在此基础上,稍加思考,就会发现对于求出最大加分,根本无需考虑树的形态,只需要在确定了子树在中序遍历中的区间后,枚举根即可。
-
划重点!!“现在的决策内容会对之后的计算价值产生影响”的一类题目。
近年来频繁出现一类动态规划问题,在这类问题中,当前“行动”的费用 的一部分需要在之前决策时被计算并以状态的形式对当前状态造成影响。造成这 一独特的计算的原因就是当前的决策会对未来的“行动”费用造成影响。这类问题 构造方程往往比较困难,需要仔细分析原题,找到矛盾所在。
应该记住:对于当前决策影响未来的问题->就是现在算将来的影响再进行DP。
上面的文字说明均摘自--> 题解 P2466 【[SDOI2008]Sue的小球】by Bartholomew。
rep(i, 1, n){ s[i] = s[i - 1] + a[i].v; if(a[i].x == m and !a[i].y) x0 = i; } memset(f1, -0x3f, sizeof f1), memset(f2, -0x3f, sizeof f2), f1[x0][x0] = f2[x0][x0] = 0; rep(len, 1, n) rep(i, 1, n) if((j = i + len) <= n){ f1[i][j] = a[i].y + max(f1[i + 1][j] - (a[i + 1].x - a[i].x) * (s[n] - (s[j] - s[i])), f2[i + 1][j] - (a[j].x - a[i].x) * (s[n] - (s[j] - s[i]))); f2[i][j] = a[j].y + max(f1[i][j - 1] - (a[j].x - a[i].x) * (s[n] - (s[j - 1] - s[i - 1])), f2[i][j - 1] - (a[j].x - a[j - 1].x) * (s[n] - (s[j - 1] - s[i - 1]))); } printf("%.3lf", max(f1[1][n], f2[1][n]) / 1000.0);
树形 dp
小视野里面还挺多这些的“一眼题”。
比如 树的最大独立集,P2015 二叉苹果树,P2014 [CTSC1997] 选课 等等,都几乎是模板。
-
一道树形背包问题,其本质是分组背包。可能比较难想到使用树上背包,但基本没有太大难度。部分代码如下:
inline int trdp(int u, int fa){ int sum = 1, tmp; go(u) if(v ^ fa){ tmp = trdp(v, u), sum += tmp; per(s, sum, 1) rep(k, 1, s - 1) f[u][s] = min(f[u][s], f[u][s - k] + f[v][k] - 1); } return sum; }
-
看起来很恶心,但是可以发现如果一个节点,它与一个出现了怪物的节点的距离最大值不超过
的话,它就是一个魔书肯可能存在的节点。如何维护这个最大值?进一步地,我们知道求出一个节点到自己子树内怪物节点的最大距离和次大距离只需要一次
。这里比较巧妙:定义 分别为上述最大值与次大值,并都初始为一个极小值。特别地,对于怪物节点,都赋为 0。剩下的都是基本操作了。(代码实现见附代码的 函数。)所以我们只需要再维护一个点到自己子树之外的怪物节点即可。注意此时要从根节点往下求。细节见注释:
inline void dfs2(int u, int fa){ go(u) if(v ^ fa){ if(f[v][0] + 1 == f[u][0])//如果 v 和 u 能到达的最远怪物节点在同一子树内 f[v][2] = max(f[u][2] + 1/*要么在 u 子树外*/, f[u][1] + 1/*要么是 u 子树内的次大值*/); else f[v][2] = max(f[u][2] + 1, f[u][0] + 1);//同理 dfs2(v, u); } }
-
求解树上每个节点的期望时间戳。注意细节。
-
题面看起来很难。但是要试着将题面的“无序”变为“有序”。遍历每个节点,对于枚举到的一个节点,我们要求的是,该节点为联通子图中权值最大的节点时的情况数。特别地,为了防止重复计算,还要限定若一子节点与其权值相等,只有子节点的编号大于这个节点的编号才能算入联通子图中。
剩下的,对于每个节点,我们且把它当作根节点,然后遍历所有它能到的子节点,若子节点的权值合法,就计入,最后
(乘法原理)。inline void trdp(int u, int fa, int anc){ f[u] = 0; if(a[anc] < a[u] or (a[anc] == a[u] and anc > u) or a[anc] - a[u] > d) return; f[u] = 1; go(u) if(v ^ fa) trdp(v, u, anc), f[u] = f[u] * (f[v] + 1) % mod; }
-
不要局限了思维,通常对于树上距离的问题,都会对每个节点都记录下它子树中与他距离为
的点的数量。这题给的距离是确定的,比点分治的题要简单许多(然而我还是写不出点分治做法)。先说树形 dp 做法。因为
和 的范围较小,所以可以允许暴力合并。所以我们 dfs 一遍,然后对于每个节点,记录其子树中与其距离为 的点的数量。注意每次要先统计答案再将父亲节点与子节点信息合并。代码实现://其中 d 是 k inline void trdp(int u, int fa){ f[u][0] = 1; go(u) if(v ^ fa){ trdp(v, u); rep(k, 0, d) ans += f[u][k] * f[v][d - k - 1]; rep(k, 0, d) f[u][k + 1] += f[v][k]; } }
点分治做法:其实就是点分治模板啦,对于每个“根”(也就是某子树的重心),统计完距离之后也像上面树形 dp 做法一样统计答案即可。
-
网络流 + 树形 dp。 【DSY-2117】摩尔庄园 题解。
-
AT4352 [ARC101C] Ribbons on Tree:
非常恶心的容斥 + 树形 dp 优化。[ARC101C] Ribbons on Tree 题解。
-
李超线段树合并 优化 树形 dp。树形 dp 的部分十分显然,然后使用 dsu on tree 或者李超线段树合并能够将
优化至 。具体内容在《Tricks 整理》中李超线段树部分有所收录。
数位 dp
-
先预处理出每一个位数时每一个数字的出现次数(各个数字的次数是相等的)。比如
则代表在区间 中每一个数字的出现次数。故,有 。这两部分前一个是来自前 位数字的贡献,后一个是来自第 位的数字的贡献。余下细节和实现见代码及注释:inline void slv(ll n/*统计第 1 到 n 位每个数字的出现次数*/, ll k/*放入不同的数组*/){ int tot = 0; ll tmp = n; while(n) a[++tot] = n % 10, n /= 10; per(i, tot, 1){ rep(j, 0, 9) ans[j][k] += f[i - 1] * a[i]; //每个数字在每次 1~99..9(10^i - 1) 中出现了 f[i-1] 次,有 a[i] 次,所以共出现了 f[i-1]*a[i] 次 rep(j, 0, a[i] - 1) ans[j][k] += p[i - 1]; //0~a[i]-1 这些数字在第 i 位上出现了 p[i-1] 次(暂不考虑前导 0) tmp -= p[i - 1] * a[i], ans[a[i]][k] += tmp + 1; //tmp 减去已经处理过的位,留下第 i+1 到 tot 位; //而 a[i] 在第 i 位上出现了 tmp+1 次(加一是考虑情况 a[i]00..0) ans[0][k] -= p[i - 1];//减去前导 0 的出现次数 } } int main(){ scanf("%lld%lld", &l, &r); p[0] = 1ll; rep(i, 1, 13) f[i] = f[i - 1] * 10 + p[i - 1], p[i] = p[i - 1] * 10ll; slv(r, 0), slv(l - 1, 1); rep(i, 0, 9) printf("%lld ", ans[i][0] - ans[i][1]); return 0; }
-
按照上一题的套路,预处理出
数组,记 表示在满足是 位的数并且最高位是 的数中,windy 数有多少。然后,根据一个性质:若 比 小(两者位数相同),则必定有一位 ,其前面的位数中两者相同,第 位 比 小。用这个性质处理最后一种情况:inline int calc(int n){ tot = 0; int ans = 0; while(n) a[++tot] = n % 10, n /= 10; rep(i, 1, tot - 1) rep(j, 1, 9) ans += f[i][j];//位数小于 tot 的数一定都被涵盖了,直接加即可 rep(i, 1, a[tot] - 1) ans += f[tot][i];//最高位比 a[tot] 小的与 n 同位的数也都被涵盖了 per(i, tot - 1, 1){//最后处理位数相同、且最高位也相同的情况 rep(j, 0, a[i] - 1) //注意,j 一定不能超过 a[i],这是根据上述性质得到的 if(abs(a[i + 1] - j) >= 2) ans += f[i][j]; if(abs(a[i] - a[i + 1]) < 2) break;//windy 数的定义 } return ans; } int main(){ l = rd(), r = rd(); init(), wr(calc(r + 1) - calc(l)); }
-
算是一道套路题。发现想要统计
中所有的月之数,我们需要考虑的变量有:数的位数、各位位数之和。所以,我们每次会在确定了数字和总和 的情况下,dfs 逐步讨论每一位可以放什么数字,过程中需要去维护当前数字和对 取模的结果。然后需要使用记忆化搜索来优化(套路操作)。
inline int dfs(int pos, int sum, int mod, bool lmt){ if(!pos) if(!mod and sum == p) return 1; else return 0; if(!lmt and f[pos][sum][mod] != -1) return f[pos][sum][mod];//记忆化 int res = 0, up = lmt ? num[pos] : 9; rep(i, 0, up) res += dfs(pos - 1, sum + i, (mod * 10 + i) % p, (lmt and i == up));//当前这一位填 i return lmt ? res : f[pos][sum][mod] = res; } inline int calc(int n){//统计 1~n 中月之数的个数 int cnt = 0; while(n) num[++cnt] = n % 10, n /= 10; int res = 0; for(p = 1; p < maxn; ++p)//确定当前枚举的数字和 memset(f, 2147483647, sizeof f), res += dfs(cnt, 0, 0, 1); return res; }
-
板题,注意数组开二维
, 判断上一位是不是 。(若是 的话这一位一定不能取 。)inline int dfs(int nw, int pre, int st, bool lmt){ if(!nw) return 1; if(f[nw][st] != -1 and !lmt) return f[nw][st]; int up = lmt ? a[nw] : 9, tmp = 0; rep(i, 0, up) if(i != 4){ if(pre == 6 and i == 2) continue; tmp += dfs(nw - 1, i, i == 6, lmt and i == a[nw]); } if(!lmt) f[nw][st] = tmp; return tmp; }
-
P6218 [USACO06NOV] Round Numbers S:
像这种二进制填
的问题,一定要注意判断是否是前导 !前导 无意义。
-
从反面入手考虑。发现数字积结果可能性约为
,可直接枚举出来。然后数位 dp 计算出,对于每个结果
,有多少个数的数字积恰好为 。最后使用优先队列求出最后前
大结果之和即可。
-
首先需要把
的情况单独拎出来讨论。目前我不明白其必要性,但这可以让我们懂得在数字积问题中,一定要注意数字积为 的情况。这种情况就是普通的数位 dp 问题。而对于
的情况,就是上一题淘金的做法。不过需要额外注意的是,此题是求符合条件的数的总和。所以我们考虑每次维护两个参数,和(即答案)与个数。个数辅助和的维护。inline node dfs(ll nw, ll sm, bool lmt, bool ld){ if(nw == -1){ node tmp; tmp.cnt = (sm == 1), tmp.s = 0; return tmp; } if(!lmt and f[nw][mp[sm]].cnt != -1 and !ld) return f[nw][mp[sm]]; node ans, tmp; ll up = lmt ? num[nw] : 9; ans.cnt = 0; rep(i, 0, up){ if(!i and ld) tmp = dfs(nw - 1, sm, lmt and i == num[nw], ld); else{ if(!i) continue; if(sm % i != 0 or sm < i) continue; tmp = dfs(nw - 1, sm / i, lmt and i == num[nw], 0); } (ans.cnt += tmp.cnt) %= mod; (ans.s += tmp.s + p[nw] * i % mod * tmp.cnt % mod) %= mod; } if(!lmt and !ld) f[nw][mp[sm]] = ans; return ans; }
动态 dp(动态树分治)
这个...见《(动态)树分治》之 Part 3。
斜率优化 dp
-
题解里面提到将斜率优化 dp 去模式化。就目前的做题经历来看,大致是:列出当
优于 时满足的不等式 -> 化简不等式 -> 将与 有关的项转移到左边,其余放在右边并合并 -> 将左式中与 相关的系数除到右边 -> 根据右边分数形式的式子,通过设 、 来将右边的式子转化为一个形式上是求斜率的一个式子。具体地,见此图:https://img2023.cnblogs.com/blog/2517232/202303/2517232-20230314195702293-1379568876.png
(取自上面的题解。)
-
真的跟摆渡车迷之相似。思考过程中有些步骤会与摆渡车(见朴素 dp)解法重合。
不禁引导我们去思考到底是什么复杂化了此题。思考后发现:
- 变量多。不仅有一个“可接走时间”,还有一个山与山之间的不定距离。说白了,就是可能就算到了,但还是接不走这种情况需要考虑,两个变量使得情况繁多棘手。
- 答案不具有单调性。即,如果在当前时间点我接走第
只猫,但这并不意味着后面的猫我到达时都能接走,亦不意味着后面的我都接不了。
这时候需要引入一个变量,记录如果想要恰好接走第
只猫,需要在那个是时间点出发,再把猫按照这个数组排序。那么此时,我们不再为变量动态变化所约束,同时答案满足单调,可以快速统计第 个人当前选择下最多能接走多少猫。剩下的步骤就跟摆渡车差不多了,然后再使用斜率优化即可。题解。
-
自己推出的柿子!(喜
记
表示序列 的答案。根据题意,显然有
,其中 表示 是该数值第几个出现的(在整个序列中),且满足 (此时最优)。把这个柿子拆开,把 只和 有关的、只和 有关的、和 有关的 分隔出来,分别用 表示。像这样:过程图示。现在的任务就变成:对于每个
的求解,维护一个上凸壳,并拿一条斜率为 的直线从上往下平移,直到与此凸壳相切,得到的在 轴上的截距 即为答案。注意,我们保证 单调递增。所以直线与凸壳的相切点一定在不断后移,就有了如下的实现过程:(完整代码)rep(i, 1, n){ int w = a[i], k = 2 * a[i] * c[i]; while(v[w].size() > 1 and K(B, i) >= K(B, A)) v[w].pop_back(); v[w].push_back(i); while(v[w].size() > 1 and calc(A, k) <= calc(B, k)) v[w].pop_back(); f[i] = calc(A, k) + a[i] * c[i] * (c[i] + 2); }
状压 dp
-
从范围特征入手,发现任何一个数都不可能由两个大于
的质数相乘,故我们在状压部分维护小于 的 个质数的涵盖状态,剩下大于 的质数单独维护即可。code.
-
前置知识 -> 最小斯坦纳树(详解见 Tricks 整理)。
比最小斯坦纳树的板子不同的就是,此题多了个限制:频道相同的连通,不同的不必要连通。同时,我们也发现此题的频道种数和总关键点数都是非常小的(
)。故而我们可以对问题进行拆分,设
,其中 表示当前状态下有哪几个频道是互相连通的。而对于每个 ,找到它所包含的所有关键点,使用状态 表示,则有:(其中 即为板题中的状态。)实际上在实现时只是在外面多套了一个状态枚举。
TLE?吸氧吧。code.
高维前缀和 dp(SOS dp)
感觉也可以理解为更复杂的状压 dp。
-
CF499D Jzzhu and Numbers:状压/SOS dp + 容斥。
概率与期望 dp
-
考虑可以充上电的情况。共有三种。对每种考虑转移。转移时注意多种情况的转移与实现。
转移的时候,对于相对未知量,可以考虑通过已知量去转化求解。注意:在两件事件同时发生不矛盾时,两者事件发生的概率不能直接相加,需考虑容斥。
-
巧妙运用期望 dp 的优越性。My Solution.
-
一眼概率 dp。突破口显然是教室数很小且对于第
节课,只可能有两个上课地点。根据上课地点的局限性可以很容易得出状态转移方程,注意选择申请与不申请的两种情况不能归为一类进行概率计算,需要分类再取
。主要因为申请的话需要占用机会。因为课室数很小,所以直接
最短路即可。AC Code.
咕。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 使用C#创建一个MCP客户端
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· 按钮权限的设计及实现