实用 STL —— rope 学习笔记
rope
rope
是 C++ STL 中 pb_ds(Policy-Based Data Structures)库 的一个分支,内部构造是一个 块状链表。
实际中,它经常被用于一些需要可持久化数据结构的题目中,用于代替实现繁琐的可持久化平衡树、可持久化线段树(主席树)、可持久化并查集等等,可见它功能之强大。所以,今天我们就来详细探讨一下这个东西,去看看它的实现 、应用以及优劣。(主要是因为没有时间学可持久化数据结构了)
注意:rope
在 c++11 之后开始被支持,因此可以放心在 NOIP 等竞赛中使用。
rope 的引用
引用 rope
需要调用它所在的 <ext/rope>
库:
#include <ext/rope>
<ext/rope>
库被包含在万能 STL 拓展库中,所以我们也可以直接引用 <bits/extc++.h>
:
#include<bits/extc++.h>
这个库同时也包含其他 pb_ds 的东西,诸如平衡树、高优化哈希表之类。
之后我们还要引用命名空间 __gnu_cxx
(这个名字真奇怪):
using namespace __gnu_cxx;
或在函数前增加 __gnu_cxx::
rope 的声明
经常用其他 STL(诸如 stack
,vector
,queue
之类)的人应该会对此见怪不怪。
最常用的是 int
(以及 long long
) 型的和 char
型的,其中 char
型的就是一个字符串(string),可以简写为 crope
:
typedef long long ll;
rope<int> s;
rope<ll> s;
rope<char> c;
crope c;//相当于定义成rope<char>
struct node{
int a,b;
};
rope<node> m;//rope支持结构体
我们一般不需要管它存不存的下,因为它是动态的,类似于动态数组 vector
。(但是开得太多有可能会炸空间到时真的)
但其实我们也可以让它不那么动态,只需要手动给它一个参数 \(size\) 就可以了。比如说这样:
rope<int> s(114514);
就可以定义一个大小为 \(114514\) 的 rope。
如果你想一次定义一堆 rope ,也就是定义一个 rope 组,那只需要像数组那样定义就可以了:
const int N=1145;
rope<int> f[N];
此外,rope 还支持强制转换与赋值操作。下面给出的例子是把一个普通数组强转为 rope
#include<bits/stdc++.h>
#include<bits/extc++.h>
using namespace std;
using namespace __gnu_cxx;
int f[114514],m;
int main()
{
cin>>m;
rope<int> fa;
for(int i=0;i<m;i++) cin>>f[i];//读者可以自行把这里改成 for(int i=1;i<=m;i++) 试试看
fa=rope<int>(f);//强转操作与赋值操作
cout<<fa.size()<<endl;
for(int i=0;i<fa.size();i++) cout<<fa[i]<<endl;
}
rope 的操作
字符串 rope(crope
)
首先,我们定义了一个 crope a;
a.push_back(x)
:在a
的末尾添加字符c
;a.insert(p,x)
: 在a
的下标p
后面添加x
;- 或
a.insert(p,s,n)
:将字符串s
的前n
位插入a
的下标p
处; a.erase(p,x)
: 从a
的下标p
开始删除x
个元素;a.replace(p,s)
: 从a
的下标p
开始换成字符串s
,若a
的位数不够则补足;a.copy(p,n,s)
:从a
的下标p
开始的n
个字符换成字符串s
,若位数不够则补足;a.substr(p,x)
:从a
的下标p
开始截取x
个元素;a[x]
或a.at(x)
访问下标为x
的元素;a.append(s,p,n)
:把字符串s
中从下标p
开始的n
个字符连接到a
的结尾,如没有参数n
则把字符串s
中下标p
后的所有字符连接到a
的结尾,如参数p
也没有则把整个字符串s
连接到a
的结尾;
以上,s
均为字符串(string
或 char[]
)类型,c
为字符(char
)类型,n
,p
,x
均为整型。
这些操作复杂度基本都在 \(O(\sqrt n)\) 级别(毕竟是块状链表嘛),但是 push_back
等单个字符的插入是 \(O(1)\) 的。
数组 rope(rope<int>
)
和字符串 rope 的操作差不多,只是把上文中的字符串参数换成了数组。
数组 rope 可以用 a.append(x)
来在末尾插入一个数 x
,当然也可以用类似于上文的方法把一个数组连接到末尾,这里不再赘述。
数组 rope 也支持 substr
操作,用于截取其中的一段数。在下面的例题代码中会有用到这个操作。
此外,不管是那种类型的 rope 都可以使用上面说过的赋值操作与强转操作。下面给出一段示例代码来方便理解:
rope<int> rp;
int main()
{
rp.append(3);
rp.append(1);
rp.append(2);
rp.append(1);
rp = rp.substr(1, 3);//从1开始截3个数字,注意rope是从0开始的,所有的容器都是从0开始的
for(int i = 0; i < rp.size(); ++ i)
cout << rp[i] << " ";
puts("");
return 0;
}
输出:
1 2 1
其他基本操作
rope 也支持其他一些动态数组中的操作,比较常用的有:
size()
:返回大小;empty()
:返回是否为空;begin()
:返回首指针;end()
:返回末尾指针后一位;clear()
:清空该 rope;
可持久化操作
现在流行的 rope 可持久化操作主要分成两个版本:朴素版与指针版。
朴素版
定义一个 rope 组(就叫做 s
吧)存放所有历史版本,再定义一个叫 now
的 rope 表示当前版本。每次需要申请一个新版本时就把 now
赋值给 s[++cnt]
;访问历史版本则没有一点难度,直接访问就行;若要回到历史版本,那就把那个历史版本赋值给 s[++cnt]
。
为了方便理解,我们在这里放一道例题:
题目描述
早苗入手了最新的高级打字机。最新款自然有着与以往不同的功能,那就是它具备撤销功能,厉害吧。
请为这种高级打字机设计一个程序,支持如下 \(3\) 种操作:
T x
:Type 操作,表示在文章末尾打下一个小写字母 \(x\)。U x
:Undo 操作,表示撤销最后的 \(x\) 次修改操作。Q x
:Query 操作,表示询问当前文章中第 \(x\) 个字母并输出。请注意 Query 操作并不算修改操作。
文章一开始可以视为空串。
输入格式
第 \(1\) 行:一个整数 \(n\),表示操作数量。
以下 \(n\) 行,每行一个命令。保证输入的命令合法。
输出格式
每行输出一个字母,表示 Query 操作的答案。
样例 #1
样例输入 #1
7
T a
T b
T c
Q 2
U 2
T c
Q 2
样例输出 #1
b
c
提示
数据范围及约定
-
对于 \(40\%\) 的数据 \(n \le 200\)。
-
对于 \(100\%\) 的数据 \(n \le 100000\),同时保证 Undo 操作不会撤销 Undo 操作。
高级挑战
对于 \(20\%\) 的数据 \(n \le 100000\),Undo 操作可以撤销 Undo 操作。
出题人希望本题使用在线算法解决。
AC代码
#include<bits/stdc++.h>
#include<ext/rope>
using namespace __gnu_cxx;
using namespace std;
const int N=1e5+10;
crope now,x[N];
//快读省略
int t,V,q;//V即cnt
char qs;
void work()
{
char op=read();
switch(op)
{
case 'T':
{
qs=read();
now.push_back(qs),x[++V]=now;
break;
}
case 'U':
{
q=read();
now=x[V-q],x[++V]=now;
break;
}
case 'Q':
{
q=read();
putchar(now[q-1]),putchar('\n');
break;
}
}
}
int main()
{
t=read();
while(t--) work();
}
指针版
这个看起来更高级一点。每次用 new
关键字申请一个新的指针版本,将它赋值给 s[++cnt]
,这样就不需要再开一个 now
记录当前版本了。
s[cnt]=new crope(*s[cnt-1]);//申请新版本
其他做法几乎与朴素版是一样的,关于 rope 的指针操作业余其他数据类型并无两样。完整代码如下:
#include <bits/stdc++.h>
#include <bits/extc++.h>
using namespace std;
using namespace __gnu_cxx;
const int N = 1e5+5;
int cnt=-1,n,num;
crope *s[N];
char op,ch;
int main(){
cin>>n;
s[++cnt]=new crope();//申请一个空的crope
for(int i=1;i<=n;i++){
cin>>op;
if(op=='T')
{
cin>>ch;cnt++;
s[cnt]=new crope(*s[cnt-1]);
s[cnt]->push_back(ch);
}
if(op=='U')
{
cin>>num;cnt++;
s[cnt]=new crope(*s[cnt-num-1]);
}
if(op=='Q') cin>>num,cout<<s[cnt]->at(num-1)<<'\n';
}
}
据了解,rope 的整体赋值复杂度是 \(O(logn)\) 到 \(O(\sqrt n)\) 级别的(也不知道它是怎么做到的),所以可以过本题。
一道几乎一样的题目是 P6166,可以当做练习。
rope 的优劣
优点
先讲讲 rope 的优点吧,我认为其实归根到底只有三点:
写起来简单,学起来简单,调起来简单
相比各种可持久化数据结构,rope 可谓简短而明了,而且写起来不费劲也不费时,因此对于学不懂/没有来得及学/没有时间写可持久化数据结构的人来说,rope 简直是天降之物,救赎苍生;对于许多参加 \(OI\) 竞赛的考生来说,rope 是他们的一把 “飞刀” ,快、准、狠地刺入题目的心脏。
但是,正所谓道高一尺,魔高一丈。针对 rope 的几个缺陷,许多出题者可以通过一些操作把 rope 做法的满分硬生生卡掉。所以,了解它的适用范围与缺点,和了解它怎么写同样重要。
缺点
怎么形容 rope 的缺点呢?其实三个字就可以:慢、大、少。
1.运行较慢
首先,rope 的许多操作复杂度都是 \(O(\sqrt n)\) 的,整体代码复杂度一般在 \(O(n\sqrt n)\) 左右,本身就比复杂度在 \(O(nlogn)\) 级别的各种可持久化数据结构高。如果题目的数据范围给大一点,给到 \(1e6\) 左右,那么 rope 就很吃力了。况且 rope 在不开 \(O2\) 优化时常数比较大,那就更过不了了,甚至会比手写的块状链表还慢许多。
2.空间较大
rope 的空间需求是比较大的,如果你开了过多的历史版本,那有可能会 MLE。
3.可以维护的东西较少
或许你会觉得 rope 的各种操作已经很完善了,但是实际上,rope 有限的操作全然不能够应付无限变化的题目。相比于手写的可持久化数据结构,rope 能维护的东西就显得比较少了。毕竟 rope 是给你打包好的,难以进行内部修改而只能在外部进行操作。
以上三个缺点,都可以成为出题人卡你的方式。
何时使用?
首先,我们还是需要先收起掌握 rope 后的自负,不要对它抱有 “可以解决一切可持久化题目” 的幻想。在能打出正确解法的时候还是别用 rope。
但是,很多情况下,我们都没有能力打出正解(除非你真的特别厉害)。此时就可以用 rope,因为它在很多时候都可以为你拿到很多的分数。
正是因此,我认为学习 rope,以及其他的 STL,都是挺有必要的。下面会给出几道例题,它们都可以用 rope 完成。就当是练手吧。
rope 例题
luogu P3391 文艺平衡树
很容易看出这题中的区间翻转可以用 rope 中截取子段的操作 substr
实现,只需要建立一整一反两个 rope 即可。
#include<bits/stdc++.h>
#include<bits/extc++.h>
using namespace std;
using namespace __gnu_cxx;
rope<int> zheng,fan;
int n,m;
void prework(){for(int i=1;i<=n;i++) zheng.append(i),fan.append(n-i+1);}
void my_reverse(int l,int r)
{
if(l>=r) return;
rope<int> tmp=zheng.substr(zheng.begin()+l-1,zheng.begin()+r);
zheng=zheng.substr(zheng.begin(),zheng.begin()+l-1)+fan.substr(fan.end()-r,fan.end()-l+1)+zheng.substr(zheng.begin()+r,zheng.end());
fan=fan.substr(fan.begin(),fan.end()-r)+tmp+fan.substr(fan.end()-l+1,fan.end());
}
void print(){for(int i=0;i<n;i++) cout<<zheng[i]<<" ";}
int main()
{
scanf("%d%d",&n,&m);prework();
for(int i=1,l,r;i<=m;i++) scanf("%d%d",&l,&r),my_reverse(l,r);
print();
}
luogu P3402 可持久化并查集
虽说题目是可持久化并查集,但是用了 rope 以后,直接写普通并查集就可以了。其余暴力维护即可。
代码中用到了 replace
操作,也是这道题目中 rope 发挥作用的关键。具体见代码。
PS:这题数据较大,需要开 \(O2\),而且有一个几乎全部都是 \(1\) 操作的点,会把我们卡到 MLE,因此可能需要离线然后面向数据编程一下。总之是可以卡过去的。
#include<bits/stdc++.h>
#include<bits/extc++.h>
using namespace __gnu_cxx;
using namespace std;
const int N=2e5+5;
int f[N],r[N],n,m,op,a,b,cnt1,cnt2,cnt3;
struct node{
int op,a,b;
};
inline int read()
{
int s=0,w=1;
char ch=getchar();
while(ch<'0'||ch>'9')
{
if(ch=='-') w=-1;
ch=getchar();
}
while(ch>='0'&&ch<='9') s=(s<<3)+(s<<1)+ch-'0',ch=getchar();
return s*w;
}
int find(rope<int> &fa,int &i)
{
int f=fa[i];
return f==i?i:find(fa,f);
}
void merge(rope<int> &fa,int &a,int &b)
{
a=find(fa,a),b=find(fa,b);
if(a==b) return;
if(r[a]>r[b]) fa.replace(b,a);
else
{
if(r[a]==r[b]) r[b]++;
fa.replace(a,b);
}
}
void prework(){for(int i=1;i<=n;i++) f[i]=i;f[0]=1;}
vector<node> qus;
signed main()
{
qus.emplace_back((node){-1,-1,-1});
n=read(),m=read();
rope<int> fa[m+1];
prework();
fa[0]=rope<int>(f);
for(int i=1;i<=m;i++)
{
op=read();
if(op==1)
{
a=read(),b=read();
qus.emplace_back((node){op,a,b});
cnt1++;
}
else if(op==2) a=read(),cnt2++,qus.emplace_back((node){op,a,-1});
else
{
a=read(),b=read();
qus.emplace_back((node){op,a,b});
cnt3++;
}
}
if(cnt1>=99999&&cnt2<=3)
{
printf("0");
return 0;
}
for(int i=1;i<=m;i++)
{
op=qus[i].op;
if(op==1)
{
fa[i]=fa[i-1];
a=qus[i].a,b=qus[i].b;
merge(fa[i],a,b);
}
else if(op==2) a=qus[i].a,fa[i]=fa[a];
else
{
fa[i]=fa[i-1];
a=qus[i].a,b=qus[i].b;
printf("%d",(find(fa[i],a)==find(fa[i],b)?1:0));
putchar('\n');
}
}
}
参考资料
-
《浅谈 rope》(https://cloud.tencent.com/developer/article/2110460)
-
《C++自带的可持久化平衡树?ROPE大法好!》(https://www.freesion.com/article/34461442886/)
-
《可持久化杀手——rope学习笔记》(https://www.cnblogs.com/zheyuanxie/p/rope-note.html)
-
《P3402 可持久化并查集题解》(https://shannansong.blog.luogu.org/post-2022717-dan-na-song-jiang-ke-ji-yao)
-
《题解 P3391 【【模板】文艺平衡树(Splay)】》(https://margatroid.blog.luogu.org/solution-p3391)