【学习笔记】FHQ Treap

· · 算法·理论

前言

免责声明:本文仅用于记录学习心得、思考,仅作复习用。又由于作者文笔很烂且有些内容理解不一定正确,因此不具有一定的参考价值。

本文中用到的树生成工具

平衡树

前置知识:二叉搜索树&平衡树。

二叉搜索树(Binary Sorted Tree/Binary Search Tree, BST)是一种二叉树的树形数据结构。它的作用是能维护一段序列,可实现插入某数、删除某数、查找某数、查询最值、查询某数排名、查询某排名下的数、查询某数前驱或后继等。一般情况下大多数操作的平均复杂度均为 \mathcal{O}(\log n)。它的实现方法较为简单,在这里不再赘述。

平衡树是一种建立在 BST 的基础上的数据结构,与普通 BST 不同的是,它引入了「平衡」的概念。对于平衡的定义,每种实现方法下均不相同。一般来说判断一棵树是否平衡,取决于其左右子树的高度或大小的差值或比值是否在某个阈值内。

平衡树是一般的 BST 的优化版本。它可以使得在大多数情况下树的形态保持相对稳定,从而使各种操作的复杂度稳定在 \mathcal{O}(\log n)

举个例子,若有一组数据 [1,2,3,4,5],使用一般 BST 的顺序插入,会使得整棵树变成一条链。不难看出之后的操作复杂度都将近 \mathcal{O}(n)。而平衡树插入则可以判断某一时刻树是否平衡,若平衡被打破则恢复平衡并不影响 BST 的性质,这样可以使之后操作的复杂度依然稳定在 \mathcal{O}(\log n)

平衡树的实现多种多样,多数平衡树维持平衡都靠旋转操作,模板不好背且码量十分长。而接下来要提到的 FHQ Treap 就是无旋平衡树的一种。我个人认为 FHQ Treap 是所有实现当中最好理解、实现最方便且码量最小的一种,十分推荐初学者学习。

普通 Treap

前置知识:有旋 Treap。

Treap 是平衡树的一种。这个名字是由树(Tree)和堆(Heap)的英文各取前后合成而来,因此中文名也叫树堆(Treap)。顾名思义,其实现方法融合了树和堆的性质。具体地,该树上的每个结点都包含了两个信息,分别称其为权值优先级。其中权值满足 BST 的性质,优先级满足堆的性质。一般在新建结点时默认使优先级为一个随机数。如下图是一颗大根堆 Treap,其中每个结点内逗号分隔的两个数分别为权值和优先级。

对于该树,我们利用优先级按照堆的性质使树平衡。不过需要注意的是,期望的平衡是建立在完全随机的优先级的基础上的,因此 Treap 是一种弱平衡的平衡树。此处某一子树平衡的定义较特殊,优先级满足堆的性质即为平衡。Treap 上的每个结点的期望高度均为 \mathcal{O}(\log n)。关于证明参见此处。

对于普通 Treap 其最重要的操作便是旋转。旋转分为左旋和右旋,感性理解即是将根节点右孩子或左孩子“提”上来,让自己的孩子“取代”自己的位置。如下图是一种右旋操作过程,其中每个结点内的数表示权值。

不难看出旋转后的树仍满足原本 BST 的性质。

普通 Treap 的插入、删除、查询排名、查询值以及查询前驱后继的方式与一般 BST 相同,不过是使用优先级检查树的平衡性,并用旋转操作维持树的平衡而已。此处不过多给出代码实现。

无旋 Treap

无旋 Treap 又称分裂合并 Treap。顾名思义,其操作实现不需要繁琐的旋转操作,而是只需通过两种基本操作:分裂(split)与合并(merge)即可实现大部分操作。这里需要提到的就是 FHQ Treap,在《范浩强谈数据结构》内详细给出了支持区间操作和可持久化操作的无旋 Treap 实现方法。无旋 Treap 在每个结点需要维护基础的信息有五种,分别是权值、优先级、左右孩子编号和子树大小。接下来将详细讲解 FHQ Treap 的理论操作及实现方法。每种操作的理论复杂度也均为 \mathcal{O}(\log n)

分裂

无旋 Treap 的分裂操作分为权值分裂与排名分裂。每种分裂的目的均是将一棵完整的 Treap 分裂成 AB 两棵 Treap。不过使用两种不同分裂操作的 Treap 又有所不同,下文分别特称为「Value Treap」与「Rank Treap」。

权值分裂

权值分裂完成后 A 内所有结点的权值均不大于给定权值,B 内所有结点的权值均大于给定权值。比如按 k 的值来执行权值分裂,过程就是找到整个树的中序遍历序列内最后一个 k 的位置 i,并在 ii+1 的位置中间放一把刀将整个序列“切”成两部分,前半部分即是 A 的中序遍历,后半部分即是 B 的中序遍历。如下图是将整棵 Treap 按 3 的值权值分裂后的结果(标粗的结点为分裂的临界点)。

