彻底搞懂状压 DP:NOIP 2017《宝藏》重构题解
P3959 [NOIP 2017 提高组] 宝藏
题目背景
NOIP2017 D2T2
题目描述
参与考古挖掘的小明得到了一份藏宝图,藏宝图上标出了 n 个深埋在地下的宝藏屋, 也给出了这 n 个宝藏屋之间可供开发的 m 条道路和它们的长度。
小明决心亲自前往挖掘所有宝藏屋中的宝藏。但是,每个宝藏屋距离地面都很远,也就是说,从地面打通一条到某个宝藏屋的道路是很困难的,而开发宝藏屋之间的道路则相对容易很多。
小明的决心感动了考古挖掘的赞助商,赞助商决定免费赞助他打通一条从地面到某个宝藏屋的通道,通往哪个宝藏屋则由小明来决定。
在此基础上,小明还需要考虑如何开凿宝藏屋之间的道路。已经开凿出的道路可以任意通行不消耗代价。每开凿出一条新道路,小明就会与考古队一起挖掘出由该条道路所能到达的宝藏屋的宝藏。另外,小明不想开发无用道路,即两个已经被挖掘过的宝藏屋之间的道路无需再开发。
新开发一条道路的代价是 L\times K。其中 L 代表这条道路的长度,K 代表从赞助商帮你打通的宝藏屋到这条道路起点的宝藏屋所经过的宝藏屋的数量(包括赞助商帮你打通的宝藏屋和这条道路起点的宝藏屋) 。
请你编写程序为小明选定由赞助商打通的宝藏屋和之后开凿的道路,使得工程总代价最小,并输出这个最小值。
输入格式
第一行两个用空格分离的正整数 n,m,代表宝藏屋的个数和道路数。
接下来 m 行,每行三个用空格分离的正整数,分别是由一条道路连接的两个宝藏屋的编号(编号为 1\sim n),和这条道路的长度 v。
输出格式
一个正整数,表示最小的总代价。
输入输出样例 #1
输入 #1
4 5
1 2 1
1 3 3
1 4 1
2 3 4
3 4 1
输出 #1
4
输入输出样例 #2
输入 #2
4 5
1 2 1
1 3 3
1 4 1
2 3 4
3 4 2
输出 #2
5
说明/提示

