实现方式
包括记忆化搜索与递推两种.
线性DP
没什么好说的. 规模在n < 1e6左右.
背包DP
规模一般在n < 5000左右.
0-1 背包
dp[i][j] 表示前i个物品, 重量总和为j时最大价值. dp[i][j] = max(dp[i][j], dp[i - 1][j - w[i]] + v[i]).
也可以进行值域交换.
dp[i][j] 表示前i个物品, 价值总和为j时最小重量. dp[i][j] = min(dp[i][j], dp[i - 1][j - v[i]] + w[i]). 注意初始化.
第一维可以压掉, 变成dp[j]表示前i个物品价值总和为j时最大价值. dp[i][j] = max(dp[i][j], dp[i - 1][j - w[i]] + v[i])注意横向要倒序遍历, 保证旧值更新旧值.
完全背包
0-1 背包横向改成顺序遍历即可, 用新值更新新值.
多重背包
每样物品最多拿k[i]次. 朴素一点的做法是当成k[i]个物品, 转化成0-1 背包来做.
可以使用二进制来进行分组, 减少分组数量.
混合背包
加个if判断即可.
二维费用背包
dp[i][j][k]. 一般要压掉第一维, 不然容易空间超限.
分组背包
与0-1 背包如出一辙. 不过外层循环改成枚举每个组, 再加一层枚举组内每个物品的循环即可.
有依赖的背包
写成主件+若干附件的形式. 转化成0-1 背包.
输出方案
多开一个数组g[i][j]表示前i个物品质量总和为j时是否选择了此物品. 要从最终状态逆推.
求方案数
没什么好讲的. max换成求和即可.
子集和的一些优化
比如说, 寻找数组A中某子集a_i0, a_i1, ..., a_ik的最接近sum(A) / 2的和. 最快步骤如下:
- 轻重分治: 对数组排序. 若
a[0] > tot / 2, 说明其他所有节点加起来都打不过a[0]. 这时候一定是a[0]分一组, 其他子节点分一组. 这是树上背包优化的关键, 复杂度为O(n\sqrt{n}), 证明比较困难. - 二进制优化. 这个没什么好讲, 如果有多个相同节点就分成
1, 2, 4...即可. 代码如下:
sort(tmp.rbegin(), tmp.rend());int l = 0;for (int i = 1; i <= tmp.size(); i++) { if (i == tmp.size() || tmp[i] != tmp[l]) { ll val = tmp[l]; ll cnt = i - l;
ll k = 1; while (cnt >= k) { item.emplace_back(val * k); cnt -= k; k *= 2; } if (cnt > 0) { item.emplace_back(val * cnt); } l = i; }}其中tmp为原数组, item为新数组.
3. 跑子集和. 详见竞赛算法3-位运算 基于模板生成的动态bitset实现
如果是在树上(或其他具有轻重性质的结构上), 对每个节点都跑一遍子集和, 其复杂度仍为, 而非.
例题和复杂度分析见1856E2及题解.
完全背包的第x大(大小为k的)子集和
步骤如下:
- 先算出最大的子集和, 即最大价值的物品取k个.
- 我们考虑怎么算出次大子集和. 容易注意到次大子集和一定为取一个次大值 + 剩下全取最大值.
- 次大值怎么到第3大值? 只有两种办法: 要么把一个次大值换成第3大值, 要么把一个最大值换成次大值.
- 于是对于任意的第
x大的子集和来说: 要么把第idx大的物品换成idx + 1大的物品, 要么把一个最大值换成第idx大的物品. 我们把这两种情况都推进小根堆里, 再取出小根堆里最小的即可. 容易证明, 这样一定可以遍历所有的前x大值.
例题abc440e. 代码如下
void solution() { /*code here*/ ll n, k, x; cin >> n >> k >> x; vector<ll> arr(n); for (int i = 0; i < n; i++) { cin >> arr[i]; } sort(arr.begin(), arr.end(), greater<ll>()); ll tot = k * arr[0]; vector<ll> d(n); for (int i = 1; i < n; i++) { d[i] = arr[0] - arr[i]; // 转换为不超过k个元素的最小值 } using point = tuple<ll, int, int>; min_heap<point> mh; mh.emplace(d[1], 1, 1); // 从次小值开始转移 vector<ll> ans; ans.emplace_back(tot); // 最大的情况 while (!mh.empty() && ans.size() < x) { auto [dv, dx, dn] = mh.top(); mh.pop(); ans.emplace_back(tot - dv); if (dx < n - 1) { mh.emplace(dv - d[dx] + d[dx + 1], dx + 1, dn); } if (dn < k) { mh.emplace(dv + d[dx], dx, dn + 1); } } for (int i = 0; i < x; i++) { cout << ans[i] << '\n'; }}区间DP
规模一般在n < 400.
区间DP的问题常具有如下特征: 将两个或多个部分进行整合, 或将一个部分分裂成两个/多个部分. 我们可以将问题分成两两合并的形式. 求解时枚举合并点, 使合并两个部分的最优值最优即可.
枚举时先枚举长度len, 再枚举左端点lt, 通过lt与len计算出右端点. 再枚举合并点k即可.
环的处理
延长一倍即可.
DP on DAG
通常来说是最长路或最短路问题.
一般可以使用拓扑排序实现状态转移.
树形DP
一般使用dfs实现状态转移. 从子节点向父节点转移需要先递归, 再转移, 从父节点向子节点则需要在递归前转移.
换根dp
某节点的状态并不只由叶节点状态决定, 而是由整棵树的状态决定.
- 先以1为根, 做一次树形dp, 维护出子树的某些信息.
- 再从父节点开始, 将父节点和兄弟节点推向子节点.
例: 1324F(维护可差分信息, 直接从父节点中减去子节点的信息即可).
如果需要维护不可差分信息的话也很简单. 在父节点推向子节点之前: 维护出所有子节点的前缀信息pref和后缀信息suff, 剔除子节点y的信息只需要用pref[i - 1]和suff[i + 1]进行运算即可. 注意: 为了统一下标, dfs的时候需要先把所有子节点提取到children序列里, 再进行前缀max与后缀max等处理.
状压DP
一般n < 22左右. 用二进制存状态.
概率DP
典型的比如马尔科夫链等.
数位DP
一个典型框架如下
int a[20];ll dp[20][StateSize]; // 记忆化数组,注意:不要把 limit 记进去!
// pos: 当前处理到第几位(从高到低,len -> 1)// state: 当前的状态(比如:数字之和 % D,或者已有的非零个数)// limit: 当前位是否受到 N 的最高位限制// lead: 当前是否处于前导零状态(视题目而定是否需要)ll dfs(int pos, int state, bool limit, bool lead) { // 1. 递归边界:填完了所有位 if (pos == 0) { // 返回 1 表示找到一个合法数字 // 或者检查 state 是否满足最终条件(例如 sum % D == 0) return 1; }
// 2. 记忆化搜索 // 只有当 !limit && !lead 时,状态才是通用的,可以读取/记录 dp // 解释:如果 limit=true,说明当前是在贴着 N 的边缘走,这个状态是特例,不能复用。 if (!limit && !lead && dp[pos][state] != -1) return dp[pos][state];
// 3. 确定当前位能填的最大数字 // 如果 limit 为 true,只能填到 a[pos];否则可以填到 9 int up = limit ? a[pos] : 9;
ll ans = 0;
// 4. 枚举当前位填什么数字 i for (int i = 0; i <= up; ++i) { // 更新状态 // 比如题目要求数位之和: new_state = (state + i) % D // 比如题目要求非零个数: new_state = state + (i != 0)
// 处理前导零对 state 的影响: // 如果原本是前导零且当前填了 0,state 通常不变
// 递归 // new_limit 的逻辑:只有当前本来就受限(limit=true) 且 当前填了最大值(i==up) 时,下一位才继续受限 // new_lead 的逻辑:当前是前导零 且 当前填了 0,下一位还是前导零 ans += dfs(pos - 1, new_state, limit && (i == up), lead && (i == 0)); }
// 5. 记录 memo if (!limit && !lead) dp[pos][state] = ans;
return ans;}
ll solve(string s) { int len = s.length(); // 把字符串转成 int 数组,注意顺序,通常 a[len] 是最高位,a[1] 是最低位 for (int i = 0; i < len; ++i) a[len - i] = s[i] - '0';
// 初始化 dp 数组,通常为 -1 memset(dp, -1, sizeof(dp));
return dfs(len, 0, true, true);}