字典树

Trie

一个常见的问题:在一大堆单词中,快速查找某个单词是否存在?或者,需要快速找到所有以 auto 开头的单词(比如自动补全功能)?

如果用简单的遍历或者哈希表,处理前缀相关的问题会显得非常低效。为了解决这类问题,可以使用一种专门用于字符串和前缀的数据结构——字典树(Trie)

字典树,又称前缀树单词查找树,是一种树形结构。它的核心思想是用空间换时间,利用字符串的公共前缀来降低查询时间的开销,从而达到高效的字符串检索和前缀查询。

想象一下查英文字典的过程:先按第一个字母 a 找到第一个章节,再按第二个字母 p 找到 ap 开头的区域,以此类推,字典树的构造过程和这个过程非常相似。

一棵字典树包含以下基本元素:

  1. 节点
    • 根节点:不代表任何字符,是所有字符串的共同“祖先”。
    • 内部节点:每个节点代表一个前缀。
  2. :从一个节点指向其子节点的边代表一个字符,从根节点到任意一个节点的路径,将路径上的字符拼接起来,就构成了一个字符串(或前缀)。
  3. 节点信息:通常需要在节点上存储一些额外信息,最常见的是一个标记,用于表示“从根节点到当前节点的路径是否构成了一个完整的单词”。

例如,将单词 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] 火星商店问题

解题思路

这是一个动态的、多维度的数据查询问题,每次查询都包含三个限制维度:

  1. 空间维度:商店编号在区间 \([l,r]\) 内。
  2. 时间维度:商品是最近 \(d\) 天内进货的。
  3. 值域维度:在满足前两个条件的商品中,找到一个标价 \(\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;
}
posted @ 2025-12-11 15:45  RonChen  阅读(16)  评论(0)    收藏  举报