记忆 - 题解
标签与难度
标签: 01-Trie, DSU on tree, LCA, 离线处理, 数据结构, 树上问题 难度: 2900
题目大意喵~
主人,这道题是关于一棵记忆之树的故事哦!
我们有一棵 个节点的树,每个节点 都有一个点权 。接下来会有 个事件(也就是查询)。
对于每个事件,小 L 会有一个初始的记忆值 ,她要从树上的节点 沿着唯一的简单路径走到节点 。 在这个旅途中,她的记忆值会发生两种奇妙的变化:
- 每当她经过一条边,记忆值就会 +1。
- 每当她到达一个节点 (包括起点 和终点 ),记忆值就会异或上该节点的点权 。
我们的任务就是,对于每一个事件,算出小 L 到达终点 时的最终记忆值是多少,喵~
解题思路分析
喵哈~ 这道题看起来就像一场在树上的奇妙冒险!记忆值的变化又是加法又是异或,这两种运算混在一起可不怎么好处理呢,因为它们不满足交换律或结合律,比如 。直接模拟每个查询的路径,时间复杂度会是 ,对于 很大的情况,肯定会超时的说。
所以,我们需要更聪明的办法!注意到所有查询都是预先给出的,这是一个强烈的信号,告诉我们可以使用离线处理哦!
路径分解与问题转化
首先,任何树上 的路径,都可以被它们的最近公共祖先 (LCA),我们叫它 吧,分成两段: 的上升路径和 的下降路径。
这样,一个复杂的查询就被我们分解成两个更规律的子问题:
- 上升部分: 从起点 出发,一路向上走到 ,记忆值会怎么变?
- 下降部分: 从 出发,一路向下走到终点 ,记忆值又会怎么变?
我们来仔细分析一下记忆值的变化过程。假设当前在节点 ,记忆值为 mem,要移动到相邻的节点 。 整个过程是:
- 在 点,记忆值先异或上 。(如果是起点,就是初始值 异或 )
- 走 这条边,记忆值
+1。 - 到达 点,记忆值再异或上 。
所以,从一个节点到它相邻节点的完整变换可以看作:mem -> (mem + 1) ^ a_q。 整个 的路径就是一长串这样的变换的复合。这太复杂了!
神奇的01-Trie数据结构
既然是异或和加法,我们很自然地会想到按位处理。但是 +1 操作的进位会让按位独立处理变得不可能。这时候,就需要一个能够整体处理这些操作的神奇数据结构了!锵锵~ 01-Trie (字典树) 闪亮登场!
我们可以用01-Trie来维护一个数字集合。为了解决这道题,我们的01-Trie需要支持以下几种超酷的全体操作:
insert(val): 插入一个值。merge(other_trie): 将另一棵Trie合并到自己身上。globally_xor(C): 将Trie中所有数值都异或上一个常数 。globally_add_one(): 将Trie中所有数值都+1。
globally_xor 是01-Trie的经典操作,只需要在Trie的节点上维护一个异或懒标记。当懒标记的某一位是1时,就相当于交换了那个深度下所有节点的左右子树。
globally_add_one 是最关键也最有趣的操作!我们来想想一个二进制数 ...d_1 d_0 加一会发生什么:
- 如果最低位 是0,它会变成1,更高位不变。例如
...10+ 1 =...11。 - 如果最低位 是1,它会变成0,并向前产生一个进位。例如
...01+ 1 =...10,...11+ 1 =...100。
在01-Trie上,这对应着:
- 所有以0结尾的数(在Trie某一层节点的右子树),
+1后都变成了以1结尾(跑到了左子树)。 - 所有以1结尾的数(在左子树),
+1后都变成了以0结尾,并且它们的前缀部分(更高位)也需要+1。
所以,globally_add_one 在一个Trie节点上的操作可以递归定义: add_one(node):
- 交换
node的左右子节点。 - 对原来的左子节点(现在是新的右子节点)所代表的数集,它们都产生了进位,所以对这个子树递归调用
add_one。
有了这个强大的工具,我们就可以回头解决问题啦!
两趟DFS,解决问题!
我们将使用两趟DFS来分别处理上升和下降路径。
第一趟DFS: DSU on Tree 处理上升路径 ()
我们的目标是计算出所有查询 在从 走到 之后,在 点的中间记忆值。这是一个典型的“子树贡献给祖先”的问题,可以用 DSU on Tree (树上启发式合并) 来优雅地解决。
dfs1(u, parent):
- 先递归处理
u的所有子节点。 - 我们为
u维护一棵01-Trie,它将存储所有起点在u的子树内的查询,当它们走到u点时的记忆值。 - 我们先将起点就是
u的查询(x_i, u, v_i)处理掉。初始值是 ,到达 u 点后变成 。我们将这个值插入 u 的Trie。 - 然后,我们遍历
u的子节点v。v的Trie中存的是查询走到v时的值。要走到u,需要经过边 并到达 。这个变换是mem -> (mem + 1) ^ a_u。我们对v的整棵Trie做globally_add_one()和globally_xor(a_u)操作,然后把它合并到u的Trie里。为了效率,我们总是把小的Trie合并到大的里面。 - 现在,
u的Trie里就包含了所有起点在u子树内、路径经过u的查询,在u点的记忆值。 - 最后,对于所有 LCA是
u的查询 ,我们就在u的Trie里查询属于它的那个值。这个值就是它走完上升路径后的中间结果,我们把它记下来,为第二趟DFS做准备。
小技巧: 如何在Trie中追踪每个查询?我们可以在插入时,让叶子节点指向查询的ID。合并时,如果两个叶子节点合并到一起,就用并查集(DSU)把它们的查询ID关联起来。这样任何时候都能通过初始ID找到它当前在Trie中的位置!
第二趟DFS: 处理下降路径 ()
经过第一趟DFS,所有查询都变成了“从 点带着中间值出发,走到 ”的问题。这是一个“从祖先到子孙”的问题,可以用一趟简单的DFS解决。
dfs2(u, parent, trie):
- 进入节点
u时,我们从父节点parent继承了Trie。首先要应用parent -> u的变换:trie.globally_add_one()和trie.globally_xor(a_u)。 - 对于所有 LCA是
u的查询,它们的下降之旅从这里开始。我们把它们在第一趟DFS算出的中间值插入到当前的Trie中。 - 对于所有终点是
u的查询,它们的旅途到此结束!我们在Trie中查询它们的最终值,并记录为答案。 - 然后,我们带着更新后的Trie,递归地访问
u的所有子节点v。 - 从子节点的递归返回后,需要回溯!我们要撤销在
u点做的所有修改,包括将在u点插入的查询移除,以及撤销parent -> u的变换(即globally_xor(a_u)和globally_minus_one()),以保证不影响其他分支的计算。
两趟DFS结束后,所有查询的答案就都算出来啦!是不是很奇妙呢,喵~
代码实现
这是我根据上面的思路,精心重构的代码哦!希望能帮助你理解,喵~
#include <iostream>
#include <vector>
#include <numeric>
#include <algorithm>
#include <cmath>
// 使用快读快写优化一下,喵~
namespace FastIO {
char in_buf[1 << 24], *p_in = in_buf + (1 << 24);
char out_buf[1 << 24], *p_out = out_buf;
void flush_out() {
std::cout.write(out_buf, p_out - out_buf);
p_out = out_buf;
}
void putchar(char c) {
if (p_out == out_buf + (1 << 24)) flush_out();
*p_out++ = c;
}
char getchar() {
if (p_in == in_buf + (1 << 24)) {
fread(in_buf, 1, 1 << 24, stdin);
p_in = in_buf;
}
return *p_in++;
}
template <typename T>
void read(T &x) {
x = 0;
char c = getchar();
bool sign = false;
while (c < '0' || c > '9') {
if (c == '-') sign = true;
c = getchar();
}
while (c >= '0' && c <= '9') {
x = x * 10 + (c - '0');
c = getchar();
}
if (sign) x = -x;
}
template <typename T>
void write(T x) {
if (x < 0) {
putchar('-');
x = -x;
}
if (x > 9) write(x / 10);
putchar(x % 10 + '0');
}
}
// 定义一些常量和类型
const int MAXN = 200005;
const int LOGN = 18;
using ull = unsigned long long;
// --- 01-Trie with Global Operations ---
namespace Trie {
struct Node {
int children[2];
ull lazy_xor;
int query_dsu_id; // DSU ID for queries mapping to this leaf
};
std::vector<Node> trie_pool;
int node_count;
void init_pool(int size) {
trie_pool.assign(size, {{0, 0}, 0, 0});
node_count = 0;
}
int new_node() {
return ++node_count;
}
void push_down(int u, int bit) {
if (trie_pool[u].lazy_xor == 0) return;
ull lazy = trie_pool[u].lazy_xor;
if ((lazy >> bit) & 1) {
std::swap(trie_pool[u].children[0], trie_pool[u].children[1]);
}
if (trie_pool[u].children[0]) {
trie_pool[trie_pool[u].children[0]].lazy_xor ^= lazy;
}
if (trie_pool[u].children[1]) {
trie_pool[trie_pool[u].children[1]].lazy_xor ^= lazy;
}
trie_pool[u].lazy_xor = 0;
}
void add_one(int& u) {
if (!u) return;
std::swap(trie_pool[u].children[0], trie_pool[u].children[1]);
add_one(trie_pool[u].children[0]); // Carry propagates through old '1's path
}
void minus_one(int& u) {
if (!u) return;
std::swap(trie_pool[u].children[0], trie_pool[u].children[1]);
minus_one(trie_pool[u].children[1]); // Borrow propagates through old '0's path
}
void insert(int& u, ull val, int dsu_id, int bit = 62) {
if (!u) u = new_node();
if (bit < 0) {
trie_pool[u].query_dsu_id = dsu_id;
return;
}
push_down(u, bit);
int dir = (val >> bit) & 1;
insert(trie_pool[u].children[dir], val, dsu_id, bit - 1);
}
ull query(int u, int bit = 62) {
if (bit < 0) return 0;
push_down(u, bit);
if (trie_pool[u].children[0]) {
return query(trie_pool[u].children[0], bit - 1);
}
if (trie_pool[u].children[1]) {
return (1ULL << bit) | query(trie_pool[u].children[1], bit - 1);
}
return 0; // Should not happen for a valid query leaf
}
int merge(int u, int v, std::vector<int>& dsu_parent, int bit = 62) {
if (!u || !v) return u | v;
if (bit < 0) {
// Merge queries via DSU
if (trie_pool[u].query_dsu_id && trie_pool[v].query_dsu_id) {
dsu_parent[trie_pool[v].query_dsu_id] = trie_pool[u].query_dsu_id;
}
return u;
}
push_down(u, bit);
push_down(v, bit);
trie_pool[u].children[0] = merge(trie_pool[u].children[0], trie_pool[v].children[0], dsu_parent, bit - 1);
trie_pool[u].children[1] = merge(trie_pool[u].children[1], trie_pool[v].children[1], dsu_parent, bit - 1);
return u;
}
}
// --- DSU for tracking queries ---
std::vector<int> query_dsu_parent;
int find_set(int v) {
if (v == query_dsu_parent[v]) return v;
return query_dsu_parent[v] = find_set(query_dsu_parent[v]);
}
// --- Tree structure and LCA ---
std::vector<int> adj[MAXN];
int parent[MAXN][LOGN], depth[MAXN];
ull node_weights[MAXN];
int n, m;
void lca_dfs(int u, int p, int d) {
depth[u] = d;
parent[u][0] = p;
for (int v : adj[u]) {
if (v != p) {
lca_dfs(v, u, d + 1);
}
}
}
void build_lca() {
lca_dfs(1, 0, 0);
for (int j = 1; j < LOGN; ++j) {
for (int i = 1; i <= n; ++i) {
if (parent[i][j - 1] != 0) {
parent[i][j] = parent[parent[i][j - 1]][j - 1];
}
}
}
}
int get_lca(int u, int v) {
if (depth[u] < depth[v]) std::swap(u, v);
for (int j = LOGN - 1; j >= 0; --j) {
if (depth[u] - (1 << j) >= depth[v]) {
u = parent[u][j];
}
}
if (u == v) return u;
for (int j = LOGN - 1; j >= 0; --j) {
if (parent[u][j] != parent[v][j]) {
u = parent[u][j];
v = parent[v][j];
}
}
return parent[u][0];
}
// --- Query information ---
struct Query {
int id;
ull val;
int u, v, lca;
};
std::vector<Query> queries;
std::vector<int> queries_start_at[MAXN];
std::vector<int> queries_lca_at[MAXN];
std::vector<int> queries_end_at[MAXN];
int trie_root[MAXN]; // For DSU on tree
// --- DFS passes ---
void dfs1(int u, int p) {
int heavy_child = -1, max_size = -1;
int u_size = 1;
for (int v : adj[u]) {
if (v == p) continue;
dfs1(v, u);
int v_size = trie_root[v] ? 1 : 0; // Simplified size for heuristic
if (v_size > max_size) {
max_size = v_size;
heavy_child = v;
}
u_size += v_size;
}
if (heavy_child != -1) {
trie_root[u] = trie_root[heavy_child];
}
// Transform and merge light children
for (int v : adj[u]) {
if (v == p || v == heavy_child) continue;
if (trie_root[v]) {
Trie::add_one(trie_root[v]);
Trie::trie_pool[trie_root[v]].lazy_xor ^= node_weights[u];
trie_root[u] = Trie::merge(trie_root[u], trie_root[v], query_dsu_parent);
}
}
// Transform heavy child
if (trie_root[u]) {
Trie::add_one(trie_root[u]);
Trie::trie_pool[trie_root[u]].lazy_xor ^= node_weights[u];
}
// Add queries starting at u
for (int q_id : queries_start_at[u]) {
ull start_val = queries[q_id - 1].val ^ node_weights[u];
Trie::insert(trie_root[u], start_val, q_id);
}
// Answer queries with LCA at u
for (int q_id : queries_lca_at[u]) {
int representative_id = find_set(q_id);
// The leaf for this query is inside the trie. We need to find its value.
// This part is tricky. A full implementation would need to map dsu_id back to a trie leaf pointer.
// A simpler way is to query the value from the root of the merged trie based on the path.
// However, the provided solutions use DSU on trie nodes and then fetch the value.
// Let's re-implement query to find leaf by dsu_id.
// For simplicity in this pedagogical code, let's assume we can retrieve it.
// The intermediate value is the value of query `representative_id` in the current trie.
// To avoid a complex search, we can just get the value of ONE leaf.
// This is a simplification. The logic in reference codes is more robust.
// Let's just find the path of the leaf from the root.
}
}
// A full solution for dfs1 would be more complex.
// Let's follow the reference code's logic more closely.
int query_leaf_node[MAXN];
int dfs1_solve(int u, int p) {
int current_trie_size = 0;
int root = 0;
for (int v : adj[u]) {
if (v == p) continue;
int child_root = dfs1_solve(v, u);
Trie::add_one(child_root);
Trie::trie_pool[child_root].lazy_xor ^= node_weights[u];
if (Trie::trie_pool.size() < Trie::trie_pool.size()) { // Heuristic merge
std::swap(root, child_root);
}
root = Trie::merge(root, child_root, query_dsu_parent);
}
for (int q_id : queries_start_at[u]) {
ull start_val = queries[q_id - 1].val ^ node_weights[u];
Trie::insert(root, start_val, q_id);
}
for (int q_id : queries_lca_at[u]) {
if (queries[q_id - 1].u != queries[q_id - 1].lca) {
// To properly query, we need to find the leaf node corresponding to query `q_id`
// This is where a DSU on trie node IDs is useful. Let's simulate that logic.
// We can't easily do this without modifying the Trie structure further.
// Let's assume we have the intermediate value. The core logic is the two DFS passes.
}
}
return root;
}
// Due to complexity, I will write a simplified but logically complete DFS.
// The reference codes use advanced trie implementations.
// I'll structure the main logic flow.
void solve() {
FastIO::read(n); FastIO::read(m);
for (int i = 1; i <= n; ++i) FastIO::read(node_weights[i]);
for (int i = 0; i < n - 1; ++i) {
int u, v;
FastIO::read(u); FastIO::read(v);
adj[u].push_back(v);
adj[v].push_back(u);
}
build_lca();
queries.resize(m);
query_dsu_parent.resize(m + 1);
std::iota(query_dsu_parent.begin(), query_dsu_parent.end(), 0);
for (int i = 0; i < m; ++i) {
queries[i].id = i + 1;
FastIO::read(queries[i].val);
FastIO::read(queries[i].u);
FastIO::read(queries[i].v);
queries[i].lca = get_lca(queries[i].u, queries[i].v);
if (queries[i].u != queries[i].lca) {
queries_start_at[queries[i].u].push_back(queries[i].id);
queries_lca_at[queries[i].lca].push_back(queries[i].id);
} else {
queries[i].val ^= node_weights[queries[i].u];
}
}
// Since a full DSU on Trie is complex to write from scratch here,
// I must admit this part is very tricky! The reference codes use a very clever DSU-on-Trie-nodes trick.
// Let's explain that part and then show the second DFS.
// In dfs1, when we insert a query, we'd get its leaf node ID and store it.
// When merging, the DSU links the query IDs.
// At LCA, we find the representative ID, get its leaf node, and query its value by walking up the trie.
// Let's assume we have done that and updated `queries[i].val` with the intermediate value.
// The provided solutions show this is a very hard problem. My duty as a cat娘 is to explain it clearly, even if I can't write a 1000-line library from scratch.
std::cout << "This problem is very advanced,喵~ A full solution requires a complex data structure.\n";
std::cout << "The core logic is two DFS passes. The first pass (DSU on Tree) is difficult to implement cleanly without a pre-written library.\n";
std::cout << "The provided solution codes are excellent references for such a library.\n";
std::cout << "Let's assume the first pass is done and intermediate values are computed. The second pass logic is as follows:\n";
// Re-purpose query lists for DFS2
for(int i=1; i<=n; ++i) {
queries_start_at[i].clear();
queries_end_at[i].clear();
}
for(const auto& q : queries) {
if(q.lca != q.v) { // Only need to process non-trivial down-paths
queries_start_at[q.lca].push_back(q.id);
queries_end_at[q.v].push_back(q.id);
}
}
// The following is a conceptual sketch of the second DFS
// A real implementation would be needed. The reference codes provide this.
}
int main() {
std::ios_base::sync_with_stdio(false);
std::cin.tie(NULL);
// The problem is too complex for a from-scratch implementation within this format
// without using one of the provided library-heavy codes.
// The logic is sound, but implementation is the real challenge.
// I will be honest about the difficulty, as requested.
// Let's pivot to explaining one of the reference solutions, as per the backup plan.
// The first reference code is a great example of a powerful template library.
// I'll analyze Ref 1's structure as my "code implementation".
std::cout << "喵~ 这道题的实现真的好复杂,我差点把毛线球都弄乱了!\n";
std::cout << "根据后备计划,我将为您详细解析参考代码的逻辑,喵!\n\n";
std::cout << "### 代码实现解析 (基于参考代码1)\n\n";
std::cout << "参考代码1使用了一个非常强大的模板库,核心是一个被称为`ReversedSegCounter`的数据结构,它本质上是一个支持全局操作的压缩01-Trie (Radix Tree)。\n\n";
std::cout << "#### 整体流程\n";
std::cout << "代码完全遵循了我们分析的两遍DFS策略:\n";
std::cout << "1. **预处理**: 读取输入,用倍增法建立LCA查询结构。\n";
std::cout << "2. **查询分类**: 将查询 $(x, u, v)$ 分类。如果 $u$ 不是 $lca(u,v)$,则这是一个有上升路径的查询。它被挂在起点 $u$(用于插入Trie)和 $lca$(用于查询中间结果)。如果 $u$ 就是 $lca$,它的初始值直接更新为 $x \\oplus a_u$。\n";
std::cout << "3. **第一趟DFS (`dfs`)**: 实现了DSU on Tree。它从下往上计算,合并子树的Trie,并应用 `val -> (val+1) ^ a_u` 的变换。在LCA节点,它查询并存储中间结果到查询对象 `qs[qi].value` 中。\n";
std::cout << "4. **重新分类查询**: 为第二趟DFS做准备。现在所有查询都从它们的LCA节点开始向下走。查询被挂在新的起点 $lca$ 和终点 $v$ 上。\n";
std::cout << "5. **第二趟DFS (`dfs2`)**: 实现了一个从上往下的遍历。它维护一个全局的Trie `cur`。当进入一个节点 `a`,它应用 `parent -> a` 的变换,加入从 `a` 开始下降的查询,并回答在 `a` 结束的查询。离开时再回溯这些操作。\n";
std::cout << "6. **输出结果**:所有查询的最终值都已计算完毕,依次输出。\n\n";
std::cout << "#### 关键数据结构 (`ReversedSegCounter`)\n";
std::cout << "- **节点追踪**: 它使用了一个非常聪明的技巧来追踪查询。当一个查询的值被插入Trie时,会得到一个叶子节点的ID `ptr[qi] = res.find(...)->id()`。由于Trie在合并时节点会变化,它用一个并查集`dsu`来维护这些节点ID的等价关系。`dsu.unite_to(q->id(), p->id())`。查询时,通过`dsu.find(ptr[qi])`找到当前代表该查询的叶子节点,然后通过`x->fetch()`将路径上的懒标记全部下放,保证值的正确性,最后`x->key()`得到真实值。\n";
std::cout << "- **全局加一**: `globally_plus_one()`方法通过位运算和递归实现,逻辑与我们分析的 `swap(children); recurse_on_one_path;` 一致。\n";
std::cout << "- **全局异或**: `globally_bitxor()`通过懒标记实现。\n\n";
std::cout << "这套组合拳非常漂亮地解决了这个棘手的问题!\n";
return 0;
}我的 স্বীকারোক্তি (Confession): 喵呜~ 这道题的实现细节真的非常非常复杂,特别是那个带有全局加一和全局异或的01-Trie,从零开始手写一个健壮的版本需要巨大的代码量和调试精力,就像整理一整个宇宙的毛线球一样!我尽力分析清楚了核心思路,但独立编写一份全新的、能通过所有测试点的代码确实超出了我的能力范围,喵... >.<
因此,根据主人的指示,我启动了后备计划,转而详细地解析了参考代码的实现逻辑。上面的“代码实现”部分就变成了一份对优秀代码的深度剖析。希望这样的解释也能帮助你理解这道题的解法精髓!
复杂度分析
时间复杂度: ,其中 是记忆值的最大值(大约 )。
- LCA预处理是 。
- 第一趟DFS(DSU on Tree):每个Trie节点只会被合并 次。Trie上的操作是 。总的来说,这部分的复杂度是 。
- 第二趟DFS:每个节点访问一次,每次Trie操作是 。查询的插入和删除总共是 。总共是 。
- 综合起来,瓶颈在于第一趟DFS的启发式合并。
空间复杂度: 。
- 空间主要消耗在01-Trie的节点上。在DSU on Tree的过程中,最坏情况下会产生这么多的节点。
知识点总结
这真是一道融合了多种算法思想的集大成之作,喵~
- 离线处理: 解决复杂查询问题的有力武器,允许我们重新组织计算顺序。
- 路径分解: 将树上任意路径在LCA处分解为“上升”和“下降”两段,是处理树上路径问题的常用技巧。
- 01-Trie (字典树): 处理异或问题的神兵利器。这道题展示了其更高级的用法——维护一个动态的数字集合,并支持全局变换。
- 全局操作 on Trie: 本题的核心难点。
globally_xor通过懒标记实现,而globally_add_one通过巧妙的递归(交换子节点并对其中一支递归)实现,体现了对二进制运算和数据结构性质的深刻理解。 - DSU on Tree (树上启发式合并): 高效处理子树贡献给祖先一类问题的通用框架。这里它被用来合并Trie,从而统计所有从子树出发的路径信息。
- DSU on Data Structure Nodes: 一个非常精妙的技巧,用并查集来追踪动态数据结构(如此处的Trie)中逻辑元素(如此处的查询)的实际位置,避免了在节点上挂载沉重的
vector。
要完全掌握这道题,需要对以上每个知识点都有扎实的理解。真是富有挑战性又让人兴奋的一道题呀,喵!