平衡树(FHQ-Treap)有交合并
最近了解了一下这个,但感觉网上写的一些分析有点奇怪(当然也不排除我太菜看不懂),所以还是自己写一篇。
参考: https://codeforces.com/blog/entry/108601
修复了一个问题。
定义
有交合并,就是把两个值域有交的平衡树合并。一般的 fhq 里的 merge 函数是把两个树直接拼接,属于无交合并。
过程
fhq 有交合并大概是这样的过程:
假如两个有序序列 \(A\) 和 \(B\) 的值域分布长这样
A: aaaaa aaa aaaa
B: bbbbb bbbbb
那考虑维护一个答案序列 \(C\),每次从 \(A\) 或 \(B\) 中提出一个最靠前的无交前缀,然后并到 \(C\) 中
A: aaa aaaa
B: bbbbb bbbbb
C: aaaaa
A: aaa aaaa
B: bbbbb
C: aaaaabbbbb
以此类推。然而这个看似暴力的过程是总的 \(\mathcal{O}((n+m)\log n\log V)\) 的。(\(n\) 是元素个数,\(m\) 是合并次数,\(V\) 是值域)
原理
考虑分析有序序列序列 \(x_1,x_2,...,x_n\) 中的势能,定义为:
假设两个有序序列 \(A\) 和 \(B\) 的值域分布长这样:
<-d1-> <--d2--> <--d3--> <---d4--->
A = {[--a1--] [-a2-] [--a3--]}
B = { [---b1---] [-b2-] }
其中 a,b,d 等字母表示一个值域区间的长度。当然 [] 内也不一定每个位置都有值,只不过这样考虑的话 [] 内部势能不发生变化,方便分析。
现在计算把这两个序列并起来的势能减小量 \(\Delta\)。其中 \(k\) 是间隔数,上图中 \(k=4\)。
因为 \(\log\) 函数是上凸的,所以有:
所以有
由此,合并间隔数为 \(k\) 所消耗的势能是 \(\mathcal{O}(k)\) 的,而时间复杂度为 \(\mathcal{O}(k\log n)\)。
初始,\(n\) 个元素,值域为 \(V\),势能为 \(\mathcal{O}(n\log V)\)。
所以总时间复杂度是 \(\mathcal{O}(\frac{n\log V}{k} k\log n)=\mathcal{O}(n\log V\log n)\)。
另外也可以随意分裂,因为不影响势能。(所以可以把线段树分裂做了)
实现
具体实现的话,我是维护一个最小值,然后比较两个序列的最小值判断放谁。
void pushup(int u)
{
sz[u] = sz[son[u][0]] + num[u] + sz[son[u][1]];
mn[u] = son[u][0] ? mn[son[u][0]] : val[u];
}
...
int join(int u, int v)
{
if (!u || !v)
return u | v;
int x = mn[u], y = mn[v], t;
if (x <= y)
splitv(u, y, t, u);
else
splitv(v, x, t, v);
return merge(t, join(u, v));
}
线段树分裂
以下为 P5494 【模板】线段树分裂 代码。
// Author: Aquizahv
#include <bits/stdc++.h>
#define ll long long
using namespace std;
const int N = 2e5 + 5, ND = N + N;
int n, m, root[N], tot = 1;
struct treap
{
int tot, son[ND][2], rank[ND], val[ND], mn[ND];
ll num[ND], sz[ND];
int build(int x, int q)
{
int u = ++tot;
son[u][0] = son[u][1] = 0;
sz[u] = num[u] = q, mn[u] = val[u] = x, rank[u] = rand();
return u;
}
void pushup(int u)
{
sz[u] = sz[son[u][0]] + num[u] + sz[son[u][1]];
mn[u] = son[u][0] ? mn[son[u][0]] : val[u];
}
void splitv(int u, int key, int &x, int &y)
{
if (!u)
{
x = y = 0;
return;
}
if (val[u] <= key)
{
x = u;
splitv(son[u][1], key, son[u][1], y);
}
else
{
y = u;
splitv(son[u][0], key, x, son[u][0]);
}
pushup(u);
}
int merge(int u, int v)
{
if (!u || !v)
return u | v;
if (rank[u] > rank[v])
{
son[u][1] = merge(son[u][1], v);
pushup(u);
return u;
}
else
{
son[v][0] = merge(u, son[v][0]);
pushup(v);
return v;
}
}
int join(int u, int v)
{
if (!u || !v)
return u | v;
int x = mn[u], y = mn[v], t;
if (x <= y)
splitv(u, y, t, u);
else
splitv(v, x, t, v);
return merge(t, join(u, v));
}
int kth(int u, ll key)
{
if (!u)
return -1;
if (key <= sz[son[u][0]])
return kth(son[u][0], key);
if (key <= sz[son[u][0]] + num[u])
return val[u];
return kth(son[u][1], key - sz[son[u][0]] - num[u]);
}
} t;
int main()
{
cin >> n >> m;
ll op, u, v, x, y;
int p, q, r;
for (int i = 1; i <= n; i++)
{
scanf("%lld", &x);
if (x)
root[1] = t.merge(root[1], t.build(i, x));
}
while (m--)
{
scanf("%lld%lld", &op, &u);
if (op == 0)
{
scanf("%lld%lld", &x, &y);
t.splitv(root[u], x - 1, p, q);
t.splitv(q, y, root[++tot], q);
root[u] = t.merge(p, q);
}
if (op == 1)
{
scanf("%lld", &v);
root[u] = t.join(root[u], root[v]);
}
if (op == 2)
{
scanf("%lld%lld", &x, &y);
t.splitv(root[u], y, p, q);
root[u] = t.merge(t.merge(p, t.build(y, x)), q);
}
if (op == 3)
{
scanf("%lld%lld", &x, &y);
t.splitv(root[u], x - 1, p, q);
t.splitv(q, y, q, r);
printf("%lld\n", t.sz[q]);
root[u] = t.merge(t.merge(p, q), r);
}
if (op == 4)
{
scanf("%lld", &x);
printf("%d\n", t.kth(root[u], x));
}
}
return 0;
}
关于其他写法
另外,网上也有一种很多人用的写法(链接博客里就是这种,是 wiki 给出的),就是选优先级更高的根(意味着树更大)去把另一个(树更小)树切开:
int join(int u, int v)
{
if (!u || !v)
return u | v;
if (rank[u] < rank[v])
swap(u, v);
int p, q;
splitv(v, val[u], p, q);
son[u][0] = join(son[u][0], p);
son[u][1] = join(son[u][1], q);
pushup(u);
return u;
}
这个写法的时间复杂度应该也可以证明(参见洛谷题解),并且运行效率差不多。
运行效率测试
这里我用了 P5494 【模板】线段树分裂 来测试,其中倒数第二个点是对某些平衡树写法的 hack。
三种写法的测试结果:(从下往上:我的写法、优先切开小的树、优先切开大的树)

