Graph Games - 题解
标签与难度
标签: 数据结构, 分块, 哈希, 图论, 随机化, XOR 难度: 2200
题目大意喵~
哈喵~ 各位算法大师们,今天我们来玩一个有趣的图论游戏,喵~
题目给了我们一个有 个点和 条边的无向图。这些边从 1 到 进行了编号。一开始,所有这 条边都存在于图中哦。
我们定义了一个集合 ,它表示从顶点 出发,只走一条边能到达的所有顶点的集合。换句话说, 就是顶点 的所有邻居的集合啦,是不是很简单喵?
接下来,我们需要处理 次操作,操作有两种类型:
(1, l, r): 这表示我们要把编号在 区间内的所有边的状态翻转一下。如果一条边存在,我们就把它删掉;如果它不存在,我们就把它加上。就像猫咪翻肚子一样,一下正一下反,喵~(2, u, v): 这是一个询问。我们要判断两个顶点 和 的邻居集合, 和 ,是否完全相同。如果相同,就输出 '1',否则输出 '0'。
所有询问的答案最后要连成一个字符串一起输出。准备好接受挑战了吗?让我们开始吧,喵!
解题思路分析
这道题看起来有点棘手呢,喵~ 它混合了图、集合比较和区间修改,直接模拟肯定会超时的说。每次询问都去遍历两个点的邻接表来比较集合,当图变得很稠密时,效率会非常低。而区间修改边的状态,更是让问题变得复杂。
不过别担心,我有妙计!我们可以把问题分解成两个部分来解决:
- 如何快速判断两个集合是否相等?
- 如何高效地处理边的区间状态翻转?
步骤一:用哈希来比较集合,喵!
要判断两个集合 和 是否相等,一个一个元素比较太慢啦。这时候,我们可以请出强大的哈希来帮忙!
想象一下,我们给每个顶点 分配一个独一无二的、非常大的随机数,作为它的“身份指纹”,我们叫它 hash_val[i]。为了让这个指纹尽可能独特,我们可以用 unsigned long long 类型的随机数,这样冲突的概率就会小到可以忽略不计,就像在沙滩上找到两粒完全一样的沙子一样困难,喵~
有了每个顶点的哈希值,我们就可以定义一个集合的哈希值了。一个非常巧妙的方法是使用异或和 (XOR sum)。对于一个顶点 的邻居集合 ,它的哈希值 可以定义为:
其中 表示异或操作。异或有个很棒的性质: 且 。
现在,判断 和 是否相等,就转化为了判断它们的哈希值 和 是否相等。这一下就把复杂的集合比较变成了简单的整数比较,是不是很神奇,喵!
步骤二:边的状态变化与哈希值的关系
接下来,我们看看边的变化如何影响哈希值。
添加一条边 :
- 成为了 的新邻居,所以 中增加了 。 的新值就是旧值异或上
hash_val[v],即 。 - 同理, 也成为了 的新邻居,所以 。
- 成为了 的新邻居,所以 中增加了 。 的新值就是旧值异或上
删除一条边 :
- 不再是 的邻居了。根据异或的性质,我们只需要再异或一次
hash_val[v]就可以把它从哈希和中“移除”。所以,。 - 同理,。
- 不再是 的邻居了。根据异或的性质,我们只需要再异或一次
看呐!无论是添加还是删除边 ,对哈希值的操作都是完全一样的!这意味着“翻转”一条边的状态,我们只需要对两个端点的哈希值进行相应的异或操作就行了。
步骤三:用分块来处理区间修改,喵~
现在问题变成了:对编号在 区间内的每条边 ,执行 H(u_i) ^= hash_val[v_i] 和 H(v_i) ^= hash_val[u_i]。这本质上是一个区间操作,会引发一系列对顶点哈希值的点更新。如果每次都遍历 ,当区间很大时还是会超时。
这种“区间修改,单点查询”的模式,让我们想到了分块 (Block Decomposition)!我们可以把 条边分成大约 个块。
预处理:
- 我们为每个块
b和每个顶点k,预计算一个值block_contribution[b][k]。它表示如果块 b 中的所有边都存在,它们会对顶点k的哈希值产生多大的贡献。
这个可以在 的时间内完成,因为每条边只对两个顶点的两个块贡献值有影响。
- 我们为每个块
状态维护:
is_block_active[b]: 一个布尔数组,作为懒惰标记。is_block_active[b]为true表示块b中的边目前是“开启”状态(即它们对总哈希值的贡献是算在内的)。初始时,所有块都是 active 的。scattered_hash[k]: 一个数组,用来记录那些不能用懒惰标记整块处理的、被零散修改的边对顶点k的哈希值产生的影响。
处理操作:
修改
(1, l, r):- 如果
l和r在同一个块里,我们就暴力遍历从l到r的边,将它们的影响直接异或到scattered_hash数组上。复杂度是 。 - 如果
l和r在不同的块里,我们把操作分成三部分:l所在的那个不完整的块(从l到块尾):暴力修改,更新scattered_hash。r所在的那个不完整的块(从块头到r):暴力修改,更新scattered_hash。- 中间所有完整的块:我们不一个个修改边,而是直接翻转这些块的
is_block_active懒惰标记。比如is_block_active[b] = !is_block_active[b]。这非常快! 整个修改操作的复杂度是 。
- 如果
查询
(2, u, v):- 要计算顶点
k的最终哈希值,我们需要合并两部分贡献:- 所有 active 的块的总贡献。
- 零散修改的贡献。
- 所以,
H(k) = scattered_hash[k] \oplus (\bigoplus_{b \text{ where } is\_block\_active[b]} \text{block\_contribution}[b][k])。 - 我们用这个公式分别计算出
H(u)和H(v),然后比较它们是否相等。查询的复杂度也是 。
- 要计算顶点
这样一来,我们就把问题完美地解决啦!整个算法的时间复杂度是 ,空间复杂度是 ,对于题目给定的数据范围是完全可以接受的,喵~
代码实现
这是我根据上面的思路,精心重构的一份代码哦~ 希望能帮到你,喵~
#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;
}复杂度分析
时间复杂度:
- 初始化: 生成哈希值需要 ,读取边需要 。预计算
block_contribution数组时,每条边访问一次,所以是 。总初始化时间是 。 - 操作:
- 修改操作(type 1): 对于不完整的块,最多遍历 条边,即 。对于完整的块,最多遍历
num_blocks个块,也是 。所以单次修改是 。 - 查询操作(type 2): 计算一个顶点的最终哈希值需要遍历所有
num_blocks个块,复杂度是 。
- 修改操作(type 1): 对于不完整的块,最多遍历 条边,即 。对于完整的块,最多遍历
- 因此,总时间复杂度为 。由于 通常和 在一个数量级,可以简化为 。
- 初始化: 生成哈希值需要 ,读取边需要 。预计算
空间复杂度:
vertex_hash占用 。edges占用 。scattered_hash占用 。is_block_active和edge_to_block分别占用 和 。- 主要的内存开销来自于
block_contribution数组,它的大小是 num_blocks (N+1),大约是 。这是本算法空间复杂度的瓶颈。
知识点总结
这道题是一道非常经典的数据结构与图论结合的好题,喵~ 从中学到的知识点有:
- 集合哈希: 当需要频繁比较集合是否相等时,使用XOR哈希是一种非常高效且优雅的技巧。
- 随机化算法: 给每个元素赋予一个随机哈希值是随机化思想的体现,它能以极高的概率得到正确结果。
- 分块思想: 分块是一种非常灵活的数据结构,它通过将数据分为“大块”和“边角料”,在暴力和复杂数据结构之间取得了很好的平衡,通常能将复杂度优化到根号级别。
- 懒惰标记: 在处理区间操作时,懒惰标记是核心。对于完整的块,我们只修改标记而不动数据,将修改的效应推迟到查询时再计算,从而大大提高效率。
希望这篇题解能帮助你理解这道有趣的题目!继续加油,探索更多算法的奥秘吧,喵~