FHQ-Treap 学习笔记


前言

学了 \(\text{Splay}\),就顺便把之前洛谷博客上的 \(\text{FHQ-Treap}\) 笔记搬下来复习一下。

简介

FHQ-Treap 是一种平衡树。它的平衡原理和 Treap 一样,但是维护平衡的方式不一样。它并不依赖旋转来维护平衡,而是依赖 分裂合并

它的代码简短,实现简单,功能强大(理论上可以支持可持久化与序列操作)。

本文将引用一些链接,以保证更好观感,感谢这些博主的博客教会了我这个数据结构。


实现

我们将会讲解它的实现。

首先我们从一些简单的开始。

前置定义

每个 FHQ-Treap 的节点保存了左儿子,右儿子,节点值,随机值,子树大小。

我们再使用 \(n\) 表示题目的操作数,\(tot\) 表示总节点数,\(root\) 表示根,三个 \(tmp\) 表示我们进行操作会用到的三个临时的根。它们的顺序也满足:\(t[tmp1].val

struct FHQ_Treap{
	int lc,rc;
	int val;
    int rnd;
	int size;
}t[N];
int n,tot,root;
int tmp1,tmp2,tmp3;

基本操作

  • \(\text{update}\)

    \(\text{update}\) 操作用于更新某个节点为根的子树大小。

void update(int x)
{
	t[x].size = t[t[x].lc].size + t[t[x].rc].size + 1;
}
  • \(\text{newnode}\)

    \(\text{newnode}\) 操作用于开一个新节点。

int newnode(int val)
{
	tot++;
	t[tot].size = 1;
	t[tot].val = val;
	t[tot].rnd = rand();
	return tot;
}

功能实现

我们先不考虑最难的 \(\text{spilt}\)\(\text{merge}\)。假设我们已经实现了它们的功能,可以直接调用:

  • 调用 \(\text{void spilt(int now,int val,int &x,int &y)}\) 可以把以 \(now\) 为根的树按照 \(val\) 分成两棵树:\(x\) 的权值全部小于等于 \(val\)\(y\) 的则大于。这里 \(x\)\(y\) 是根节点的下标。

  • 调用 \(\text{merge(int x,int y)}\) 可以把以 \(x\)\(y\) 为根的树合并,返回新的根节点的编号。

    这里我们的 \(x\) 的节点的值必须小于 \(y\) 的节点的值,而且 \(x\)\(y\) 必须是 “经过某次分裂之后” 得到的。(当然,如果是插入操作,一个全新的只有根的节点也可以看作是“经过某次分裂之后”)

好的。那么我们看一下平衡树的基本操作:

这些操作在分裂之后,合并的时候记得维护新的根节点

  • \(\text{Insert}\)

    \(\text{Insert}\) 函数支持我们插入一个值为 \(val\) 的新节点。

    我们可以这么考虑:先把原来的树按照 \(val\) 进行分裂,之后得到的是 \(tmp1,tmp2\)。假设我们新增节点的下标是 \(x\),我们的三个根节点的顺序应该是 \(tmp1,x,tmp2\)。把它们三个合并即可。

void Insert(int val)
{
	spilt(root,val,tmp1,tmp2);
	root = merge(merge(tmp1,newnode(val)),tmp2);
}
  • \(\text{Delete}\)

    \(\text{Delete}\) 函数支持我们删除一个值为 \(val\) 的节点。

    我们可以把树按照 \(val\) 分裂,得到 \(tmp3\)。之后按照 \(val-1\) 分裂,得到 \(tmp1,tmp2\)。这样,我们的 \(tmp2\) 就是权值为 \(val\) 的节点。

    直接合并 \(tmp2\) 的左右儿子得到新的 \(tmp2\),这个值为 \(val\) 的节点就消失了。之后按顺序合并 \(tmp1,tmp2,tmp3\) 即可。

void Delete(int val)
{
	spilt(root,val,tmp1,tmp3);
	spilt(tmp1,val - 1,tmp1,tmp2);
	tmp2 = merge(t[tmp2].lc,t[tmp2].rc);
	root = merge(merge(tmp1,tmp2),tmp3);
}
  • \(\text{QueryRank}\)

    \(\text{QueryRank}\) 操作支持我们查询权值为 \(val\) 的节点的排名。

    我们可以把树按照 \(val-1\) 分裂,之后左子树的大小 + 1 就是排名。

void QueryRank(int val)
{
	spilt(root,val - 1,tmp1,tmp2);
	printf("%d\n",t[tmp1].size + 1);
	root = merge(tmp1,tmp2);
}
  • \(\text{Kth}\)

    \(\text{Kth}\) 操作支持我们查询给定排名 \(rank\) 的节点的权值。

    这个其实就和普通 Treap 没啥不一样。

    • 如果 \(rank\) 不大于左子树 \(size\),走左子树
    • 如果 \(rank\) 和左子树大小 + 1 相等就找到了,直接返回
    • 不然就把 \(rank\) 减去左子树大小 + 1,之后进右子树寻找
int Kth(int now,int rank)
{
	while(1)
	{
		if(rank <= t[t[now].lc].size)
			{now = t[now].lc;continue;}
		if(rank == t[t[now].lc].size + 1)
			return now;
		rank -= t[t[now].lc].size + 1;
		now = t[now].rc;
	}
}
  • \(\text{QueryPre}\ and\ \text{QueryNxt}\)

    \(\text{QueryPre}\) 支持我们查询值为 \(val\) 的节点的前驱,\(\text{QueryNxt}\) 支持我们查询值为 \(val\) 的节点的后继。

    查询前驱时,我们按照 \(val-1\) 分裂,左子树 \(tmp1\) 里的值都小于 \(val\)。那么左子树里最大的数,也就是左子树里排名为左子树 \(size\) 的就是前驱。

    查询后继时,我们按照 \(val\) 分裂,右子树 \(tmp2\) 里的值都大于 \(val\)。那么右子树里最小的数,也就是右子树里排名为 \(1\) 的就是后继。

void QueryPre(int val)
{
	spilt(root,val - 1,tmp1,tmp2);
	printf("%d\n",t[Kth(tmp1,t[tmp1].size)].val);
	root = merge(tmp1,tmp2);
}
void QueryNxt(int val)
{
	spilt(root,val,tmp1,tmp2);
	printf("%d\n",t[Kth(tmp2,1)].val);
	root = merge(tmp1,tmp2);
}

到这里,我们用分裂与合并解决了平衡树的操作。可以看出,这样做很简洁,很优美。

分裂与合并

我们逃不开的是分裂和合并这两个核心操作。我们之前的操作都是建立在分裂与合并基础上的。

要记住这里的实现是:

分裂出的左树 \(\le val\),右树 \(> val\)

  • \(\text{spilt}\)

    这里不再赘述它的作用。

    分裂的方式分为 “按权值分裂” 和 “按子树大小分裂。这里实现按权值分裂。

    我们每次分裂的时候按照这样的规则执行:

    • 比较根节点权值与 \(val\) 的大小关系

      • 如果小于等于 \(val\),当前的根和左子树都归入分裂出的左树,递归进右树分裂。
      • 否则,根和右子树都归入分裂出的右树,递归进左树分裂。
    • 如果进入叶子节点,退出。

    单次分裂复杂度 \(O(\log n)\)

void spilt(int now,int val,int &x,int &y)
{
	if(!now) {x = y = 0;return;}
	if(t[now].val <= val) 
		x = now,spilt(t[now].rc,val,t[now].rc,y);
	else
		y = now,spilt(t[now].lc,val,x,t[now].lc);
	update(now);
}
  • \(\text{merge}\)

    作用也不再赘述。

    我们考虑根据它的随机值。我们称 \(x\) 为左树,\(y\) 为右树。

    • 比较左树和右树的随机值

      • 如果左树随机值小,左树根作为新的根,新树 继承左树的左子树,递归合并 右树左树的右子树,作为新树的右子树。
      • 否则,右树根作为新的根,新树 继承右树的右子树,递归合并 左树右树的左子树,作为新树的左子树
    • 左树或者右树不存在则退出。

    这样就实现了把两棵树合并为一棵。

int merge(int x,int y)
{
	if(!x || !y) return x + y;
	if(t[x].rnd < t[y].rnd)
	{
		t[x].rc = merge(t[x].rc,y);
		update(x);
		return x;
	}
	t[y].lc = merge(x,t[y].lc);
	update(y);
	return y;
}

这个过程不容易理解。可以配合上述算法流程,观看 ARFA- 的图解FHQ-Treap 的分裂和合并的图解。当然,前面简单的操作也有图解。

小结

综合以上代码就可以实现一个 FHQ-Treap。

一个可以通过【模板】普通平衡树的代码:云剪贴板-FHQ-Treap,模板-普通平衡树

这部分内容主要参考了 ,我结合自己的理解顺序对其进行了重排和修改。

序列操作

FHQ-Treap 可以对于序列进行操作,代替 Splay 的作用。

我们的 \(\text{spilt}\) 变成 按照子树大小分裂。调用 \(\text{spilt(int\ now,int\ k,int\ \&x,int\ \&y)}\) 可以把它分成两棵树,第一棵大小为 \(k\)

这个就和根据排名查值有点类似。

void spilt(int now,int k,int &x,int &y)
{
	if(!now) {x = y = 0;return;}
	if(k <= t[t[now].lc].size)// 左子树内部进行分裂
		y = now,spilt(t[now].lc,k,x,t[now].lc);
	else// 左子树被分裂出去,进入右子树分裂
		x = now,spilt(t[now].rc,k - t[t[now].lc].size - 1,t[now].rc,y);
	update(now);// 更新当前节点
}

之后就可以愉快地进行序列操作了。

然后你发现:我不理解,我背板子把!但是:

我超!我记不住!

实际上!只需要!记一个!不然你就寄了!因为这个和上一个是很对称的。你发现!其实!这个的分支和上一个相反的!(虽然有一些细节不是很一样)

  1. 区间操作

具体而言,每次对于区间 \([l,r]\) 操作的时候,我们都先把它 \(\text{spilt}\) 成三部分:\((\cdots,l-1),(l,r),(r+1,\cdots)\)。也就是执行 \(\text{spilt(l-1)}\)\(\text{spilt(r)}\)

比如 P3391 【模板】文艺平衡树,要求支持区间翻转。那么我们 以序列下标为关键字 把这个序列扔进平衡树。这样,平衡树里面维护的是下标的有序性。

具体而言就是一个一个申请新节点,暴力 \(\text{merge}\) 它和根。其实可以一次性通过一个 \(\text{build}\) 操作进行建树,但是暴力也够用。

加个 \(lazytag\),表示是否区间翻转。这个是可以在修改操作的时候直接打上标记,在 \(\text{pushdown}\) 的时候再修改,给孩子打标记。

也可以和线段树保持一致,修改操作的时候就直接翻转,打标记,\(\text{pushdown}\) 的时候给孩子修改,打标记。

具体地,对于每个操作,直接分裂出它区间对应的树根打上标记,再合并回去。对于 \(\text{spilt}\)\(\text{merge}\) 而言,都需要先进行一次 \(\text{pushdown}\),再搞其他操作。而 \(\text{pushdown}\) 就是直接交换这个点的左右子树,更新两个儿子的标记,再把自己的标记清空。

最后的序列可以经过一次从根进行的中序遍历得到。这个是 BST 的基本性质。

代码:云剪贴板-P3391-文艺平衡树-FHQ-Treap

  1. 序列操作

这个在下面的例题里面。

引用与鸣谢

本文内容参考于以下几个博客,在此再次表示感谢:

cgheavenhealer-FHQ_Treap学习笔记

ARFA-题解 P3369 【【模板】普通平衡树】

远航休息栈-fhq-treap总结

一个小屁孩-题解 P3391 【【模板】文艺平衡树(Splay)】

顺便 \(\%\%\%\) \(\text{Luckybolck}\)!(单方面膜拜大佬)

一些例题

P2234 [HNOI2002]营业额统计

经过胡乱分析,知道这个题要求前驱和后继。

抄抄抄,板子抄完,发现,出问题。

哦!我应该先插一个 \(\inf\) 和一个 \(-\inf\)

改改改,板子改完,发现,出问题。

输出调试,发现这个板子求的是严格的前驱后继。但是这个题的是不严格的。

就把前驱按照 val - 1 分裂改成按照 val 分裂,后继按照 val 分裂改成按照 val - 1 分裂,让它不严格就可以了。

代码:云剪贴板-P2234


P2286 [HNOI2004]宠物收养场

还是支持求不严格的前驱后继。支持删除。配对时优先考虑和前驱配对。

代码:云剪贴板-P2286


P1486 [NOI2004] 郁闷的出纳员

题意:给一个下限,写一个数据结构,支持:

  1. 插入一个元素,如果这个元素小于下限,不插入。
  2. 给所有元素整体加一个值。
  3. 给所有元素整体减一个值。
  4. 支持查询第 \(k\) 的值。
  5. 对于任意时刻,元素被加减之后,低于下限那么删除。

要求:对于每个类型 \(4\) 的询问,输出这个值,或者这个值,不存在,为 \(-1\)。最后输出所有要求 \(5\) 统计到的删掉的元素的总数。

题解

首先看到整体加减,就不难想到拿一个 \(tag\) 维护。那么我们就有:

\[\begin{aligned} val + tag & < minn \\ val & < minn - tag \end{aligned} \]

其中 \(minn\) 是下限。

那么我们依据 \(\text{FHQ-Treap}\) 的分裂操作,每次对于 \(tag\) 修改之后,把所有的 \(\le minn - tag - 1\) 的元素裂到左子树,之后直接让根等于右子树,也就删掉了左子树。删除的个数就是左子树的 \(size\)

因为我们的分裂,分裂出的是一个形如 \([\cdots],(\cdots]\) 的区间,你要是直接裂出 \(< minn - tag\) 的元素到左子树的话是按照 \(minn - tag - 1\) 分裂的。

之后我们插入元素的时候,首先是只插入那些按照题意,满足 \(val \ge minn\) 的。但是你现在的 \(tag\) 是全局有效,但是你在某些 \(tag\) 之后插入的元素其实并不被 \(tag\) 修改。那么怎么办?

我们直接插入 \(val - tag\) 即可。原因:

\[\begin{aligned} val - tag & < minn - tag \\ val & < min \end{aligned} \]

你一整理,发现你只要插入的时候按照 \(val - tag\) 插入就能消除当前 \(tag\) 对于 \(val\) 的影响。但是最后输出的时候要记得加上 \(tag\)

再就是查询第 \(k\) 了。我们的平衡树的 \(\text{Kth}\) 显然是查询第 \(k\) 小的。其实你稍微想一下,自己举几个例子就发现,其实第 \(k\) 小的就是第 \(n - k + 1\) 大的。这里的 \(n\) 就是树的元素个数,可以拿 \(t[root].size\) 获取。

这样就完成维护。

代码:云剪贴板-P1486


P3850 [TJOI2007]书架

序列操作

题意:给定一个序列,支持单点插入,单点查询。

题解

对于序列操作的题目我们按照 \(size\) 进行 \(\text{spilt}\) 操作。

这个题里面我们把字符串映射为一个整数扔进平衡树里面。之后,对于单点插入,按照位置裂开,把它扔进去。对于单点查询基本不变。一个完全一致的 \(\text{Kth}\),之后每次询问先按照询问位置裂开为左子树 \(tmp1\),右子树 \(tmp2\),之后把右子树裂一个出来,裂为 \(tmp3\)\(tmp4\)。这时候 \(tmp3\) 的值就是对应字符串编号。最后把 \((tmp1,(tmp3,tmp4))\) 合并即可。

代码:云剪贴板-P3850


P2042 [NOI2005] 维护数列

序列操作

题意:给一个序列,支持以下操作:

  1. pos 位置后插入 tot 个数。
  2. 删除从第 pos 个位置开始的 tot 个数。
  3. 推平从第 pos 个位置开始的 tot 个数。
  4. 翻转从第 pos 个位置开始的 tot 个数。
  5. 求从第 pos 个位置开始的 tot 个数的和。
  6. 求全局的最大子段和。

其中,序列长度保证在任意时间都 \(\le 5 \times 10^5\),插入的数字保证总数不超过 \(4 \times 10^6\)

一图流题解:

题解

建议右键图片,在新标签页打开并观看。

这个序列操作全家桶是真的麻烦。要有很清晰的思路才能开始写。

这里说一些细节:

  • 翻转标记和推平标记随便先传哪一个都可以。
  • 这个题的节点要维护很多信息,直接开开不下,需要把删掉的节点扔进一个内存池(比如一个栈)里来维护删掉之后腾出来空间的节点编号,新建节点重复利用。
  • 这个题的暴力插入是不可以的,对于一个区间每一个数都 \(\text{spilt}\) 一次再 \(\text{merge}\) 一次实在是花费太大。只能采用类似线段树的递归建树,再合并进去。
  • 这个题翻转之后,左右端点的最大左右子段和也要翻转。
  • 这个题最大子段和不能选空的子段,至少要选一个,所以我们的左右子段和可以和 \(0\)\(\max\) 表示选空,但是总子段和在很多处需要更新的时候至少是 \(val\) 来保证非空。
  • 新建节点的时候由于你很有可能是从内存池里蒯来一个点,所以你要删掉之前的信息。
  • 初始数列拿 \(\text{build}\) 构建好之后应该把 root 指向初始树。

代码:云剪贴板-P2042


P4008 [NOI2003] 文本编辑器

序列操作

题意:写一个数据结构,支持以下操作:

  1. 把当前指针位置 ptr 更改为第 pos 个字符后面。
  2. 在当前指针位置 ptr 后面插入一个长度为 \(n\) 的字符序列。
  3. 在当前指针位置 ptr 后面删除一个长度为 \(n\) 的字符序列。
  4. 输出当前指针位置 ptr 后面 \(n\) 个字符。
  5. 当前指针位置 ptr 前移一个字符。
  6. 当前指针位置 ptr 后移一个字符。

题解

区间插入,区间删除,区间查询。这个是可以块状链表做的,但是为什么不拿平衡树写一下呢?

经过了上一题序列操作全家桶的洗礼,我们滴序列操作能力已经大大加强了。于是这个题就写了几下就切了...吗?

所以不再赘述做法。记录一下我的傻逼错误:

写挂了几个地方:

\(\text{spilt}\) 记混了。但是发现了不会记混的方法!

输出应该是 “左 \(\rightarrow\)\(\rightarrow\) 右”。

毒瘤的数据输入格式。逐字符读入写错了。

局部变量和全局变量重名了。

但是!基本无伤大雅。

然后这题挺恶心的就是告诉你最大可能有 \(2M\) 的字符。但是 \(2M\) 有多少个?还好它告诉你 \(1M = 1024 \times 1024\) 字节。也就是 \(1M = 2^{20}\) 字节。但是 char 几个字节来着?我们开个测试程序输出 sizeof('A') 发现,一个字节。那么我们需要给平衡树开 \(2^{21}\) 的空间。

代码:云剪贴板-P4008


P4567 [AHOI2006]文本编辑器

序列操作

题意:写一个数据结构。其中,支持的操作与 P4008 [NOI2003] 文本编辑器 不同的地方在:支持区间翻转;区间输出变成单点输出。

题解

加强版。加强的不止是旋转操作,主要是毒瘤读入。然后还有一些奇怪的输出问题。具体可以加、看讨论区。我这不再赘述了。

这个区间翻转就和文艺平衡树一样。

题号是 \(4567\) 的屑题。

代码:云剪贴板-P4567


P3224 [HNOI2012]永无乡

题意:给一张图,支持:

  1. 联通两个点。
  2. 查询和一个点联通的点中,第 \(k\) 小的点编号。

题解

可以很容易想到用并查集维护连通性!

可以很容易想到合并两个平衡树!

怎么合并?好像不是很好整?

启发式合并!把小树暴力拆掉合并进大树里面!

然后就结束了。