题解:洛谷 CF1709E XOR Tree
【题目来源】
【题目描述】
给定一棵包含 \(n\) 个顶点的树。每个顶点上写有一个数字,第 \(i\) 个顶点上的数字为 \(a_i\)。
我们称一条简单路径为每个顶点最多访问一次的路径。路径的权值定义为该路径上所有顶点的值的按位异或。我们称一棵树是“好”的,如果不存在权值为 \(0\) 的简单路径。
你可以进行如下操作任意次(也可以不进行):选择树上的一个顶点,将其上的值替换为任意正整数。请问,最少需要进行多少次操作,才能使这棵树变为“好”的?
【输入】
第一行包含一个整数 \(n\)(\(1 \le n \le 2 \cdot 10^5\)),表示顶点数。
第二行包含 \(n\) 个整数 \(a_1, a_2, \ldots, a_n\)(\(1 \le a_i < 2^{30}\)),表示每个顶点上的数字。
接下来 \(n-1\) 行,每行包含两个整数 \(x\) 和 \(y\)(\(1 \le x, y \le n; x \ne y\)),表示一条连接顶点 \(x\) 和顶点 \(y\) 的边。保证这些边构成一棵树。
【输出】
输出一个整数,表示最少需要进行多少次操作,才能使这棵树变为“好”的。
【输入样例】
6
3 2 1 3 2 1
4 5
3 4
1 4
2 1
6 1
【输出样例】
2
【核心思想】
-
问题分析:给定一棵 \(n\) 个节点的树,每个节点有权值 \(a_i\)。定义一条简单路径的权值为路径上所有节点权值的按位异或。若存在一条权值为 \(0\) 的简单路径,则称该树不是“好”的。每次操作可以将任意一个节点的权值改为任意正整数,求使整棵树变为“好”的所需最少操作次数。本质是树上的异或路径检测与贪心剪枝问题,关键在于利用根路径异或值将路径权值转化为两个端点信息的组合,并通过树上启发式合并高效检测所有异或为 \(0\) 的路径。
-
算法选择:
- 根路径异或前缀:定义 \(f[u]\) 为从根到节点 \(u\) 路径上所有节点权值的异或和。则任意两点 \(u, v\) 之间的路径异或值为 \(f[u] \oplus f[v] \oplus a[lca(u,v)]\)。
- 树上启发式合并(DSU on Tree)+ set 集合:自底向上处理,对每个节点 \(u\) 维护一个集合 \(S[u]\),存储以 \(u\) 为根的子树中,从根到该子树各节点路径异或值 \(f[x]\) 的集合(包含 \(f[u]\) 本身)。合并子节点时,若子节点集合中的某个值 \(x\) 与当前节点集合中的 \(x \oplus a[u]\) 同时存在,则说明存在一条经过 \(u\) 且异或和为 \(0\) 的路径。此时必须修改节点 \(u\) 的值,操作次数加 \(1\),并清空 \(S[u]\)(因为修改后该子树内所有经过 \(u\) 的路径信息失效)。
- 贪心最优性:自底向上处理,遇到冲突时修改当前节点而非子孙节点,因为修改节点 \(u\) 能一次性消除所有经过 \(u\) 的非法路径,且不会影响已处理的子树,是局部最优且全局最优的策略。
-
关键步骤:
- 读入与预处理:读入 \(n\)、权值 \(a[1..n]\) 及 \(n-1\) 条边,建树。任选一个根节点(如 \(1\) 或入度为 \(0\) 的节点)。
- DFS 预处理:计算每个节点的根路径异或值 \(f[u] = f[fa] \oplus a[u]\)。
- 递归合并与检测:
- 对节点 \(u\),初始化集合 \(S[u] = \{f[u]\}\)。
- 遍历子节点 \(v\),递归处理,然后执行启发式合并(将较大的集合作为主集合)。
- 冲突检测:遍历小子集合 \(S[v]\) 中的每个值 \(x\),若主集合 \(S[u]\) 中存在 \(x \oplus a[u]\),则说明存在一条经过 \(u\) 的异或和为 \(0\) 的路径,标记 \(flag = true\)。
- 合并完毕后,若 \(flag\) 为真,则 \(ans++\),并将 \(S[u]\) 清空(表示该节点被修改,其子树不再贡献有效路径信息)。
- 输出答案:\(ans\)。
-
时间/空间复杂度:
- 时间复杂度:\(O(n \log^2 n)\),树上启发式合并中每个元素最多被合并 \(O(\log n)\) 次,set 查找 \(O(\log n)\)。
- 空间复杂度:\(O(n)\),存储树和集合。
-
树上启发式合并 + 异或性质的核心思想:
- 异或路径转化:将路径异或问题转化为 \(f[u] \oplus f[v] \oplus a[lca]\),便于在 DFS 过程中利用集合快速检测。
- 自底向上贪心:当子树内出现非法路径时,修改当前节点(LCA)是最优的,因为一次操作可切断所有经过该节点的非法路径,且不影响已处理的下层子树。
- 启发式合并优化:每次合并时将较小的集合合并到较大的集合,保证每个元素最多被移动 \(O(\log n)\) 次,总复杂度 \(O(n \log^2 n)\)。
- 清空集合的意义:修改节点后,其子树中所有路径信息失效,清空集合避免向上层传递错误信息。
- 适用于树上异或路径、路径权值统计及最小修改次数类问题。
【算法标签】
提高+ #树上启发式合并
【代码详解】
#include <bits/stdc++.h>
using namespace std;
const int N = 200005;
int n, ans, a[N]; // n: 节点数, ans: 最少修改次数, a[i]: 节点i上的数值
int d[N]; // d[u]: 从根节点到u的路径上所有节点值的异或和(根节点路径异或为 a[root])
int st[N]; // st[v] 标记节点v是否有父节点(用于寻找根节点,读边时设置)
vector<int> g[N]; // 邻接表存储树
set<int> s[N]; // s[u]: 以u为根的子树中,从根到该子树中各节点路径异或值的集合(不含根节点? 实际包含根节点d[u])
// 深度优先搜索,处理以u为根的子树,返回该子树内是否存在异或和为0的路径
void dfs(int u, int fa)
{
s[u].insert(d[u]); // 将当前节点到根的路径异或值加入集合(包含当前节点自身)
bool flag = 0; // 标记当前子树内是否存在异或和为0的简单路径
// 遍历所有子节点
for (int v : g[u])
{
if (v == fa)
{
continue;
}
d[v] = d[u] ^ a[v]; // 计算子节点v的路径异或值
dfs(v, u); // 递归处理子节点
// 启发式合并:让s[u]是较大的集合,便于遍历较小的集合
if (s[u].size() < s[v].size())
{
swap(s[u], s[v]);
}
// 检查是否存在一条经过当前节点u的路径,其异或和为0
// 对于子节点集合s[v]中的每个值z,如果s[u]中存在z ^ a[u],
// 则说明存在两个端点,其路径异或值异或后为0(即路径异或为0)
// 因为从根到两个端点的路径异或值分别为z和z^a[u],它们异或的结果为a[u],
// 而经过u的路径异或值应为两条路径异或值的异或,若为0则需要z == z ^ a[u],
// 但这里检查的是z ^ a[u]是否在s[u]中,实际判断逻辑可能基于路径性质,
// 但具体证明略去,这是标准算法。
for (int z : s[v])
{
if (s[u].find(z ^ a[u]) != s[u].end())
{
flag = 1;
}
}
// 将子节点集合v中的元素合并到u集合中
for (int z : s[v])
{
s[u].insert(z);
}
}
// 如果存在异或和为0的路径,则必须修改当前节点u的值,使该子树变"好"
// 修改后,所有经过u的路径异或值都会改变,因此清空集合,并增加操作次数
if (flag)
{
ans++;
s[u].clear(); // 清空集合,表示该子树已经被处理,不再贡献路径
}
}
int main()
{
cin >> n;
// 读入每个节点的初始值
for (int i = 1; i <= n; i++)
{
cin >> a[i];
}
// 读入树边,并标记每个节点的父节点(用于寻找根节点)
for (int i = 1; i < n; i++)
{
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
st[v] = 1; // 标记v有父节点(假设边是有向的,但这里仅用于找根,若v被标记则不是根)
}
// 寻找根节点(入度为0的节点,即没有父节点的节点)
int root = 1;
while (st[root])
{
root++;
}
// 初始化根节点的路径异或值为 a[root]
d[root] = a[root];
dfs(root, 0); // 从根开始深度优先搜索
cout << ans << endl; // 输出最少操作次数
return 0;
}
【运行结果】
6
3 2 1 3 2 1
4 5
3 4
1 4
2 1
6 1
2
浙公网安备 33010602011771号