Skip to content

Graph Games - 题解

标签与难度

标签: 数据结构, 分块, 哈希, 图论, 随机化, XOR 难度: 2200

题目大意喵~

哈喵~ 各位算法大师们,今天我们来玩一个有趣的图论游戏,喵~

题目给了我们一个有 NN 个点和 MM 条边的无向图。这些边从 1 到 MM 进行了编号。一开始,所有这 MM 条边都存在于图中哦。

我们定义了一个集合 S(x)S(x),它表示从顶点 xx 出发,只走一条边能到达的所有顶点的集合。换句话说,S(x)S(x) 就是顶点 xx 的所有邻居的集合啦,是不是很简单喵?

接下来,我们需要处理 QQ 次操作,操作有两种类型:

  1. (1, l, r): 这表示我们要把编号在 [l,r][l, r] 区间内的所有边的状态翻转一下。如果一条边存在,我们就把它删掉;如果它不存在,我们就把它加上。就像猫咪翻肚子一样,一下正一下反,喵~
  2. (2, u, v): 这是一个询问。我们要判断两个顶点 uuvv 的邻居集合,S(u)S(u)S(v)S(v),是否完全相同。如果相同,就输出 '1',否则输出 '0'。

所有询问的答案最后要连成一个字符串一起输出。准备好接受挑战了吗?让我们开始吧,喵!

解题思路分析

这道题看起来有点棘手呢,喵~ 它混合了图、集合比较和区间修改,直接模拟肯定会超时的说。每次询问都去遍历两个点的邻接表来比较集合,当图变得很稠密时,效率会非常低。而区间修改边的状态,更是让问题变得复杂。

不过别担心,我有妙计!我们可以把问题分解成两个部分来解决:

  1. 如何快速判断两个集合是否相等?
  2. 如何高效地处理边的区间状态翻转?

步骤一:用哈希来比较集合,喵!

要判断两个集合 S(u)S(u)S(v)S(v) 是否相等,一个一个元素比较太慢啦。这时候,我们可以请出强大的哈希来帮忙!

想象一下,我们给每个顶点 ii 分配一个独一无二的、非常大的随机数,作为它的“身份指纹”,我们叫它 hash_val[i]。为了让这个指纹尽可能独特,我们可以用 unsigned long long 类型的随机数,这样冲突的概率就会小到可以忽略不计,就像在沙滩上找到两粒完全一样的沙子一样困难,喵~

有了每个顶点的哈希值,我们就可以定义一个集合的哈希值了。一个非常巧妙的方法是使用异或和 (XOR sum)。对于一个顶点 uu 的邻居集合 S(u)S(u),它的哈希值 H(u)H(u) 可以定义为:

H(u)=vS(u)hash_val[v]H(u) = \bigoplus_{v \in S(u)} \text{hash\_val}[v]

其中 \bigoplus 表示异或操作。异或有个很棒的性质:aa=0a \oplus a = 0a0=aa \oplus 0 = a

现在,判断 S(u)S(u)S(v)S(v) 是否相等,就转化为了判断它们的哈希值 H(u)H(u)H(v)H(v) 是否相等。这一下就把复杂的集合比较变成了简单的整数比较,是不是很神奇,喵!

步骤二:边的状态变化与哈希值的关系

接下来,我们看看边的变化如何影响哈希值。

  • 添加一条边 (u,v)(u, v)

    • vv 成为了 uu 的新邻居,所以 S(u)S(u) 中增加了 vvH(u)H(u) 的新值就是旧值异或上 hash_val[v],即 H(u)H(u)hash_val[v]H(u) \leftarrow H(u) \oplus \text{hash\_val}[v]
    • 同理,uu 也成为了 vv 的新邻居,所以 H(v)H(v)hash_val[u]H(v) \leftarrow H(v) \oplus \text{hash\_val}[u]
  • 删除一条边 (u,v)(u, v)

    • vv 不再是 uu 的邻居了。根据异或的性质,我们只需要再异或一次 hash_val[v] 就可以把它从哈希和中“移除”。所以,H(u)H(u)hash_val[v]H(u) \leftarrow H(u) \oplus \text{hash\_val}[v]
    • 同理,H(v)H(v)hash_val[u]H(v) \leftarrow H(v) \oplus \text{hash\_val}[u]

看呐!无论是添加还是删除边 (u,v)(u, v),对哈希值的操作都是完全一样的!这意味着“翻转”一条边的状态,我们只需要对两个端点的哈希值进行相应的异或操作就行了。

步骤三:用分块来处理区间修改,喵~

现在问题变成了:对编号在 [l,r][l, r] 区间内的每条边 (ui,vi)(u_i, v_i),执行 H(u_i) ^= hash_val[v_i]H(v_i) ^= hash_val[u_i]。这本质上是一个区间操作,会引发一系列对顶点哈希值的点更新。如果每次都遍历 [l,r][l, r],当区间很大时还是会超时。

