珂朵莉树学习笔记

StudyingFather

2019-08-01 21:58:58

Solution

珂朵莉树(Chtholly Tree),又名老司机树(Old Driver Tree, ODT),是一种非常暴力的维护序列信息的数据结构。 其通过维护值相同的连续段来保证效率,在特殊构造的数据下会退化为普通暴力算法。 **注:下列代码均可以在 `C++14` 标准下编译。** ## 1 前置知识 1. 熟练掌握 `std::set` 的用法。 没了?没错。 ## 2 一个例子 [CF896C](http://codeforces.com/problemset/problem/896/C) 是一个非常经典的模板题,珂朵莉树也正是来源于本题。 下面是题面部分: > 你需要维护一个序列,并支持如下几种操作: > > 1. 给区间 $ [l,r] $ 内的所有数字加上 $ x $ 。 > 2. 将区间 $ [l,r] $ 内的所有数字赋值为 $ x $ 。 > 3. 求区间 $ [l,r] $ 内所有数字中第 $ x $ 小的数字(重复数字多次计算)。 > 4. 求 $ \sum_{i=l}^{r} a_i^x \bmod y $ 的值。 > > 题目保证数据随机。 前三个操作都不算太难,使用常规的数据结构(主席树)都可以圆满解决。 问题在于第四个操作。为什么常规的数据结构在第四个操作面前无能为力呢?主要在于其并不能方便地分解为两个子区间的问题。 这时候珂朵莉树就要出场了。 ## 3 ODT 的基本操作 ### 3.1 节点结构 我们这样定义一个珂朵莉树的节点: ```cpp struct node { int l,r;//该节点对应的区间 mutable long long val; //mutable 修饰该变量之后,就可以直接在 set 中修改该变量的值,而不是取出元素修改后再重新插入 set node(int L,int R=-1,long long Val=0) { l=L,r=R,val=Val; } bool operator<(const node&a)const { return l<a.l; } }; ``` 接下来,我们定义一个 `set<node> odt` 来维护一棵 ODT。 ### 3.2 分割区间操作:split 给出一个区间 $ [l,r] $ 和一个位置 $ pos $ ,怎么把这个区间分割为 $ [l,pos-1] $ 和 $ [pos,r] $ 两个区间呢? 大致流程很简单: 1. 先在 ODT 中找到含有 $ pos $ 位置的区间。 2. 如果 $ pos $ 已经是一个区间的左端点,则无需分割。 3. 否则我们把原来的区间删除,插入两个新区间。 详细代码如下: ```cpp auto split(int pos) { auto it=odt.lower_bound(node(pos));//找到左端点不小于 pos 的区间 if(it!=odt.end()&&it->l==pos)return it;//pos 是区间左端点时无需分割 it--;//pos 一定在前一个区间中 int l=it->l,r=it->r; long long val=it->val; odt.erase(it);//删除原来的区间 odt.insert(node(l,pos-1,val)); return odt.insert(node(pos,r,val)).first;//插入两个新区间 //这里的返回值是后半段区间对应的迭代器 } ``` 经过这样的分割操作后,容易发现任意两个区间没有相交的部分,这是保证我们接下来操作正确性的前提。 ### 3.3 合并区间操作:assign 如果光分割区间而不合并的话,我们事实上就是对一堆长度为 $ 1 $ 的区间进行操作,珂朵莉树也就会退化为普通暴力算法。 通过合并操作,我们可以迅速降低珂朵莉树的大小,保证珂朵莉树的效率。 这里先给出合并操作的代码: ```cpp void assign(int l,int r,long long val) { auto itr=split(r+1),itl=split(l); odt.erase(itl,itr);//删除[itl,itr)区间内的所有元素(注意左闭右开区间) odt.insert(node(l,r,val));//将原来的诸多小区间用一个大区间代替 } ``` 注意:**在执行 `split` 操作时,应先 `split` 右端点,再 `split` 左端点**,否则可能会 RE。 通过两次 `split` 操作, $ [l,r] $ 区间内一定都是整段区间。因此我们可以安全地删除原来的零散区间,用大区间代替。 经过 `assign` 操作后,ODT 的规模会下降不少,从而保证 ODT 的实际运行效率。 ### 3.4 其他操作 所有区间操作都可以套这样的一个模板: 1. 先 `split` 右端点,再 `split` 左端点,获得两个端点(左闭右开)的迭代器。 2. 对两个端点之间的所有区间暴力更改。 代码差不多长这样: ```cpp void update(int l,int r) { auto itr=split(r+1),itl=split(l); for(auto it=itl;it!=itr;it++) //do something } ``` 我们回到那道模板题。 首先是区间加一个值: ```cpp void add(int l,int r,long long val) { auto itr=split(r+1),itl=split(l); for(auto it=itl;it!=itr;it++) it->val+=val;//因为 val 被 mutable 关键字修饰,从而可以直接修改 set 里元素的值 } ``` 接下来是区间第 $ k $ 小,暴力取出区间内所有段排序一遍即可: ```cpp typedef pair<long long,int> pii; long long kth(int l,int r,int k) { vector<pii> a; auto itr=split(r+1),itl=split(l); for(auto it=itl;it!=itr;it++) a.push_back(pii(it->val,(it->r)-(it->l)+1)); sort(a.begin(),a.end()); for(auto it=a.begin();it!=a.end();it++) { k-=it->second; if(k<=0)return it->first; } return -1; } ``` 然后是区间幂次和,还是暴力,取出区间内所有段累加求和: ```cpp long long sum(int l,int r,int x,int y) { long long ans=0; auto itr=split(r+1),itl=split(l); for(auto it=itl;it!=itr;it++) ans=(ans+fpow(it->val,x,y)*((it->r)-(it->l)+1))%y; return ans; } ``` **注意到我们的区间操作都是直接对值相同的连续段进行处理,当段数较多的时候,效率就会降低。** 模板题的完整代码如下: ```cpp #include <iostream> #include <algorithm> #include <set> #include <vector> #define MOD 1000000007 using namespace std; struct node { int l,r; mutable long long val; node(int L,int R=-1,long long Val=0) { l=L,r=R,val=Val; } bool operator<(const node&a)const { return l<a.l; } }; typedef pair<long long,int> pii; set<node> odt; long long a[100005],n,m,seed,vmax; long long rnd() { long long ret=seed; seed=(seed*7+13)%MOD; return ret; } long long fpow(long long x,long long y,long long mod) { long long ans=1; x%=mod; while(y) { if(y&1)ans=ans*x%mod; x=x*x%mod; y>>=1; } return ans; } auto split(int pos) { auto it=odt.lower_bound(node(pos)); if(it!=odt.end()&&it->l==pos)return it; it--; int l=it->l,r=it->r; long long val=it->val; odt.erase(it); odt.insert(node(l,pos-1,val)); return odt.insert(node(pos,r,val)).first; } void assign(int l,int r,long long val) { auto itr=split(r+1),itl=split(l); odt.erase(itl,itr); odt.insert(node(l,r,val)); } void add(int l,int r,long long val) { auto itr=split(r+1),itl=split(l); for(auto it=itl;it!=itr;it++) it->val+=val; } long long kth(int l,int r,int k) { vector<pii> a; auto itr=split(r+1),itl=split(l); for(auto it=itl;it!=itr;it++) a.push_back(pii(it->val,(it->r)-(it->l)+1)); sort(a.begin(),a.end()); for(auto it=a.begin();it!=a.end();it++) { k-=it->second; if(k<=0)return it->first; } return -1; } long long sum(int l,int r,int x,int y) { long long ans=0; auto itr=split(r+1),itl=split(l); for(auto it=itl;it!=itr;it++) ans=(ans+fpow(it->val,x,y)*((it->r)-(it->l)+1))%y; return ans; } int main() { cin>>n>>m>>seed>>vmax; for(int i=1;i<=n;i++) { a[i]=rnd()%vmax+1; odt.insert(node(i,i,a[i])); } for(int i=1;i<=m;i++) { int op=rnd()%4+1,l=rnd()%n+1,r=rnd()%n+1,x,y; if(l>r)swap(l,r); if(op==3)x=rnd()%(r-l+1)+1; else x=rnd()%vmax+1; if(op==4)y=rnd()%vmax+1; if(op==1)add(l,r,x); else if(op==2)assign(l,r,x); else if(op==3)cout<<kth(l,r,x)<<endl; else cout<<sum(l,r,x,y)<<endl; } return 0; } ``` ## 4 效率? ODT 的做法看起来非常暴力,但是在随机数据的情况下,它的表现其实非常优秀。 我们可以证明,一个有 $n$ 个数的序列,在经过 $k$ 次**随机的**区间赋值操作后,期望段数大约在 $O(\dfrac{n}{k})$ 的级别。 证明可以点击 [这里](https://codeforces.com/blog/entry/56135?#comment-398940) 查看,这里不再展开。 在模板题中,因为数据随机,有 $\dfrac{1}{4}$ 的操作均为**随机的**区间赋值操作,因此 ODT 的实际段数很低,效率当然不错。 ## 5 一点扩展 珂朵莉树并不单单只能解决序列问题,对于树上问题,可以通过重链剖分后转化为序列问题,再考虑用珂朵莉树解题。 当然珂朵莉树虽然名为树,也并不一定必须用平衡树(`std::set`)实现。 我们发现区间分裂操作就是删除一个区间,再插入两个小区间;区间合并操作就是将一些区间删掉,再插入一个更大的区间。这个操作可以使用链表来实现,效率也很不错。 ## 6 总结 说了这么多,不过要注意的是:使用珂朵莉树的前提是题目有区间赋值的性质,且数据随机。 **在数据中区间赋值操作较多的时候,珂朵莉树的实际运行效率较高**。但特殊构造的数据往往并不具有这样的性质,导致其退化为普通暴力算法,因此要结合题目性质来考虑是否使用珂朵莉树来解题。 虽然事实上珂朵莉树在很多题目中都可以用其他常规数据结构代替,但其简单直接,易于调试的特点让它成为了一个解决不少题目的第二选择。