题单 · 习题解析

洛谷 P2420 让我们异或吧

★★★ 这一页的全部功课是**先问一句「这道题要不要 LCA」**,而答案是「不要」—— 先一遍 BFS 求出根到每个点的异或和 `d[u]`,答案就是 `d[u] ^ d[v]`(根到 LCA 那一段被算了两遍,而 `x ^ x = 0`,**自己抵消掉了**)⇒ 不建表、不求 LCA、查询 O(1),连 `u == v` 都不用特判;★★ 而「老老实实求 LCA 再拼」**同样正确、也照样能过**(四种顶格形状逐字节相同),代价是**那张 6.5 MiB 的表**(峰值 14.3 vs 7.4 MiB,差的 6.9 MiB 几乎全是 `up[100005][17] × 4` 字节)⇒ **省下来的是一张表和一段代码,不是分数线**;★★★ 真正的分数线在「沿着树走」那一版上,而它又是一次[顶格 ≠ 最坏](/sol/p3916/):同样 `N = M = 10⁵`,随机树 **0.04 秒**、一条链 **22.82 秒(差 570 倍)** —— **这一章的题单里,树的形状已经连着第三道题决定生死**;★★★ 而三个错法里第一个是「上一章的正确写法」:路径**和**是 `s[u] + s[v] − 2·s[lca]`,照猫画虎写成 `d[u] ^ d[v] ^ d[lca]` 就把那一段**又加了回去**(**加法要减两遍,异或一遍都不用减**)—— ⚠⚠ 而官方样例那三个询问的 **LCA 全都是根**、`d[根] = 0` ⇒ **整组放过它**,和 [P4551](/sol/p4551/) 那次**一模一样**;★★★ 外加对拍表上最值钱的一格:「无向边只存了一遍」在顺手档是**结构性的精确的 0** (生成器一律按 `(fa[i], i)` 写边,正好就是 BFS 要走的方向),而加一句 `if (rng() & 1) swap(a, b);` 同一列当场 **0 → 300** ⇒ **改的不是数据规模,是「边写下来的顺序」**

原题:洛谷 P2420出自 第 51 章 LCA 与倍增:把「往上跳多少步」拆成二进制 的题单题面本地存档:2026-09-11
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P2420,日期见页头。两边不一致时信原站。

题目描述

异或是一种神奇的运算,大部分人把它总结成不进位加法。

在生活中 xor 运算也很常见。比如,对于一个问题的回答,是为 1,否为 0,那么: (A 是否是男生)xor(B 是否是男生)= AB 是否能够成为情侣。

好了,现在我们来制造和处理一些复杂的情况。比如我们将给出一颗树,它很高兴自己有 N 个结点。 树的每条边上有一个权值。我们要进行 M 次询问,对于每次询问, 我们想知道某两点之间的路径上所有边权的异或值。

输入格式

输入文件第一行包含一个整数 N,表示这颗开心的树拥有的结点数, 以下有 N−1 行,描述这些边,每行有 3 个数,u, v, w,表示 uv 之间有一条权值为 w 的边。 接下来一行有一个整数 M,表示询问数。之后的 M 行,每行两个数 u, v, 表示询问这两个点之间的路径上的权值异或值。

输出格式

输出 M 行,每行一个整数,表示异或值。

数据范围

  • 对于 40% 的数据,有 1 ≤ N, M ≤ 3000
  • 对于 100% 的数据,有 1 ≤ N, M ≤ 100000

保证边权在 int 范围内。

时限 1 秒,内存 128000 KB(125 MiB)。

输入输出样例

输入

5
1 4 9644
2 5 15004
3 1 14635
5 3 9684
3
2 4
5 4
1 1

输出

975
14675
0

★ 注意第三个询问是 1 1 —— 题面允许问同一个点,答案是 0

1★★★ 这一页的全部功课:先想清楚这道题要不要 LCA

★★ 答案是「不要」—— 而理由只有一行

先一遍 BFS 求出「根到每个点的路径异或和d[u],那么

   路径(u, v) 的异或和  =  d[u] ^ d[v]

道理:d[u] ^ d[v] 里,根到 LCA 那一段被算了两遍,而 x ^ x = 0 —— 自己抵消掉了

不用求 LCA、不用建倍增表、查询是 O(1)u == v 时天然给 0(一个特判都不用写)。

★ 而这道题被放进第 51 章的题单,恰恰是为了让你先问这一句: 题单注解自己写着「练『先想清楚要不要 LCA』」。

