[CSP-S 2022] 星战

GitHub跳转原题关系图返回列表

给每个据点一个 64 位权值,用全图可用虫洞的权值和维护出度状态,判断是否所有据点出度恰好为 1。

OJ: luogu

题目 ID: P8819

难度:提高+/省选-

标签:图论哈希模拟

创建: 2026-07-06 08:46

更新: 2026-10-07 10:45

形式化题目

给定一张 nn 个据点、mm 条有向虫洞的图,初始时所有虫洞都可用。虫洞按终点归组:终点为 vv 的全部虫洞称为据点 vv 的虫洞。

依次执行 qq 条指令:

  • t=1t=1:摧毁虫洞 u→vu\to v;
  • t=2t=2:摧毁据点 vv 的全部虫洞(即所有终点为 vv 的虫洞);
  • t=3t=3:修复虫洞 u→vu\to v;
  • t=4t=4:修复据点 vv 的全部虫洞。

每次指令执行后,判断当前时刻能否反攻。

反攻要求两件事同时成立:

  1. 实现反击:从任一据点出发都能无限穿梭(可重复经过同一据点或虫洞);
  2. 实现连续穿梭:每个据点恰好只有一条可用虫洞从它出发。

条件 2 明确给出“每个据点的可用出度恰好为 11”。而一旦每个据点出度都恰好为 11,从任意据点走每一步都只有一条出边可选,图是有限的,必然走进一个环,从而可以无限穿梭,条件 1 自动满足。

因此判定条件可以压缩成一句话:

每个据点当前的可用出度恰好等于 1. \text{每个据点当前的可用出度恰好等于 } 1.

暴力解法

思路

用二维数组记录每条虫洞是否存在、是否可用,每次指令后扫描所有据点统计出度,判断是否全部等于 11。

代码

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-10-01 22:28
 * update_at: 2026-10-01 22:28
 */
// brute.cpp:小数据暴力解,逐条维护虫洞是否可用,每次指令后重新统计各据点出度。
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

const int MAXN = 35;   // 暴力只服务小数据,n 取到 30 左右

int n, m, q;
bool exist_edge[MAXN][MAXN]; // exist_edge[u][v]:原图是否存在虫洞 u -> v
bool active_edge[MAXN][MAXN]; // active_edge[u][v]:虫洞 u -> v 当前是否可用

// 反攻时刻要求每个据点恰好有一条可用出边。
bool can_counterattack() {
    for (int u = 1; u <= n; u++) {
        int out_degree = 0;
        for (int v = 1; v <= n; v++) {
            if (active_edge[u][v]) {
                out_degree++;
            }
        }
        if (out_degree != 1) {
            return false;
        }
    }
    return true;
}

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

    cin >> n >> m;
    for (int i = 1; i <= m; i++) {
        int u, v;
        cin >> u >> v;
        exist_edge[u][v] = true;
        active_edge[u][v] = true;
    }

    cin >> q;
    while (q--) {
        int type;
        cin >> type;

        if (type == 1) {
            // 摧毁虫洞 u -> v。
            int u, v;
            cin >> u >> v;
            active_edge[u][v] = false;
        } else if (type == 2) {
            // 摧毁据点 v 的全部虫洞,即终点为 v 的那些。
            int v;
            cin >> v;
            for (int u = 1; u <= n; u++) {
                if (exist_edge[u][v]) {
                    active_edge[u][v] = false;
                }
            }
        } else if (type == 3) {
            // 修复虫洞 u -> v。
            int u, v;
            cin >> u >> v;
            if (exist_edge[u][v]) {
                active_edge[u][v] = true;
            }
        } else {
            // 修复据点 v 的全部虫洞。
            int v;
            cin >> v;
            for (int u = 1; u <= n; u++) {
                if (exist_edge[u][v]) {
                    active_edge[u][v] = true;
                }
            }
        }

        cout << (can_counterattack() ? "YES" : "NO") << '\n';
    }

    return 0;
}

复杂度