(我的那个代码比较长是因为加了调试,实际上差不多)
我的写法:

“优先切开小的树”的写法:

“优先切开大的树”的写法:

可见:(快/慢指运行时间)
- 在一般数据下,两种用树大小判断的写法表现略好一点;
- 在极端数据下,“优先切开大的树”比较慢,我的写法和“优先切开小的树”表现更好。
因此,我的写法和“优先切开小的树”是比较好的选择,并且我的写法更稳定。(当然,最稳定的还属线段树分裂,不过功能性没有平衡树强。)
修复的一个问题
如果你做过 lxl 的一道题:TEST_176(或者其他卡常题),你会发现无论哪种写法都会 TLE。
这是因为,遇到相同的值要合并(并查集),否则复杂度是假的!
切大树写法:
int join(int u, int v)
{
if (!u || !v)
return u | v;
if (rank[u] < rank[v])
swap(u, v);
int p, q;
split(v, val[u], p, q);
split(p, val[u] - 1, p, v); // 把 v 单独分出来
dfs_merge(v, u); // 并查集合并
fa[p] = fa[q] = 0;
pushdown(u);
son[u][0] = join(son[u][0], p);
son[u][1] = join(son[u][1], q);
pushup(u);
return u;
}
感谢您的阅读。
如果有任何错误或者疑问,欢迎评论或私信指出!

浙公网安备 33010602011771号