算法学习笔记(21): 平衡树(二)

算法,学习,笔记,平衡 · 浏览次数 : 184

小编点评

**平衡树(二)概述** 平衡树是一种可持久化的二叉树,它可以有效地实现各种排序操作,并且具有高效的删除和插入操作。 **主要特点:** * 使用数组 `siz` 和数组 `ch` 来存储节点的信息,其中 `siz[i]` 表示在节点 `i` 的子树中包含的节点数量,`ch[i][0]` 和 `ch[i][1]` 分别表示在节点 `i` 的左子树和右子树的节点数量。 * 使用数组 `usage` 来记录每个节点的深度,以便快速找到节点在平衡树中的位置。 **可持久化** 平衡树的可持久化技术基于以下原则: * 只复制那些需要被持久化的节点。 * 对于每个被复制的节点,创建一个新的节点并将其与原始节点关联。 * 将所有新节点的深度设置为原始节点的深度加 1。 **时间复杂度** 平衡树的插入和删除操作的时间复杂度为 O(log n),其中 n 是节点数量。 **可持久化版本** 平衡树的持久化版本 FHQ-Treap 使用了高效的合并操作来实现可持久化。 **插入** 在插入一个新节点之前,首先从其符号位中推断出该节点的深度。然后,如果该节点不在平衡树中,则创建一个新节点并将其与原始节点关联。最后,将该新节点的深度设置为原始节点的深度加 1。 **删除** 在删除一个节点之前,从其符号位中推断出该节点的深度。然后,如果该节点在平衡树中,则将其从其子树中删除。最后,将该节点的深度设置为原始节点的深度减 1。 **查询排名** 在查询排名时,从该节点的深度中推断出该节点的排名。

正文

平衡树(二)

平衡树(一)链接:算法学习笔记(18): 平衡树(一) - jeefy - 博客园

本文中将讲述一下内容:

  • 可持久化Treap

  • 基于Trie 平衡树(后文称之为 BSTrie

  • BSTrie的可持久化

可持久化Treap

可持久化Treap基于FHQ-Treap。其实不难发现,FHQ-Treap在分裂和合并时在每一层只对一个结点产生影响。于是我们可以大胆的可持久化此结构,且保证复杂度为 \(O(\log n)\)

我们以此图为例。我们只需要复制下被影响的结点,基于原树获得一条新链:

红色节点是新产生的节点(可能实际上产生的顺序不一样)

其中 (8, 11) 作为新右树的根,(7, 8) 作为新左树的根

也就是说,我们经过一个结点复制一次即可。

inline void splitVal(int p, int val, int &x, int &y, bool simple = true) {
    if (!p) return (void)(x = y = 0);
    int np = simple ? p : clone(p);
    if (val(p) <= val)
        x = np, splitVal(rp(p), val, rp(x), y, simple), update(x);
    else
        y = np, splitVal(lp(p), val, x, lp(y), simple), update(y);
}

simple就是标志是否需要可持久化……特别简单

其实到这里就已经讲完了可持久化Treap了……因为其他绝大部分操作只需要对于分裂时持久化,在合并时并不需要持久化。

例如删除操作:

inline int insert(int root, int val, bool simple = false) {
    int x(0), y(0), p(++usage);
    nodes[p].init(val);
    splitVal(root, val, x, y, simple);
    return merge(merge(x, p), y);
}

这也请读者自行思考为什么不需要合并时可持久化。

基于Trie的 平衡树(BSTrie)

这里基于的Trie指的是 \(01Trie\),考虑其实每一个数都可以被拆分为二进制,于是有了此做法。

说实话,代码无比之简短,并且十分迅速,除了空间复杂度较大之外,令我不禁想要抛弃WBLT……

后记:空间复杂度……真的很大,很多普通平衡树能过的空间,Trie真不一定能过

我们首先只考虑正数的情况。如果我们把每一个数都扩展成同一个位长,从高位向低位保存成一棵树。我们从左到右(认为0在左,而1在右)观察其叶节点所对应的值(类似于Leafy Tree),可以知道是单调递增的,于是我们可以轻易的将之进行魔改,从而做到普通平衡树所能做到的事。


template<int N = 100000>
class BSTrie {
private:
    int siz[N << 5];
    int ch[N << 5][2];
    int usage;
    #define newNode() ({ \
        ++usage; \
        siz[usage] = ch[usage][0] = ch[usage][1] = 0; \
        usage; \
    })
}

