CC++ & Algorithm

可持久化Trie:能返回过去的字典树

较难3
语言版本:C++
概述:可持久化Trie(前缀树)可以记录每次插入操作的历史版本,用于查询之前任意时刻的字符串或二进制数信息,如最大异或值等。

可持久化Trie:像游戏存档一样回溯历史版本

Trie(字典树)就像一本英语词典,能帮你快速查单词。但如果我想看“昨天我记单词到第50个”时的词典版本呢?普通Trie做不到,因为它只有最新状态。可持久化Trie就是给Trie加了“快照”功能:每次插入一个新单词,都会生成新版本,旧版本依然能查到。你可以把它想象成游戏里的存档——每过一关保存一次,随时可以读档回到过去。

这种数据结构常用来处理“区间查询”问题,比如:给定一个数组,多次询问某个区间内的数与给定值异或的最大值。它就像一台可以倒带的时间机器,让你能访问任意历史时刻的字典树。

什么是Trie(字典树)?先复习一下

字典树是一种树形结构,专门用来高效存储和查找字符串或数字的二进制位。比如,我们要插入单词 catcardog

        root
       /    \
      c      d
     /        \
    a          o
   / \          \
  t   r          g

每个节点代表一个字符,从根到叶子是一条完整单词。查找时只需沿着字符走,时间复杂度 O(长度)。对于数字,我们把数字拆成二进制位(比如30位),每一位是0或1,就构成一棵二叉树。这种Trie常用于求最大异或值:因为异或时我们希望每一位尽量不同,所以从高位到低位尽量走相反的分支。

为什么需要可持久化?生活比喻

想象你用复写纸写单词本:每次写一个新单词,就垫一张新复写纸,翻到哪一页就能看到那一页及之前的所有单词。可持久化Trie的做法类似——每次插入只新建从根到叶子路径上的 logN 个节点(N是数值范围,比如2^30),其他节点与旧版本共享。这样既保存了历史版本,又只增加了少量空间。

更贴近生活的例子:你在玩“贪吃蛇”游戏,每次吃到一个食物屏幕就更新一次。你希望回看第3步时的地图,可持久化Trie就像录屏,每一步都单独存一份,但只记录变化的部分(蛇头新位置),其他不变的部分直接引用上一帧。

核心思想:版本共享

可持久化Trie的实现思路和主席树(可持久化线段树)非常相似:

  • 每个版本都有一个根节点编号。
  • 插入一个新数时,从根出发,新建一条从根到叶子的路径,路径上的每个节点都复制旧版本对应节点的孩子指针和计数,然后在新开的孩子节点上递增计数。
  • 对于路径之外的节点,直接指向旧版本的对应节点(共享)。
  • 版本之间通过根节点区分。

这样,每个版本都拥有完整的Trie,但实际新增的节点数量只有 O(bit_length) 个,总节点数为 O(N * bit_length)。

常见应用:区间最大异或

最经典的应用是:给定一个长度为 n 的数组 a[1..n],有 q 次询问,每次给一个区间 [l, r] 和一个数 x,要求找出 a[l..r] 中与 x 异或的最大值。
思路:

  1. 用可持久化Trie维护前缀(即插入 a[1], a[1..2], ..., a[1..n] 后的版本)。
  2. 查询区间 [l, r] 时,用版本 r 的信息减去版本 l-1 的信息(节点计数相减),就得到只包含区间内数字的Trie。
  3. 在这个“虚拟”Trie上从高位到低位贪心走相反位,得到最大异或值。

注意:这里使用的是二进制Trie,每个节点只有两个子节点(0和1)。

代码逐段讲解(附详细中文注释)

下面代码假设所有数字在 0~2^30-1 之间(MAXBIT=30)。我们要实现一个可持久化二进制Trie,支持区间最大异或查询。

#include <bits/stdc++.h>
using namespace std;

const int MAXN = 300005;          // 数组最大长度
const int MAXBIT = 30;            // 二进制位数,假设数值小于2^30

// 树节点结构
struct Node {
    int ch[2];   // 两个子节点编号,ch[0]表示走0,ch[1]表示走1
    int cnt;     // 经过该节点的数字个数
} t[MAXN * MAXBIT];               // 节点池,大小 = 版本数 * 位数

