利用边只跨越至多 6 个编号的限制,维护窗口连通分量做 Steiner Tree 前沿 DP。
OJ: shumeng
题目 ID: CSP201604E
难度:提高+/省选-
标签:状态压缩连通性DP轮廓DP图论Steiner Tree
日期: 2026-07-31 16:21
形式化题目
给定
思路
小数据可以直接枚举每条边是否选择,然后用并查集检查所有用户设备是否在同一集合中。
/**
* 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-07-31 16:21
* update_at: 2026-08-17 22:48
*/
// brute.cpp:小数据暴力解,递归枚举每条边选或不选。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 505;
const int MAXM = 3005;
const long long INF = (1LL << 60);
struct Edge {
int u, v, w;
};
int n, m, p;
int is_user[MAXN];
int choose_edge[MAXM];
Edge edges[MAXM];
int parent[MAXN];
long long answer;
int find_root(int x) {
if (parent[x] == x) {
return x;
}
parent[x] = find_root(parent[x]);
return parent[x];
}
bool check_connected() {
for (int i = 1; i <= n; i++) {
parent[i] = i;
}
for (int i = 1; i <= m; i++) {
if (!choose_edge[i]) {
continue;
}
int x = find_root(edges[i].u);
int y = find_root(edges[i].v);
if (x != y) {
parent[x] = y;
}
}
int root = 0;
for (int i = 1; i <= n; i++) {
if (!is_user[i]) {
continue;
}
if (root == 0) {
root = find_root(i);
} else if (root != find_root(i)) {
return false;
}
}
return true;
}
void dfs(int index, long long cost) {
if (cost >= answer) {
return;
}
if (index > m) {
if (check_connected()) {
answer = cost;
}
return;
}
choose_edge[index] = 0;
dfs(index + 1, cost);
choose_edge[index] = 1;
dfs(index + 1, cost + edges[index].w);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int test_count;
cin >> test_count;
while (test_count--) {
cin >> n >> m >> p;
string users;
cin >> users;
for (int i = 1; i <= n; i++) {
is_user[i] = users[i - 1] == '1';
}
for (int i = 1; i <= m; i++) {
cin >> edges[i].u >> edges[i].v >> edges[i].w;
}
answer = INF;
dfs(1, 0);
cout << answer << '\n';
}
return 0;
}这有
窗口状态设计
令插入设备
- 窗口每个位置是
0(该设备不选)或一个连通分量标签;相同标签表示已经连通。 - 每个连通分量额外记录它是否含有已处理的用户设备。
- 状态值是已选择边的最小费用。标签按从左到右第一次出现的顺序重新编号,使同一个划分只有一个编码。
插入与枚举连接
插入
这是充分的:任意可行解都可删去环变成森林;若新设备与同一个旧分量连了两次,也会产生环。边权非负,删去多余边不会变差。
样例窗口转移
下表展示官方样例处理设备 6 时的一轮前沿 DP。设备 1 和 3 已由边 1-3(费用 100)连成一个含用户的分量,0 表示空槽或未选设备。
| 阶段 | 窗口设备 | 分量标签 | 本轮选择 | 累加费用 |
|---|---|---|---|---|
插入 6 前 |
0,1,2,3,4,5 |
0,1,0,1,0,0 |
已有分量 1={1,3} |
- |
插入 6 |
0,1,2,3,4,5,6 |
0,1,0,1,0,0,1 |
选 3-6,费用 100 |
+100 |
| 忘记最左空槽后 | 1,2,3,4,5,6 |
1,0,1,0,0,1 |
分量仍在窗口内 | 不变 |
设备 6 还可以连边 1-6,但它和 3-6 指向同一旧分量,费用更高。DP 对该分量只保留 3-6,正好对应上面的删环结论。
离开窗口的判断
每次插入后忘记窗口最左设备:若它所在分量还留有其他窗口设备,继续保留;若整个分量不含用户,可以丢弃;若含用户却完全离开窗口,则它永远不能再与未来连边。这样的状态只有在没有未来用户、也没有其他含用户分量时才是一个完整答案,其余情况全部无效。
扫描完
代码
/**
* 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-07-31 16:21
* update_at: 2026-08-17 22:48
*/
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 505;
const int MAXP = 6;
const int STATE_BITS = 24;
const int STATE_COUNT = 1 << STATE_BITS;
const long long INF = (1LL << 60);
struct StateCost {
int code;
long long value;
};
int n, m, p;
int is_user[MAXN];
int suffix_user[MAXN + 2];
long long edge_cost[MAXN][MAXP + 1];
long long *best;
long long answer;
vector<StateCost> current_states;
vector<int> next_codes;
// 把窗口内的连通分量重新编号。标签按首次出现的位置编号,保证同一状态只有一种编码。
int pack_state(int label[]) {
int remap[8] = {};
int next_label = 0;
int code = 0;
int new_has_terminal[8] = {};
for (int i = 1; i <= p; i++) {
int old_label = label[i];
if (old_label == 0) {
continue;
}
if (remap[old_label] == 0) {
remap[old_label] = ++next_label;
new_has_terminal[next_label] = (label[7] >> old_label) & 1;
}
code |= remap[old_label] << (3 * (i - 1));
}
for (int i = 1; i <= next_label; i++) {
if (new_has_terminal[i]) {
code |= 1 << (18 + i - 1);
}
}
return code;
}
void update_next(int code, long long value) {
if (best[code] == INF) {
best[code] = value;
next_codes.push_back(code);
} else if (value < best[code]) {
best[code] = value;
}
}
// label[0] 是本轮离开窗口的设备;label[7] 的各 bit 记录原分量是否含用户设备。
void forget_oldest(int label[], long long value, int future_user_count) {
int leaving_label = label[0];
bool still_active = false;
for (int i = 1; i <= p; i++) {
if (label[i] == leaving_label) {
still_active = true;
}
}
if (leaving_label != 0 && !still_active && ((label[7] >> leaving_label) & 1)) {
bool has_other_user_component = false;
for (int i = 1; i <= p; i++) {
int component = label[i];
if (component != 0 && component != leaving_label
&& ((label[7] >> component) & 1)) {
has_other_user_component = true;
}
}
// 这个分量再也无法和未来连边。它只能恰好是包含所有用户的最终连通块。
if (!has_other_user_component && future_user_count == 0) {
answer = min(answer, value);
}
return;
}
update_next(pack_state(label), value);
}
void transfer_one_state(const StateCost &state, int position) {
int base_label[8] = {};
int max_label = 0;
for (int i = 0; i < p; i++) {
base_label[i] = (state.code >> (3 * i)) & 7;
max_label = max(max_label, base_label[i]);
}
for (int i = 1; i <= max_label; i++) {
if ((state.code >> (18 + i - 1)) & 1) {
base_label[7] |= 1 << i;
}
}
int future_user_count = position < n ? suffix_user[position + 1] : 0;
if (position > n) {
base_label[p] = 0;
forget_oldest(base_label, state.value, 0);
return;
}
// 非用户设备可以完全不选。
if (!is_user[position]) {
base_label[p] = 0;
forget_oldest(base_label, state.value, future_user_count);
}
// 选入当前设备,并枚举它分别连接哪些已有连通分量。
int new_label = max_label + 1;
base_label[p] = new_label;
int min_cost[8];
for (int i = 0; i < 8; i++) {
min_cost[i] = INT_MAX;
}
for (int distance = 1; distance <= p; distance++) {
int component = base_label[p - distance];
if (component != 0 && edge_cost[position][distance] < min_cost[component]) {
min_cost[component] = (int)edge_cost[position][distance];
}
}
int available[6];
int available_count = 0;
for (int component = 1; component <= max_label; component++) {
if (min_cost[component] != INT_MAX) {
available[available_count++] = component;
}
}
for (int mask = 0; mask < (1 << available_count); mask++) {
int label[8];
for (int i = 0; i < 8; i++) {
label[i] = base_label[i];
}
if (is_user[position]) {
label[7] |= 1 << new_label;
}
long long added_cost = 0;
for (int k = 0; k < available_count; k++) {
if ((mask & (1 << k)) == 0) {
continue;
}
int old_label = available[k];
added_cost += min_cost[old_label];
if ((label[7] >> old_label) & 1) {
label[7] |= 1 << new_label;
}
for (int i = 0; i < p; i++) {
if (label[i] == old_label) {
label[i] = new_label;
}
}
}
forget_oldest(label, state.value + added_cost, future_user_count);
}
}
void solve_one_case() {
cin >> n >> m >> p;
string users;
cin >> users;
for (int i = 1; i <= n; i++) {
is_user[i] = users[i - 1] == '1';
}
suffix_user[n + 1] = 0;
for (int i = n; i >= 1; i--) {
suffix_user[i] = suffix_user[i + 1] + is_user[i];
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= p; j++) {
edge_cost[i][j] = INF;
}
}
for (int i = 1; i <= m; i++) {
int u, v;
long long w;
cin >> u >> v >> w;
edge_cost[v][v - u] = w;
}
current_states.clear();
StateCost initial_state = {0, 0};
current_states.push_back(initial_state);
answer = INF;
for (int position = 1; position <= n + p; position++) {
next_codes.clear();
for (int i = 0; i < (int)current_states.size(); i++) {
transfer_one_state(current_states[i], position);
}
current_states.clear();
for (int i = 0; i < (int)next_codes.size(); i++) {
int code = next_codes[i];
StateCost next_state = {code, best[code]};
current_states.push_back(next_state);
best[code] = INF;
}
}
cout << answer << '\n';
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
best = new long long[STATE_COUNT];
fill(best, best + STATE_COUNT, INF);
int test_count;
cin >> test_count;
while (test_count--) {
solve_one_case();
}
delete[] best;
return 0;
}复杂度
设宽度为
代码按上界
总结
当图的边只会跨越很短的编号区间时,按编号扫描可以把全局连通性压缩为一个小窗口的分量划分。难点不是“是否选边”,而是设备离开窗口的瞬间必须判断:这个连通分量以后是否还有机会接回用户网络。
图示解析
这张图串起本题从带宽限制到最优网络的主线:
边只跨越至多 p 个编号
|- 扫描设备时只保留最近 p 个设备
| `- 状态记录窗口内的连通分量及其用户标记
|- 插入新设备:枚举它连接哪些旧分量
`- 最左设备离开窗口
|- 无用户分量:直接丢弃
`- 用户分量:必须继续留在窗口,或成为唯一最终分量
`- 虚拟空设备清空窗口,得到最小费用最左设备离开窗口后,编号差限制保证它不可能再和未来设备连边,所以此时检查用户分量是否断开是充分且必要的。 窗口宽度最多为 6,连通关系的状态总数固定;每一步只需枚举新设备与窗口分量的连接集合。