把宝石划成若干个能量和为 0 的连通块:块内代价是子集最小生成树,再用子集划分 DP 合并出全局最小花费。
OJ: roj
题目 ID: 3213
难度:提高+/省选-
标签:状态压缩最小生成树Kruskal动态规划图论
创建: 2026-10-02 02:11
更新: 2026-10-10 11:30
形式化题目
给定一张
要求选出一个边集 Impossible。
因为总和恒为 0,"所有能量相同"只能是全部为 0。所以目标等价于:
第
样例 1(
0:+50
/ \
10 100
/ \
1:-20 --20-- 2:-30顶点 0 富余 50,顶点 1、2 分别亏缺 20 和 30。取边
正解
思路
朴素做法的瓶颈。 最直接的想法是枚举用哪些边:
观察一:代价与流量无关,所以每个连通块里只需要一棵树。 既然一条边只收一次钱,
同一连通块里多留一条边不会让代价变低(非负权),而
观察二:边集可行
- 必要性:能量只能沿
中的边流动,一个连通分量与外界没有通路,分量内的总能量守恒。 目标态要求分量内每颗宝石都是 0,即总量为 0,所以分量内初始总能量必须为 0。 - 充分性:设分量内总能量为 0。取这个分量的一棵生成树,从叶子开始自下而上处理:
叶子
只有一条通向外面的树边,把它当前的差值沿这条边传给父节点, 然后删掉 。每一步都保持"剩下的树上总需求仍为 0",最后根节点的需求必然是 0。 整棵树每条边只传一次定量能量,所以这个边集确实可行。
这条判据把"流量怎么安排"整个问题消掉了:不需要考虑流量,只需要考虑点怎么分块。
观察三:给定块
用 Kruskal 就能算出来:把全局的边按代价升序排好,只保留"两端都在
观察四:代价可加,所以可以按点集做划分 DP。 连通块之间互不影响,
总代价 = 各块代价之和。于是记 dp[S] 为"把点集
答案是
为什么强制块含最低位? 一份划分里"含最低位的那一块"是唯一的,强制带上它就等于给 每份划分指定了唯一的枚举顺序,同一个划分不会被按块的不同排列重复枚举;顺带还把内层 枚举量减半。
用小数据看这个 DP 怎么转。 自造一组
块 A(能量和 0): {0:+3} --4-- {1:-3}
块 B(能量和 0): {2:+5} --6-- {3:-5}
跨块边(没人用): {1:-3} ---100--- {2:+5}两个零和块各自连起来就完事了,那条 100 的边根本不需要选。
ok[sub] = total[sub] == 0 and cost[sub] < INF 是"这个点集能不能自己调平",填表结果:
| 子集掩码 | 点集 | total |
cost |
dp |
说明 |
|---|---|---|---|---|---|
| 1 | {0} | 3 | INF | INF | 单点能量不为 0 |
| 2 | {1} | -3 | INF | INF | 单点能量不为 0 |
| 3 | {0,1} | 0 | 4 | 4 | 一条边 |
| 4 | {2} | 5 | INF | INF | 单点能量不为 0 |
| 5 | {0,2} | 8 | INF | INF | 能量和不为 0,不能成块 |
| 6 | {1,2} | 2 | INF | INF | 能量和不为 0,不能成块 |
| 7 | {0,1,2} | 5 | INF | INF | 能量和不为 0,不能成块 |
| 8 | {3} | -5 | INF | INF | 单点能量不为 0 |
| 9 | {0,3} | -2 | INF | INF | 能量和不为 0,不能成块 |
| 10 | {1,3} | -8 | INF | INF | 能量和不为 0,不能成块 |
| 11 | {0,1,3} | -5 | INF | INF | 能量和不为 0,不能成块 |
| 12 | {2,3} | 0 | 6 | 6 | 一条边 |
| 13 | {0,2,3} | 3 | INF | INF | 能量和不为 0,不能成块 |
| 14 | {1,2,3} | -3 | INF | INF | 能量和不为 0,不能成块 |
| 15 | {0,1,2,3} | 0 | 110 | 10 | 整块连通要 110,拆成 {0,1} 和 {2,3} 只要 10 |
三列的含义:total 是子集能量和,只有它为 0 的集合才可能独立调平;cost 是该子集导出
子图上的 MST 代价(不连通或非零和都是 INF);dp 是把子集划成若干合法块的最小总花费。
第一列是子集掩码的十进制值(第 for sub in range(1, 1 << n) 一致。
最后一行的对比最值得看:整块连通要 110,而答案是 10。因为把 {0,1} 与 {2,3} 分开后 两块各自能量和为 0,可以自己调平,根本用不上那条 100 的跨组边。这直接说明不能偷懒去跑一次全图 MST——块内零和才是判据,全局连通不是必要条件。
实现对应。 solve() 先把边按代价升序排好,再用 lowbit 递推一次算出所有 total[sub]
(去掉最低位再加回它,cost 用列表推导对每个零和子集
调用一次 mst_weight;DP 用 block = (block - 1) & rest 枚举去掉最低位后的所有子集,
low | block 就是含最低位的候选块。dp[sub] 的转移用到的是 sub 的真子集,
下标必然更小,所以正序循环就够,不需要记忆化递归。
代码
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-10 11:30
* update_at: 2026-10-10 11:30
*/
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll INF = 1000000000000000000LL;
int n, m;
ll a[20]; // 各宝石能量
int eu[130], ev[130]; // 边的两端
ll ew[130]; // 边的代价
// total[sub]:sub 内所有宝石能量之和
ll total[1 << 16];
// cost[sub]:把 sub 内部连成一棵树的最小代价,连不通为 INF
ll cost[1 << 16];
char ok[1 << 16]; // sub 能否独立调平
ll dp[1 << 16]; // 拆成若干可独立调平的块的最小总花费
struct Edge { int u, v; ll w; };
Edge edges[130];
bool edge_cmp(const Edge& x, const Edge& y) { return x.w < y.w; }
// 只允许 sub 内的点参与,求其 MST 代价
ll mst_weight(int sub) {
int parent[16];
int vertices = 0;
for (int i = 0; i < n; i++) {
if ((sub >> i) & 1) { parent[i] = i; vertices++; }
}
// 朴素 find
int merged = 0;
ll weight = 0;
for (int ei = 0; ei < m; ei++) {
int u = edges[ei].u, v = edges[ei].v;
if (!((sub >> u) & 1) || !((sub >> v) & 1)) continue;
int ru = u; while (parent[ru] != ru) { parent[ru] = parent[parent[ru]]; ru = parent[ru]; }
int rv = v; while (parent[rv] != rv) { parent[rv] = parent[parent[rv]]; rv = parent[rv]; }
if (ru == rv) continue;
parent[ru] = rv;
merged++;
weight += edges[ei].w;
if (merged == vertices - 1) return weight;
}
return (merged == vertices - 1) ? weight : INF;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
for (int i = 0; i < n; i++) cin >> a[i];
for (int i = 0; i < m; i++) {
cin >> edges[i].u >> edges[i].v >> edges[i].w;
eu[i] = edges[i].u; ev[i] = edges[i].v; ew[i] = edges[i].w;
}
// 按代价升序(Kruskal)
sort(edges, edges + m, edge_cmp);
int full = (1 << n) - 1;
// total 打表:去掉最低位再加回
total[0] = 0;
for (int sub = 1; sub <= full; sub++) {
int low = sub & (-sub);
int idx = __builtin_ctz(low);
total[sub] = total[sub ^ low] + a[idx];
}
// cost 打表
cost[0] = 0;
for (int sub = 1; sub <= full; sub++) {
if (total[sub] == 0) cost[sub] = mst_weight(sub);
else cost[sub] = INF;
}
// ok 判定
for (int sub = 1; sub <= full; sub++)
ok[sub] = (total[sub] == 0 && cost[sub] < INF);
// dp:只枚举含 sub 最低位的块
dp[0] = 0;
for (int sub = 1; sub <= full; sub++) {
int low = sub & (-sub);
int rest = sub ^ low;
ll best = INF;
int block = rest;
while (true) {
int cand = low | block;
if (ok[cand] && dp[sub ^ cand] < INF) {
ll v = dp[sub ^ cand] + cost[cand];
if (v < best) best = v;
}
if (block == 0) break;
block = (block - 1) & rest;
}
dp[sub] = best;
}
if (dp[full] < INF) cout << dp[full] << "\n";
else cout << "Impossible\n";
return 0;
}#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-02 02:11
# update_at: 2026-10-02 02:17
import sys
INF = 10 ** 18 # 远超任何可行总花费:MST 最多约 15*1000,分组总和只会更小
def mst_weight(sub: int, edges: list[tuple[int, int, int]]) -> int:
"""只允许 sub 内的宝石参与时,把它们连成一体的最小花费;连不通返回 INF。
Kruskal:edges 已按代价升序,两端都落在 sub 内的边才可用。并到"点数 - 1 条边"
就提前收工,稠密图上不必白扫剩余边。
"""
vertices = sub.bit_count()
# 只给 sub 里的点建并查集:range(sub.bit_length()) 恰好覆盖 sub 可能出现的所有位
parent = {i: i for i in range(sub.bit_length()) if sub >> i & 1}
def find(x: int) -> int:
while parent[x] != x:
parent[x] = parent[parent[x]] # 路径压缩,顺手改写成迭代
x = parent[x]
return x
merged = weight = 0
for u, v, t in edges:
if not (sub >> u & 1 and sub >> v & 1):
continue # 有端点不在 sub 内,这条边用不上
ru, rv = find(u), find(v)
if ru == rv:
continue
parent[ru] = rv
merged += 1
weight += t
if merged == vertices - 1:
return weight # n-1 条边到齐,已经是一棵树
return weight if merged == vertices - 1 else INF # 没并满 = 这个点集本身不连通
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n, m = next(data), next(data)
a = [next(data) for _ in range(n)]
edges = sorted([(next(data), next(data), next(data)) for _ in range(m)], key=lambda e: e[2])
full = (1 << n) - 1
# total[sub]:sub 里所有宝石的能量之和。去掉最低位再加回它,一次 O(2^n) 打表。
total = [0] * (1 << n)
for sub in range(1, 1 << n):
low = sub & -sub
total[sub] = total[sub ^ low] + a[low.bit_length() - 1]
# cost[sub]:只调平 sub 内部所需的最小花费。总能量不为 0 的集合永远调不平,代价记 INF。
cost = [0] + [mst_weight(sub, edges) if total[sub] == 0 else INF
for sub in range(1, 1 << n)]
# ok[sub]:sub 能否"独立调平"——总能量为 0(进出的能量相等)且内部连通(能量传得动)。
ok = bytearray(1 << n)
for sub in range(1, 1 << n):
ok[sub] = total[sub] == 0 and cost[sub] < INF
# dp[sub]:把 sub 拆成若干个可独立调平的块的最小总花费。只枚举含 sub 最低位的块,
# 因为每一份拆法里含最低位的块唯一,这样同一份拆法不会换着顺序被重复枚举。
dp = [0] + [INF] * full
for sub in range(1, 1 << n):
low = sub & -sub
rest = sub ^ low
block, best = rest, INF
while True:
cand = low | block # 候选块:sub 中含最低位的子集
if ok[cand]:
best = min(best, dp[sub ^ cand] + cost[cand])
if block == 0:
break
block = (block - 1) & rest # 枚举 rest 的所有子集
dp[sub] = best
print(dp[full] if dp[full] < INF else "Impossible")
if __name__ == "__main__":
solve()复杂度
- 时间:子集能量和
,每个零和子集求一次 MST 共 , 划分 DP 枚举"含最低位的子集",总量为 , 即 。总复杂度 , 时约 次转移。 - 空间:
total、cost、dp三个长度的数组,加 ok的字节和边表 ,共 。 时实测峰值内存约 19 MB。
总结
- 本题的关键不在图论技巧,而在换一个枚举对象:从"枚举边集"(
)换成 “枚举点集的划分”( )。 的提示就在这里。 - 换过去的依据是观察一、二:一条边只收一次钱 ⇒ 每个连通块是一棵树; 能量出不了连通块 ⇒ 块内能量和必须为 0。这两条把"流量安排"整个消掉, 可行性变成一条只与点集有关的判据。
- 判据立住之后,块内最优代价就是子集 MST,块与块之间代价可加, 于是只需一个划分 DP 把它们拼起来。
- 两个容易踩的坑:不要直接跑全图 MST(块内零和才是判据,见上面那个
cost = 110/dp = 10的例子);DP 转移要固定"含最低位的那块", 否则同一份划分会被反复枚举。 - 全部
为 0 时答案是 0(每个单点自己就是零和块)。无解有明确的判据: 块必须整个落在全图的一个连通分量里,所以只要某个连通分量的能量和不为 0, 它内部的块之和就不可能全为 0,答案一定是 Impossible;反之若每个连通分量都零和, 直接按连通分量分块即可(因此在全图连通时必然有解)。
图示解析
这张图把从题意到答案的整条路线串起来:
N 颗宝石 + 能量 A + 带权边
`- 一条边只收一次钱 -> 最优解每个连通块都是一棵树
`- 能量出不了连通块 -> 合法块 <=> 块内 sum A = 0
`- 块内最小代价 -> 该点集导出子图的 MST (Kruskal 过滤边)
`- 块与块代价可加 -> 子集划分 DP
`- dp[S] = min(cost[T] + dp[S \ T]), T 含 lowbit(S)
`- 答案 dp[2^n - 1], 无解则 Impossible沿着箭头看:前两步是建模(把"流量"消成"点集划分"),中间两步是预处理
(total 与 cost 两张表),最后一步是合并(划分 DP)。每一步都只用到上一步的结论,
中间任何一步换掉都会让复杂度升一个量级——比如跳过"块内零和"直接跑全图 MST,
得到的就会是上面那个例子里 cost = 110,而不是正确答案 dp = 10。