「FHQ_Treap」学习笔记

news/2025/3/17 9:16:30/文章来源:https://www.cnblogs.com/lunjiahao/p/18503758

一、前言 & 基本理论

来自笔者的肯定:最容易理解且比较好写的平衡树(不过就是常数有点大了),可能是笔者花了较长的时间去理解旋转 Treap 和 Splay 的旋转吧()

FHQ 不仅比旋转法编码简单,而且能用于区间翻转移动持久化等场合。——《算法竞赛》

FHQ_Treap 的所有操作都只用到了分裂合并这两个基本操作,这两个操作的复杂度都为 \(O(\log n)\)

二、定义

定义结构体 FHQ_Treap

struct FHQ_Treap
{int ls,rs;int val,pri;int siz;
}t[N];
  • ls,rs,左儿子 / 右儿子。
  • val,键值(权值)。
  • pri,随机的优先级,搭配 srand(time(0)); 食用。
  • siz,当前节点为根的子树的节点数量,用于求第 \(k\) 大和排名。

全文的宏定义如下:

#define ls(u) t[u].ls//左儿子
#define rs(u) t[u].rs//右儿子

三、分裂和合并的代码实现

写在前面:

  • update 操作:
void update(int u)//更新以 u 为根的子树的 siz
{t[u].siz=t[ls(u)].siz+t[rs(u)].siz+1;
}
  • 本小节所有的代码都没有引用 push_down 函数,再后面几小节会出现其代码(其实和线段树懒标记一个东西)。
    详见 5.2节 区间翻转

3.1 分裂操作

void split(int u,int x,int &L,int &R),其中 &L&R引用传递,函数返回 \(L\)\(R\) 的值。

  1. 权值分裂

分裂后,左子树上所有的节点的键值 \(val\) 都小于右树。

即左树 \(L\) 上所有的节点的键值都小于或等于 \(x\),右树 \(R\) 上所有节点的键值都大于 \(x\)

void split(int u,int x,int &L,int &R)//权值分裂,返回以 L 和 R 为根的两棵树
{if(!u)//0 表示没有孩子,到达叶子,递归返回{L=R=0;return;}if(t[u].val<=x)//本节点的键值比 x 小,那么到右子树上找 x{L=u;//左树的根是本节点split(rs(u),x,rs(u),R);//通过 rs 传回新的子节点}else//本节点比 x 大,继续到左子树上找 x{R=u;//右树的根是本节点split(ls(u),x,L,ls(u));}update(u);//更新当前节点的 siz
}
  1. 排名分裂

把树 \(u\) 分裂成包含前 \(x\) 个数的 \(L\) 和包含其他数的 \(R\)

void split(int u,int x,int &L,int &R)//排名分裂
{if(!u){L=R=0;return;}if(t[ls(u)].siz+1<=x)//第 x 个数在 u 的右子树上{L=u;split(rs(u),x,rs(u),R);}else//第 x 个数在左子树上{R=u;split(ls(u),x,L,ls(u));}update(u);
}

3.2 合并操作

int merge(int L,int R),合并树 \(L\)\(R\)。因为有 \(L\) 上所有节点的键值 \(val\) 都小于 \(R\) 的节点的隐含条件,所以合并时只需考虑节点的优先级 \(pri\)

显然,新树的根是 \(L\)\(R\)优先级最大的那个。

int merge(int L,int R)//合并以 L 和 R 为根的两棵树,返回一棵树的根
{if(!L||!R) return L+R;//到达叶子,如 L==0 就是返回 L+R=Rif(t[L].pri>t[R].pri)//左树 L 优先级大于右树 R,则 L 节点是父节点{t[L].rs=merge(t[L].rs,R);//合并 R 和 L 的右儿子,并更新 L 的右儿子update(L);return L;//合并后的根是 L}else//合并后 R 是父节点{t[R].ls=merge(L,t[R].ls);//合并 L 和 R 的左儿子,并更新 R 的左儿子update(R);return R;//合并后的根是 R}
}

