博物馆

用点双连通分量树把删点后的最大标记连通块转化为虚树上的路径统计。

OJ: shumeng

题目 ID: CSP202506E

难度:提高+/省选-

标签:点双连通分量虚树LCA

日期: 2026-07-31 16:21

形式化题目

连通无向图,每个节点上可能被标记。施工点 xx 被删除后,游客可以住在任意剩余节点,因此最多能参观的博物馆数量等于删除 xx 后各连通块中标记数的最大值。对每份攻略(一组标记点),求所有 xx 的该最大值之和。

思路

先看暴力做法:枚举每个删除点,对剩余图做连通块搜索:

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:58
 */
// brute.cpp:枚举施工点并 BFS 每个连通块,适合小图验证。
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m, q;
    cin >> n >> m >> q;
    vector<vector<int> > graph(n + 1);
    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        graph[u].push_back(v);
        graph[v].push_back(u);
    }
    while (q--) {
        int c;
        cin >> c;
        vector<int> weight(n + 1, 0);
        for (int i = 0; i < c; i++) {
            int x;
            cin >> x;
            weight[x] = 1;
        }
        long long answer = 0;
        for (int removed = 1; removed <= n; removed++) {
            vector<int> visited(n + 1, 0);
            int best = 0;
            for (int start = 1; start <= n; start++) {
                if (start == removed || visited[start]) {
                    continue;
                }
                int current = 0;
                queue<int> que;
                que.push(start);
                visited[start] = 1;
                while (!que.empty()) {
                    int u = que.front();
                    que.pop();
                    current += weight[u];
                    for (int i = 0; i < (int)graph[u].size(); i++) {
                        int v = graph[u][i];
                        if (v != removed && !visited[v]) {
                            visited[v] = 1;
                            que.push(v);
                        }
                    }
                }
                best = max(best, current);
            }
            answer += best;
        }
        cout << answer << '\n';
    }
    return 0;
}

点双树

用 Tarjan 求点双连通分量,建点双树:原图节点和每个点双分量都是树节点,节点属于某个分量就连边。删除原图节点 xx 后,点双树删掉 xx,它各相邻分支恰好对应原图中的各个连通块。于是问题变成:在点双树上对每个原图节点 xx,求删去它后各分支标记数的最大值。

虚树压缩

设攻略含 CC 个标记点。若不是割点,删除它只影响该点自身的标记,贡献为 C[x 被标记]C - [x\text{ 被标记}]。先累加基准 (n1)C(n-1)C,再修正那些"能改变答案的割点"——它们只会出现在标记点之间的路径上。

对标记点排序并补上相邻 LCA 后建虚树。对虚树节点 xx 记录子树标记数 sub[x]sub[x]

  • 节点 xx 的每个虚树子树分支权值为对应孩子的 subsub,父亲方向为 Csub[x]C - sub[x]
  • 虚树边中间的普通节点不改变分支构成,整条链上朝向孩子的分支数都相同,可用 DFS 序前缀统计链上的割点个数后批量修正。

代码

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:58
 */
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100000;
const int MAXM = 200000;
const int MAXV = MAXN + MAXM + 5;
const int LOG = 20;

struct Edge {
    int to;
    int id;
};

int n, m, q;
vector<Edge> graph[MAXN + 1];
int edge_u[MAXM + 1];
int edge_v[MAXM + 1];
int discovery[MAXN + 1];
int low[MAXN + 1];
int dfs_clock;
vector<int> edge_stack;

vector<int> block_tree[MAXV];
int total_nodes;
int component_seen[MAXN + 1];
int component_id;
bool is_articulation[MAXV];

int tree_parent[MAXV];
int tree_depth[MAXV];
int tree_tin[MAXV];
int tree_tout[MAXV];
int tree_timer;
int up[LOG][MAXV];
int articulation_prefix[MAXV];

int mark_stamp[MAXV];   // 标记节点最近一次属于哪个查询
int virtual_index[MAXV]; // 虚树节点在原数组中的下标

// 按 DFS 序比较两个节点,用于排序后建虚树
bool tin_less(int x, int y) {
    return tree_tin[x] < tree_tin[y];
}

