2106 字
11 分钟
竞赛算法(9) 动态规划
2025-12-03
无标签

实现方式#

包括记忆化搜索与递推两种.

线性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的和. 最快步骤如下:

  1. 轻重分治: 对数组排序. 若a[0] > tot / 2, 说明其他所有节点加起来都打不过a[0]. 这时候一定是a[0]分一组, 其他子节点分一组. 这是树上背包优化的关键, 复杂度为O(n\sqrt{n}), 证明比较困难.
  2. 二进制优化. 这个没什么好讲, 如果有多个相同节点就分成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实现

如果是在树上(或其他具有轻重性质的结构上), 对每个节点都跑一遍子集和, 其复杂度仍为O(nnw)O(\frac{n \sqrt{n}}{w}), 而非O(n2nw)O(\frac{n^2 \sqrt{n}}{w}).

例题和复杂度分析见1856E2及题解.

完全背包的第x大(大小为k的)子集和#

步骤如下:

  1. 先算出最大的子集和, 即最大价值的物品取k个.
  2. 我们考虑怎么算出次大子集和. 容易注意到次大子集和一定为取一个次大值 + 剩下全取最大值.
  3. 次大值怎么到第3大值? 只有两种办法: 要么把一个次大值换成第3大值, 要么把一个最大值换成次大值.
  4. 于是对于任意的第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, 通过ltlen计算出右端点. 再枚举合并点k即可.

环的处理#

延长一倍即可.

DP on DAG#

通常来说是最长路或最短路问题.

一般可以使用拓扑排序实现状态转移.

树形DP#

一般使用dfs实现状态转移. 从子节点向父节点转移需要先递归, 再转移, 从父节点向子节点则需要在递归前转移.

换根dp#

某节点的状态并不只由叶节点状态决定, 而是由整棵树的状态决定.

  1. 先以1为根, 做一次树形dp, 维护出子树的某些信息.
  2. 再从父节点开始, 将父节点和兄弟节点推向子节点.

例: 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);
}
竞赛算法(9) 动态规划
https://fuwari.vercel.app/posts/竞赛算法9-动态规划/
作者
ykindred
发布于
2025-12-03
许可协议
CC BY-NC-SA 4.0