该部分的代码实现如下:

// x表示当前的结点编号,w表示要分裂的权值,l/r为分裂后两个子树的根结点编号
void split(int x,int w,int &l,int &r){// l,r引用传参,可直接修改值,避免额外的返回值
    if(!x)//如果是空结点
        return (void)(l=r=0);
    // 对于每个结点,x表示权值,l/r表示左右孩子编号
    // ...
    if(t[x].x<=w){// 根据BST的性质,左小右大,因此此时要的值一定在右子树上
        l=x;// 因为要去右子树了所以暂时将左点存为当前点 
        split(t[x].r,w,t[x].r,r);// 由上图可知右孩子的左子树上所有权值均大于自己,而又小于等于右孩子,因此先将自己右孩子查到的左值并入自己的右子树
    }else{// 同上,此时值在左子树
        r=x;
        split(t[x].l,w,l,t[x].l);
    }
    // ...
}

排名分裂

Rank Treap 准确来说并不满足 BST 的性质,而是维护了序列的原本顺序,即中序遍历出来的序列即为原序列而非排序后的。而按排名分裂出来的两棵 Treap 分别满足 A 内权值的排名均不大于给定排名,而 B 内权值的排名均大于给定排名。注意此处排名与一般而论的排名有所不同,由于此时 Treap 维护的是序列原有顺序,因此此时某数的排名指的是该数在原序列的位置。同样举例理解,此时若按 r 的值执行排名分裂,过程就是在整个序列 rr+1 的位置放一把刀将整个序列“切”成两部分,前半部分即是 A 的中序遍历,后半部分即是 B 的中序遍历。如下图是将整棵 Treap 按 3 排名分裂后的结果(标粗的结点为分裂的临界点)。

该部分的代码实现如下:

// 同上,不过此时w变成了排名
void split(int x,int w,int &l,int &r){
    if(!x)
        return (void)(l=r=0);
    // 此处每个结点的sz表示以其为根结点的子树大小
    // ...
    if(t[t[x].l].sz+1<=w)// 注意此处写法的不同,因为结点左子树大小表示排名在自己之前的结点个数,该语句表示判断是否自己的排名都比不上w,意味着要往自己右子树走
        l=x,split(t[x].r,w-t[t[x].l].sz-1,t[x].r,r);// 对于此处的w传参可以这么理解,对于每个子树最左边的结点排名一定为1,因此在父树上的排名规则不适用了,需要减掉比自己小的来获取在子树上的排名
    else
        r=x,split(t[x].l,w,l,t[x].l);// 同上,不过此时由于本来的排名规则是从左边开始的,因此到左子树时排名规则没有变
    // ...
}

合并

无旋 Treap 的合并操作十分好理解,即是将两棵断裂开的树合并成一棵完整的树。具体实现也是通过递归实现,每次根据优先级考虑两个结点的父子关系,不断将两棵树以及各自子树连接在一起。该部分的代码实现如下:

// 函数返回值为合并后的树的根节点编号
int merge(int l,int r){// l/r表示要合并的两棵树的根节点编号
    if(!l||!r)// 如果其中一棵为空树
        return l|r;// 一棵为空树(编号为0)则合并结果就是另一棵树
    if(t[l].p>t[r].p){// 按优先级判断谁是谁的长辈
        t[l].r=merge(t[l].r,r);// l是r的长辈,继续递归合并右边
        // ...
        return l;
    }else{
        t[r].l=merge(l,t[r].l);// 同上
        // ...
        return r;
    }
}

插入

实现了分裂与合并两个操作,Treap 的插入也变得十分简单。

Value Treap 的插入操作非常好理解,若插入一个数 x,则关于 x 执行权值分裂,而后新建一个 x 的结点,将分裂出来的两个树与新的结点(此时也是一棵独立的树)按照左-中-右的顺序合并在一起,便完成了插入操作。下方图表示了在整棵 Treap 中插入 3 的过程。

该部分的代码实现如下:

inline void newnode(int x){// 新建结点
    t[++cnt].x=x;// cnt为结点计数器,既表示当前结点数量,也表示最晚新建的结点编号
    t[cnt].p=rand();// 优先级设为随机
    t[cnt].l=t[cnt].r=0;// 初始时没有孩子
    t[cnt].sz=1;// 该节点是独立的一棵树,因此大小仅是它自己的贡献
}
void insert(int x){// x表示待插入数字
    int l,r;
    split(rt,x,l,r);// 按x权值分裂,全局变量rt表示整个树的根节点编号
    newnode(x);// 新建x结点,即新建一棵仅包含x的树
    rt=merge(merge(l,cnt),r);// 因为结点x是最后新建的结点,因此cnt表示x的结点编号
}