void add_block() {
    component_id++;
    vector<int> vertices;
    while (true) {
        int edge_id = edge_stack.back();
        edge_stack.pop_back();
        int x = edge_u[edge_id];
        int y = edge_v[edge_id];
        if (component_seen[x] != component_id) {
            component_seen[x] = component_id;
            vertices.push_back(x);
        }
        if (component_seen[y] != component_id) {
            component_seen[y] = component_id;
            vertices.push_back(y);
        }
        if (edge_id == -1) {
            break;
        }
    }
    // The marker -1 is not used; this branch is kept unreachable.
}

void make_block_until(int stop_edge) {
    component_id++;
    vector<int> vertices;
    while (true) {
        int edge_id = edge_stack.back();
        edge_stack.pop_back();
        int x = edge_u[edge_id];
        int y = edge_v[edge_id];
        if (component_seen[x] != component_id) {
            component_seen[x] = component_id;
            vertices.push_back(x);
        }
        if (component_seen[y] != component_id) {
            component_seen[y] = component_id;
            vertices.push_back(y);
        }
        if (edge_id == stop_edge) {
            break;
        }
    }
    total_nodes++;
    int block = total_nodes;
    for (int i = 0; i < (int)vertices.size(); i++) {
        block_tree[block].push_back(vertices[i]);
        block_tree[vertices[i]].push_back(block);
    }
}

void tarjan(int u, int parent_edge) {
    discovery[u] = low[u] = ++dfs_clock;
    for (int i = 0; i < (int)graph[u].size(); i++) {
        int v = graph[u][i].to;
        int edge_id = graph[u][i].id;
        if (edge_id == parent_edge) {
            continue;
        }
        if (discovery[v] == 0) {
            edge_stack.push_back(edge_id);
            tarjan(v, edge_id);
            low[u] = min(low[u], low[v]);
            if (low[v] >= discovery[u]) {
                make_block_until(edge_id);
            }
        } else if (discovery[v] < discovery[u]) {
            edge_stack.push_back(edge_id);
            low[u] = min(low[u], discovery[v]);
        }
    }
}

void build_tree_info() {
    vector<int> iterator_index(total_nodes + 1, 0);
    vector<int> stack_nodes;
    stack_nodes.push_back(1);
    tree_parent[1] = 0;
    tree_depth[1] = 0;
    while (!stack_nodes.empty()) {
        int u = stack_nodes.back();
        if (tree_tin[u] == 0) {
            tree_tin[u] = ++tree_timer;
            articulation_prefix[u] = (u <= n && is_articulation[u]) ? 1 : 0;
            if (tree_parent[u] != 0) {
                articulation_prefix[u] += articulation_prefix[tree_parent[u]];
            }
        }
        if (iterator_index[u] == (int)block_tree[u].size()) {
            tree_tout[u] = tree_timer;
            stack_nodes.pop_back();
            continue;
        }
        int v = block_tree[u][iterator_index[u]++];
        if (v == tree_parent[u]) {
            continue;
        }
        tree_parent[v] = u;
        tree_depth[v] = tree_depth[u] + 1;
        stack_nodes.push_back(v);
    }

    for (int u = 1; u <= total_nodes; u++) {
        up[0][u] = tree_parent[u];
    }
    for (int j = 1; j < LOG; j++) {
        for (int u = 1; u <= total_nodes; u++) {
            up[j][u] = up[j - 1][up[j - 1][u]];
        }
    }
}

bool is_ancestor(int u, int v) {
    return tree_tin[u] <= tree_tin[v] && tree_tout[v] <= tree_tout[u];
}

int lca(int u, int v) {
    if (is_ancestor(u, v)) {
        return u;
    }
    if (is_ancestor(v, u)) {
        return v;
    }
    int x = u;
    for (int j = LOG - 1; j >= 0; j--) {
        if (up[j][x] != 0 && !is_ancestor(up[j][x], v)) {
            x = up[j][x];
        }
    }
    return tree_parent[x];
}

int marked_value(int u, int query_id) {
    return mark_stamp[u] == query_id ? 1 : 0;
}

