区间dp,数据结构优化dp等5种dp,各种trick深度讲解
DP提高课(一文讲全)
注:题单请看评论区。
之前我在写DP基础课的时候提到过会写提高课,结果因为各种事情拖了很久……这次终于补上了。
这次我们讲点进阶的动态规划:区间DP、数据结构优化DP、树形DP、倍增优化DP、状态压缩DP,以及这些DP中常用的技巧和优化方法。内容会比较多,我们一步一步来。
part1. 区间DP
part1.1 定义与深入理解
区间DP的核心思想是分治+合并。当我们面对一个区间问题时,可以将其分解为两个或多个子区间,分别求解后再合并。
更一般的状态设计思路:
- 基础版:
f[l][r]
表示区间[l, r]
的最优解 - 带附加信息:
f[l][r][k]
表示区间[l, r]
且具有某种特性k时的最优解 - 多维度:
f[l][r][x][y]
表示区间两端状态分别为x,y时的最优解
为什么枚举长度?
因为我们需要保证在计算大区间时,所有小区间都已经计算完成。这是DP的无后效性要求。
part1.2 经典例题深度分析
石子合并的数学证明
设 f(l, r)
为合并 [l, r]
的最小代价,s(l, r)
为区间和。
最优子结构证明:
对于任意分割点 k
,有:
f(l, r) = f(l, k) + f(k+1, r) + s(l, r)
这是因为最后一次合并的代价固定为 s(l, r)
,而前后两部分的合并各自独立。
为什么这样设计正确?
- 合并顺序不影响总代价(因为每次合并的代价都是被合并石子的总质量)
- 问题具有最优子结构性质
- 满足重叠子问题特性
环形处理的严格证明
对于环形问题,我们将其转化为长度为 2n
的线性问题。设原环上区间 [i, j]
对应线性数组中的 [i, j]
或 [i, j+n]
。
正确性证明:
环上任意长度为n的区间在线性数组中都存在且唯一,因此枚举所有长度为n的区间取最小值即可得到环上的最优解。
part1.3 高级技巧与优化
四边形不等式优化
定义: 代价函数 w(i, j)
满足:
- 区间包含单调性:
w(i, j) ≤ w(i', j')
当[i, j] ⊆ [i', j']
- 四边形不等式:
w(i, j) + w(i', j') ≤ w(i, j') + w(i', j)
当i ≤ i' ≤ j ≤ j'
应用: 如果区间DP的代价函数满足四边形不等式,那么决策点具有单调性,可以将复杂度从O(n³)优化到O(n²)。
// 优化后的石子合并
for (int len = 2; len <= n; len++) {for (int l = 1; l + len - 1 <= n; l++) {int r = l + len - 1;int best_k = opt[l][r-1]; // 利用决策单调性for (int k = best_k; k <= opt[l+1][r]; k++) {if (f[l][k] + f[k+1][r] + s[r] - s[l-1] < f[l][r]) {f[l][r] = f[l][k] + f[k+1][r] + s[r] - s[l-1];opt[l][r] = k;}}}
}
记忆化搜索的威力
对于复杂的区间划分,记忆化搜索往往更直观:
long long dfs(int l, int r) {if (l == r) return 0;if (f[l][r] != INF) return f[l][r];long long res = INF;for (int k = l; k < r; k++) {res = min(res, dfs(l, k) + dfs(k+1, r) + s[r] - s[l-1]);}return f[l][r] = res;
}
part2. 数据结构优化DP
part2.1 系统化分类
数据结构优化DP可以分为以下几类:
1. 单调队列优化
适用条件:转移方程形如
f[i] = min_{j∈[i-k, i-1]} { f[j] + cost(j, i) }
且代价函数具有单调性。
经典例题: 滑动窗口最大值、多重背包单调队列优化
// 单调队列优化框架
deque<int> q;
for (int i = 1; i <= n; i++) {// 维护队列单调性while (!q.empty() && f[q.back()] >= f[i-1]) q.pop_back();q.push_back(i-1);// 移除过期决策while (!q.empty() && q.front() < i - k) q.pop_front();// 转移f[i] = f[q.front()] + cost(q.front(), i);
}
2. 线段树/树状数组优化
适用条件:需要区间查询最值或区间和。
// 线段树优化DP框架
struct SegmentTree {// 实现区间最值查询、单点更新
};SegmentTree seg;
for (int i = 1; i <= n; i++) {// 查询区间 [i-k, i-1] 的最值int min_val = seg.query(i-k, i-1);f[i] = min_val + cost(i);seg.update(i, f[i]);
}
3. 李超线段树优化
适用条件:转移方程包含一次函数项。
原理: 将每个决策j看作一条直线 y = k_j * x + b_j
,用线段树维护在x处的最小值直线。
part2.2 斜率优化详解
斜率优化是数据结构优化DP的精华,用于形如:
f[i] = min_{j<i} { f[j] + (a[i] - a[j])² }
步骤:
- 推导斜率公式
- 维护凸包
- 二分查找最优决策点
// 斜率优化模板
vector<int> q; // 单调队列,存储决策点下标
for (int i = 1; i <= n; i++) {// 维护凸包:删除不满足凸性的点while (q.size() >= 2) {int j = q[q.size()-2], k = q.back();if ((f[k]-f[j])/(a[k]-a[j]) >= (f[i]-f[k])/(a[i]-a[k]))q.pop_back();else break;}q.push_back(i);// 二分查找最优决策int l = 0, r = q.size()-1;while (l < r) {int mid = (l+r)/2;if (get_slope(q[mid], q[mid+1]) <= 2*a[i])l = mid+1;elser = mid;}f[i] = f[q[l]] + (a[i]-a[q[l]])*(a[i]-a[q[l]]);
}
part3. 树形DP的深入探讨
part3.1 状态设计的艺术
树形DP的状态设计远比基础课中丰富:
1. 树上背包问题
状态:f[u][j]
表示以u为根的子树中选择j个节点的最优解
void dfs(int u) {size[u] = 1;// 初始化:通常f[u][0] = 0, f[u][1] = w[u]for (int v : children[u]) {dfs(v);// 背包合并:注意倒序枚举避免重复for (int j = size[u]; j >= 0; j--) {for (int k = 0; k <= size[v]; k++) {f[u][j+k] = max(f[u][j+k], f[u][j] + f[v][k]);}}size[u] += size[v];}
}
2. 换根DP
用于求解每个节点作为根时的答案。
技巧: 第一次DFS计算子树信息,第二次DFS通过父节点信息更新子节点。
void dfs1(int u, int parent) {// 计算子树信息for (int v : adj[u]) {if (v == parent) continue;dfs1(v, u);f[u] += f[v] + size[v]; // 示例:求深度和}
}void dfs2(int u, int parent) {// 换根:g[v] = g[u] - size[v] + (n - size[v])for (int v : adj[u]) {if (v == parent) continue;g[v] = g[u] - 2 * size[v] + n;dfs2(v, u);}
}
3. 复杂状态设计示例
例题: 树的直径(路径最长)
// f[u][0]: u为根的子树中最长路径
// f[u][1]: u为根的子树中次长路径(与最长路径不相交)
// 或者:
// f[u]: 从u出发的最长链
// g[u]: 经过u的最长路径void dfs(int u, int parent) {for (int v : adj[u]) {if (v == parent) continue;dfs(v, u);// 更新经过u的最长路径g[u] = max(g[u], f[u] + f[v] + 1);// 更新从u出发的最长链f[u] = max(f[u], f[v] + 1);}
}
part3.2 树形DP的优化技巧
1. 长链剖分优化
用于深度相关的树形DP,将复杂度从O(n²)优化到O(n)。
原理: 继承重儿子信息,暴力合并轻儿子。
void dfs(int u, int parent) {// 继承重儿子if (heavy_son[u]) {dfs(heavy_son[u], u);f[u] = f[heavy_son[u]]; // 指针操作,实际是O(1)}// 合并轻儿子for (int v : adj[u]) {if (v == parent || v == heavy_son[u]) continue;dfs(v, u);// 暴力合并轻儿子信息for (int i = 0; i <= len[v]; i++) {f[u][i+1] += f[v][i]; // 示例操作}}
}
2. 虚树优化
当只需要处理部分关键节点时,构建虚树减少节点数量。
part4. 倍增优化DP的扩展应用
part4.1 倍增思想的本质
倍增的核心是二进制分解:任何数字都可以表示为2的幂次之和,任何跳跃都可以分解为2^k步的跳跃。
part4.2 扩展应用场景
1. 树上路径查询
预处理每个节点向上2^k步的信息(最大值、最小值、和等)。
// 预处理
for (int k = 1; k < LOG; k++) {for (int i = 1; i <= n; i++) {fa[i][k] = fa[fa[i][k-1]][k-1];max_val[i][k] = max(max_val[i][k-1], max_val[fa[i][k-1]][k-1]);}
}// 查询u到v路径上的最大值
int query_max(int u, int v) {if (depth[u] < depth[v]) swap(u, v);int res = -INF;// u向上跳至与v同深度for (int k = LOG-1; k >= 0; k--) {if (depth[fa[u][k]] >= depth[v]) {res = max(res, max_val[u][k]);u = fa[u][k];}}if (u == v) return res;// 同时向上跳for (int k = LOG-1; k >= 0; k--) {if (fa[u][k] != fa[v][k]) {res = max(res, max(max_val[u][k], max_val[v][k]));u = fa[u][k], v = fa[v][k];}}return max(res, max(max_val[u][0], max_val[v][0]));
}
2. 序列上的倍增DP
例题: 给定序列,求每个位置向右跳k步后的位置。
// f[i][k]: 从i开始跳2^k步到达的位置
for (int k = 1; k < LOG; k++) {for (int i = 1; i <= n; i++) {f[i][k] = f[f[i][k-1]][k-1];}
}// 查询从x开始跳y步
int jump(int x, int y) {for (int k = 0; k < LOG; k++) {if (y >> k & 1) {x = f[x][k];}}return x;
}
3. 倍增优化状态转移
当DP转移具有重复性时,可以用倍增优化。
示例: f[i][j] = f[f[i][j-1]][j-1]
part5. 状态压缩DP的高级应用
part5.1 从基础到进阶
1. 子集枚举的优化技巧
经典枚举方法:
// 枚举mask的所有子集
for (int submask = mask; submask; submask = (submask-1) & mask) {// 处理子集submask
}
时间复杂度分析:
- 对于有k个1的mask,有2^k个子集
- 总复杂度:ΣC(n,k)*2^k = 3^n
2. 轮廓线DP(插头DP)
插头DP是状态压缩DP的高级形式,用于网格图上的路径问题。
核心概念:
- 插头:网格边界的连通性状态
- 括号表示法:用括号序列表示连通性
- 逐格递推:逐个格子进行状态转移
// 插头DP框架(哈密顿路径计数)
struct State {int mask; // 轮廓线状态long long cnt;
};unordered_map<int, long long> f[2]; // 滚动数组void dp() {int cur = 0;f[cur][0] = 1;for (int i = 0; i < n; i++) {for (int j = 0; j < m; j++) {int nxt = cur ^ 1;f[nxt].clear();for (auto &[mask, cnt] : f[cur]) {int left = (mask >> j) & 1; // 左插头int up = (mask >> (j+1)) & 1; // 上插头if (!grid[i][j]) { // 障碍物if (!left && !up) f[nxt][mask] += cnt;continue;}if (!left && !up) {// 新建连通分量int new_mask = mask | (1 << j) | (1 << (j+1));f[nxt][new_mask] += cnt;} else if (left && !up) {// 延续左插头// 两种情况:向右或向下} else if (!left && up) {// 延续上插头// 两种情况:向右或向下} else {// 合并两个连通分量int new_mask = mask ^ (1 << j) ^ (1 << (j+1));f[nxt][new_mask] += cnt;}}cur = nxt;}}
}
3. 集合幂级数优化
使用FWT(快速沃尔什变换)优化状态压缩DP的卷积运算。
// 子集卷积优化
void fwt(vector<int> &a) {int n = a.size();for (int k = 1; k < n; k <<= 1) {for (int i = 0; i < n; i += k << 1) {for (int j = 0; j < k; j++) {a[i+j+k] += a[i+j]; // 或使用其他变换}}}
}
part5.2 状态设计的高级技巧
1. 双轮廓线DP
用于更复杂的网格问题,同时维护两行状态。
2. 三进制状态压缩
用于需要区分多种状态的情况(如:已选/未选/禁止选)。
3. 状态压缩+Meet in Middle
将状态分成两半,分别处理后再合并。
// 折半搜索框架
void dfs1(int idx, int mask, int value) {if (idx == n/2) {left_states[mask] = value;return;}dfs1(idx+1, mask, value); // 不选当前dfs1(idx+1, mask | (1<<idx), value + w[idx]); // 选当前
}void dfs2(int idx, int mask, int value) {if (idx == n) {// 在left_states中寻找互补状态int target = total_mask ^ mask;if (left_states.count(target)) {ans = max(ans, value + left_states[target]);}return;}// 类似dfs1
}
part6. 综合技巧与实战策略
part6.1 DP优化技巧的系统总结
1. 空间优化技巧
- 滚动数组:
f[i][j]
→f[i%2][j]
- 降维打击:分析状态依赖关系,减少维度
2. 时间优化技巧
- 预处理:提前计算常用值
- 剪枝:排除不可能状态
- 记忆化:避免重复计算
3. 常数优化
- 循环展开
- 位运算优化
- 缓存友好访问
part6.2 实战解题策略
1. 状态设计思维流程
1. 识别问题类型(计数、最优、可行性)
2. 确定状态维度(位置、选择情况、附加条件)
3. 设计状态表示(数组维度、含义)
4. 推导转移方程(如何从小状态推到大状态)
5. 确定边界条件(最小子问题的解)
6. 优化空间时间(数据结构、数学性质)
2. 调试技巧
- 打印DP表观察状态转移
- 小数据手动验证
- 对拍验证正确性
part6.3 进阶学习方向
1. 动态DP
支持修改操作的动态规划,通常用矩阵表示转移,用线段树维护。
2. 概率DP与期望DP
处理随机过程,需要概率论知识。
3. 数位DP
按数位进行状态压缩,处理数字相关问题。
4. 状压DP的进一步扩展
- 斯坦纳树问题
- 一般图匹配
- 旅行商问题的进一步优化
最后的话
DP提高课到这里就真正结束了。从基础的区间DP到复杂的插头DP,我们覆盖了竞赛中常见的各种DP类型和优化技巧。
关键收获:
- 状态设计是核心:好的状态设计决定了解题的成败
- 优化需要洞察力:发现问题的特殊性质才能有效优化
- 实践出真知:只有大量练习才能掌握这些技巧
学习建议:
- 每学一个技巧,找3-5道相关题目练习
- 总结同类问题的共性
- 尝试一题多解,比较不同方法的优劣
其实 DP 本质上需要多练,否则你看懂了其实并没有用,但是你没看懂直接去练题也没有用,所以说请认真读懂,否则等于没用。