网络连接

利用边只跨越至多 6 个编号的限制,维护窗口连通分量做 Steiner Tree 前沿 DP。

OJ: shumeng

题目 ID: CSP201604E

难度:提高+/省选-

标签:状态压缩连通性DP轮廓DP图论Steiner Tree

日期: 2026-07-31 16:21

形式化题目

给定 nn 个设备的带权无向图,其中可以建立的连线 (u,v)(u, v) 一定满足 uvp|u - v| \le p。若干设备是用户设备。可以选择任意设备作中继并挑选若干连线,要求所有用户设备互相连通,求所选边权和的最小值。

思路

小数据可以直接枚举每条边是否选择,然后用并查集检查所有用户设备是否在同一集合中。

cpp
/**
 * 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;
}

这有 2m2^m 种选择,无法处理正式数据。关键是按设备编号从小到大扫描时,处理到设备 ii 后,编号小于 ipi-p 的设备不可能再与未来设备相连。因此,未来真正能接触到的只有最近 pp 个设备。

窗口状态设计

令插入设备 ii 前的窗口为 ip,ip+1,,i1i-p, i-p+1, \ldots, i-1,编号不在 1n1 \sim n 内的位置视为空。一个 DP 状态记录:

  • 窗口每个位置是 0(该设备不选)或一个连通分量标签;相同标签表示已经连通。
  • 每个连通分量额外记录它是否含有已处理的用户设备。
  • 状态值是已选择边的最小费用。标签按从左到右第一次出现的顺序重新编号,使同一个划分只有一个编码。

插入与枚举连接

插入 ii 时,用户设备必须选入,普通设备还可以直接不选。选入时先把它作为新分量;对于每个能与它连边的旧分量,只保留其中最便宜的一条边,再枚举连接哪些旧分量。

这是充分的:任意可行解都可删去环变成森林;若新设备与同一个旧分量连了两次,也会产生环。边权非负,删去多余边不会变差。

样例窗口转移

下表展示官方样例处理设备 6 时的一轮前沿 DP。设备 13 已由边 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,正好对应上面的删环结论。

离开窗口的判断

每次插入后忘记窗口最左设备:若它所在分量还留有其他窗口设备,继续保留;若整个分量不含用户,可以丢弃;若含用户却完全离开窗口,则它永远不能再与未来连边。这样的状态只有在没有未来用户、也没有其他含用户分量时才是一个完整答案,其余情况全部无效。

扫描完 nn 后,再插入 pp 个虚拟的空设备,以相同规则清空窗口并触发最终答案检查。

代码

cpp
/**
 * 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;
}

复杂度

设宽度为 pp 的合法窗口状态数为 SS。每个状态最多枚举 2p2^p 个连接分量集合,每次转移和状态规范化耗时 O(p)O(p),总时间复杂度为 O(nS2pp)O(nS2^p p)。由于 p6p \le 6SS 是常数,实际对 nn 线性。

代码按上界 p=6p=6 固定分配状态表:每个槽位标签编码为 3 位,并使用 6 位记录分量的用户标记,共 2242^{24} 个位置,约占 128MB。

总结

当图的边只会跨越很短的编号区间时,按编号扫描可以把全局连通性压缩为一个小窗口的分量划分。难点不是“是否选边”,而是设备离开窗口的瞬间必须判断:这个连通分量以后是否还有机会接回用户网络。

图示解析

这张图串起本题从带宽限制到最优网络的主线:

text
边只跨越至多 p 个编号
|- 扫描设备时只保留最近 p 个设备
|  `- 状态记录窗口内的连通分量及其用户标记
|- 插入新设备:枚举它连接哪些旧分量
`- 最左设备离开窗口
   |- 无用户分量:直接丢弃
   `- 用户分量:必须继续留在窗口,或成为唯一最终分量
      `- 虚拟空设备清空窗口,得到最小费用

最左设备离开窗口后,编号差限制保证它不可能再和未来设备连边,所以此时检查用户分量是否断开是充分且必要的。 窗口宽度最多为 6,连通关系的状态总数固定;每一步只需枚举新设备与窗口分量的连接集合。