《浅谈亚 log 数据结构在 OI 中的应用》阅读随笔

image

这篇又长长长了!
843583759729

早就馋这篇了!终于学了(
压位 Trie 确实很好写啊 但是总感觉使用范围不是很广的样子
似乎是见的题少

原文里都在用 log2,但我不是很习惯,所以还是用 log

不出意外的话下一篇是 EI 树。出了意外的话(指完全看不懂)就咕咕咕。

引言

数据结构!该学还是得学的,这给你讲讲亚 log 的,挺nb!但是有些没用的就不说了,常数大还难写。这讲讲压位 Trie 和 vEB 树。
这玩意用来解决动态前趋问题(Dynamic Predecessor Problem)。我还给你测了测,反正你知道这玩意厉害就行了!这里是代码!
反正不要钱,多少看一点~

1 Dynamic Predecessor Problem

动态前趋问题

你需要写一种数据结构,维护一个数字集合。需要支持以下几种操作:

  1. 若原来不存在数 x,插入数 x
  2. 若原来存在数 x,删除数 x
  3. 求数 x 的前趋(前趋定义为小于 x 且最大的数,若不存在输出 1);
  4. 求数 x 的后继(后继定义为大于 x 且最小的数,若不存在输出 1);

假设操作数为 n,保证 1n,x107

时间限制:1s
空间限制:32MB

常见解法

  1. 平衡树
    不展开。卡时间还卡空间,它已经死力
  2. 树状数组
    可以树状数组维护 01,树状数组上二分解决。
    单次时间复杂度是 O(logV),空间复杂度是 O(V)。常数小但是太慢了。
  3. 整型压位
    适用于值域在 264 以内的情况。
    可以使用 ctz 等函数和位运算在 O(1) 复杂度内解决。

2 压位 Trie

压位 Trie,又称 ω 叉 Trie,常数小还好写。
各操作的复杂度均为 logωV,并且具有较高的拓展性。这里的 ω 常取 64,使用 unsigned long long 压位。

2.1 结构

假设 c=logω

一棵大小为 2k 的压位 Trie 可以维护一个值域为 [0,2k) 的集合。
如果 2kω,显然可以使用一个整型压位解决。下面只讨论 2k>ω 的情况。

我们定义 high(x)=x2kclow(x)=xmod2kc。两个函数的作用是取 x 的低 c 位与高位。
对于一棵大小为 2k 的压位 Trie,其有 ω 棵大小为 2kc 的子压位 Trie Ai(0i<ω),其中 Ai 维护目前高位为 i 的值的低位组成的集合。每个节点维护一个 ω 大小的 word 维护第 i 棵子树内是否维护了值。

论文里的图:

ω=4 的情况。这棵压位 Trie 维护了 [0,24) 里的值,集合为 {0,2,9,10,11,12,13,14,15}。可以看到每一个节点的 word。

2.2 插入/删除

假设现在在一棵大小为 2k 的压位 Trie 中插入元素 x。首先需要将 low(x) 插入 Ahigh(x) 中。然后递归。结束了!
删除同理。递推地删除掉只有 x 的子树对其父亲的标记即可。如果子树删后非空退出即可。
每次都会进入一个大小为原来的 1ω 的压位 Trie 中,因此插/删的复杂度为 O(logωV) 的。

2.3 前趋/后继

假设现在在一棵大小为 2k 的压位 Trie 中查询元素 x 的前趋。我们首先在 Ahigh(x) 里查询 low(x) 的前趋。查到了就退出。
到这了就是没查到。找一个 Aj 满足 0j<high(x) 而且里面有元素。如果存在就返回这个玩意里面的最大值,不存在返回 1
因为压位 Trie 是对称的,后继平凡。
复杂度还是 O(logωV) 的。

2.4 复杂度

发现所有操作都是 O(logωV)=O(logVlogω) 的。一般取 ω=O(logV),所以有复杂度 O(logVloglogV)
假设一棵大小为 2k 的压位 Trie 的空间复杂度为 F(k)。容易发现  12iω, F(i)=1,且 F(k)=ωF(kc)+1。因此 F(k)=O(2k)。调整第一层子压位 Trie 个数能做到 O(2kω) 的空间复杂度。评价:奥妙重重。

2.5 具体实现

边看代码边说(

Submission.

you wanna see code? click here!
template<const long long Nb>
struct comp_trie {
    #define clz(x) __builtin_clzll(x)
    #define ctz(x) __builtin_ctzll(x)
 
    int siz;
    vector<vector<ull> > t;
    ull a[1 << 6], b[1 << 6];
    comp_trie() { 
        ll nowlen = 1;
        while (nowlen <= Nb) {
            t.emplace_back(vector<ull>(nowlen));
            nowlen <<= 6;
        } reverse(t.begin(), t.end());
        siz = t.size(); cerr << siz << '\n';
        a[0] = 0, b[0] = ~1ull; 
        for (int i = 1; i < 64; ++ i) 
            a[i] = a[i - 1] << 1 | 1, b[i] = b[i - 1] << 1;
    }
 
    inline void insert(int x) { for (int i = 0; i < siz; ++ i, x >>= 6) t[i][x >> 6] |= 1ull << (x & 63); }
    inline void erase(int x) { for (int i = 0; i < siz; ++ i, x >>= 6) if (t[i][x >> 6] &= ~(1ull << (x & 63))) return; }
    inline void assign(int x, int i) { i ? insert(x) : erase(x); }
 
    inline int prev(int x) {
        int i, pre; ull tmp;
        for (i = 0; ; i ++, x >>= 6) {
            if (i == siz) return -1;
            tmp = t[i][x >> 6] & a[x & 63];
            if (tmp) {
                pre = (63 ^ clz(tmp)) | (x & ~63);
                break;
            }
        } for (; i; -- i) pre = pre << 6 | (63 ^ clz(t[i - 1][pre]));
        return pre;
    }
    inline int next(int x) {
        int i, nxt; ull tmp;
        for (i = 0; ; i ++, x >>= 6) {
            if (i == siz) return -1;
            tmp = t[i][x >> 6] & b[x & 63];
            if (tmp) {
                nxt = ctz(tmp) | (x & ~63);
                break;
            }
        } for (; i; -- i) nxt = nxt << 6 | ctz(t[i - 1][nxt]);
        return nxt;
    }
}; comp_trie<100005> Tr;

一个节点就是一个 word。为了空间可以写成五个数组+指针数组的形式,速度应该不是很差。
然后我们就可以从底向上更新了,常数会更小一点(确信

论文里说的关于删除和查询的剪枝都用了,就是,接下来再做操作没意义了就退出。但是这点我不会在插入上实现。想实现但是挂了www
经过少许卡常后是 infoj 的最优解。

2.6 拓展

改个操作。现在是 Dynamic Prefix Problem 了,我们需要支持插入删除和查询排名。

新的查询在原来的压位 Trie 结构上无法实现,因为我们无法快速查询前 k 个子树内总共有几个元素。然后改一下叉数。设现在叉数为 B,每当一个节点的子树插入了 B 个元素后我们重构一次,计算出每个位置的子树内有多少元素,求前缀和后备用。现在只需要维护最近的 O(B) 个元素中有几个大于 x 的,即一个前缀内的子树元素个数,

我们考虑用一个 word 存储目前子树内信息。具体地,我们的 word 里有恰好 B+10,第 i0 和第 i+10 之间的 1 的个数表示第 i 棵子树内有几个元素了。最前和最后的 0 可以去掉。如果向查询前 k 棵子树有多少元素,就可以查询第 k+10 的位置,这自然能导出在它前面有几个 1。我们可以使用 Method of Four Russians 来优化,即打出一张表,存储每一个不同的查询的答案。插入一个 1 可以使用位运算平凡解决。如果我们取 B=logn3 ,那么预处理复杂度将小于 O(n),因此我们能以均摊 O(logVloglogn) 的时间复杂度解决这个问题,我们也有一些手段将其变为严格 O(logVloglogn)

3 vEB 树

压位 Trie 的复杂度里 ω 是在分母上的。所以叉数越大效率越高。但是如果你对 word 的操作复杂度不是 O(1),那就得不偿失了。
vEB 树(van-Emde Boas Tree)采用了一定的策略,使得其在叉数增加的情况下保证了时间复杂度,因此可以在 O(loglogV) 的复杂度下支持每个操作。

3.1 结构

一棵大小为 2k 的 vEB 树可以维护一个值域为 [0,2k) 的集合。
如果 k<2,显然可以使用 2k 个布尔值维护。否则我们令 m=k2。我们维护 2km2m 大小的子 vEB 树 Ai(0i<2km)
我们定义 high(x)=x2mlow(x)=xmod2m
对于一棵 vEB 树维护的集合,我们记录其 maxmin(若集合为空则分别为 )并将除了最小值的元素插入其子 vEB 树中。对于 x,我们在 Ahigh(x) 中插入 low(x)

为加速查找,我们再定义一个大小 2km 的 vEB 树 B,其维护出现的数的高位的集合。B 可以加速我们的查询。

3.2 边界

对于一棵空的 vEB 树,我们插入元素 x 时只需要将 maxmin 都设为 x
对于一棵 vEB 树,其集合大小为 1 当且仅当其的 max=min
对于一棵集合大小为 1 的 vEB 树,我们删除元素时只需要将 maxmin 初始化。
对于一棵大小为 2k 的 vEB 树,假设子 vEB 树 Ax 中存在元素 y,就说明本树中存在值为 2m×x+y 的元素。
这些操作都可以 O(1) 完成。

3.3 插入

假设我们要在一棵大小为 2k 的 vEB 树中插入一个元素 x
如果该树为空则直接按边界插入即可。
如果 x<min,交换 xmin 后接着插入原来的 min。如果 x>max,更新 max

首先我们需要在 Ahigh(x) 中插入 low(x),并在 B 中插入 high(x)。容易发现,当 Ahigh(x) 非空时,B 中一定已存在 high(x),所以我们只需要在 Ahigh(x) 中插入。否则 Ahigh(x) 为空,按边界插入即可。因此每次只会向一侧插入元素。
假设 T(k) 为在大小为 2k 的 vEB 树中插入元素的时间复杂度,我们有 T(0)=T(1)=1,T(k)=T(k2)+O(1)。主定理得 T(k)=O(logk),而 k=O(logV),因此总时间复杂度为 O(loglogV)

3.4 删除

假设我们要在一棵大小为 2k 的 vEB 树中插入一个元素 x
如果该树为空则直接按边界删除即可。

如果 x=min,则我们需要在删除 min 后求得新的 min。新 min 的高位必定是最小的,所以我们在 B 中找出高位最小值,并在高位对应的子 vEB 树中找到低位最小值,即可得到新的 min
然后我们需要在 Ahigh(x) 中删除 low(x)。删空了就在 B 中删除 high(x)
如果 Ahigh(x) 维护的集合大小为 1,则在 Ahigh(x) 中删除元素的复杂度为 O(1) 的,因此这里只会向 B 递归。反之我们不需要维护 B。因此每次只会向一侧删除元素。
复杂度同样是 O(loglogV)

3.5 前趋/后继

假设我们要在一棵大小为 2k 的 vEB 树中查询元素 x 的前趋。

x 的前趋与 x 高位相同当且仅当 Ahigh(x)min 小于 low(x),因此可以 O(1) 判断其前趋是否在 Ahigh(x) 中。如果在的话直接查询 low(x) 的前趋。
否则,x 的前趋一定是 high(x) 的前趋,这个可以向 B 递归求得。递归回后取 high(x) 前趋对应子树的最大值即可。
后继镜像,不展开。总时间复杂度 O(loglogV)

对于前趋,如果 B 中的最小值大于等于 high(x),则前趋将是整棵 vEB 树的最小值。
对于后继,发现我们要取子树的最小值。需要注意的是,最小值在 vEB 树的子结构中。

3.6 注意点

可以将维护集合大小为 64 的情况特殊判断,采用一个 unsigned long long 型的 word 维护。若 ω=64,即便是维护一个大小为 224 的集合也只用递归两层,大大减小了常数。
这里注意$老师的模板中 vEB 树的 size 是 k2

由于 vEB 树的结构复杂,因此可以采用 template 语法定义不同大小的 vEB 树,特化小型的 vEB 树也比较方便。

3.7 复杂度

可以发现单次操作的时间复杂度均为 O(loglogV)

对于空间复杂度,假设一棵大小为 2k 的 vEB 树空间复杂度为 T(k)。容易得到  12iω, T(i)=1。同时有 T(k)=T(k2)+2k2T(k2)+2
我们可以证明 T(k) 可能会达到 O(2kω),但可以通过调整 vEB 树的叉数得到 O(2kω) 的空间复杂度。
在值域大的情况下可以考虑哈希表等方式降低 vEB 树的空间复杂度。

4 对于部分数据结构效率的一些测试

image

操作次数为 107,值域范围为 [0,224)
第一组数据是插入 107 个不同的数字。
第二组数据是插入 106 个不同的数字后进行 9×106 次前趋/后继查询。
第三组数据是插入 105 个不同的数字后进行 99×105 次前趋/后继查询。
第四组数据是插入 5×106 个不同的数字后随机地删除这 5×106 个数字。

从时间角度来说:可以发现 vEB 树和压位 Trie 在时间上还是远远快于其它两个 log 数据结构,在集合数字比较稀疏的情况下的查询甚至比树状数组快了 10 倍多。相对来说,vEB 树的插入删除略慢于压位 Trie,但是其查询效率还是可以的。这仅仅是随机数据,在更加强力的数据下 vEB 树可能会有相对更高的查询效率。但是压位 Trie 已经完全能够满足我们的使用需求。
从空间角度来说:四个数据结构使用的空间分别为:455MB, 64MB, 2.06MB, 2.03MB。可以见压位 Trie 与 vEB 树在操作数和值域基本同阶的情况下,空间也是非常占优势的。而树状数组的空间也相对来说较小。
从实现难度来说:笔者认为压位 Trie,vEB 树实现难度均在可接受范围之内,而压位 Trie 相对来说更加容易实现,且在很多情况下效率更高。

这段粘的。

5 例题

5.1 [北大集训 2018] ZYB 的游览计划

给定一棵 n 个节点的树,和一个 1n 的排列 p。定义一个整数区间 [l,r] 的权值为从 1 号节点出发遍历完 pl,pl+1,,pr(无需按顺序)后回到 1 号节点需要经过的最小边数。
现在将 [1,n] 划分成 k 个区间,问权值和的最大值。

2knn×k2×105

时间限制:7s
空间限制:512MB

可以决策单调性,这里不提。
容易发现我们沿着 dfs 序从小到大遍历一定是最优的。因此我们需要维护一个有序集合中任意两个点的距离以及 1 号节点和 dfs 序最小/最大的点的距离。
插入一个点 b:求出点的前趋 a 和后继 c,答案变化 dis(a,b)+dis(b,c)dis(a,c)
删除一个点 b:求出点的前趋 a 和后继 c,答案变化 dis(a,c)dis(a,b)dis(b,c)
使用 vEB 树或压位 Trie 实现可以做到 O(nklognloglogn)

使用压位 Trie 的实现经测试可以做到 150ms 左右。

5.2 [ZJOI2019] 语言

有一棵 n 个顶点的树,和 q 条树上的链 (ui,vi)。对于一条链,链上的任意不同的两个点之间都可以展开贸易活动。

询问一共有多少点对之间可以开展贸易活动。

时间限制:3s
空间限制:512MB

动态开点压位 Trie 合并?

先叨叨原题做法。维护一些点的虚树大小,支持虚树合并。可以直接线段树合并。
然后现在有一个压位 Trie,拉过来考虑。

因为是动态开点压位 Trie,所以每个点需要维护所有子节点的编号。每次合并需要把一棵树的子节点编号扔到另一棵树上。根据线段树合并的启发式做法,这玩意的复杂度是 O(nlogωn) 的。
但是空间复杂度有点问题,同一时刻最多是 O(nωlogωn) 个节点,不太行。
考虑一个树启。我们直接拿原重儿子的压位 Trie 过来,任意时刻只有 O(logn) 个压位 Trie,每个 Trie 只有 O(nω) 个点,总空间复杂度为 O(nlogn)

image

posted @   joke3579  阅读(645)  评论(3编辑  收藏  举报
相关博文:
阅读排行:
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】
点击右上角即可分享
微信分享提示