【样例解释 1】
小明选定让赞助商打通了 1 号宝藏屋。小明开发了道路 1 \to 2,挖掘了 2 号宝藏。开发了道路 1 \to 4,挖掘了 4 号宝藏。还开发了道路 4 \to 3,挖掘了 3 号宝藏。
工程总代价为 1 \times 1 + 1 \times 1 + 1 \times 2 = 4 。
【样例解释 2】
小明选定让赞助商打通了 1 号宝藏屋。小明开发了道路 1 \to 2,挖掘了 2 号宝藏。开发了道路 1 \to 3,挖掘了 3 号宝藏。还开发了道路 1 \to 4,挖掘了 4 号宝藏。
工程总代价为 1 \times 1 + 3 \times 1 + 1 \times 1 = 5。
【数据规模与约定】
对于 20\% 的数据: 保证输入是一棵树,1 \le n \le 8,v \le 5\times 10^3 且所有的 v 都相等。
对于 40\% 的数据: 1 \le n \le 8,0 \le m \le 10^3,v \le 5\times 10^3 且所有的 v 都相等。
对于 70\% 的数据: 1 \le n \le 8,0 \le m \le 10^3,v \le 5\times 10^3。
对于 100\% 的数据: 1 \le n \le 12,0 \le m \le 10^3,v \le 5\times 10^5。
\text{upd 2022.7.27}:新增加 50 组 Hack 数据。
面对 n \leq 12 这样的数据范围,我们立刻就能反应过来这是 状态压缩 DP。但与普通的旅行商问题 (TSP) 不同,这道题的边权成本是动态的——它等于“打通的边长 \times 终点在树中的深度”。
书上的代码通过极其复杂的位运算来枚举,这掩盖了题目本质的动态规划逻辑。我们来看看如何用最清晰的思路拿下这道题。
1. 核心状态定义:分层生成树
因为边权的代价与“深度(层数)”直接相关,我们的 DP 状态里必须包含深度信息。 设 dp[i][S] 表示:
- 当前生成的树,最大深度为 i(规定起点的深度为 0)。
- 树中已经包含的节点集合为 S(用二进制表示)。
dp[i][S]的值就是达到这种状态的最小总花费。
为什么不用记录树的精确形态?
初学者最大的困惑是:“我怎么知道集合 S 里的点,哪个在第 1 层,哪个在第 2 层?”
这里用到了一个非常巧妙的 **“贪心与松弛”** 思想: 我们在转移时,假设从状态 dp[i-1][S0] 转移到 dp[i][S]。也就是我们把 S \setminus S_0 这个差集里的所有新点,强行全部挂在第 i 层。
- 疑问:如果有个点明明可以挂在更浅的第 k < i 层,我强行把它算作第 i 层,答案不就错了吗?
- 解答:没关系!因为 DP 会遍历所有的状态。如果那个点真的挂在 k 层更省钱,那么在之前的某次转移(比如从某个 S' 转移到 S'' 时),它就已经被算在 k 层里了,那个状态计算出的总代价一定会比你强行挂在 i 层的代价更小。最终取
min时,错误的、代价大的“伪状态”自然会被淘汰。
2. 预处理:砍掉多余的复杂度
在转移时,我们需要把新集合 S \setminus S_0 里的每一个点,连接到旧集合 S_0 中的某个点。为了让代价最小,我们显然希望它连接到 S_0 中距离它最近的那个点。
如果每次都在 DP 循环里去现找这个“最近的点”,会增加好几层循环。所以我们需要预处理。
设 min_cost[S][v] 表示:从节点 v 连接到集合 S 里的任意一个点,所需的最小边权。
// 预处理 min_cost 数组
// 枚举所有可能的已选集合 S
for (int S = 1; S < (1 << n); ++S) {
// 枚举不在集合 S 中的节点 i
for (int i = 0; i < n; ++i) {
if ((S & (1 << i)) == 0) { // i 不在 S 中
int mn = INF;
// 找 S 中距离 i 最近的节点 j
for (int j = 0; j < n; ++j) {
if (S & (1 << j)) {
mn = min(mn, adj[j][i]);
}
}
min_cost[S][i] = mn; // 记录最小距离
}
}
}
3. 状态转移方程
有了预处理,我们的转移就非常干净了。 最外层循环深度 i,中层循环当前集合 S,内层循环 S 的子集。
这里使用一个经典的枚举子集的技巧:for (int S0 = S; S0 > 0; S0 = (S0 - 1) & S)。这个循环能无重复、无遗漏地遍历出集合 S 的所有非空子集。
转移过程:
- 提取出新增加的点集:
diff = S ^ S0(即 S \setminus S_0)。 - 遍历
diff里的每一个点 u。 - 如果 u 无法连接到 S_0 (
min_cost[S0][u] == INF),说明这个转移是不合法的,直接放弃。 - 如果可以连接,累加这批新点产生的代价:
cost += min_cost[S0][u] * i。 - 更新 DP:
dp[i][S] = min(dp[i][S], dp[i-1][S0] + cost)。
4. 完整满分代码 (C++)
这份代码使用了 long long 防御溢出,去掉了所有书本上的故弄玄虚,还原了算法本来的面貌。
#include <iostream>
#include <vector>
#include <cstring>
#include <algorithm>
using namespace std;
const long long INF = 0x3f3f3f3f3f3f3f3fLL; // 使用 long long 级别的无穷大
int n, m;
long long adj[15][15]; // 邻接矩阵,存两点间最短边
long long min_cost[1 << 12][15]; // min_cost[S][u] 表示点 u 到集合 S 的最小边权
long long dp[15][1 << 12]; // dp[树深度][点集]
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
// 1. 初始化与读入
if (!(cin >> n >> m)) return 0;
memset(adj, 0x3f, sizeof(adj));
for (int i = 0; i < m; ++i) {
int u, v;
long long w;
cin >> u >> v >> w;
u--; v--; // 转换为 0-based 索引,方便位运算
// 处理重边,保留最小的
adj[u][v] = min(adj[u][v], w);
adj[v][u] = min(adj[v][u], w);
}
// 2. 预处理 min_cost 数组 (空间换时间)
int max_state = (1 << n);
for (int S = 1; S < max_state; ++S) {
for (int i = 0; i < n; ++i) {
if ((S & (1 << i)) == 0) { // i 不在集合 S 中
long long mn = INF;
for (int j = 0; j < n; ++j) {
if (S & (1 << j)) { // j 在集合 S 中
mn = min(mn, adj[j][i]);
}
}
min_cost[S][i] = mn;
}
}
}
// 3. DP 初始化
memset(dp, 0x3f, sizeof(dp));
// 深度为 0 的时候,树里只能有一个点(作为根节点),代价为 0
for (int i = 0; i < n; ++i) {
dp[0][1 << i] = 0;
}
// 4. 开始 DP
// 枚举树的最大深度 i (最多为 n-1 层)
for (int i = 1; i < n; ++i) {
// 枚举当前所有的节点集合 S
for (int S = 1; S < max_state; ++S) {
// 枚举 S 的所有子集 sub_S (代表上一层及以前已经建好的集合)
// 这种枚举方式可以正好遍历 S 的所有真子集
for (int sub_S = S; sub_S > 0; sub_S = (sub_S - 1) & S) {
if (sub_S == S) continue; // 上一层的集合不能是 S 本身,必须有新点加入
// 上一层状态都达不到,直接跳过
if (dp[i - 1][sub_S] == INF) continue;
long long current_expand_cost = 0;
bool is_valid = true;
// 找出这一次扩展加入的新点:diff = S 剔除 sub_S
int diff = S ^ sub_S;
// 遍历每一个新加入的点 u
for (int u = 0; u < n; ++u) {
if (diff & (1 << u)) {
// 寻找 u 连接到旧集合 sub_S 的最小代价
if (min_cost[sub_S][u] == INF) {
is_valid = false; // 原图根本不连通,无法扩展
break;
}
// 累加代价:边权 * 当前深度 i
current_expand_cost += min_cost[sub_S][u] * i;
}
}
// 如果转移合法,松弛更新 dp 值
if (is_valid) {
dp[i][S] = min(dp[i][S], dp[i - 1][sub_S] + current_expand_cost);
}
}
}
}
// 5. 统计答案
long long ans = INF;
// 最终包含所有点 (即状态 (1<<n)-1),它可能在任意深度 0 到 n-1 完成
for (int i = 0; i < n; ++i) {
ans = min(ans, dp[i][max_state - 1]);
}
cout << ans << "\n";
return 0;
}
5. 复杂度分析
- 时间复杂度:最内层循环不仅要枚举状态 S,还要枚举它的子集。这一步的经典时间复杂度是 O(3^n)。在这个循环内部,我们又遍历了未加入的点,花费 O(n)。再加上外层的深度循环 O(n),总时间复杂度为 O(n^2 \cdot 3^n)。对于 n=12,运算量大约为 12^2 \cdot 3^{12} \approx 6 \times 10^7,在 1 秒的时限内完全可以稳过。
- 空间复杂度:
dp数组大小为 O(n \cdot 2^n),毫无压力。
总结
比起死记硬背那些像“黑魔法”一样的位运算优化,理解 **“状态的放宽与转移的松弛”** 才是这道题真正想教给你的内容。只要转移逻辑没问题,DP 会自动帮你过滤掉那些“不划算”的强行连接。