题解:洛谷 CF1709E XOR Tree

【题目来源】

洛谷: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

【核心思想】

  1. 问题分析:给定一棵 \(n\) 个节点的树,每个节点有权值 \(a_i\)。定义一条简单路径的权值为路径上所有节点权值的按位异或。若存在一条权值为 \(0\) 的简单路径,则称该树不是“好”的。每次操作可以将任意一个节点的权值改为任意正整数,求使整棵树变为“好”的所需最少操作次数。本质是树上的异或路径检测与贪心剪枝问题,关键在于利用根路径异或值将路径权值转化为两个端点信息的组合,并通过树上启发式合并高效检测所有异或为 \(0\) 的路径。

  2. 算法选择

    • 根路径异或前缀:定义 \(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\) 的非法路径,且不会影响已处理的子树,是局部最优且全局最优的策略。
  3. 关键步骤

    • 读入与预处理:读入 \(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\)
  4. 时间/空间复杂度

    • 时间复杂度:\(O(n \log^2 n)\),树上启发式合并中每个元素最多被合并 \(O(\log n)\) 次,set 查找 \(O(\log n)\)
    • 空间复杂度:\(O(n)\),存储树和集合。
  5. 树上启发式合并 + 异或性质的核心思想

    • 异或路径转化:将路径异或问题转化为 \(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
posted @ 2026-09-08 20:30  团爸讲算法  阅读(3)  评论(0)    收藏  举报