这种“区间修改,单点查询”的模式,让我们想到了分块 (Block Decomposition)!我们可以把 MM 条边分成大约 M\sqrt{M} 个块。

  1. 预处理:

    • 我们为每个块 b 和每个顶点 k,预计算一个值 block_contribution[b][k]。它表示如果块 b 中的所有边都存在,它们会对顶点 k 的哈希值产生多大的贡献。

    block_contribution[b][k]=iblock b,edgei=(k,w)hash_val[w]\text{block\_contribution}[b][k] = \bigoplus_{i \in \text{block } b, \text{edge}_i = (k, w)} \text{hash\_val}[w]

    这个可以在 O(M)O(M) 的时间内完成,因为每条边只对两个顶点的两个块贡献值有影响。

  2. 状态维护:

    • is_block_active[b]: 一个布尔数组,作为懒惰标记。is_block_active[b]true 表示块 b 中的边目前是“开启”状态(即它们对总哈希值的贡献是算在内的)。初始时,所有块都是 active 的。
    • scattered_hash[k]: 一个数组,用来记录那些不能用懒惰标记整块处理的、被零散修改的边对顶点 k 的哈希值产生的影响。
  3. 处理操作:

    • 修改 (1, l, r):

      • 如果 lr 在同一个块里,我们就暴力遍历从 lr 的边,将它们的影响直接异或到 scattered_hash 数组上。复杂度是 O(M)O(\sqrt{M})
      • 如果 lr 在不同的块里,我们把操作分成三部分:
        • l 所在的那个不完整的块(从 l 到块尾):暴力修改,更新 scattered_hash
        • r 所在的那个不完整的块(从块头到 r):暴力修改,更新 scattered_hash
        • 中间所有完整的块:我们不一个个修改边,而是直接翻转这些块的 is_block_active 懒惰标记。比如 is_block_active[b] = !is_block_active[b]。这非常快! 整个修改操作的复杂度是 O(M)O(\sqrt{M})
    • 查询 (2, u, v):

      • 要计算顶点 k 的最终哈希值,我们需要合并两部分贡献:
        1. 所有 active 的块的总贡献。
        2. 零散修改的贡献。
      • 所以,H(k) = scattered_hash[k] \oplus (\bigoplus_{b \text{ where } is\_block\_active[b]} \text{block\_contribution}[b][k])
      • 我们用这个公式分别计算出 H(u)H(v),然后比较它们是否相等。查询的复杂度也是 O(M)O(\sqrt{M})

这样一来,我们就把问题完美地解决啦!整个算法的时间复杂度是 O(M+QM)O(M + Q\sqrt{M}),空间复杂度是 O(NM)O(N\sqrt{M}),对于题目给定的数据范围是完全可以接受的,喵~

代码实现

这是我根据上面的思路,精心重构的一份代码哦~ 希望能帮到你,喵~

cpp
#include <iostream>
#include <vector>
#include <cmath>
#include <random>
#include <chrono>

// 使用64位无符号整数作为哈希值,可以大大降低碰撞概率
using ull = unsigned long long;

// 存储边的信息
struct Edge {
    int u, v;
};