每次指令后重新统计出度是 O(n2)O(n^2)(用邻接矩阵时),总时间复杂度 O(qn2)O(qn^2),空间 O(n2)O(n^2)。

瓶颈

n,m,qn,m,q 都可以到 5×1055\times10^5,O(qn2)O(qn^2) 完全不可行。真正的困难在于:t=2t=2 / t=4t=4 这两条指令会成批改动“终点为 vv 的所有虫洞”,而每条虫洞的源点出度各不相同,逐条更新无法避免线性代价。

所以必须换一种表示:不再记录每个据点出度的精确数值,而是用一个全局聚合量间接判断“是否所有出度都等于 11”。

正解

思路

用权值和代表出度向量

给每个据点 uu 一个固定 64 位权值 w[u]w[u]。把每条当前可用的虫洞 u→vu\to v 记作对全局贡献 w[u]w[u],那么全图的贡献和是

current=∑uw[u]⋅outdeg(u), current=\sum_{u} w[u]\cdot outdeg(u),

其中 outdeg(u)outdeg(u) 是据点 uu 当前的可用出度。

如果所有出度都恰好为 11,那么

current=∑uw[u]=target. current=\sum_u w[u]=target.

反过来,若某个据点出度不是 11,则

current−target=∑uw[u]⋅(outdeg(u)−1) current-target=\sum_u w[u]\cdot(outdeg(u)-1)

是一个非零整数线性组合。只要 w[u]w[u] 取得足够“随机”,这个组合撞上 00 的概率就极低。用 64 位哈希时,5×1055\times10^5 次询问里出现一次碰撞的概率约 2.7×10−142.7\times10^{-14},在竞赛里可以接受。

代码用 splitmix64 把编号打散成权值,并刻意选用 unsigned long long:哈希依赖 2642^{64} 的自然回绕来做模运算,而有符号溢出是未定义行为,不能当取模用。

为什么还要维护每终点的入边和

t=2t=2 / t=4t=4 要批量改动“终点为 vv 的全部虫洞”。如果只维护一个全局 current_sum,就没法一次把这批虫洞的贡献扣掉。于是再维护两个数组:

  • full_in_sum[v]full\_in\_sum[v]:原图中所有终点为 vv 的虫洞的源点权值和;
  • cur_in_sum[v]cur\_in\_sum[v]:当前可用、终点为 vv 的虫洞的源点权值和。

四条指令就都变成常数次加减:

指令 对 current_sum 的改动 对 cur_in_sum 的改动
t=1t=1:摧毁 u→vu\to v − w[u]-\ w[u] cur_in_sum[v]−=w[u]cur\_in\_sum[v]\mathrel{-}=w[u]
t=2t=2:摧毁据点 vv − cur_in_sum[v]-\ cur\_in\_sum[v] cur_in_sum[v]←0cur\_in\_sum[v]\leftarrow 0
t=3t=3:修复 u→vu\to v + w[u]+\ w[u] cur_in_sum[v]+=w[u]cur\_in\_sum[v]\mathrel{+}=w[u]
t=4t=4:修复据点 vv + (full_in_sum[v]−cur_in_sum[v])+\ (full\_in\_sum[v]-cur\_in\_sum[v]) cur_in_sum[v]←full_in_sum[v]cur\_in\_sum[v]\leftarrow full\_in\_sum[v]

每次指令后只需判断 current_sum==target_sumcurrent\_sum == target\_sum。

与代码的对应

行内变量 含义
value_of_node[u] 据点 uu 的权值 w[u]w[u]
full_in_sum[v] full_in_sum[v]full\_in\_sum[v]
cur_in_sum[v] cur_in_sum[v]cur\_in\_sum[v]
target_sum ∑uw[u]\sum_u w[u]
current_sum 当前全图贡献和

代码

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-10-01 22:28
 * update_at: 2026-10-01 22:28
 */
// main.cpp:给每个据点一个 64 位权值,用全图可用边的权值和判断是否所有据点出度恰好为 1。
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

const int MAXN = 500005;   // n <= 5e5

int n, m, q;