这是这一颗树需要用到的内容。其实从这里就应该可以知道,其空间复杂度为 \(O(n \log C)\) 其中 \(C\) 表示值域大小,一般为 \(32\)。这与其他空间为 \(O(n)\) 的平衡树相比远远不如……

插入

首先看代码:

void insert(int x) {
    int p = 1; 
    for (int i = BS; ~i; --i) {
        int bt = bit(x, i), &np = ch[p][bt];
        if (!np) np = newNode();
        p = np, ++siz[p];
    }
}

写法有些许迷惑,见谅

其中BS指的是 \(\lceil \log C \rceil\)

由于我们需要用到子树的大小以方便 \(rank, kth\) 操作,所以对于路径上 ++siz[p]

不就是普通的 Trie 插入操作吗?不讲了。

删除

void remove(int x) {
    int p = 1;
    for (int i = BS; ~i; --i) {
        int np = ch[p][bit(x, i)];
        if (!np) return;
        p = np;
    }

    p = 1;
    for (int i = BS; ~i; --i) {
        p = ch[p][bit(x, i)], --siz[p];
    }
}

这里需要注意的是需要两遍向下,以防止 x 并不存在的情况。

与普通的 Trie 删除操作类似,想必代码也十分易懂。

查询排名

在普通平衡树内查询排名怎么查,这里就怎么查:

  • 进入右子节点,则累加左子树的大小

  • 进入左子节点,则不累加

  • 没有子节点,直接返回当前结果

int rank(int x, bool within = false) {
    int p = 1;
    int rk = 0;
    if (x >= 0) rk += siz[1];
    for (int i = BS; ~i; --i) {
        int bt = bit(x, i), np = ch[p][bt];
        if (bt) rk += siz[ch[p][0]];
        if (!np) return rk;
        p = np;
    }

    return within ? rk + siz[p] : rk;
}

这里对于加入了 within 参数,用于提示是否需要包含 x 出现的次数。

为什么在第8行直接返回是正确的?简单来说就是空子树不会对结果造成影响。具体一点请读者自行思考。

查询第k大

呃,令 x 为当前结果:

  • 若进入左子树,则令 x = x << 1

  • 否则令 x = (x << 1) | 1(打括号是为了方便理解)

如果保证树内存在至少 \(k\) 个数,则一定可以找到正确答案,且不会进入空子树。

但是没有这么多个数……则结果不可预测

int kth(int k) {
    int p = 1;
    int x = 0;
    for (int i = BS; ~i; --i) {
        if (k <= siz[lc(p)]) p = lc(p), x <<= 1;
        else k -= siz[lc(p)], p = rc(p), x = (x << 1) | 1;
    }
    return x;
}

于是,你可以在100行内写出一个优秀的平衡树了……


现在考虑有复数的情况。有两种解决方法:

  • 依据符号位,建两棵树。根据补码的知识,对于有符号类型的整数,其对应的无符号整型数值越大,其值越大。所以负数也可以利用同样的代码处理。

    而改变方法也很简单,将语句 int p = 1 改为 int p = x < 0 ? 1 : 2(标号随意)即可。
    只是在 kth 时,需要有:int x = k <= siz[1] ? (1 << (32 - BS)) - 1 : 0;

    rank 前要多加一句:if (x >= 0) rk += siz[1];

    其他的都没大区别。

  • 第二种方案相对简单,把所有数都加上一个 offset,保证为正整数即可……(这种方法很简答,而且可持久化时也更简单)

于是我们优先采用第二种方法(尤其是需要可持久化的时候)。


可持久化BSTrie

可持久化 Trie 会吧……OK,下课!