// 单份攻略:在虚树上统计删去每个割点后各分支标记数的最大值之和
long long solve_query(const vector<int> &marked, int query_id) {
    int count_marked = (int)marked.size();
    vector<int> nodes = marked;
    sort(nodes.begin(), nodes.end(), tin_less);
    int original_size = (int)nodes.size();
    // 补上相邻节点的 LCA,构成虚树节点集合
    for (int i = 1; i < original_size; i++) {
        nodes.push_back(lca(nodes[i - 1], nodes[i]));
    }
    sort(nodes.begin(), nodes.end(), tin_less);
    nodes.erase(unique(nodes.begin(), nodes.end()), nodes.end());

    int size = (int)nodes.size();
    for (int i = 0; i < size; i++) {
        virtual_index[nodes[i]] = i;
    }
    vector<int> parent_index(size, -1);
    vector<int> stack_nodes;
    for (int i = 0; i < size; i++) {
        while (!stack_nodes.empty() && !is_ancestor(stack_nodes.back(), nodes[i])) {
            stack_nodes.pop_back();
        }
        if (!stack_nodes.empty()) {
            parent_index[i] = virtual_index[stack_nodes.back()];
        }
        stack_nodes.push_back(nodes[i]);
    }

    vector<int> subtree_count(size, 0);
    for (int i = 0; i < size; i++) {
        subtree_count[i] = marked_value(nodes[i], query_id);
    }
    for (int i = size - 1; i > 0; i--) {
        subtree_count[parent_index[i]] += subtree_count[i];
    }

    vector<int> maximum_child(size, 0);
    for (int i = 1; i < size; i++) {
        int p = parent_index[i];
        maximum_child[p] = max(maximum_child[p], subtree_count[i]);
    }

    // 基准:非割点的贡献恒为 C - w_x(删去它只少一个标记点),累加 n-1 次
    long long answer = 1LL * (n - 1) * count_marked;
    // 修正割点:比较各分支标记数与补集,取最大值
    for (int i = 0; i < size; i++) {
        int u = nodes[i];
        if (u <= n && is_articulation[u]) {
            int best = max(maximum_child[i], count_marked - subtree_count[i]);
            int normal = count_marked - marked_value(u, query_id);
            answer += best - normal;
        }
    }

    // 虚树边中间的普通节点:整条链上分支标记数相同,按割点个数批量统计
    for (int i = 1; i < size; i++) {
        int child = nodes[i];
        int parent = nodes[parent_index[i]];
        int between = articulation_prefix[child] - articulation_prefix[parent]
                      - ((child <= n && is_articulation[child]) ? 1 : 0);
        if (between == 0) {
            continue;
        }
        int branch = subtree_count[i];
        int best = max(branch, count_marked - branch);
        answer += 1LL * between * (best - count_marked);
    }
    return answer;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> m >> q;
    for (int i = 1; i <= m; i++) {
        cin >> edge_u[i] >> edge_v[i];
        graph[edge_u[i]].push_back({edge_v[i], i});
        graph[edge_v[i]].push_back({edge_u[i], i});
    }
    total_nodes = n;
    tarjan(1, 0);
    for (int u = 1; u <= n; u++) {
        is_articulation[u] = block_tree[u].size() > 1;
    }
    build_tree_info();

    for (int query_id = 1; query_id <= q; query_id++) {
        int c;
        cin >> c;
        vector<int> marked(c);
        for (int i = 0; i < c; i++) {
            cin >> marked[i];
            mark_stamp[marked[i]] = query_id;
        }
        cout << solve_query(marked, query_id) << '\n';
    }
    return 0;
}

复杂度

预处理 Tarjan 与 LCA 为 O(n+m)O(n+m)。设所有攻略的标记点总数为 SS,每份攻略排序建虚树总复杂度 O(SlogS)O(S \log S),空间复杂度 O(n+m)O(n+m)

总结

点双树完整刻画了"删点后连通块的结构",虚树把"所有标记点之间的路径"压缩出来,使长链上的相同贡献能一次统计,避免了逐个枚举割点。