事实上需要使用 Rank Treap 维护的序列操作中一般是在固定位置插入数,因此 Rank Treap 的插入也是类似的,在这里便不多赘述。

删除

同插入,Treap 的删除操作也很好理解。

若删除 Value Treap 上的一个x,先按 x 权值分裂,再将分裂出来的左树按 x-1 权值分裂,此时左树的右树便是仅含有权值为 x 的结点的树。注意此处的表达,FHQ Treap 在处理重复数据时不像别的平衡树统计数量,而是直接存对应数量的结点。因此若直接删除分裂出来的树会将所有 x 都删除,不符合操作预期,也是一个易错点。正确的处理方法是仅丢弃根结点,即合并根结点的两个子树并将结果代替原根结点。下方图表示了在整棵 Treap 中删除一个 4 的过程。

该部分的代码实现如下:

void del(int x){// x表示待删除数字
    int l,r,p;// 临时变量
    split(rt,x,l,r);// 第一次分裂,此时树l内的权值均不大于x,树r的权值均大于x
    split(l,x-1,l,p);// 第二次分裂,此时树l内的权值均小于x,树p内的权值均等于x
    p=merge(t[p].l,t[p].r);// 注意这里的易错点,若此时直接丢弃p来合并lr的话会使得全部x都被丢弃,而这么写只会丢弃p的根结点
    rt=merge(merge(l,p),r);// 将剩下的树全部合并
}

Rank Treap 的删除操作一般是删除对应位置的数,与 Value Treap 几乎相同,此处不再赘述。

查询排名

FHQ Treap 维护了子树大小,这使得 Value Treap 的排名查询变得极其方便。因为对于每个结点,其左子树大小加 1 即为该结点在其子树内的排名。

根据这一点,查询排名的实现过程非常简洁。若查询 x 的排名,则按 x-1 权值分裂,由于分裂出来的左树权值均小于 x,因此其大小加 1 即为 x 的排名。该部分的代码实现如下:

int rk(int x){
    int l,r,ans;// 临时记录的变量
    split(rt,x-1,l,r);// 按x-1分裂
    ans=t[l].sz+1;// 数的排名即是比它小的数的个数+1
    rt=merge(l,r);// 分裂完后别忘了合并回去
    return ans;
}

对于一般的 Rank Treap 是不存在排名查询操作的,因为它并没有遵循 BST 的性质而是维护了序列的原顺序,因此此处不给出代码。

排名查值

由于 Value Treap 与 Rank Treap 对排名的定义并不相同,此处分别讨论。

Value Treap 根据排名查询值的操作可能是唯一没有用到分裂与合并的操作。它的过程是不断通过比较子树大小确定值的位置,并通过递归查找。它的实现方法与一般平衡树十分类似,便不多细讲。该部分的代码实现如下:

int kth(int x,int k){// x表示当前的结点编号,k表示要查找的排名
    if(k==t[t[x].l].sz+1)// 如果k刚好等于x的排名
        return x;// 找到了
    if(k<=t[t[x].l].sz)// 在左子树上,因为需求排名比当前排名小
        return kth(t[x].l,k);
    else// 同上,此时在右子树
        return kth(t[x].r,k-t[t[x].l].sz-1);// 此处k的传参解释详见Rank Treap的分裂操作
}

Rank Treap 根据排名查询值的操作是基于排名分裂操作的。参考 Value Treap 的删除操作,我们发现找到一个数所在树上位置可以通过两次分裂实现。该部分的代码实现如下:

int kth(int k){// 查找排名为k的值
    int l,r,p,ans;
    split(rt,k,l,r);// 第一次分裂,树l内结点的排名均不大于k
    split(l,k-1,l,p);// 第二次分裂,p即为找到的数的所在结点编号
    ans=t[p].x;// 记录答案
    rt=merge(merge(l,p),r);// 别忘了合并回去
    return ans;
}

查询前驱