int root[MAXN];  // 每个版本对应的根节点编号
int tot;         // 当前已使用的节点总数

// 插入一个数 x,返回新版本根节点编号
// pre 是上一个版本的根节点编号
int insert(int pre, int x) {
    int now = ++tot;              // 新版本根节点编号
    int cur = now;                // cur指向当前正在构建的节点
    for (int i = MAXBIT; i >= 0; i--) {  // 从高位到低位
        int bit = (x >> i) & 1;   // 取出第i位(0或1)
        t[cur] = t[pre];          // 复制旧版本该节点的所有信息(包括ch[0],ch[1],cnt)
        t[cur].ch[bit] = ++tot;   // 为当前位新建一个子节点
        // 复制旧版本对应子节点的信息到新子节点(但cnt还没更新)
        t[t[cur].ch[bit]] = t[t[pre].ch[bit]];
        // 新子节点的计数+1,表示多了一个数经过
        t[t[cur].ch[bit]].cnt++;
        // 继续向下:pre移动到旧版本的相应子节点,cur移动到新版本的子节点
        pre = t[pre].ch[bit];
        cur = t[cur].ch[bit];
    }
    return now;   // 返回新版本根编号
}

// 查询区间 (版本lRoot, 版本rRoot] 中的数与 x 的最大异或值
// 注意:区间内数字 = 版本rRoot包含的数字 - 版本lRoot包含的数字
int query(int lRoot, int rRoot, int x) {
    int res = 0;          // 记录异或结果(最大值)
    int curL = lRoot;     // 指向左版本当前节点
    int curR = rRoot;     // 指向右版本当前节点
    for (int i = MAXBIT; i >= 0; i--) {
        int bit = (x >> i) & 1;      // x的第i位
        int want = bit ^ 1;          // 我们希望走的相反位(0变1,1变0)
        // 计算区间内有多少个数在want分支上
        int cntWant = t[t[curR].ch[want]].cnt - t[t[curL].ch[want]].cnt;
        if (cntWant > 0) {           // 有数可以走want分支
            res |= (1 << i);          // 异或结果的这一位变为1
            curL = t[curL].ch[want]; // 两版本都沿着want走
            curR = t[curR].ch[want];
        } else {                     // 没有数走want,只能走bit分支
            curL = t[curL].ch[bit];
            curR = t[curR].ch[bit];
        }
    }
    return res;
}

int main() {
    int n, q;
    scanf("%d%d", &n, &q);          // 读入数组长度和询问次数

    // 初始化一个空Trie:只有一个根节点,孩子都为0,cnt=0
    tot = 1;                         // 节点编号从1开始
    t[1].ch[0] = t[1].ch[1] = 0;    // 0表示空节点
    t[1].cnt = 0;
    root[0] = 1;                     // 版本0(空版本)的根是1

    // 依次插入数组中的每个数,构建可持久化Trie的前缀版本
    for (int i = 1; i <= n; i++) {
        int x;
        scanf("%d", &x);             // 读入a[i]
        root[i] = insert(root[i-1], x);  // 基于上个版本插入x,得到新版本
    }

    // 处理询问
    while (q--) {
        int l, r, x;
        scanf("%d%d%d", &l, &r, &x); // 读入左端点、右端点和要查询的数
        // 区间[l,r]对应版本l-1和版本r
        printf("%d\n", query(root[l-1], root[r], x));
    }
    return 0;
}

代码关键点解析

  • 节点池大小t[MAXN * MAXBIT],因为每个版本最多新建 MAXBIT+1 个节点,n个版本最多 n * (MAXBIT+1) 个节点,这里取 300005 * 31 ≈ 930万,足够。
  • 初始化空版本:用 root[0] = 1 表示没有插入任何数字的版本,节点1的孩子都是0(表示空),cnt=0。
  • insert中的复制t[cur] = t[pre] 复制了整个节点,然后只修改当前位的孩子指针。这一步保证了新节点除了当前位,其他分支都指向旧版本对应节点(共享)。但注意,我们随后又新建了当前位的孩子节点并复制了旧版本该孩子的信息,再递增计数。
  • query中的差分cntWant = t[t[curR].ch[want]].cnt - t[t[curL].ch[want]].cnt 计算了区间内有多少个数在want分支上。如果大于0,就贪心选择want,否则只能走bit。

