可持久化Trie简介
极难2可持久化Trie:让字典树拥有“时光机”
你有没有想过,如果你的单词本可以像游戏存档一样,随时回到任意一天查看当时的单词列表,那该多方便?普通字典树只能看到最新状态,而可持久化Trie(Persistent Trie)就是字典树的“时光机”——它保存了每一次插入后的历史版本,让你能随时查询“在第5次插入之前”或“第i个版本”的字典树信息。
可持久化Trie的核心思想是:每次插入时,只复制路径上的节点,其他节点与旧版本共享。这样,你既拥有了所有历史版本,又不会浪费太多空间。它常用于解决历史前缀查询、区间异或极值等问题,是数据结构和算法竞赛中非常有用的工具。
为什么需要可持久化?—— 生活中的例子
? 单词本的“差异备份”
假设你有一本单词本(字典树)。每天你都会添加几个新单词,比如:
- 周一:
cat,dog - 周二:
bird,car - 周三:
apple,cat(重复添加也没关系)
你想知道“周二之前”单词本里有哪些词,或者“周一之后、周三之前”有哪些词。如果每天把整个单词本复印一份,那就太浪费纸了。聪明的方法是:每次添加时,只记录变化的部分,其他部分直接沿用旧版本。这就是可持久化的核心——共享已有节点,只复制修改路径。
? 游戏中的“存档机制”
玩RPG游戏时,你会在不同关卡保存多个存档。每个存档只记录从上一个存档以来的差异(比如等级、道具),而不是整个游戏数据。可持久化Trie就像这些存档,每次插入(存档)后,你都能通过对应的“根节点”进出那个时间点的字典树。
可持久化Trie的核心原理
1. 节点复制 vs 节点修改
普通字典树插入时,会沿着路径找到插入位置,并直接修改沿途节点的子节点指针。但这样会丢失之前的状态。可持久化Trie的做法是:在需要修改的路径上,每个节点都新建一个副本,新节点的子节点指针中,继承那些没被修改的分支的旧指针,而将需要修改的分支指向新创建的下一个节点。
这样,原始路径被保留下来,新路径独立存在,但共享了大部分节点。
2. 版本管理:根节点数组
每个历史版本都有一个唯一的根节点。比如 root[0] 表示空字典树(版本0),root[1] 表示插入第1个数字后的树,root[2] 表示插入第2个数字后的树……以此类推。我们用一个数组或列表来存储这些根节点。
查询时,指定版本号(比如版本2),然后从 root[2] 开始,像普通Trie一样搜索即可。
3. 插入过程详解(以二进制01-Trie为例)
假设我们要向一个空Trie中插入数字 5(二进制 101,我们只用3位表示)。
- 版本0:空树,
root[0]是一个空节点(没有子节点)。 - 插入5(二进制101):
- 从
root[0]开始,创建新节点newRoot1(版本1的根)。 - 看最高位(bit=2):数字5的二进制第2位是
1,所以要在root[0]的next[1]分支(原来为空)创建一个新节点。 - 但
root[0]的next[0]分支原本就是空,我们直接让newRoot1的next[0]指向root[0]的next[0](即空,但这样写是为了说明共享)。 - 然后递归到
newRoot1的next[1]创建新节点node2,处理下一位(bit=1,数字5的二进制第1位是0),同样复制旧节点信息。 - 继续直到处理完所有位,最后在叶子节点标记计数+1。
- 从
最终,版本1只新创建了 新根 + 路径上的单独节点,未修改的部分(比如 root[0] 的其他分支)全部共享。
4. 查询过程
只要给定版本号,从对应根节点出发,完全按照普通Trie的查询逻辑进行。因为每个版本的结构是完整的,只是共享了节点。
5. 空间与时间
每次插入新增 O(L) 个节点(L为二进制位数,例如31位就是31个新节点)。总空间 O(N*L) 但实际远小于完全复制,因为大量节点共享。查询时间复杂度 O(L)。
新手容易犯的常见错误
❌ 错误1:直接修改旧节点,忘记复制
在递归插入时,容易写成直接修改 node->next[b],而不是创建新节点。这样会破坏旧版本的数据。
正确做法:每次递归都要创建一个新节点,并复制旧节点的所有子节点指针。
❌ 错误2:更新cnt时忘记以子节点为准
有些实现里,每个节点记录以它为根的子树中的数字个数。当创建新节点后,需要从子节点重新计算 cnt,而不是简单地 +1。特别是当旧节点本身不是叶子时,需要累加两个子节点的cnt。
❌ 错误3:递归时没有处理好空指针
当旧节点为空(即该路径在旧版本中不存在)时,需要新建一个完整的路径,而不是试图复制旧节点。在代码中,需要判断 node 是否为 nullptr。
❌ 错误4:混淆版本号与插入次数
版本号通常从0开始(空树)。第一次插入后,版本号为1;第二次插入后,版本号为2。查询版本2意味着查询前两个插入后的状态。注意:版本号下标不一定等于插入次数,但通常我们让 roots[ver] 对应第ver次插入后的树(roots[0]为空)。
❌ 错误5:未处理重复插入
当同一个数字被插入多次,节点上的 cnt 应该累加。普通Trie中节点可能只标记存在与否,但可持久化Trie需要统计数量,否则查询“是否存在于某个版本”会出错。我们的代码中,在叶子节点用 cnt += 1,并向上维护总个数,这样就能正确处理重复插入。
完整可运行代码示例(C++ 和 Python)
原有代码已经非常标准,保留不变。下面我将补充更多注释和说明。
C++ 完整代码
#include <iostream>
#include <vector>
using namespace std;
const int MAX_BIT = 30; // 数字二进制位数,0~30共31位(适合int范围)
// 可持久化Trie节点
struct PersistentTrieNode {
PersistentTrieNode* next[2]; // 两个子节点指针:0和1
int cnt; // 该节点为根的子树中包含的数字个数
PersistentTrieNode() {
next[0] = next[1] = nullptr;
cnt = 0;
}
};
class PersistentTrie {
private:
vector<PersistentTrieNode*> roots; // 存储每个版本的根节点
PersistentTrieNode* root0; // 空版本根
// 递归插入,返回新创建的节点
PersistentTrieNode* insert(PersistentTrieNode* node, int num, int bit) {
PersistentTrieNode* newNode = new PersistentTrieNode();
if (node != nullptr) {
// 复制旧节点的所有数据:子节点指针和cnt
newNode->next[0] = node->next[0];
newNode->next[1] = node->next[1];
newNode->cnt = node->cnt;
}
if (bit < 0) {
// 所有位处理完,表示到达叶子节点
// 将数字计数加1(如果重复插入,cnt会累加)
newNode->cnt++;
return newNode;
}
int b = (num >> bit) & 1; // 当前位的值(0或1)
// 递归插入b分支:如果旧节点有该分支则传入,否则传入nullptr
newNode->next[b] = insert(newNode->next[b] ? newNode->next[b] : nullptr, num, bit - 1);
// 更新新节点的cnt:两个子节点cnt之和
newNode->cnt = (newNode->next[0] ? newNode->next[0]->cnt : 0) +
(newNode->next[1] ? newNode->next[1]->cnt : 0);
return newNode;
}
// 查询版本ver中是否有数字num(实际查看节点上cnt)
bool queryExist(PersistentTrieNode* node, int num, int bit) {
if (node == nullptr) return false;
if (bit < 0) {
return node->cnt > 0; // 如果cnt>0,说明至少有一个该数字
}
int b = (num >> bit) & 1;
if (node->next[b] == nullptr) return false;
return queryExist(node->next[b], num, bit - 1);
}
// 查询版本ver中与num异或的最大值
int queryMaxXor(PersistentTrieNode* node, int num, int bit) {
if (node == nullptr || bit < 0) return 0;
int b = (num >> bit) & 1;
int want = 1 - b; // 我们希望走相反的分支,使异或值更大
if (node->next[want] != nullptr) {
// 如果能走相反分支,累加这一位的贡献并递归
return (1 << bit) | queryMaxXor(node->next[want], num, bit - 1);
} else {
// 否则只能走相同分支,这一位贡献为0
return 0 | queryMaxXor(node->next[b], num, bit - 1);
}
}
public:
PersistentTrie() {
root0 = new PersistentTrieNode(); // 空版本根
roots.push_back(root0); // 版本0:空树
}
// 在当前最新版本上插入数字,返回新版本索引(从1开始)
int insert(int num) {
PersistentTrieNode* lastRoot = roots.back();
PersistentTrieNode* newRoot = insert(lastRoot, num, MAX_BIT);
roots.push_back(newRoot);
return roots.size() - 1; // 例如第一次插入后大小为2,索引1为版本1
}
// 查询版本ver(从0开始)中是否存在num
bool exist(int ver, int num) {
if (ver < 0 || ver >= (int)roots.size()) return false;
return queryExist(roots[ver], num, MAX_BIT);
}
// 查询版本ver中与num异或的最大值(假设版本ver中有数字)
int maxXor(int ver, int num) {
if (ver < 0 || ver >= (int)roots.size()) return 0;
return queryMaxXor(roots[ver], num, MAX_BIT);
}
int getVersionCount() { return roots.size(); }
};
int main() {
PersistentTrie pt;
// 每次插入后返回新版本号
int v1 = pt.insert(5); // 版本1
int v2 = pt.insert(3); // 版本2
int v3 = pt.insert(7); // 版本3
cout << "版本2中是否存在3? " << (pt.exist(2, 3) ? "是" : "否") << endl; // 是
cout << "版本1中是否存在3? " << (pt.exist(1, 3) ? "是" : "否") << endl; // 否(版本1只有5)
cout << "版本3中与5异或的最大值: " << pt.maxXor(3, 5) << endl; // 5 xor 3 = 6, 5 xor 7 = 2, 最大6
return 0;
}
Python 完整代码
class PersistentTrieNode:
__slots__ = ('next', 'cnt')
def __init__(self):
self.next = [None, None] # 两个子节点:0和1
self.cnt = 0 # 该节点为根的子树中的数字个数
class PersistentTrie:
MAX_BIT = 30 # 处理0~30共31位(int范围)
def __init__(self):
self.root_zero = PersistentTrieNode() # 版本0根(空树)
self.roots = [self.root_zero] # 所有版本的根列表,索引即版本号
def _insert(self, node: PersistentTrieNode, num: int, bit: int) -> PersistentTrieNode:
"""递归插入,返回新节点"""
new_node = PersistentTrieNode()
# 复制旧节点的数据
if node is not None:
new_node.next[0] = node.next[0]
new_node.next[1] = node.next[1]
new_node.cnt = node.cnt
if bit < 0:
# 到达叶子,计数+1(支持重复插入)
new_node.cnt += 1
return new_node
b = (num >> bit) & 1 # 当前位的值
# 递归插入b分支:如果旧节点有该分支则传入,否则传入None
child = node.next[b] if node else None
new_node.next[b] = self._insert(child, num, bit - 1)
# 更新新节点的cnt
cnt0 = new_node.next[0].cnt if new_node.next[0] else 0
cnt1 = new_node.next[1].cnt if new_node.next[1] else 0
new_node.cnt = cnt0 + cnt1
return new_node
def insert(self, num: int) -> int:
"""插入一个数字,返回新版本号(从1开始)"""
last_root = self.roots[-1]
new_root = self._insert(last_root, num, self.MAX_BIT)
self.roots.append(new_root)
return len(self.roots) - 1
def _exist(self, node: PersistentTrieNode, num: int, bit: int) -> bool:
if node is None:
return False
if bit < 0:
return node.cnt > 0
b = (num >> bit) & 1
if node.next[b] is None:
return False
return self._exist(node.next[b], num, bit - 1)
def exist(self, ver: int, num: int) -> bool:
"""查询版本ver中是否存在数字num"""
if ver < 0 or ver >= len(self.roots):
return False
return self._exist(self.roots[ver], num, self.MAX_BIT)
def _max_xor(self, node: PersistentTrieNode, num: int, bit: int) -> int:
if node is None or bit < 0:
return 0
b = (num >> bit) & 1
want = 1 - b # 优先走相反分支
if node.next[want] is not None:
return (1 << bit) | self._max_xor(node.next[want], num, bit - 1)
else:
return self._max_xor(node.next[b], num, bit - 1)
def max_xor(self, ver: int, num: int) -> int:
"""查询版本ver中所有数字与num异或的最大值"""
if ver < 0 or ver >= len(self.roots):
return 0
return self._max_xor(self.roots[ver], num, self.MAX_BIT)
if __name__ == "__main__":
pt = PersistentTrie()
v1 = pt.insert(5) # 版本1
v2 = pt.insert(3) # 版本2
v3 = pt.insert(7) # 版本3
print("版本2中是否存在3?", pt.exist(2, 3))
print("版本1中是否存在3?", pt.exist(1, 3))
print("版本3中与5异或的最大值:", pt.max_xor(3, 5)) # 输出6
应用场景与延伸学习
可持久化Trie虽然代码稍显复杂,但它在解决“区间异或极值”和“可持久化线段树替代”等问题上有奇效。下面列举几个典型场景:
- 查询第i次插入前的字典树:直接使用
roots[i]即可。 - 子数组异或最大值(LeetCode 1707 等):先建立前缀异或的可持久化Trie,再对每个右端点查询区间内的最大异或值。
- 可持久化线段树的替代:当字符集很小(如二进制)时,可持久化Trie可以替代主席树,实现区间第k大(结合二分)或区间计数。
- 字符串版本管理:虽然字符集较大(26),但可持久化思想同样适用,只是节点复制成本稍高。
如果你想进一步了解可持久化数据结构,推荐学习 可持久化线段树(主席树)、可持久化并查集。它们共享同样的“复制节点、共享未改部分”的思想。
此外,掌握可持久化Trie后,你可以尝试自己实现一个支持历史版本中“插入”、“删除”、“查询前缀个数”的字典树(注意删除需要额外计数)。
可持久化Trie是通往高级数据结构的一扇门,虽然第一次接触可能觉得有些绕,但多写几次代码,你就能体会到这种“时间回溯”的奇妙之处了!
例题精讲
可持久化Trie在插入一个新单词时,其空间复杂度与单词长度成正比的主要原因是什么?
在需要查询‘第i次插入操作之前,是否存在以某个前缀开头的字符串’这类问题时,可持久化Trie相比普通Trie的优势是什么?
可持久化Trie插入一个新单词时,会为从根到该单词路径上的每一个节点创建一个新节点,这些新节点与旧版本中对应节点的子节点指针全部相同。
以下是用C++实现可持久化Trie插入操作的代码片段(假设字符集为小写字母,使用数组存储节点)。请补全空白处的代码。
struct TrieNode {
int ch[26];
int cnt; // 经过该节点的单词数
} nodes[MAX_NODES];
int root[MAX_VERSIONS]; // 每个版本对应的根节点索引
int node_cnt = 0;
int insert(int pre_root, const string &s) {
int cur_root = ++node_cnt;
nodes[cur_root] = nodes[pre_root]; // 复制旧根节点
int u = cur_root;
for (char c : s) {
int idx = c - 'a';
int nxt = ___; // 填空1:创建新节点或复用?
if (nxt == 0) {
nxt = ___; // 填空2:创建一个新的子节点
} else {
// 需要复制该子节点以保持持久化
int old = nxt;
nxt = ++node_cnt;
nodes[nxt] = nodes[old];
}
u = nodes[u].ch[idx] = nxt;
nodes[u].cnt++;
}
return cur_root;
}以下是一个在可持久化Trie中查询某个版本中是否存在给定前缀的代码片段。假设节点结构如之前,root数组中存储每个版本的根节点索引。请补全空白处。
bool query(int version, const string &prefix) {
int u = root[version];
for (char c : prefix) {
int idx = c - 'a';
if (___ == 0) { // 填空1:判断子节点是否存在
return false;
}
u = ___; // 填空2:移动到子节点
}
return true;
}