其实,由于这里的优先级 \(phi\) 是随机生成的,尽可能的使树的结构趋向于平均,其实在一些空间卡得比较死的题可以不用定义 \(phi\),用以下代码来实现合并的优先级:

int merge(int L,int R)
{if(!L||!R) return L|R;if(rand()%(t[L].siz+t[R].siz)<t[L].siz){L=clone(L);t[L].rs=merge(t[L].rs,R);update(L);return L;}else{t[R].ls=merge(L,t[R].ls);update(R);return R;}
}

四、其他操作的代码实现

4.1 插入节点

int newNode(int x),新建节点和 void insert(int x),插入节点,按新节点 \(x\) 的键值把树分裂成 \(L\)\(R\) 两棵树,新建节点 \(x\),合并 \(L\)\(x\),再继续与 \(R\) 合并。

int newNode(int x)//建立只有一个点的树
{int u=++cnt;t[u].ls=t[u].rs=0;//0 表示没有子节点t[u].val=x;t[u].pri=rand();t[u].siz=1;return u;
}
void insert(int x)//插入数字 x
{int L,R;split(root,x,L,R);root=merge(merge(L,newNode(x)),R);
}

4.2 删除节点

写在前面:

下文所提到的删除节点写的模板是针对删除 \(1\) 个数的,如果要删除区间 \([x,y]\) 只需将第一个 split\(x\) 改成 \(y\) 即可。

  1. 权值分裂删除

void del(int x),把树 \(u\)\(x\) 分裂为根小于或等于 \(x\) 的树 \(L\)大于 \(x\) 的树 \(R\),再把 \(L\) 分裂为根小于 \(x\) 的树 \(L\) 和根等于 \(x\) 的树 \(p\),合并 \(p\) 的左右儿子,即删除了 \(x\),最后合并 \(L,p,R\)(这里是指删除一个 \(x=p\) 的数,若要全部删除就无须合并 \(x=p\) 的左右子树)。

void del(int x)
{int L,R,p;split(root,x,L,R);//<=x 的树和 >x 的树split(L,x-1,L,p);//<x 的树和 >=x 的树p=merge(t[p].ls,t[p].rs);//合并 x=p 的左右子树,即删除了 xroot=merge(merge(L,p),R);
}
  1. 排名分裂删除

void del(int x),把树 \(u\) 分裂成包含前 \(x\) 个树的 \(L\) 和包含其他数的 \(R\),不需要合并 \(p\) 的左右子树。

void del(int x)
{int L,R,p;split(root,x,L,R);split(L,x-1,L,p);root=merge(merge(L,p),R);
}

4.3 排名

int rnk(int x),求数字 \(x\) 的排名,把树 \(u\)\(x-1\) 分裂成 \(L\)\(R\)\(L\) 中包含了所有小于 \(x\) 的树,那么 \(x\) 的排名为 \(siz_L+1\),排名之后记得合并 \(L\)\(R\) 恢复成原来的树。

int rnk(int x)//查询 x 的排名
{int L,R;split(root,x-1,L,R);//<x 的树和 >=x 的树int ans=t[L].siz+1;root=merge(L,R);//恢复return ans;
}

4.4 求第 \(k\) 大数

int kth(int u,int k),根据节点的 \(siz\) 值不断递归整棵树,求得第 \(k\) 大数。

int kth(int u,int k)//求排名第 k 的数
{if(k==t[ls(u)].siz+1) return u;//这个数为根if(k<=t[ls(u)].siz) return kth(ls(u),k);//在左子树if(k>t[ls(u)].siz) return kth(rs(u),k-t[ls(u)].siz-1);//在右子树
}

主函数记得写的是 t[kth(root,k)].val

4.5 前驱

int pre(int x),求比 \(x\) 小的数。把树 \(u\) 分裂成 \(L\)\(R\),在 \(L\) 中找最大的树(利用求第 \(k\) 大的操作)。找到后,合并 \(L\)\(R\) 恢复成原来的树。

int pre(int x)//求 x 的前驱
{int L,R;split(root,x-1,L,R);int ans=t[kth(L,t[L].siz)].val;root=merge(L,R);//恢复return ans;
}