新手容易犯的错误

  1. 忘记初始化空版本:必须有一个根节点代表“没有数字”的状态,否则 root[0] 为0会导致后续访问越界。
  2. 节点数组开太小:可持久化Trie需要 n * (bit位+1) ´ 2 左右的空间(因为每个节点有两个孩子)。开小了会段错误。
  3. 查询时参数顺序搞反query(root[l-1], root[r], x) 中第一个参数是左边界的前一个版本,第二个是右边界版本。如果写成 query(root[r], root[l-1], x) 会得到错误结果。
  4. 复制节点时忘记更新cnt:插入时新节点计数要递增,否则差分时计数不准。
  5. 二进制位数不足:如果题目中数值范围超过2^30,要相应增大MAXBIT,比如31或60。

完整可运行示例(含输入输出)

上面代码已经是完整可运行的。下面给一个输入输出样例:

输入

5 3
3 10 5 25 2
1 5 6
2 4 7
1 3 10

解释:数组 [3,10,5,25,2],三次查询:

  1. [1,5] 中与6异或最大:6与25异或=31,输出31
  2. [2,4] 中与7异或最大:7与10异或=13,7与5异或=2,7与25异或=30,最大30
  3. [1,3] 中与10异或最大:10与3异或=9,10与10异或=0,10与5异或=15,最大15

输出

31
30
15

相关知识点指引

  • 可持久化线段树(主席树):和可持久化Trie的实现思路完全一样,只是节点维护的是区间和而不是0/1分支。建议先理解主席树的差分思想,再学可持久化Trie会容易很多。
  • 二进制Trie(普通版本):如果你不了解Trie,可以先学普通二进制Trie求最大异或对(LeetCode 421题)。
  • 异或的性质:相同为0,不同为1;另外,a^b = c 等价于 a^c = b。贪心从高位到低位求最大异或的核心就是优先让高位为1。
  • 区间第k小:可持久化线段树也可以解决区间第k小问题,思路类似:用前缀版本相减得到区间内数字的权值线段树,然后在树上二分。

可持久化Trie把字典树变成了“时间胶囊”,让你能对任意历史版本进行查询,是处理二进制区间问题的利器。掌握了它,你就能轻松解决很多看似复杂的区间异或查询题目啦!

例题精讲

1单选题

可持久化Trie与普通Trie相比,最主要的特性是什么?

A支持插入和删除操作
B能够访问任意历史版本的Trie结构
C使用指针而不是数组实现
D每个节点可以存储多个值
2判断题

可持久化Trie在进行插入操作时,会修改原版本的所有节点,因此时间复杂度为O(n)(n为Trie中节点总数)。

3填空题
以下是一个可持久化Trie的插入函数,请补全缺失的部分。
struct Node { int ch[2]; int cnt; } trie[MaxNode];
int root[MaxVersion];
int tot; // 当前节点总数
int insert(int pre, int x) {
    int now = ++tot;
    trie[now] = trie[pre];
    int cur = now;
    for (int i = 30; i >= 0; i--) {
        int b = (x >> i) & 1;
        int nxt = ___;
        trie[cur].cnt++;
        // 新建节点
        int new_node = ++tot;
        trie[new_node] = trie[nxt];
        trie[cur].ch[b] = new_node;
        cur = new_node;
    }
    trie[cur].cnt++;
    return now;
}
4单选题

给定一个整数序列a[1..n],现需要支持多次询问:在区间[l,r]中,选择一个数a[i](l≤i≤r)使得a[i] XOR x最大。若使用可持久化Trie解决,以下预处理和查询方法正确的是?

A以每个前缀构建可持久化Trie,查询时在版本r和版本l-1的差值上查询x
B以每个后缀构建可持久化Trie,查询时在版本l和版本r+1的差值上查询x
C以每个前缀构建可持久化Trie,查询时在版本l-1上查询x,然后与版本r的结果取最大
D以每个前缀构建可持久化Trie,查询时在版本r上查询x,但需要减去版本l-1的计数
5判断题

可持久化Trie的空间复杂度与插入的字符串长度总数成正比,与历史版本数量无关。