字典树
Trie
一个常见的问题:在一大堆单词中,快速查找某个单词是否存在?或者,需要快速找到所有以 auto 开头的单词(比如自动补全功能)?
如果用简单的遍历或者哈希表,处理前缀相关的问题会显得非常低效。为了解决这类问题,可以使用一种专门用于字符串和前缀的数据结构——字典树(Trie)。
字典树,又称前缀树或单词查找树,是一种树形结构。它的核心思想是用空间换时间,利用字符串的公共前缀来降低查询时间的开销,从而达到高效的字符串检索和前缀查询。
想象一下查英文字典的过程:先按第一个字母 a 找到第一个章节,再按第二个字母 p 找到 ap 开头的区域,以此类推,字典树的构造过程和这个过程非常相似。
一棵字典树包含以下基本元素:
- 节点
- 根节点:不代表任何字符,是所有字符串的共同“祖先”。
- 内部节点:每个节点代表一个前缀。
- 边:从一个节点指向其子节点的边代表一个字符,从根节点到任意一个节点的路径,将路径上的字符拼接起来,就构成了一个字符串(或前缀)。
- 节点信息:通常需要在节点上存储一些额外信息,最常见的是一个标记,用于表示“从根节点到当前节点的路径是否构成了一个完整的单词”。
例如,将单词 i、int、integer、intern、internet 插入一棵字典树中,它会形成如下结构:
(root)
|
i (单词"i"的结尾)
|
n
|
t (单词"int"的结尾)
/ \
e e
/ \
g r
/ \
e n (结尾)
/ \
r e
(结尾) \
t
(结尾)
从这个结构中,可以清晰地看到:
- int 是 integer 和 internet 的公共前缀。
- 所有以 int 开头的单词都在 t 节点的子树中。
可以用一个二维数组来模拟字典树。
int tr[N][26]; // N是节点总数,26是字符集大小(假设只包含小写字母)
int tot; // 记录当前已经分配的节点总数
tr[u][c] 表示节点 u 通过字符 c 到达的子节点的编号,如果 tr[u][c] 等于 0,则表示该路径不存在。tot 用于为新节点分配唯一的编号,根节点的编号通常为 0。
插入操作
void insert(char* s) {
int u = 0; // 从根节点开始
for (int i = 0; s[i]; i++) {
int c = s[i] - 'a';
if (!tr[u][c]) { // 如果路径不存在
tr[u][c] = ++tot; // 创建一个新节点
}
u = tr[u][c]; // 移动到子节点
}
// 在单词的结尾节点 u 上做标记,例如 is_end[u] = true;
}
例题:P1481 魔族密码
这个问题天然地与“前缀”有关,适合用字典树来维护。
定义 \(f_w\) 表示以单词 \(w\) 结尾的最长词链的长度,要构成以 \(w\) 结尾的词链,它的前一个单词必须是 \(w\) 的一个前缀。为了让词链最长,会选择那个能构成最长词链的前缀。所以,递推关系为 \(f_w = 1 + \max \{ f_p \}\),其中 \(p\) 是 \(w\) 的任意一个前缀,且 \(p\) 必须是单词表中的一个完整单词。如果 \(w\) 没有任何前缀在单词表中,则 \(f_w = 1\)。
可以将所有单词插入一棵字典树,在字典树的每个节点 \(u\) 上,存储一个 \(c_u\) 值,它代表 \(f_w\),表示“以节点 \(u\) 代表的单词结尾的最长词链长度”。当插入一个新单词 \(s\) 时,会从根节点走到代表 \(s\) 的结尾节点,在这条路径上,经过的所有节点都代表 \(s\) 的一个前缀。只需要在路径上检查哪些节点是“完整单词的结尾”(即 \(c\) 值不为 \(0\)),并找出其中最大的 \(c\) 值。那么,对于当前的单词 \(s\),其结尾结点的 \(c\) 值就等于前面最大的 \(c\) 值再加一。
注意:题目输入保证了单词按字典序排列,这意味着任何一个单词的前缀如果存在于单词表中,一定会在该单词之前出现。这保证了在计算 \(f_w\) 时,其所有前缀的结果都已经被正确计算出来了,符合动态规划的无后效性原则。
参考代码
#include <cstdio>
#include <cstring>
#include <algorithm>
using std::max;
const int N = 150005; // Trie 树节点的最大数量。估算:2000个单词 * 最多75个字母 ≈ 150000
const int LEN = 80; // 每个单词的最大长度 + 缓冲区
char s[LEN]; // 用于读取每个单词的字符数组
// --- 字典树 (Trie) 定义 ---
int tr[N][26]; // Trie树的邻接表表示法。tr[u][c] 表示节点u通过字符c到达的子节点
int tot; // 当前Trie树中节点的总数
int cnt[N]; // DP数组,cnt[u] 表示以节点u代表的单词结尾的最长词链长度
int main()
{
int n;
scanf("%d", &n); // 读取单词数量
int ans = 0; // 全局变量,用于记录所有词链长度的最大值
// 循环处理 n 个单词
for (int i = 1; i <= n; i++) {
scanf("%s", s + 1); // 读取一个单词
int len = strlen(s + 1);
int u = 0; // u 是当前在Trie树中的节点指针,从根节点0开始
int tmp = 0; // tmp 用于记录当前单词所有前缀构成的最长词链长度
// --- 遍历当前单词的每个字符,同时在Trie树上移动 ---
for (int j = 1; j <= len; j++) {
int c = s[j] - 'a'; // 将字符转换为 0-25 的索引
// 如果路径不存在,则创建一个新节点
if (!tr[u][c]) {
tr[u][c] = ++tot;
}
// 移动到下一个节点
u = tr[u][c];
// 核心DP步骤:
// 在路径上,每个节点都代表当前单词的一个前缀。
// 检查这个前缀是否也是一个已记录的单词的结尾。
// cnt[u] 存储了以该前缀单词结尾的最长词链长度。
// tmp 不断更新,以找到所有前缀中最长的那个词链。
tmp = max(tmp, cnt[u]);
}
// 单词遍历结束后,u 指向该单词在Trie树中的末尾节点。
// 根据DP递推关系,以当前单词结尾的最长词链长度 = 1 + 其所有前缀构成的最长词链长度。
cnt[u] = tmp + 1;
// 更新全局最长词链的答案
ans = max(ans, cnt[u]);
}
printf("%d\n", ans); // 输出最终结果
return 0;
}
习题:P2580 于是他错误的点名开始了
解题思路
在标准的字典树基础上,在每个节点上附加两种信息,已满足本题的需求:一个用于标识“从根节点到当前节点的路径是否构成一个完整的、存在于初始名单中的名字”的标记、一个用于标识“以当前节点为结尾的那个名字,是否已经被点到过”的标记。
参考代码
#include <cstdio>
#include <cstring>
const int N = 500005; // Trie树节点的最大数量, 估算: 10000个名字 * 50长度 ≈ 500000
const int LEN = 55; // 名字的最大长度 + 缓冲区
char s[LEN]; // 用于读取每个名字的字符数组
// --- 字典树 (Trie) 定义 ---
int tr[N][26]; // Trie树的邻接表表示法。tr[u][c] 表示节点u通过字符c到达的子节点编号
int tot; // 当前Trie树中节点的总数,用于分配新节点编号
bool name[N]; // 标记数组,name[u] = true 表示节点u是一个合法名字的结尾
bool f[N]; // 标记数组,f[u] = true 表示以节点u结尾的名字已经被点到过
int main()
{
// --- 第一阶段:构建学生名单的字典树 ---
int n;
scanf("%d", &n);
for (int i = 1; i <= n; i++) {
scanf("%s", s + 1);
int len = strlen(s + 1);
int u = 0; // u 是当前在Trie树中的节点指针,从根节点0开始
// 遍历名字的每个字符,将其插入Trie树
for (int j = 1; j <= len; j++) {
char c = s[j] - 'a'; // 将字符转换为 0-25 的索引
// 如果从节点u到字符c的路径不存在,则创建一个新节点
if (!tr[u][c]) {
tr[u][c] = ++tot;
}
u = tr[u][c]; // 移动到子节点
}
// 名字遍历结束后,u指向该名字的末尾节点。
// 将该节点的 name 标记设为 true,表示这是一个合法的名字。
name[u] = true;
}
// --- 第二阶段:处理点名查询 ---
int m;
scanf("%d", &m);
for (int i = 1; i <= m; i++) {
scanf("%s", s + 1);
int len = strlen(s + 1);
int u = 0;
bool w = false; // w (wrong) 标志,用于记录名字是否存在于Trie树中
// 遍历被点到的名字的每个字符,在Trie树中查找
for (int j = 1; j <= len; j++) {
int c = s[j] - 'a';
// 如果路径不存在,说明这个名字肯定不在名单里
if (!tr[u][c]) {
w = true; // 标记为错误
break; // 提前退出查找
}
u = tr[u][c]; // 移动到下一个节点
}
// --- 根据查找结果进行判断和输出 ---
// 条件1: w为true -> 名字的某个前缀在树中就不存在了
// 条件2: !name[u] -> 名字在树中存在,但是它只是某个更长名字的前缀,本身不是一个完整的名字
if (w || !name[u]) {
printf("WRONG\n");
} else {
// 名字正确,此时检查是否重复
// 如果 f[u] 为 false,说明这是第一次点到
if (!f[u]) {
printf("OK\n");
} else { // 否则,说明重复点名
printf("REPEAT\n");
}
// 只要名字是正确的,就把 f[u] 设为 true,记录本次点名
f[u] = true;
}
}
return 0;
}
01-Trie
01-Trie 是字典树的一种特殊形式,专门用于处理与二进制位运算相关的问题,特别是异或操作,在 01-Trie 中:
- 树的每个节点只有两个子节点,分别代表二进制的 0 和 1。
- 从根节点到某个节点的路径,构成了一个二进制数。
- 树的深度是固定的,通常取决于要处理的整数类型的位数(例如,对于
int类型,深度通常是 31 或 32)。
通过将数字的二进制表示存入 01-Trie,可以高效地执行与位相关的查询。
例题:P10471 最大异或对 The XOR Largest Pair
给定 \(N\) 个整数 \(A_1, A_2, \cdots, A_N\),从中选出两个进行异或计算,得到的结果最大是多少?
数据范围:\(1 \le N \le 10^5\), \(0 \le A_i \lt 2^{31}\)。
最直接的方法是暴力枚举所有可能的数对 \((A_i, A_j)\),计算它们的异或值,然后取最大值。这种方法的时间复杂度为 \(O(N^2)\),对于 \(N=10^5\) 的数据规模来说,是无法接受的。
要使异或结果最大,应该关注其二进制表示。异或的性质是“相同为 0,不同为 1”。因此,为了让结果尽可能大,应该希望两个数的二进制表示在越高位上越不相同。
贪心思想:对于一个给定的数 \(x\),希望找到一个数 \(y\),使得 \(x \oplus y\) 最大,可以从 \(x\) 的最高位开始考虑:
- 如果 \(x\) 的第 \(i\) 位是 \(1\),那么最希望 \(y\) 的第 \(i\) 位是 \(0\),这样异或结果的第 \(i\) 位就是 \(1\)。
- 如果 \(x\) 的第 \(i\) 位是 \(0\),那么最希望 \(y\) 的第 \(i\) 位是 \(1\),这样异或结果的第 \(i\) 位也是 \(1\)。
这个“最优选择”的过程可以借助 01-Trie 来高效实现。
将所有 \(N\) 个整数的二进制表示插入到一个 01-Trie 中。遍历这 \(N\) 个整数,对于每一个数 \(A_i\),在 Trie 中查询与它异或能得到最大值的那个数,这个查询过程遵循上面的贪心策略。在所有查询结果中,找到最大的那个,即为最终答案。
如何查询?
假设正在为数字 \(x\) 查询其最大异或配对。从 Trie 的根节点出发,从 \(x\) 的最高位开始遍历到最低位。在第 \(i\) 位,假设 \(x\) 的那一位是 \(c\),则期望的配对数的位是 \(c \oplus 1\)。检查当前 Trie 节点是否存在指向 \(c \oplus 1\) 的子节点:如果存在,那么就找到了一个数,它在第 \(i\) 位上与 \(x\) 不同,沿着 \(c \oplus 1\) 的路径走下去,并且可以知道最终结果的第 \(i\) 位将是 \(1\);如果不存在,那就没办法,只能选择与 \(x\) 在这一位上相同的路径,即沿着 \(c\) 的路径走,最终结果的第 \(i\) 位将是 \(0\)。重复这个过程,直到处理完所有位。将每一步贪心选择得到的位组合起来,就构成了与 \(x\) 配对能产生的最大异或值。
参考代码
#include <cstdio>
#include <algorithm>
using ll = long long;
using std::max;
// 定义常量和全局变量
const int N = 100005; // 数组最大长度
int tr[N*31][2], tot; // 0-1 字典树 (Trie)。tr[u][c] 表示节点 u 的 c (0或1) 子节点。tot 是节点计数器。
int a[N]; // 存储输入的整数
/**
* @brief 在Trie中查询与给定值x异或结果最大的数。
*
* @param x 要查询的整数。
* @return 与x异或后能得到的最大结果。
*/
int query(int x) {
int u = 0; // 从Trie的根节点开始
int res = 0; // 初始化最大异或结果
// 从最高位 (30) 到最低位 (0) 进行贪心搜索
for (int i = 30; i >= 0; i--) {
int c = (x >> i) & 1; // 获取 x 的第 i 位
// 为了使异或结果的当前位为1,希望找到一个数,其当前位是 c ^ 1
// 检查是否存在与当前位相反的路径
if (tr[u][c ^ 1]) {
res |= (1ll << i); // 如果存在,结果的第 i 位置为1
u = tr[u][c ^ 1]; // 移动到那个子节点
} else {
// 如果不存在相反位路径,只能沿着相同位的路径走
u = tr[u][c]; // 结果的第 i 位默认为0,无需操作
}
}
return res;
}
int main()
{
int n;
scanf("%d", &n);
// 循环读入所有数字,并将它们插入到0-1 Trie中
for (int i = 1; i <= n; i++) {
scanf("%d", &a[i]);
int u = 0; // 从根节点开始插入
// 将数字 a[i] 的二进制表示(从高到低)插入Trie
for (int j = 30; j >= 0; j--) {
int c = (a[i] >> j) & 1; // 获取 a[i] 的第 j 位
// 如果路径不存在
if (!tr[u][c]) {
tr[u][c] = ++tot; // 创建新节点
}
u = tr[u][c]; // 移动到子节点
}
}
int ans = 0; // 初始化全局最大异或值
// 遍历所有数字,为每个数字找到能使其异或结果最大的配对
for (int i = 1; i <= n; i++) {
ans = max(ans, query(a[i]));
}
printf("%d\n", ans);
return 0;
}
设整数的最大位数为 \(W\)(本题中 \(W \approx 31\)),插入一个数和查询一个数的时间复杂度都是 \(O(W)\)。总共有 \(N\) 个数,所以总时间复杂度为 \(O(NW)\)。
习题:P4551 最长异或路径
解题思路
本题要求树上所有路径中,边权异或和的最大值。直接枚举所有路径(点对)的复杂度为 \(O(N^2)\),会超时,需要一个更高效的算法。
解决这类树上路径问题的关键通常在于问题转化。
可以利用异或运算的性质 \(A \oplus A = 0\) 来简化路径计算。
首先,任意指定一个根节点(例如,节点 \(1\))。然后,预处理出一个数组 \(s\),其中 \(s_i\) 代表从根节点到节点 \(i\) 的唯一路径上所有边权的异或和。
对于树上的任意两个节点 \(u\) 和 \(v\),它们之间的路径异或和 \(\text{path}(u,v)\) 可以通过 \(s_u\) 和 \(s_v\) 计算得出。设 \(\text{LCA}(u,v)\) 为 \(u\) 和 \(v\) 的最近公共祖先,则:
- 从根到 \(u\) 的路径异或和 \(s_u = \text{path}(\text{root},\text{LCA}) \oplus \text{path}(\text{LCA},u)\)
- 从根到 \(v\) 的路径异或和 \(s_v = \text{path}(\text{root},\text{LCA}) \oplus \text{path}(\text{LCA},v)\)
将这两个等式进行异或,得到 \(s_u \oplus s_v = (\text{path}(\text{root},\text{LCA}) \oplus \text{path}(\text{LCA},u)) \oplus (\text{path}(\text{root},\text{LCA}) \oplus \text{path}(\text{LCA},v))\)
根据 \(A \oplus A = 0\),\(\text{path}(\text{root},\text{LCA})\) 被消去,得到 \(s_u \oplus s_v = \text{path}(\text{LCA},u) \oplus \text{path}(\text{LCA},v)\)
而 \(\text{path}(\text{LCA},u) \oplus \text{path}(\text{LCA},v)\) 正是 \(u\) 和 \(v\) 之间的路径异或和 \(\text{path}(u,v)\)
结论:树上任意两点 \(u,v\) 之间的路径异或和,等于它们各自到根节点的路径异或和的异或值,即 \(\text{path}(u,v) = s_u \oplus s_v\)
通过上述转化,原问题“求树上最长异或路径”就变成了“在一组数 \(s_1, s_2, \dots, s_n\) 中,找出两个数 \(s_i\) 和 \(s_j\),使得它们的异或值最大”,而这就是 P10471 最大异或对 The XOR Largest Pair
参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
// --- 题解思路 ---
// 1. 问题转化:树上任意两点 u, v 之间的路径异或和,可以转化为 (根节点到u的路径异或和) ^ (根节点到v的路径异或和)。
// 证明:设 path(a,b) 为 a 到 b 的路径异或和,LCA(u,v) 为 u,v 的最近公共祖先。
// 则 path(u,v) = path(u, LCA) ^ path(v, LCA)。
// 而根到 u 的路径和 s[u] = path(root, LCA) ^ path(LCA, u)。
// 根到 v 的路径和 s[v] = path(root, LCA) ^ path(LCA, v)。
// 所以 s[u] ^ s[v] = (path(root, LCA) ^ path(LCA, u)) ^ (path(root, LCA) ^ path(LCA, v)) = path(LCA, u) ^ path(LCA, v) = path(u, v)。
// 2. 问题就变成了:求出所有 s[i] (从根到节点i的路径异或和),然后求 s[i] ^ s[j] 的最大值。
// 3. 这就转化成了经典的“最大异或对”问题,可以使用 0-1 字典树 (Trie) 高效解决。
const int N = 100005;
// 边结构体
struct Edge {
int v, w;
};
vector<Edge> tr[N]; // 邻接表存树
int s[N]; // s[i] 存储从根节点1到节点i的路径异或和
int trie[N*31][2], tot = 0; // 0-1 字典树
/**
* @brief 通过DFS预处理出所有节点到根的路径异或和。
* @param u 当前节点
* @param fa 父节点
*/
void dfs(int u, int fa) {
for (Edge e : tr[u]) {
int v = e.v, w = e.w;
if (v == fa) continue;
s[v] = s[u] ^ w; // 路径和等于父节点的路径和异或上边权
dfs(v, u);
}
}
/**
* @brief 在0-1字典树中查询与x异或结果最大的值。
* @param x 要查询的整数。
* @return int 与x异或能得到的最大结果。
*/
int query(int x) {
int u = 0, res = 0;
// 从最高位(30)开始贪心
for (int i = 30; i >= 0; i--) {
int c = (x >> i) & 1; // 获取x的当前位
// 贪心策略:为了让结果的当前位为1,期望找到一个数,其当前位是 c^1
if (trie[u][c^1]) { // 如果存在相反的位
res |= (1<<i); // 结果的这一位就能变成1
u = trie[u][c^1]; // 移动到对应的子节点
} else { // 如果不存在
u = trie[u][c]; // 只能走相同的位,结果的这一位是0
}
}
return res;
}
/**
* @brief 将一个整数的二进制表示插入到0-1字典树中。
* @param x 要插入的整数。
*/
void insert(int x) {
int u = 0;
for (int i = 30; i >= 0; i--) {
int c = (x >> i) & 1;
if (trie[u][c] == 0) {
trie[u][c] = ++tot;
}
u = trie[u][c];
}
}
int main()
{
int n;
scanf("%d", &n);
for (int i = 1; i < n; i++) {
int u, v, w;
scanf("%d%d%d", &u, &v, &w);
tr[u].push_back({v, w});
tr[v].push_back({u, w});
}
// 1. 预处理所有节点到根的路径异或和
dfs(1, 0);
// 2. 将所有路径异或和 s[i] 插入到 0-1 字典树中
for (int i = 1; i <= n; i++) {
insert(s[i]);
}
// 3. 对每个s[i],查询Trie以找到最大的 s[i]^s[j]
int ans = 0;
for (int i = 1; i <= n; i++) {
ans = max(ans, query(s[i]));
}
printf("%d\n", ans);
return 0;
}
习题:P4735 最大异或和
解题思路
本题要求在一个动态增长的序列中,查询某个区间内后缀异或和与给定值异或的最大值。这是一个结合了动态操作、区间查询和位运算的复杂问题,可以通过巧妙的转化和数据结构来解决。
查询的目标是找到一个位置 \(p \in [l,r]\),使得 \(a_p \oplus a_{p+1} \oplus \cdots \oplus a_n \oplus x\) 最大。
直接处理后缀异或和非常不便,可以利用异或运算的性质,通过前缀异或和来简化表达式。
设 \(s_i = a_1 \oplus a_2 \oplus \cdots \oplus a_i\),并规定 \(s_0 = 0\)。根据异或的性质 \(A \oplus A = 0\),一个区间的异或和可以表示为 \(a_p \oplus \cdots \oplus a_n = (a_1 \oplus \cdots \oplus a_n) \oplus (a_1 \oplus \cdots \oplus a_{p-1}) = s_n \oplus s_{p-1}\)。
于是,查询的目标表达式可以改写为 \((s_n \oplus s_{p-1}) \oplus x\)。
其中 \(s_n\) 和 \(x\) 是对于某次查询的定值,为了使整个表达式最大,需要找到一个 \(p \in [l, r]\),使得 \(s_{p-1}\) 与 \((s_n \oplus x)\) 的异或值最大。
令 \(k = p-1\),则 \(k\) 的取值范围是 \([l-1, r-1]\),令 \(v = s_n \oplus x\)。
核心问题转化:对于每次查询,需要在 \(s\) 数组的下标区间 \([l-1, r-1]\) 中,找到一个 \(s_k\),使其与 \(v\) 的异或值最大。
由于序列只在末尾添加元素,而查询的是历史版本,这是一个典型的“可离线”问题。可以先读入所有操作,统一处理,最后回答所有查询。
首先,读入初始的 \(N\) 个数和 \(M\) 个操作,模拟所有 A x 操作,将新元素 x 加入序列,并计算出最终形态的、完整的 \(s\) 数组(长度最大为 \(N+M\))。当遇到 Q l r x 操作时,记录下查询的 l, r,同时,利用当时的序列长度 \(n\),计算出 \(v = x \oplus s_n\),将这个查询存储为一个元组 (查询ID, l, r, v)。完成这一步后,问题就变成了:有一个静态的 \(s\) 数组和一堆查询。每个查询要求在 \(s\) 数组的子区间 \([l-1, r-1]\) 中找到一个数,与给定的 \(v\) 异或起来最大。
这是一个带区间限制的最大异或对问题,如果去掉区间限制,可以用 01-Trie 轻松解决。为了处理区间 \([L,R]\),可以采用一种巧妙的离线技巧:按右端点排序。
将所有查询按照它们的右端点 \(r\)(对应到 \(s\) 数组的索引是 \(r-1\))从小到大排序。遍历排序后的查询,同时,维护一个指针 \(i\),表示 \(s_0\) 到 \(s_i\) 已经被插入到 01-Trie 中。当处理一个查询时,它的右边界 \(r-1\) 是目前遇到的最靠右的(或之一),首先将 \(s\) 数组中直到 \(s_{r-1}\) 的所有元素都插入字典树,此时,字典树保证了所有候选的 \(s_k\) 都满足 \(k \le r-1\)。
但如何满足 $k \ge l-1$ 呢?
为此,在字典树的每个节点 \(u\) 上额外维护一个信息 \(\text{last}_u\),记录所有经过节点 \(u\) 的路径所代表的 \(s_k\) 中,\(k\) 的最大值。
在为查询的 \(v\) 在字典树中寻找最大异或配对时,从高位到低位进行贪心。在第 \(j\) 位,设 \(v\) 的当前位是 \(c\)。希望找到一个路径,其第 \(j\) 位是 \(c \oplus 1\)。当考虑走向 \(c \oplus 1\) 分支时(设其子节点为 \(u'\)),必须额外检查 \(\text{last}_{u'} \ge l-1\) 是否成立。
- 如果成立,说明在 \(c \oplus 1\) 这条分支下,至少存在一个 \(s_k\) 满足 \(k \ge l-1\)。于是可以放心地走这条路,为最终答案贡献 \(2^j\)。
- 如果不成立(或者 \(c \oplus 1\) 分支不存在),只能走 \(c\) 分支。
通过这种方式,巧妙地将区间 \([l-1,r-1]\) 的限制融入了字典树的查询过程中。
参考代码
#include <cstdio>
#include <algorithm>
#include <vector>
using std::sort;
using std::max;
// --- 题解思路 ---
// 1. 问题转化:
// - 设 s[i] = a[1] ^ a[2] ^ ... ^ a[i] (前缀异或和),s[0] = 0。
// - 则 a[p] ^ ... ^ a[N] = s[N] ^ s[p-1]。
// - 询问的目标是找到 l <= p <= r,使得 (s[N] ^ s[p-1]) ^ x 最大。
// - 这等价于找到一个索引 k = p-1(其中 l-1 <= k <= r-1),使得 s[k] ^ (s[N] ^ x) 最大。
// 2. 离线处理:
// - 操作是动态的(有添加操作),但所有添加都在末尾,查询的区间都是历史版本,可以离线处理。
// - 首先,读入所有操作,处理所有 'A' 操作来构建出最终的序列和完整的前缀和数组 `s`。
// - 对于每个 'Q' 操作,将其存储起来。关键是,查询 `s[k] ^ (s[N] ^ x)` 中的 `s[N]` 是查询发生时的 `N`。
// 因此,在存储查询时,直接计算好 `val = x ^ s[当时的N]`,问题就统一为:
// 对于每个查询(l, r, val),找到 k in [l-1, r-1],使得 s[k] ^ val 最大。
// 3. 0-1字典树 + 排序:
// - 这是一个带区间限制的最大异或对问题。
// - 将所有查询按右端点 `r` 升序排序。
// - 遍历排序后的查询。同时维护一个指针 `idx`,表示已经向字典树中插入了 `s[0...idx-1]`。
// - 对于当前查询 `q[i]`,先把 `s` 数组中直到 `q[i].r-1` 的所有前缀和都插入字典树(即把 `idx` 推进到 `q[i].r`)。
// - 这样,当处理查询 `q[i]` 时,字典树里包含了所有 `k <= r-1` 的 `s[k]`。还需要满足 `k >= l-1` 的限制。
// - 为了处理左边界 `l-1`,在字典树的每个节点上维护一个信息 `last[u]`,表示经过节点 `u` 的所有 `s[k]` 中,`k` 的最大值。
// - 在查询时,贪心地选择路径。当想走 `c^1` 分支时,必须额外检查该分支上存在的最大索引是否不小于 `l-1`,即 `last[trie[u][c^1]] >= l-1`。如果满足,才能走这条路。
const int N = 600005; // 初始N + M次A操作
const int M = 300005;
int a[N]; // 最终的完整序列
int s[N]; // 完整的前缀异or和
int ans[M]; // 存储每个查询的答案
// 0-1 字典树
int tr[N * 24][2]; // 字典树节点
int last[N * 24]; // last[u]:经过节点u的所有s[k]中,k的最大值
int tot;
// 查询结构体
struct Query {
int id; // 原始查询顺序
int l, r, x; // l, r 和转化后的 x
};
Query q[M];
int main() {
int n, m;
scanf("%d%d", &n, &m);
// 计算初始的前缀异或和
s[0] = 0;
for (int i = 1; i <= n; i++) {
scanf("%d", &a[i]);
s[i] = s[i - 1] ^ a[i];
}
// --- 离线处理所有操作 ---
int cnt = 0; // 查询操作的计数器
for (int i = 1; i <= m; i++) {
char op[5];
scanf("%s", op);
if (op[0] == 'A') {
// 添加操作:直接更新最终的序列和前缀和数组
int x;
scanf("%d", &x);
a[++n] = x;
s[n] = s[n - 1] ^ x;
} else {
// 查询操作:存储起来
int l, r, x_orig;
scanf("%d%d%d", &l, &r, &x_orig);
cnt++;
// 存储转化后的查询,x_val = x_orig ^ s[当时的n]
q[cnt] = {cnt, l, r, x_orig ^ s[n]};
}
}
// 将所有查询按右端点 r 升序排序
sort(q + 1, q + cnt + 1, [](const Query &a, const Query &b) {
return a.r < b.r;
});
int idx = 0; // 指针,表示 s[0...idx-1] 已被插入Trie
// 插入 s[0]
{
int u = 0;
for (int j = 23; j >= 0; j--) {
int x = (s[0] >> j) & 1;
if (!tr[u][x]) tr[u][x] = ++tot;
u = tr[u][x];
last[u] = max(last[u], 0);
}
}
// --- 处理排序后的查询 ---
for (int i = 1; i <= cnt; i++) {
// 将 s[idx+1...q[i].r-1] 插入字典树
// 注意查询的是 [l, r] 区间的 p,对应 s[p-1],p-1范围是[l-1, r-1]
while (idx < q[i].r - 1) {
idx++;
int u = 0;
// 插入 s[idx]
for (int j = 23; j >= 0; j--) {
int x = (s[idx] >> j) & 1;
if (!tr[u][x]) {
tr[u][x] = ++tot;
}
u = tr[u][x];
// 更新经过该路径的 s[k] 的最大索引 k
last[u] = max(last[u], idx);
}
}
// --- 执行查询 ---
int u = 0, res = 0;
// 贪心查找与 q[i].x 异或最大的 s[k]
for (int j = 23; j >= 0; j--) {
int x = (q[i].x >> j) & 1;
// 尝试走相反的路 (x^1),并且要保证这条路上存在索引 >= l-1 的s[k]
if (tr[u][x ^ 1] && last[tr[u][x ^ 1]] >= q[i].l - 1) {
res |= (1 << j);
u = tr[u][x ^ 1];
} else {
// 否则只能走相同的路
u = tr[u][x];
}
}
ans[q[i].id] = res; // 按原始顺序存储答案
}
// 按原始顺序输出答案
for (int i = 1; i <= cnt; i++) {
printf("%d\n", ans[i]);
}
return 0;
}
习题:P5283 [十二省联考 2019] 异或粽子
解题思路
首先求前缀异或和数组 \(s\),将问题转化为在所有满足 \(0 \le j \lt \le n\) 的数对 \((s_i,s_j)\) 中,求 \(s_i \oplus s_j\) 的前 \(k\) 大值之和。
这是一个标准的 Top-K 问题,一个经典的解决方案是使用优先队列(大根堆)。
对于每一个 \(s_i\),都能找到一个与之配对的 \(s_j\),使得 \(s_i \oplus s_j\) 最大。可以将每个 \(s_i\) 和它的“最佳伴侣”组成的异或值,作为初始候选集合放入一个大根堆。然后,重复操作:从堆中取出当前全局最大的异或值,将这个值累加到最终答案中,假设这个值是由 \(s_i\) 和它的第 \(m\) 大伴侣组成的,那么接着找到 \(s_i\) 的第 \(m+1\) 大伴侣,并将新的异或值放入堆中。
这个思路的关键在于,需要一个能够高效查询“与给定数 \(x\) 异或结果为第 \(m\) 大”的数据结构,可以对 01-Trie 进行简单的改造,在每个节点上额外记录一个 \(\text{size}\) 属性,表示有多少个完整的数在插入时经过了这个节点(即子树大小)。
这样在查询与 \(x\) 异或的第 \(m\) 大值时,可以利用这个 \(\text{size}\) 属性决定当前应该往哪个分支继续走下去。
注意,由于 \(s_i \oplus s_j\) 和 \(s_j \oplus s_i\) 是重复的情况,在上述计算过程中实际上被重复考虑了。因此,可以将原问题改造成 Top-2K 问题,最终答案除以 2 即可。
参考代码
#include <cstdio>
#include <queue>
// --- 题解思路 ---
// 1. 问题转化:
// - 区间 [l, r] 的异或和可以表示为 (a[1]^...^a[r]) ^ (a[1]^...^a[l-1])。
// - 设前缀异或和 s[i] = a[1]^...^a[i],并令 s[0] = 0。
// - 那么区间 [l, r] 的异或和就是 s[r] ^ s[l-1]。
// - 题目要求所有 1 <= l <= r <= n 的区间异或和中,最大的 k 个之和。这等价于在所有 0 <= j < i <= n 的数对 (s[i], s[j]) 中,找到 k 个最大的 s[i] ^ s[j] 值并求和。
//
// 2. Top K 问题 -> 优先队列:
// - 这是一个经典的 "Top K" 问题,可以用优先队列(大顶堆)解决。
// - 可以为每一个 s[i] 找到与之异或值最大的 s[j],将这 n+1 个最大值放入优先队列。
// - 每次从队列中取出当前全局最大的异或和,计入总答案。然后,为这个异或和对应的 s[i] 找到次大的异或伙伴,将新的异或和放入队列。
//
// 3. 优先队列 + Trie上查询第k大:
// - 将所有 s[0...n] 插入一个数据结构。
// - 对每个 s[i],求出与它异或结果第1大的 s[j],并将这个 (s[i]^s[j], i, 1) 的三元组放入大顶堆。
// - 每次取出堆顶 (val, i, rk),将 val 加入答案。然后,去查询与 s[i] 异或结果第 rk+1 大的 s[j'],并将新的三元组 (s[i]^s[j'], i, rk+1) 放入堆中。
//
// 4. Trie上查询第K大:
// - 为了实现上述查询,需要一个支持查询“第k大异或值”的数据结构。
// - 可以在 0-1 字典树 (Trie) 的节点上维护一个 sz 属性,表示经过该节点的数字有多少个。
// - 查询时,从高位向低位贪心。对于 s[i] 的某一位 c,优先走 c^1 的分支。如果 c^1 分支的 sz 大于等于当前的 rk,说明第 rk 大的值就在这个分支里,于是走进去。否则,说明这个分支里所有的数都比目标要好,减去这个分支的 sz,更新 rk,然后走 c 分支。
//
// 5. 本代码的巧妙解法 (全局查询 + 去重):
// - 将所有 s[0...n] 插入一个普通的带 sz 的Trie。
// - 对每个 s[i],求出和它异或第1大的值,放入优先队列。
// - 每次取出堆顶 val,它是 s[idx] 和某个 s[j] 的异或值。
// - 关键: s[i]^s[j] 和 s[j]^s[i] 是同一个值。如果对每个 s[i] 都找伙伴,那么每一个合法的异或值都会被计算两次(一次是 i 找 j,一次是 j 找 i)。
// - 因此,循环 2*k 次,取出前 2k 大的异或值(包含了重复)。这些值正好是 k 个最大值,每个都出现了两次。
// - 最后将总和除以2,即可得到答案。
using ll = long long;
using std::priority_queue;
const int N = 500005;
ll a[N], s[N];
int tr[N*32][2], sz[N*32], tot; // Trie: tr是节点,sz是子树大小
int cur[N]; // cur[i] 记录 s[i] 当前已经用到了第几大的异或值
struct Node {
int idx; // 对应的s数组的下标
ll val; // 异或值
bool operator<(const Node &other) const {
return val < other.val;
}
};
/**
* @brief 在Trie中查询与x异或结果第rk大的值。
* @param x 要查询的数
* @param rk 要查询的排名
* @return ll 第rk大的异或值
*/
ll query(ll x, int rk) {
int u = 0;
ll res = 0;
// 从高位到低位贪心
for (int i = 31; i >= 0; i--) {
int c = (x >> i) & 1;
// 优先走相反的路(c^1)来让这一位异或结果为1
if (tr[u][c ^ 1] && rk <= sz[tr[u][c ^ 1]]) {
res += (1ll << i);
u = tr[u][c ^ 1];
} else {
// 如果相反的路不存在,或者第rk大的不在那边
// 就减去相反路上的数量,走相同的路
if (tr[u][c^1]) rk -= sz[tr[u][c ^ 1]];
u = tr[u][c];
}
}
return res;
}
int main()
{
int n, k;
scanf("%d%d", &n, &k);
s[0] = 0;
for (int i = 1; i <= n; i++) {
scanf("%lld", &a[i]);
s[i] = s[i - 1] ^ a[i];
}
// 将所有前缀异或和 s[0] 到 s[n] 插入Trie
for (int i = 0; i <= n; i++) {
int u = 0;
for (int j = 31; j >= 0; j--) {
int c = (s[i] >> j) & 1;
if (!tr[u][c]) {
tr[u][c] = ++tot;
}
u = tr[u][c];
sz[u]++; // 维护子树大小
}
}
priority_queue<Node> pq;
// 初始化优先队列:对每个s[i],找到与它异或第1大的值,放入队列
for (int i = 0; i <= n; i++) {
pq.push({i, query(s[i], 1)});
cur[i] = 1;
}
ll ans = 0;
// 循环2*k次,取出前2k个最大异或值
for (int i = 1; i <= k * 2; i++) {
ans += pq.top().val;
int idx = pq.top().idx;
pq.pop();
cur[idx]++; // s[idx] 要找下一个排名的异或值了
if(cur[idx] <= n + 1) { // 确保排名不超过总数
// 将s[idx]的下一个最大异或值放入队列
pq.push({idx, query(s[idx], cur[idx])});
}
}
// 每个异或对(s[i], s[j])被计算了两次,所以总和除以2
printf("%lld\n", ans / 2);
return 0;
}
习题:P4585 [FJOI2015] 火星商店问题
解题思路
这是一个动态的、多维度的数据查询问题,每次查询都包含三个限制维度:
- 空间维度:商店编号在区间 \([l,r]\) 内。
- 时间维度:商品是最近 \(d\) 天内进货的。
- 值域维度:在满足前两个条件的商品中,找到一个标价 \(\text{val}\),使得 \(\text{val} \oplus x\) 最大。
对于这种多维度的、需要在线处理的查询问题,通常需要设计一个复合的数据结构。
- 空间维度 \([l,r]\) 的区间查询,很自然地让人联想到线段树。
- 值域维度 \(\text{val} \oplus x\) 的最大化问题,是 01-Trie 的经典应用。
将这两者结合,可以设计出一种高效的解决方案。
可以构建一个外层为线段树,内层为 01-Trie 的二维数据结构。
- 外层线段树:对商店编号 \(1 \dots n\) 建立一棵线段树,线段树的每个节点代表一个商店的区间 \([L,R]\)。
- 内层字典树:线段树的每个节点 \(u\),本身都是一棵 0-1 字典树的根,这棵字典树存储了所有在 \(u\) 所管辖的商店区间 \([L,R]\) 内出现的商品标价 \(\text{val}\)。
- 处理时间维度:为了处理时间限制,在 01-Trie 的每个节点 \(v\) 上,额外记录一个信息 \(t_v\),表示所有经过节点 \(v\) 的路径所代表的商品中,最晚的进货日期(天数)。
通过这个结构,一个复杂的三维查询问题就被分解了。
参考代码
#include <cstdio>
#include <algorithm>
using std::max;
// --- 题解思路 ---
// 这是一个三维查询问题:(商店编号区间, 时间区间, 值的异或)。
// 1. 商店编号区间 [l, r] -> 提示使用线段树。
// 2. 值异或最大 -> 提示使用 0-1 字典树 (Trie)。
// 3. 时间区间 [day-d+1, day] -> 需要在数据结构中记录时间信息。
//
// 综合以上三点,可以采用一种强大的数据结构:线段树套字典树。
// - 外层是一个关于商店编号 1...n 的线段树。
// - 线段树的每个节点 u 本身都是一棵 0-1 字典树的根。这棵字典树存储了 u 所管辖的商店区间内所有商品的值。
// - 为了处理时间维度,在字典树的每个节点 v 上额外记录一个时间戳 ts[v],表示所有经过 v 的路径所代表的商品中,最晚的进货日期。
//
// 操作流程:
// - 更新 (进货): 0 s v,在 day 天给商店 s 添加商品 v。
// - 在线段树上找到所有包含 s 的区间(从根到叶子的路径上的所有节点),在这些节点对应的字典树中都插入 v。
// - 在插入 v 的过程中,更新沿途所有字典树节点的 ts 值为 max(ts[...], day)。
// - 查询: 1 l r x d,查询 [l,r] 商店 d 天内的商品与 x 异或的最大值。
// - d=0 是一个特殊情况,题意指不受时间限制,只包括特殊商品。代码通过将 min_day 设为 day+1 来实现,因为普通商品的 ts <= day,只有 ts 值超大的特殊商品能满足条件。
// - 对于普通查询,时间区间为 [day-d+1, day]。
// - 在线段树上查询,将 [l,r] 分解为 log(n) 个线段树节点。
// - 对每个节点,在其对应的字典树中进行贪心查询。查询时,除了常规的走异或相反路径外,还必须检查目标节点的 ts 是否 ts >= day-d+1,以满足时间限制。
// - 将所有分解出的节点查询结果取 max 即为最终答案。
const int N = 100005;
const int LOG = 17; // 题目数值范围 0..10^5, log2(N)<17
const int SEG_NODES = N * 4;
const int TRIE_NODES = N * LOG * LOG;
int p[N]; // 初始特殊商品,未使用
int bg[SEG_NODES], ed[SEG_NODES], mid[SEG_NODES]; // 线段树节点信息
int tr[SEG_NODES + TRIE_NODES][2]; // 字典树
int ts[SEG_NODES + TRIE_NODES]; // 字典树节点的时间戳
int tot; // 字典树节点计数器
/**
* @brief 构建线段树结构,初始化每个节点的管辖范围。
*/
void build(int u, int l, int r) {
bg[u] = l; ed[u] = r; mid[u] = (l + r) / 2;
if (l == r) return;
build(u * 2, l, mid[u]);
build(u * 2 + 1, mid[u] + 1, r);
}
/**
* @brief 更新操作:在线段树上找到对应位置并插入字典树。
* @param u 当前线段树节点
* @param idx 要更新的商店编号
* @param x 商品值
* @param tm 商品进货时间
*/
void update(int u, int idx, int x, int tm) {
// 将 x 插入到当前线段树节点 u 对应的字典树中
int v = u; // 从线段树节点 u 开始,把它当作字典树的根
for (int i = LOG - 1; i >= 0; i--) {
int c = (x >> i) & 1;
if (!tr[v][c]) {
tr[v][c] = ++tot;
}
v = tr[v][c];
ts[v] = max(ts[v], tm); // 更新路径上的最晚时间
}
if (bg[u] == ed[u]) return; // 到达叶子节点,返回
// 递归更新子节点
if (idx <= mid[u]) update(u * 2, idx, x, tm);
else update(u * 2 + 1, idx, x, tm);
}
/**
* @brief 查询操作
* @param u 当前线段树节点
* @param l, r 查询的商店区间
* @param x 喜好密码
* @param min_day 允许的最早进货日期
* @return int 找到的最大异或值
*/
int query(int u, int l, int r, int x, int min_day) {
// 如果当前线段树节点的区间被查询区间完全覆盖
if (bg[u] >= l && ed[u] <= r) {
int v = u, res = 0; // 从当前节点 u 的字典树开始查询
for (int i = LOG - 1; i >= 0; i--) {
int c = (x >> i) & 1;
// 贪心策略:尝试走相反路径(c^1),并且要保证该路径上有足够新的商品
if (tr[v][c ^ 1] && ts[tr[v][c ^ 1]] >= min_day) {
res |= (1 << i);
v = tr[v][c ^ 1];
} else { // 否则只能走相同路径
v = tr[v][c];
}
}
return res;
}
int res = 0;
// 递归查询子节点
if (l <= mid[u]) res = max(res, query(u * 2, l, r, x, min_day));
if (r > mid[u]) res = max(res, query(u * 2 + 1, l, r, x, min_day));
return res;
}
int main() {
int n, m;
scanf("%d%d", &n, &m);
build(1, 1, n);
tot = SEG_NODES; // 字典树节点从线段树节点之后开始分配
// 插入不受时间限制的特殊商品
// 使用一个非常大的时间戳(N+1),确保它们在任何时间查询中都有效(除了d=0)
for (int i = 1; i <= n; i++) {
int val;
scanf("%d", &val);
update(1, i, val, N + 1);
}
int day = 0;
for (int i = 1; i <= m; i++) {
int op;
scanf("%d", &op);
if (op == 0) { // 进货
day++;
int s, v;
scanf("%d%d", &s, &v);
update(1, s, v, day);
} else { // 查询
int l, r, x, d;
scanf("%d%d%d%d", &l, &r, &x, &d);
int min_day;
// d=0 表示只选特殊商品。通过设置一个未来时间,可以筛掉所有普通商品
if (d == 0) min_day = day + 1;
else min_day = day - d + 1;
printf("%d\n", query(1, l, r, x, min_day));
}
}
return 0;
}

浙公网安备 33010602011771号