平衡树(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\) 中的势能,定义为:

\[\sum_{i=1}^{n-1} \log (x_{i+1}-x_i) \]

假设两个有序序列 \(A\)\(B\) 的值域分布长这样:

             <-d1->         <--d2-->      <--d3-->       <---d4--->
A = {[--a1--]                       [-a2-]                         [--a3--]}
B = {              [---b1---]                      [-b2-]                  }

其中 abd 等字母表示一个值域区间的长度。当然 [] 内也不一定每个位置都有值,只不过这样考虑的话 [] 内部势能不发生变化,方便分析。

现在计算把这两个序列并起来的势能减小量 \(\Delta\)。其中 \(k\) 是间隔数,上图中 \(k=4\)

\[\Delta=(\log(d_1+b_1+d_2)+\log(d_2+a_2+d_3)+...+\log(d_{k-1}+x+d_k))-(\log d_1+\log d_2+...+\log d_k) \]

因为 \(\log\) 函数是上凸的,所以有:

\[\begin{aligned} \log(\frac{x+y}{2}) &\ge \frac{\log x+\log y}{2}\\ \log(x+y) &\ge 1+ \frac{\log x+\log y}{2} \end{aligned} \]

所以有

\[\begin{aligned} \Delta&=(\log(d_1+b_1+d_2)+\log(d_2+a_2+d_3)+...+\log(d_{k-1}+x+d_k))-(\log d_1+\log d_2+...+\log d_k)\\ &\ge(\log(d_1+d_2)+\log(d_2+d_3)+...+\log(d_{k-1}+d_k))-(\log d_1+\log d_2+...+\log d_k)\\ &\ge(k-1+\frac{\log d_1}{2}+\log d_2+\log d_3+...+\log d_{k-1}+\frac{\log d_k}{2})-(\log d_1+\log d_2+...+\log d_k)\\ &=k-1-\frac{\log d_1}{2}-\frac{\log d_k}{2}\\ &\ge\mathcal{O}(k) \end{aligned} \]

由此,合并间隔数为 \(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;
}

感谢您的阅读。

如果有任何错误或者疑问,欢迎评论或私信指出!

posted @ 2025-04-13 14:46  Aquizahv  阅读(372)  评论(2)    收藏  举报