4.6 后继

int nxt(int x),求比 \(x\) 大的数。把树 \(u\) 分裂成 \(L\)\(R\),在 \(R\) 中找最小的树(利用求第 \(k\) 大的操作)。找到后,合并 \(L\)\(R\) 恢复成原来的树。

int nxt(int x)//求 x 的后继
{int L,R;split(root,x,L,R);int ans=t[kth(R,1)].val;root=merge(L,R);//恢复return ans;
}

讲完这里其实已经讲完一道例题了()

例1. P3369 【模板】普通平衡树

您需要写一种数据结构(可参考题目标题),来维护一些数,其中需要提供以下操作:

  1. 插入一个数 \(x\)
  2. 删除一个数 \(x\)(若有多个相同的数,应只删除一个)。
  3. 定义排名为比当前数小的数的个数 \(+1\)。查询 \(x\) 的排名。
  4. 查询数据结构中排名为 \(x\) 的数。
  5. \(x\) 的前驱(前驱定义为小于 \(x\),且最大的数)。
  6. \(x\) 的后继(后继定义为大于 \(x\),且最小的数)。

代码略,刚好为上面的 \(6\) 个操作。

五、应用

5.1 文本编辑器和排名分裂

因为 FHQ_Treap 可以便捷地进行分裂与合并,所以能应用于文本编辑器,注意这里的分裂是“排名分裂”。

例2. P4008 [NOI2003] 文本编辑器

文本编辑器:由一段文本和该文本中的一个光标组成的,支持如下操作的数据结构。如果这段文本为空,我们就说这个文本编辑器是空的。

操作名称 输入文件中的格式 功能
\(Move(k)\) Move k 将光标移动到第 \(k\) 个字符之后,如果 \(k=0\),将光标移到文本开头
\(Insert(n,s)\) Insert n s 在光标处插入长度为 \(n\) 的字符串 \(s\),光标位置不变 \(n\geq1\)
\(Delete(n)\) Delete n 删除光标后的 \(n\) 个字符,光标位置不变,$n \geq$1
\(Get(n)\) Get n 输出光标后的 \(n\) 个字符,光标位置不变,\(n \geq 1\)
\(Prev()\) Prev 光标前移一个字符
\(Next()\) Next 光标后移一个字符

你的任务是:

  • 建立一个空的文本编辑器。

  • 从输入文件中读入一些操作并执行。

  • 对所有执行过的 GET 操作,将指定的内容写入输出文件。

与前面代码差不多,这里就只贴出主要部分:

void print(int u)
{if(!u) return;print(t[u].ls);printf("%c",t[u].val);print(t[u].rs);
}
int main()
{srand(time(NULL));scanf("%d",&n);while(n--){scanf("%s",opt);if(opt[0]=='M') scanf("%d",&pos);if(opt[0]=='I'){scanf("%d",&len);split(root,pos,L,R);for(int i=1;i<=len;i++){char ch=getchar();while(ch<32||ch>126) ch=getchar();L=merge(L,newNode(ch));}root=merge(L,R);}if(opt[0]=='D'){scanf("%d",&len);split(root,pos+len,L,R);split(L,pos,L,p);root=merge(L,R);}if(opt[0]=='G'){scanf("%d",&len);split(root,pos+len,L,R);split(L,pos,L,p);print(p);printf("\n");root=merge(merge(L,p),R);}if(opt[0]=='P') --pos;if(opt[0]=='N') ++pos;}return 0;
}

5.2 区间翻转

像线段树那样打上懒标记 Lazy-Tag 表示翻转即可,注意翻转不会破坏优先级 pri,操作类似于线段树的 push_down()

例3. P3391 【模板】文艺平衡树

给定一个有序序列,翻转 \(m\) 个区间 \([l_i,r_i]\),求最终序列状态。
如原序列为 \(\{5 \ 4 \ 3 \ 2 \ 1 \}\),翻转区间为 \([2,4]\),那么反转后结果为 \(\{5\ 2 \ 3 \ 4 \ 1 \}\)