还是提一下可持久化的思想:

把每一个经过的结点复制出来即可……类比可持久化Treap的操作

与算法学习笔记(21): 平衡树(二)相似的内容:

算法学习笔记(21): 平衡树(二)

# 平衡树(二) > 平衡树(一)链接:[算法学习笔记(18): 平衡树(一) - jeefy - 博客园](https://www.cnblogs.com/jeefy/p/17204439.html) 本文中将讲述一下内容: - 可持久化Treap - 基于`Trie`的 *类* 平衡树(后文称之

算法学习笔记(6): 树链剖分

树链剖分 树链剖分是一个很神奇,但是在树上可以完成一些区间操作问题 简单来说,就是把一棵树分成一条条的链,通过维护链上的信息来维护整棵树的信息 基础知识可以参考我的另外一篇博客:算法学习笔记(5): 最近公共祖先(LCA) 这里假设你已经掌握了上述博客中的所有相关知识,并清晰了其背后的原理 性质?发

算法学习笔记(11): 原根

原根 此文相对困难,请读者酌情食用 在定义原根之前,我们先定义其他的一点东西 阶 通俗一点来说,对于 $a$ 在模 $p$ 意义下的阶就是 $a^x \equiv 1 \pmod p$ 的最小正整数解 $x$ 或者说,$a$ 在模 $p$ 意义下生成子群的阶(群的大小) 再或者说,是 $a$ 在模

算法学习笔记(30):Kruskal 重构树

Kruskal 重构树 这是一种用于处理与最大/最小边权相关的一个数据结构。 其与 kruskal 做最小生成树的过程是类似的,我们考虑其过程: 按边权排序,利用并查集维护连通性,进行合并。 如果我们在合并时,新建一个节点,其权值为当前处理的边的权值,并将合并的两个节点都连向新建的节点,那么就可以得

算法学习笔记(3.1): ST算法

ST表 在RMQ(区间最值)问题中,著名的ST算法就是倍增的产物。ST算法可以在 \(O(n \log n)\) 的时间复杂度能预处理后,以 \(O(1)\) 的复杂度在线回答区间 [l, r] 内的最值。 当然,ST表不支持动态修改,如果需要动态修改,线段树是一种良好的解决方案,是 \(O(n)\

C++算法之旅、09 力扣篇 | 常见面试笔试题(上)算法小白专用

算法学习笔记,记录容易忘记的知识点和难题。详解时空复杂度、50道常见面试笔试题,包括数组、单链表、栈、队列、字符串、哈希表、二叉树、递归、迭代、分治类型题目,均带思路与C++题解

C++算法之旅、08 基础篇 | 质数、约数

算法学习笔记,记录容易忘记的知识点和难题。试除法、分解质因数、筛质数、约数个数、约数之和、最大公约数

算法学习笔记(1): 欧几里得算法及其扩展

扩展欧几里得算法详解 在了解扩欧之前我们应该先了解欧几里得算法 欧几里得算法 这是一个递归求最大公约数(greatest common divisor)的方法 $$ gcd(a, b) = gcd(b, a % b) $$ 可以通过一个类似的简单公式推导而来 好像叫做辗转相减法来着? $$ gcd(

算法学习笔记(3): 倍增与ST算法

倍增 目录倍增查找 洛谷P2249重点变式练习快速幂ST表扩展 - 运算扩展 - 区间变式答案倍增更多的用法优化矩形查询优化建图优化 DP作者有话说 倍增,字面意思即”成倍增长“ 他与二分十分类似,都是基于”2“的划分思想 那么具体是怎么样,我们以一个例子来看 查找 洛谷P2249 依据题面,我们知

算法学习笔记(4): 并查集及其优化

# 并查集 并查集,Disjoint-Set,或者通俗一点,叫做MergeFind-Set,是一种可以动态维护若干个不重叠的集合,并支持集合之间的合并与查询的数据结构。 集体来说,并查集支持下列两个操作: - Find,查询元素所属集合 - Merge,将两个元素所属集合合并 一般来说,为了具体实现