根据前驱的定义,查询 $x$ 的前驱可以转换为查询小于 $x$ 的所有数的最大值。由于前面已经实现了根据排名查询值的操作,因此该操作实现起来十分简单。该部分的代码实现如下: ```cpp int prevt(int x){// 查询x的前驱 int l,r,ans; split(rt,x-1,l,r);// 先将小于x与大于等于x的数所在树分裂出来存入l/r // 若题目特殊要求,此处需判断无解 ans=t[kth(l,t[l].sz)].x;// 此时树l中的数均小于x,而其大小即为最大的数在其子树中的排名 rt=merge(l,r);// 分裂完合并回去 return ans; } ``` ## 查询后继 $x$ 的后继的一般定义是第一个大于 $x$ 的数。由于 Rank Treap 的特殊性,接下来将不讨论 Rank Treap 的该操作。 根据后继的定义,查询 $x$ 的后继可以转换为查询大于 $x$ 的所有数的最小值。这一部分十分类似于查询前驱。该部分的代码实现如下: ```cpp int nextt(int x){// 查询x的后继 int l,r,ans; split(rt,x,l,r);// 同前驱操作,树r内的数均大于x // 若题目特殊要求,此处需判断无解 ans=t[kth(r,1)].x;// 查找树r内的最小值 rt=merge(l,r); return ans; } ``` ## 例题 掌握这些基本操作之后,你就可以开始打一些基本的题目了。 - [P3369 【模板】普通平衡树](https://www.luogu.com.cn/problem/P3369) - [P6136 【模板】普通平衡树(数据加强版)](https://www.luogu.com.cn/problem/P6136) 把以上操作结合起来,即可得到板子。注意数据加强版也可以通过,不过要处理强制在线的转换操作。 - [P1486 郁闷的出纳员](https://www.luogu.com.cn/problem/P1486) 比较好的思维板子题,统计工资的变化幅度,注意维护工资下限即可。 - [P2596 书架](https://www.luogu.com.cn/problem/P2596) 该题需要用到 Rank Treap,细节较多。但是该题需要查询值的排名,因此每个结点需要维护父亲的信息。注意到不需要额外的插入操作,可以直接将书的编号与结点编号建立映射关系。详见[代码](https://www.luogu.com.cn/paste/vxxdu7hl)。 - [P1110 报表统计](https://www.luogu.com.cn/problem/P1110) 该题需要用到两棵 Treap,分别维护相邻元素最小差值与最接近元素最小差值,注意细节处理,需要一定思维。 # 区间操作 前置知识:[线段树懒惰标记](https://oi-wiki.org/ds/seg/#%E7%BA%BF%E6%AE%B5%E6%A0%91%E7%9A%84%E5%8C%BA%E9%97%B4%E4%BF%AE%E6%94%B9%E4%B8%8E%E6%87%92%E6%83%B0%E6%A0%87%E8%AE%B0)。 FHQ Treap 是可以很方便地使用 Rank Treap 实现大部分区间操作的。下面将详细讲解理论实现方法。 ## 区间翻转 FHQ Treap 是可以实现区间操作的,例如区间翻转。下面将以一道例题来讲解 FHQ Treap 的区间翻转操作。 例题:[P3391 【模板】文艺平衡树](https://www.luogu.com.cn/problem/P3391) 由于树的中序遍历就是原序列,因此实现区间翻转可以通过递归交换左右子树实现。类似线段树的区间操作,对于每个结点维护一个标记表示是否翻转,并额外增加下传标记的操作。详见[代码](https://www.luogu.com.cn/paste/vjuwawsz)。 ## 区间移动 对于一段已知区间,FHQ Treap 可以利用分裂与合并很方便地实现区间移动。若要将区间 $[l,r]$ 移动至 $k$ 的**后面**,分三种情况讨论: 1. $0\leq k<l$ \ 将整个 Treap 排名分裂成 $[1,k]$、$(k,l)$、$[l,r]$、$(r,n]$,并按顺序将 $[1,k]$、$[l,r]$、$(k,l)$ 与 $(r,n]$ 合并即可。 2. $l\leq k<r$ \ 该操作可以转换成当插入位置为 $r+(k-l+1)$ 时的情况,请自行画图理解。 3. $r\leq k\leq n$ \ 将整个 Treap 排名分裂成 $[1,l)$、$[l,r]$、$(r,k]$、$(k,n]$ 并按顺序将 $[1,l)$、$(r,k]$、$ [l,r]$ 与 $(k,n]$ 合并即可。 ## 其他区间操作 其他大部分区间操作均可通过分裂、合并或懒惰标记实现,与单点操作类似,复杂度也基本是 $\log$ 级别,此处不多赘述。 ## 例题 - [P4008 [NOI2003] 文本编辑器](https://www.luogu.com.cn/problem/P4008) 该题算是比较板子的平衡树区间操作了,只需要多维护一个当前光标位置即可。不过要注意输入细节,插入的字符串可能被拆成多行,用 `getchar()` 处理较为方便。 - [P4567 [AHOI2006] 文本编辑器](https://www.luogu.com.cn/problem/P4567) 双倍经验了属于是,甚至名字还有题面都没怎么改,只是需要多维护一个区间翻转,掌握了文艺平衡树板子,写起来也比较轻松。需要注意的坑点就是与 P4008 不同,这题输入的时候换行也算字符,但是输出的时候如果光标后字符是换行则不再额外输出换行。 # 可持久化 FHQ Treap 的操作方式及形态使得它可以很方便地实现可持久化操作。 (To be continued.)