注意,在这里的分裂操作为「排名分裂」,其余代码与标准的 FHQ_Treap 相同。

这里贴出重要部分:

void push_down(int u)//下传 Lazy 标记
{if(!t[u].tag) return;swap(t[u].ls,t[u].rs);//翻转 u 的左右部分,翻转不会破坏优先级 prit[t[u].ls].tag^=1;t[t[u].rs].tag^=1;t[u].tag=0;//与线段树类似
}
void split(int u,int x,int &L,int &R)
{if(!u){L=R=0;return;}push_down(u);//处理 Lazy 标记if(t[t[u].ls].siz+1<=x){L=u;split(t[u].rs,x-t[t[u].ls].siz-1,t[u].rs,R);}else{R=u;split(t[u].ls,x,L,t[u].ls);}push_up(u);
}
int merge(int L,int R)
{if(!L||!R) return L+R;if(t[L].pri>t[R].pri){push_down(L);//处理 Lazy 标记t[L].rs=merge(t[L].rs,R);push_up(L);return L;}else{push_down(R);t[R].ls=merge(L,t[R].ls);push_up(R);return R;}
}
void inorder(int u)//中序遍历,打印结果
{if(!u) return;push_down(u);inorder(t[u].ls);printf("%d ",t[u].val);inorder(t[u].rs);
}

5.3 可持久化平衡树

因为 FHQ_Treap 树的基本操作是分裂与合并,这两个操作对树的形态改变很小,所以符合可持久化的要求。在下面的 例4 中,由于每个合并操作前都需要分裂,所以合并不用再重复记录,实际情况要看相对应的题目

例4. P5055 【模板】可持久化文艺平衡树

您需要写一种数据结构,来维护一个序列,其中需要提供以下操作(对于各个以往的历史版本):

  1. 在第 \(p\) 个数后插入数 \(x\)
  2. 删除第 \(p\) 个数。
  3. 翻转区间 \([l,r]\)
  4. 查询 \([l,r]\) 中所有数的和。

和原本平衡树不同的一点是,每一次的任何操作都是基于某一个历史版本,同时生成一个新的版本(操作 4 即保持原版本无变化),新版本即编号为此次操作的序号。

本题强制在线。

分裂是从一个从根到叶子的递归过程,每次递归返回 \(L\)\(R\) 两棵树。若 \(L\)\(R\) 有变化,则需要复制它们。注意只复制 \(L\)\(R\) 的根即可,不需要复制整棵树,细节详见下面代码。

注意,FHQ_Treap 树的分裂可能导致较大的变化,所以需要很大的空间,需要依据题目给的时空限制去开空间。

这里给出完整代码:

#include<bits/stdc++.h>
#define ll long long
using namespace std;
const int N=2e5+5;
int n,rt[N],L,R,p,cnt;
ll lastans;
struct FHQ_Treap
{int ls,rs,val,pri,siz,tag;ll sum;
}t[N<<7];
int newNode(int x)
{cnt++;t[cnt].ls=t[cnt].rs=0;t[cnt].val=x;t[cnt].pri=rand();t[cnt].siz=1;t[cnt].tag=0;t[cnt].sum=x;return cnt;
}
int clone(int u)//复制树 u,不需要复制整棵树,只复制根就行
{int ret=newNode(0);t[ret]=t[u];return ret;
}
void update(int u)
{t[u].siz=t[t[u].ls].siz+t[t[u].rs].siz+1;t[u].sum=t[t[u].ls].sum+t[t[u].rs].sum+t[u].val;
}
void push_down(int u)
{if(!t[u].tag) return;if(t[u].ls) t[u].ls=clone(t[u].ls);if(t[u].rs) t[u].rs=clone(t[u].rs);swap(t[u].ls,t[u].rs);t[t[u].ls].tag^=1;t[t[u].rs].tag^=1;t[u].tag=0;
}
void split(int u,int x,int &L,int &R)//排名分裂
{if(!u){L=R=0;return;}push_down(u);if(t[t[u].ls].siz+1<=x)//第 x 个数在 u 的右子树上{L=clone(u);//这个时间点的 L 是这个时间点 u 的副本split(t[L].rs,x-t[t[u].ls].siz-1,t[L].rs,R);update(L);}else{R=clone(u);//这个时间点的 R 是这个时间点 u 的副本split(t[R].ls,x,L,t[R].ls);update(R);}
}
int merge(int L,int R)
{if(!L||!R) return L+R;push_down(L);push_down(R);if(t[L].pri>t[R].pri){t[L].rs=merge(t[L].rs,R);update(L);return L;}else{t[R].ls=merge(L,t[R].ls);update(R);return R;}
}
int main()
{srand(time(0));scanf("%d",&n);for(int i=1;i<=n;i++){int v,opt;ll x,y;scanf("%d%d",&v,&opt);if(opt==1)//在第 x 个数后插入 y{scanf("%lld%lld",&x,&y);x^=lastans;y^=lastans;split(rt[v],x,L,p);rt[i]=merge(merge(L,newNode(y)),p);//记录在新的时间点上}if(opt==2)//删除第 x 个数{scanf("%lld",&x);x^=lastans;split(rt[v],x,L,R);split(L,x-1,L,p);rt[i]=merge(L,R);//记录在新的时间点上}if(opt==3)//翻转区间 [x.y]{scanf("%lld%lld",&x,&y);x^=lastans;y^=lastans;split(rt[v],y,L,R);split(L,x-1,L,p);t[p].tag^=1;rt[i]=merge(merge(L,p),R);//记录在新的时间点上}if(opt==4)//查询区间和 [x,y]{scanf("%lld%lld",&x,&y);x^=lastans;y^=lastans;split(rt[v],y,L,R);split(L,x-1,L,p);//p 树是区间 [x,y]lastans=t[p].sum;printf("%lld\n",lastans);rt[i]=merge(merge(L,p),R);//记录在新的时间点上}}return 0;
}

例5. P3835 【模板】可持久化平衡树

您需要写一种数据结构(可参考题目标题),来维护一个可重整数集合,其中需要提供以下操作(对于各个以往的历史版本):

  1. 插入 \(x\)
  2. 删除 \(x\)(若有多个相同的数,应只删除一个,如果没有请忽略该操作)。
  3. 查询 \(x\) 的排名(排名定义为比当前数小的数的个数 \(+1\))。
  4. 查询排名为 \(x\) 的数。
  5. \(x\) 的前驱(前驱定义为小于 \(x\),且最大的数,如不存在输出 \(-2^{31}+1\)
  6. \(x\) 的后继(后继定义为大于 \(x\),且最小的数,如不存在输出 \(2^{31}-1\)

和原本平衡树不同的一点是,每一次的任何操作都是基于某一个历史版本,同时生成一个新的版本。(操作3, 4, 5, 6即保持原版本无变化)

每个版本的编号即为操作的序号(版本0即为初始状态,空树)

请读者自行思考并完成 例5

咕咕咕先写到这里了,后面看有什么其他应用以及习题有时间再回来补吧(逃。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/822252.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

鲜花-CSP2024 游记

前言 坐标 \(\text{SX}\),去年 \(\text{CSP}\) 和 \(\text{NOIP}\) 都爆炸了,于是这就是我最后一年了。 初赛 赛前随便做了三份题,可以随便过线就放下不管了。 赛时光速写完选择,然后被状压题硬控,没太看懂在求什么,有点慌乱,后面的完善程序二分题很快写上去了,但是 \(…

如何使用NTP同步时间?

​ 一、NTP通信概述 很多场景中,由于业务需要,模块需要保持正确的系统时钟,才能正常工作。但是模块上电后的初试时间戳是946713600(即2000/01/01,16:00:00),所以同步时钟成为了开发者要解决的重要问题。对于Cat.1模块,移动/电信卡,通常会下发基站时间,那么sntp就不是必要…

工地升降机AI人数识别系统

工地升降机人数识别系统采用了AI神经网络和深度学习算法,工地升降机AI人数识别系统通过升降机内置的摄像头实时监测轿厢内的人员数量。通过图像处理和人脸识别算法,系统能够精确地识别升降机内的人数。一旦系统识别到人数达到或者超过设定的阈值,工地升降机AI人数识别系统会…

楼道堆积物视觉识别监控系统

楼道堆积物视觉识别监控系统采用了AI神经网络和深度学习算法,楼道堆积物视觉识别监控系统通过摄像头实时监测楼道的情况,通过图像处理、物体识别和目标跟踪算法,系统能够精确地识别楼道通道是否被堆积物阻塞。楼道堆积物视觉识别监控系统检测到堆积物的存在,立刻通过告警信…

变电站人员安全作业行为识别监测系统

变电站人员安全作业行为识别监测系统采用了AI神经网络和深度学习算法,变电站人员安全作业行为识别监测系统通过利用已装好的监控摄像头,进行人员行为分析。针对未穿戴安全帽、工作服、安全马甲、绝缘靴等不符合安全要求的行为,系统能够实时识别并发出预警,提醒工作人员及时…

redis数据库操作指令

一、数据库操作指令 2、redis中库说明 对于一个redis服务而言,包含默认有16个数据库给我们使用,从0开始编号,共15号数据库,默认使用的是0号数据库 切换库,select 库号 举例:使用1号库:select 1 库和库之间数据不共享 库和库之间的键可以重名 2、redis中清空库的指令清空…

AI智能识别未穿工作服识别

AI智能识别未穿工作服识别系统采用了AI神经网络和深度学习算法,AI智能识别未穿工作服识别系统通过现场监控摄像头对特定区域内工人的穿戴情况进行实时监测和分析。系统经过大量的数据训练和算法优化,能够准确识别工人是否穿戴合规的工作服、反光衣、安全帽等装备。AI智能识别…

从零开始学五笔(五):撇区字根

介绍下撇区的字根讲解格式:先介绍按键的区位号、口诀内容、口诀说明 然后列每个字根能组成什么汉字,难拆字将用中括号标出‍ T 键 区位号:31 口诀:禾竹一撇双人立,反文条头共三一 说明:禾:一个字根竹:指的是竹字头 ⺮一撇:作为撇区的第一个键,有一个字根为 丿双人立:…

从零开始学五笔(七):折区字根

介绍下折区的字根讲解格式:先介绍按键的区位号、口诀内容、口诀说明 然后列每个字根能组成什么汉字,难拆字将用中括号标出‍ N 键 区位号:51 口诀:已半巳满不出己,左框折尸心和羽 说明:已半巳满不出己:指的是“已”,“巳”,“己”三个字的不同之处,“已”字半封口,“…

从零开始学五笔(六):捺区字根

介绍下捺区的字根讲解格式:先介绍按键的区位号、口诀内容、口诀说明 然后列每个字根能组成什么汉字,难拆字将用中括号标出‍ Y 键 区位号:41 口诀:言文方广在四一,高头一捺谁人去 说明:言:字根,还有个变体为言字旁“讠” 文,方,广,都是字根 高头:指的是“高”字的上…

从零开始学五笔(三):横区字根

介绍下横区的字根从 1 区开始讲解字根:先介绍按键的区位号、口诀内容、口诀说明 然后列每个字根能组成什么汉字,难拆字将用中括号标出‍ G 键 区位号:11 口诀:王旁青头戋(兼)五一 说明:王旁:指的是王字旁,也可以作为某个字根 青头:指的是「青」字的上半部分(头):龶…

fastdfs管理工具Go-fastdfs-web 安装教程

Go-fastdfs-web 安装教程 安装步骤下载:前往官方下载页面下载所需版本,选择带或不带JRE的安装包。设置权限:给安装文件赋予执行权限,命令为chmod +x goFastDfsWeb.sh。启动与停止:启动命令为./goFastDfsWeb.sh start,停止为stop,查看状态为status。配置与访问:默认端口…