// 权值数组用 unsigned long long,因为哈希要依赖 2^64 的自然回绕做模运算。
// 这里刻意选无符号:有符号溢出是未定义行为,无法当作取模。
unsigned long long value_of_node[MAXN]; // value_of_node[u]:据点 u 的固定权值
unsigned long long full_in_sum[MAXN];   // full_in_sum[v]:原图中所有终点为 v 的虫洞的源点权值和
unsigned long long cur_in_sum[MAXN];    // cur_in_sum[v]:当前可用、终点为 v 的虫洞的源点权值和
unsigned long long target_sum;          // 所有据点出度都恰好为 1 时的全图贡献和
unsigned long long current_sum;         // 当前可用虫洞的全图贡献和

// splitmix64:把一个整数打散成 64 位权值,只是为了让不同据点的权值互不相关。
unsigned long long splitmix64(unsigned long long x) {
    x += 0x9e3779b97f4a7c15ULL;
    x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9ULL;
    x = (x ^ (x >> 27)) * 0x94d049bb133111ebULL;
    return x ^ (x >> 31);
}

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

    cin >> n >> m;
    for (ll i = 1; i <= n; i++) {
        value_of_node[i] = splitmix64(i);
        target_sum += value_of_node[i];
    }

    // 一条可用虫洞 u -> v 贡献 value_of_node[u],于是
    // current_sum = sum( value_of_node[u] * 据点 u 当前的出度 )。
    for (ll i = 1; i <= m; i++) {
        ll u, v;
        cin >> u >> v;
        full_in_sum[v] += value_of_node[u];
        cur_in_sum[v] += value_of_node[u];
        current_sum += value_of_node[u];
    }

    cin >> q;
    while (q--) {
        ll type;
        cin >> type;

        if (type == 1) {
            // 摧毁虫洞 u -> v,它对全图贡献 value_of_node[u]。
            ll u, v;
            cin >> u >> v;
            cur_in_sum[v] -= value_of_node[u];
            current_sum -= value_of_node[u];
        } else if (type == 2) {
            // 摧毁据点 v 的全部虫洞,也就是终点为 v 的所有可用虫洞。
            ll v;
            cin >> v;
            current_sum -= cur_in_sum[v];
            cur_in_sum[v] = 0;
        } else if (type == 3) {
            // 修复虫洞 u -> v。
            ll u, v;
            cin >> u >> v;
            cur_in_sum[v] += value_of_node[u];
            current_sum += value_of_node[u];
        } else {
            // 修复据点 v 的全部虫洞,恢复成原图中终点为 v 的那批。
            ll v;
            cin >> v;
            current_sum += full_in_sum[v] - cur_in_sum[v];
            cur_in_sum[v] = full_in_sum[v];
        }

        if (current_sum == target_sum) {
            cout << "YES\n";
        } else {
            cout << "NO\n";
        }
    }

    return 0;
}

复杂度

  • 时间复杂度:初始化 O(n+m)O(n+m),每次指令 O(1)O(1),总计 O(n+m+q)O(n+m+q)。
  • 空间复杂度:O(n)O(n),只存三个权值数组和几个全局量。

总结

这题的题意可以先压缩成一句话:每个据点当前可用出度恰好为 11。两个看似不同的条件——“能无限穿梭”和“恰好一条可用出边”——后者成立时前者必然成立。

优化的真正难点不在判定,而在于 t=2t=2 / t=4t=4 会成批改动终点相同的虫洞。解法是不再维护出度向量本身,而是用每个据点一个 64 位权值把出度向量压成一个标量:

  • 全局 current_sum 代表 ∑uw[u]⋅outdeg(u)\sum_u w[u]\cdot outdeg(u);
  • 每终点的 cur_in_sum[v] 让批量修改降成一次加减。

这类“用随机权值把集合/计数状态压成哈希和”的技巧,在需要支持批量增删、只问整体状态是否符合某个模式的题目里非常通用。代价是答案带极小的碰撞概率,属于随机化算法。

本文的 main.cpp 已用逐条统计出度的 brute.cpp 对拍 300300 组随机数据,题面样例通过。