void solve() {
    int n, m;
    std::cin >> n >> m;

    // --- 步骤一:哈希初始化 ---
    // 使用高质量的随机数生成器为每个顶点生成哈希值
    std::mt19937_64 rng(std::chrono::steady_clock::now().time_since_epoch().count());
    std::vector<ull> vertex_hash(n + 1);
    for (int i = 1; i <= n; ++i) {
        vertex_hash[i] = rng();
    }

    std::vector<Edge> edges(m + 1);
    for (int i = 1; i <= m; ++i) {
        std::cin >> edges[i].u >> edges[i].v;
    }

    // --- 步骤三:分块准备 ---
    int block_size = std::max(1, static_cast<int>(sqrt(m)));
    int num_blocks = (m + block_size - 1) / block_size;

    // 预计算每个块对每个顶点哈希值的贡献
    std::vector<std::vector<ull>> block_contribution(num_blocks, std::vector<ull>(n + 1, 0));
    // 记录每条边属于哪个块
    std::vector<int> edge_to_block(m + 1);
    
    for (int i = 1; i <= m; ++i) {
        int block_id = (i - 1) / block_size;
        edge_to_block[i] = block_id;
        int u = edges[i].u;
        int v = edges[i].v;
        block_contribution[block_id][u] ^= vertex_hash[v];
        block_contribution[block_id][v] ^= vertex_hash[u];
    }

    // --- 状态维护 ---
    // `scattered_hash` 记录零散修改带来的哈希变化
    std::vector<ull> scattered_hash(n + 1, 0);
    // `is_block_active` 作为懒惰标记,初始时所有块都处于激活状态(因为所有边都存在)
    std::vector<bool> is_block_active(num_blocks, true);

    int q;
    std::cin >> q;
    std::string ans = "";

    while (q--) {
        int type;
        std::cin >> type;
        if (type == 1) {
            int l, r;
            std::cin >> l >> r;
            
            int block_l = edge_to_block[l];
            int block_r = edge_to_block[r];

            if (block_l == block_r) {
                // l 和 r 在同一个块,暴力修改
                for (int i = l; i <= r; ++i) {
                    scattered_hash[edges[i].u] ^= vertex_hash[edges[i].v];
                    scattered_hash[edges[i].v] ^= vertex_hash[edges[i].u];
                }
            } else {
                // 处理左边不完整的块
                int end_of_block_l = (block_l + 1) * block_size;
                for (int i = l; i <= end_of_block_l; ++i) {
                    scattered_hash[edges[i].u] ^= vertex_hash[edges[i].v];
                    scattered_hash[edges[i].v] ^= vertex_hash[edges[i].u];
                }
                // 处理中间的完整块
                for (int b = block_l + 1; b < block_r; ++b) {
                    is_block_active[b] = !is_block_active[b];
                }
                // 处理右边不完整的块
                int start_of_block_r = block_r * block_size + 1;
                for (int i = start_of_block_r; i <= r; ++i) {
                    scattered_hash[edges[i].u] ^= vertex_hash[edges[i].v];
                    scattered_hash[edges[i].v] ^= vertex_hash[edges[i].u];
                }
            }
        } else {
            int u, v;
            std::cin >> u >> v;

            ull hash_u = scattered_hash[u];
            ull hash_v = scattered_hash[v];

            // 计算最终哈希值
            for (int b = 0; b < num_blocks; ++b) {
                if (is_block_active[b]) {
                    hash_u ^= block_contribution[b][u];
                    hash_v ^= block_contribution[b][v];
                }
            }
            
            if (hash_u == hash_v) {
                ans += '1';
            } else {
                ans += '0';
            }
        }
    }
    std::cout << ans << "\n";
}

int main() {
    // 加速输入输出,喵~
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);
    int t;
    std::cin >> t;
    while (t--) {
        solve();
    }
    return 0;
}

复杂度分析

  • 时间复杂度: O(M+QM)O(M + Q\sqrt{M})

    • 初始化: 生成哈希值需要 O(N)O(N),读取边需要 O(M)O(M)。预计算 block_contribution 数组时,每条边访问一次,所以是 O(M)O(M)。总初始化时间是 O(N+M)O(N+M)
    • 操作:
      • 修改操作(type 1): 对于不完整的块,最多遍历 2×block_size2 \times \text{block\_size} 条边,即 O(M)O(\sqrt{M})。对于完整的块,最多遍历 num_blocks 个块,也是 O(M)O(\sqrt{M})。所以单次修改是 O(M)O(\sqrt{M})
      • 查询操作(type 2): 计算一个顶点的最终哈希值需要遍历所有 num_blocks 个块,复杂度是 O(M)O(\sqrt{M})
    • 因此,总时间复杂度为 O(N+M+QM)O(N + M + Q\sqrt{M})。由于 N,MN, M 通常和 QQ 在一个数量级,可以简化为 O(M+QM)O(M + Q\sqrt{M})
  • 空间复杂度: O(NM)O(N\sqrt{M})

    • vertex_hash 占用 O(N)O(N)
    • edges 占用 O(M)O(M)
    • scattered_hash 占用 O(N)O(N)
    • is_block_activeedge_to_block 分别占用 O(M)O(\sqrt{M})O(M)O(M)
    • 主要的内存开销来自于 block_contribution 数组,它的大小是 num_blocks ×\times (N+1),大约是 O(M×N)O(\sqrt{M} \times N)。这是本算法空间复杂度的瓶颈。

知识点总结

这道题是一道非常经典的数据结构与图论结合的好题,喵~ 从中学到的知识点有:

  1. 集合哈希: 当需要频繁比较集合是否相等时,使用XOR哈希是一种非常高效且优雅的技巧。
  2. 随机化算法: 给每个元素赋予一个随机哈希值是随机化思想的体现,它能以极高的概率得到正确结果。
  3. 分块思想: 分块是一种非常灵活的数据结构,它通过将数据分为“大块”和“边角料”,在暴力和复杂数据结构之间取得了很好的平衡,通常能将复杂度优化到根号级别。
  4. 懒惰标记: 在处理区间操作时,懒惰标记是核心。对于完整的块,我们只修改标记而不动数据,将修改的效应推迟到查询时再计算,从而大大提高效率。

希望这篇题解能帮助你理解这道有趣的题目!继续加油,探索更多算法的奥秘吧,喵~