P3369【模板】普通平衡树复盘:01Trie 维护有序多重集
前言
P3369 【模板】普通平衡树 是一道经典的数据结构模板题。常规做法通常是 Treap、Splay、fhq Treap 或 pbds。
这篇文章记录一种非常规实现:用 01Trie 维护有序多重集合。
只要值域可控,题目要求的六类操作:
- 插入
- 删除
- 查询排名
- 查询第 小
- 查询前驱
- 查询后继
都可以通过 Trie 上的计数信息完成。
一、问题本质
题目要求维护一个支持顺序统计的可重集。
从本质上看,它并不强制要求使用“平衡树”,而是要求维护以下信息:
- 元素集合中的有序性
- 某个值前面有多少元素
- 某个排名对应哪个值
若能在 01Trie 上维护“经过某节点的元素个数”,就可以完成这些操作。
二、核心思路
01Trie 按二进制从高位到低位建树:
- 左儿子表示当前位为 0
- 右儿子表示当前位为 1
在高位前缀相同的前提下:
左子树中的所有数一定小于右子树中的所有数
因此,Trie 也具备“局部有序性”。
若再维护每个节点经过了多少个元素,就可以像顺序统计树一样支持:
- 查询严格小于某值的元素个数
- 查询第 小元素
前驱、后继则可由上述两个操作进一步推导。
三、值域平移
代码中使用了:
1 | const int offset = 1e7; |
所有输入值统一平移为:
1 | x + offset |
目的是将原本可能出现的负数整体映射到非负范围,便于按普通二进制进行 Trie 维护。
若原值范围为:
则平移后范围为:
输出答案时再减去 offset 即可恢复原值。
四、位数选择
代码按如下方式枚举二进制位:
1 | for (int i = 25; i >= 0; --i) |
原因是平移后的最大值约为 。而:
因此需要使用第 25 位到第 0 位,共 26 位,足以覆盖全部取值。
五、维护的信息
核心数组如下:
1 | int trie[maxn][2], cnt[maxn]; |
含义为:
trie[p][0]:节点p的 0 儿子trie[p][1]:节点p的 1 儿子cnt[p]:经过节点p的元素个数
这里的 cnt[p] 可以理解为:
以该节点为根的这棵子树中,共有多少个元素经过这个前缀。
由于所有数都固定走满 26 层,因此不需要单独维护结束标记。
六、插入与删除
1. 插入
1 | void Insert(int x) { |
从高位到低位依次取出每一位:
- 若对应儿子不存在,则新建节点
- 沿路径向下走
- 将经过节点的计数加一
插入完成后,x 所在路径上的所有节点计数均被正确维护。
2. 删除
1 | void Delete(int x) { |
删除时沿原路径走一遍,并将沿途 cnt 减一即可。
不必真正删除节点,只需保证计数正确。
该写法默认题目保证删除操作合法,即待删除元素一定存在。
七、排名查询 getRank
代码如下:
1 | int getRank(int x) { |
该函数返回的不是题目中的“排名”,而是:
严格小于
x的元素个数
因此主函数中查询排名时需要输出:
1 | getRank(x + offset) + 1 |
正确性分析
从高位到低位考虑当前位。
设当前位为 v:
当 v = 0
若某个数在当前位取 1,则在高位前缀相同的前提下,它一定大于 x。
因此不会对“小于 x 的元素个数”产生贡献,直接沿 0 分支继续即可。
当 v = 1
此时,所有高位前缀与 x 相同、但当前位取 0 的数,一定严格小于 x。
因此可以直接累计:
1 | rank += cnt[trie[p][0]]; |
随后继续沿 1 分支向下,统计剩余部分。
提前退出
若某一步 p 变为 0,说明当前前缀已不存在,后续更低位不可能再产生贡献,可以直接结束。
八、第 小查询 getVal
代码如下:
1 | int getVal(int x) { |
该函数返回当前集合中的第 x 小元素,x 为 1-based。
正确性分析
在某个 Trie 节点处:
- 左子树对应当前位为 0
- 右子树对应当前位为 1
由于当前位 0 < 1,因此左子树中的所有元素都小于右子树中的所有元素。
于是:
- 若左子树元素个数不少于
x,则第x小一定在左子树中 - 否则,第
x小一定在右子树中,同时应减去左子树的元素个数
若走向右子树,则当前位应置为 1:
1 | val |= 1 << i; |
最终构造出完整数值。
九、前驱与后继
1. 前驱
1 | int getPrev(int x) { |
前驱定义为“严格小于 x 的最大值”。
设严格小于 x 的元素个数为 k,那么这些元素中最大的一个,恰好就是第 k 小元素,因此:
2. 后继
1 | int getNext(int x) { |
后继定义为“严格大于 x 的最小值”。
由于 getRank(y) 返回的是严格小于 y 的元素个数,因此:
所以:
1 | getRank(x + 1) |
表示集合中小于等于 x 的元素个数。
那么严格大于 x 的最小元素,其排名就是:
1 | getRank(x + 1) + 1 |
再通过 getVal 取得对应值即可。
十、主函数中的操作对应
1 | if (op == 1) { |
各操作含义如下:
1 x:插入x2 x:删除一个x3 x:查询x的排名4 x:查询第x小的值5 x:查询x的前驱6 x:查询x的后继
由于 Trie 中维护的是平移后的值,凡是输出实际数值时都需要减去 offset。
十一、复杂度分析
设值域大小为 。
每次操作都只需沿 Trie 从高位走到低位,因此时间复杂度为:
- 插入:
- 删除:
- 查询排名:
- 查询第 小:
- 查询前驱:
- 查询后继:
在本题中,值域长度固定为 26 位,因此单次操作的常数较小。
空间复杂度方面,最坏情况下每插入一个新数都会新建 26 个节点。
若操作规模为 级别,则需要开约:
个节点,因此代码中定义:
1 | const int maxn = 2.6e6; |
十二、完整代码及提交记录
点击展开/折叠 最终AC代码
1 |
|
可以发现 01trie 写法在性能上要大大优于常规写法。
结语
这份实现的关键在于两点:
- 利用 01Trie 的二进制字典序维护数值大小关系
- 利用
cnt实现顺序统计,再由rank与kth推导前驱、后继
在值域可控的前提下,这是一种实现简洁、复杂度稳定的替代方案。