p2420.cpp★ 正解:一遍 BFS + 一句 d[u] ^ d[v]
// P2420 让我们异或吧 —— 正解:**连 LCA 都不用**
//
// ★★★ 一句话:先一遍 BFS 求出「根到每个点的路径异或和」`d[u]`,那么
//
// 路径(u,v) 的异或和 = d[u] ^ d[v]
//
// 道理:`d[u] ^ d[v]` 里,**根到 LCA 那一段被算了两遍**,而 `x ^ x = 0` —— 自己抵消掉了。
// ⇒ **不用求 LCA,不用建倍增表,查询是 O(1)。**
//
// ⚠⚠ 而这正是[第 52 章树上差分](/ch/52-tree-diff/)那条「上一章的正确写法就是这一章的 bug」
// 的反面:路径**和**是 `s[u] + s[v] − 2·s[lca]`(要减两遍),
// 路径**异或**是 `d[u] ^ d[v]`(**一遍都不用减**)。
// ⇒ 照猫画虎写成 `d[u] ^ d[v] ^ d[lca]` 就把那一段又加了回去 —— 和 [P4551](/sol/p4551/) 同一个坑。
//
// ★ 这道题被放进[第 51 章](/ch/51-lca/)的题单,恰恰是为了让你先问一句:**这题到底要不要 LCA。**
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
int head_[MAXN], nxt_[MAXN * 2], to_[MAXN * 2], wt_[MAXN * 2], ecnt;
int d[MAXN];
int n, m;
inline void addEdge(int u, int v, int w) { to_[++ecnt] = v; wt_[ecnt] = w; nxt_[ecnt] = head_[u]; head_[u] = ecnt; }
int main() {
if (scanf("%d", &n) != 1) return 0;
for (int i = 0; i < n - 1; i++) {
int u, v, w;
if (scanf("%d %d %d", &u, &v, &w) != 3) return 0;
addEdge(u, v, w);
addEdge(v, u, w); // ⚠ 无向边,两边都要存
}
{ // BFS 一趟求 d(⚠ 别用递归,链有 10⁵ 层)
vector<char> vis(n + 1, 0);
vector<int> que;
que.reserve(n);
d[1] = 0; vis[1] = 1; que.push_back(1);
for (size_t i = 0; i < que.size(); i++) {
int u = que[i];
for (int e = head_[u]; e; e = nxt_[e]) {
int v = to_[e];
if (vis[v]) continue;
vis[v] = 1;
d[v] = d[u] ^ wt_[e];
que.push_back(v);
}
}
}
if (scanf("%d", &m) != 1) return 0;
for (int i = 0; i < m; i++) {
int u, v;
if (scanf("%d %d", &u, &v) != 2) return 0;
printf("%d\n", d[u] ^ d[v]); // ★ u == v 时天然是 0,一个特判都不用写
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

2★ 那「老老实实求 LCA」白费了多少

p2420Lca.cpp★ 另一条同样正确的路:倍增求 LCA,再把两段拼起来
★★ 本机实测(顶格 N = M = 10⁵,四种形状,时限 1 秒 / 125 MiB)
耗时 峰值内存
★ 正解(d[u] ^ d[v] 0.02 秒 7.4 MiB
★ 倍增求 LCA 再拼 0.03 秒 14.3 MiB

⇒ 两版在四种顶格形状上逐字节相同rand / chain / star / binary)。 ★ 而多出来的 6.9 MiB 几乎全是那张表:up[100005][17] × 4 字节 = 6.8 MB = 6.5 MiB

⚠ 时间上只差 1.5 倍 —— 因为这道题的 N 只有 10⁵,那一趟建表才 10⁵ × 17 步。 ⇒ ★★ 所以「省了多少」这句话在这道题上要老实说:省的是一张表和一段代码, 不是分数线。 真正的分数线在下一步。

3⚠ 真正的分数线:第一版沿着树走

p2420Brute.cpp✗ 第一版:每个询问从 u 爬到 v,一路异或
p2420GenBig.cpp★ 顶格生成器:rand / chain / star / binary
★★★ 又一次「顶格 ≠ 最坏」,而且和隔壁 [P3379](/sol/p3379/) 是同一句话
顶格 N = M = 10⁵ ✗ 沿着树走 ★ 正解
rand(随机树,深度 log 级) 0.04 秒 0.02
star 0.02 秒 0.02
binary 0.03 秒 0.02
chain(一条链) 22.82 秒 0.02

同样顶格,只换树的形状,第一版从 0.04 秒变成 22.82 秒(差 570 倍)。 ★ 这就是第 51 章第 3 步那句「随机树的深度只有 log 级」—— 这一章的题单里,它已经连着第三道题决定生死了P3379 / P5903 / 这道题)。

★ 而题面 40% 档(N, M ≤ 3000)上第一版秒过(链上 0.00 秒)⇒ 暴力稳拿 40 分

4★★★ 三个错法,而第一个是「上一章的正确写法」

p2420Sub.cpp✗ 错法①:照抄路径和的写法 —— d[u] ^ d[v] ^ d[lca]
★★★ 「加法要减两遍,异或一遍都不用减」

第 52 章树上差分的路径

   s[u] + s[v] − 2·s[lca]        ← 根到 lca 那一段被加了两遍,所以要减两遍

到了异或这儿,很多人顺手写成「异或一遍 lca」。可异或的抵消是自动的: d[u] ^ d[v] 里那一段已经没了;再异或一次 d[lca],等于把它又加了回去

⚠⚠ 而这道题的官方样例一个字都挡不住它 —— 那三个询问(2 4 / 5 4 / 1 1)的 LCA 全都是根 1,而 d[根] = 0 ⇒ 多异或的那一下正好是 0。

⇒ ★★ 这和 P4551同一个坑的第二次,而那道题上官方样例也是这么放过它的 (最优那一对的 LCA 正好是根)。两道题、两组样例、同一个理由。

p2420Sum.cpp✗ 错法②:把异或当成加法(s[u]+s[v]-2·s[lca])
p2420One.cpp✗ 错法③:无向边只存了一遍
⚠⚠ 而「只存一遍」被顺手写的生成器藏得严严实实

addEdge(u, v, w)addEdge(v, u, w),BFS 就只能顺着输入给的方向走。 可顺手写的生成器是这样造边的

   for (int i = 2; i <= n; i++)  printf("%d %d %d\n", fa[i], i, w);

—— 每条边都是「父亲写在前、儿子写在后」,正好就是 BFS 要走的方向 ⇒ 那一档它是结构性的精确的 0。 ★ 而只要在生成器里加一句「随机决定写成 (u,v) 还是 (v,u)」,同一列当场 300 / 300

5★ 对拍:三个错法 + 一份「一律输出 0」

p2420Zero.cpp★ 试金石:一律输出 0
p2420Gen.cpp★ 生成器:四个档位
★★★ 300 轮 × 四档(本机实测,2026-09-11)
档位 ①Sub ②Sum ③One 试金石
0 顺手写法(边按 (fa[i], i) 给、编号不打乱) 294 300 0 300
1 + 每条边随机决定写成 (u,v) 还是 (v,u) 297 300 300 300
2 + 询问取「同一棵子树里的两个点」(LCA 不是根) 300 234 0 234
3 最终档 = 1 + 2 + 编号打乱 300 229 225 229

★ ③One 那两个 0 → 300 的跳变,是这张表最值钱的一格: 改的不是数据规模,是「边写下来的顺序」 —— 一句 if (rng() & 1) swap(a, b);

⚠ 而档 2 / 3 里试金石从 300 掉到 229 是有原因的:那两档的询问是「从同一棵子树里往下走三步」取两个点, 两个点很容易走成同一个 ⇒ 答案本来就是 0。 ★ ②Sum 跟着一起掉到 229(u == v 时它也给 0)—— ⇒ 两列一起掉,说明掉的不是抓获能力,是「这一档有多少个询问在问真问题」。

6★ 哪一版就已经能过了

★★ 结论:三条正确的路,两条都能过
版本 能过吗 数字(顶格最坏形状)
✗ 沿着树走 40 分 chain 22.82 秒 / 时限 1 秒
★ 倍增求 LCA 再拼 0.03 秒、14.3 MiB
正解(d[u] ^ d[v] 0.02 秒7.4 MiB、代码短一半

⇒ ★★★ 这道题不是「倍增题」,是「先想清楚要不要倍增」题。 而想清楚之后的收获很具体:少一张 6.9 MiB 的表、少一段 30 行的代码、 少一个「LCA 那一下到底该异或几遍」的坑。

★ 一句话带走

x ^ x = 0 让 LCA 自己消掉了 —— 而这句话有一个反面: 正因为它会自动消,所以「照着路径和的公式再减一次」就把它加了回去。 ⇒ 第 52 章那条「上一章的正确写法可能就是这一章的 bug」, 这一页是它在同一个运算符上的第二次(第一次是 P4551), ⚠ 而两道题的官方样例都因为「LCA 恰好是根」而放过了它