浅论 珂朵莉树/ODT 复习
引入
珂朵莉呜呜呜——
珂朵莉树是一个神奇的数据结构,她起源于 CF896C。
嗯对,珂朵莉树是一个十分年轻而可爱的数据结构。虽然叫珂朵莉树,但她其实是一种线性数据结构。
为什么叫珂朵莉树呢?
大概率是因为 发明出珂朵莉树的那位 真的很喜欢珂朵莉吧
\(\small \texttt{准备开始学习珂朵莉❤} \sout{\texttt{\scriptsize 树}}\)
wait a minute, 这里有一些你必须会的前置知识要讲...
前置知识
1. 指针前置知识
指针,或称迭代器,顾名思义,是表示一个指向某一个东西的东西(?)
(其实可以当做数组下标用啦)
//在 C++11 及以上版本中,可以使用 auto 作为关键字存储任意类型的数据,因此常被用作通用指针。
vector<int> v;//以 vector 为例
v.push_back(4);
v.push_back(2);
v.push_back(1);
v.push_back(3);
auto it=v.begin();
cout<<*it<<endl; //输出:4
it++; //注:指针不支持 +1 或 -1 操作,但支持自增自减,可以看做数组下标的加减。
cout<<*it<<endl; //输出:2
对于结构体:
struct Node{
int x,y;
};
vector<Node> v;
v.push_back({1,2});
v.push_back({1717,406});
v.push_back({233,233});
auto it=v.begin();
cout<<(*it.x)<<endl; //输出 1
cout<<(it->x)<<endl; //输出 1,与上面完全等价
//这种形式更形象:-> 就是一个箭头,此处 "it->x" 可以看做 "it 指向的那个 x 的值"。
2. set 前置知识
std::set 是一个非常好的数据类型!一般用于维护不可重集合。
它是一个 stl 标准模板库中的数据类型,其底层逻辑是 红黑树 这个高级平衡树算法,因此时间复杂度十分优秀!
下文要用到的一些内置函数:
using namespace std;
set<int> s; //定义一个整数类型集合,空间大小和 vector 一样是动态的。
//set 维护的内容需要按照从小到大的顺序自动排序,因此如果套用结构体记得重载运算符。
int x;
pair<auto,bool> it=s.insert(x); //插入一个 x,若已有该元素则认为插入失败,时间复杂度 O(log n)。
//返回一个二元组,第一个为插入元素(或相同元素)的迭代器(指针),第二个为布尔类型,表示插入是否成功。
s.erase(x); //删除 s 中所有值为 x 的元素,要求该值必须存在。时间复杂度 O(log n)
auto it=s.lower_bound(x) //返回集合中大于等于 x 的最小值,时间复杂度 O(log n)
auto a=s.begin() //返回 s 的起始(最小值)迭代器,或称指针。
auto b=s.end() //返回 s 的终止迭代器(指针),这个指针指向的是最后一个未越界的值。
s.erase(a); //O(log n) 删除迭代器为 a 的元素,需要保证 a 有效。
s.erase(a,b); //O(log n) 删除迭代器在 a ~ b 范围内的所有元素
需要注意的是,std::set 是会自动去重的集合,如果不想让他自动去重可以使用 std::multiset;
还有,std::set 维护的值是不可以更改的。如果要更改有两种办法:
- 结构体定义时在类型前加入
mutable关键字,表示可修。 - 直接删掉旧值,重新加入新值。
下面是源题:
CF896C Willem, Chtholly and Seniorious
题目描述
——威廉……
——怎么了?
——好像瑟尼欧里斯出问题了……
——我去看看……

瑟尼欧里斯是通过特定顺序连接特殊护符制成的。
经过了 500 多年,这个圣剑现在状况不太好,所以威廉决定彻底检查它。
瑟尼欧里斯有 \(n\) 片护符。威廉将它们排成一行,第 \(i\) 片护符是一个整数 \(a _ i\)。
为了维护它,威廉需要执行 \(m\) 次操作。
有四种操作类型:
- \(1\ l\ r\ x\) :对于 \(i\) 满足 \(l \le i \le r\),将 \(a _ i + x\) 赋值给 \(a _ i\)。
- \(2\ l\ r\ x\) :对于 \(i\) 满足 \(l \le i \le r\),将 \(x\) 赋值给 \(a _ i\)。
- \(3\ l\ r\ x\) :输出范围 \([l, r]\) 中第 \(x\) 小的数字,即在所有满足 \(l \le i \le r\) 的 \(a _ i\) 排序后第 \(x\) 小的数字。保证 \(1 \le x \le r - l + 1\)。
- \(4\ l\ r\ x\ y\) :输出范围 \([l, r]\) 中所有 \(a _ i\) 的 \(x\) 次幂之和模 \(y\),即 \(\left( \sum _ {i = l} ^ r {a _ i} ^ x \right) \bmod y\)。
输入格式
第一行包含四个整数 \(n, m, seed, v _ {max}\)(\(1 \le n, m \le 10 ^ 5\),\(0 \le seed < 10 ^ 9 + 7\),\(1 \le v _ {max} \le 10 ^ 9\))。
初始值和操作通过如下伪代码生成:
def rnd():
ret = seed
seed = (seed * 7 + 13) mod 1000000007
return ret
for i = 1 to n:
a[i] = (rnd() mod vmax) + 1
for i = 1 to m:
op = (rnd() mod 4) + 1
l = (rnd() mod n) + 1
r = (rnd() mod n) + 1
if (l > r):
swap(l, r)
if (op == 3):
x = (rnd() mod (r - l + 1)) + 1
else:
x = (rnd() mod vmax) + 1
if (op == 4):
y = (rnd() mod vmax) + 1
这里的 \(op\) 是题目中提到的操作类型。
输出格式
对于每个类型为 \(3\) 或 \(4\) 的操作,输出答案。
题目分析 & 正文
很好现在你已经了解了所有情景,现在你可以自己发明珂朵莉树了呢!%%%
终于可以开始 珂朵莉大学习 珂朵莉树大学习了!
实际上,单看前三个操作就会发现这其实是一道平衡树板子题,但是加上了第四种就变成了一种神秘的闻所未闻的不知可不可做题……
那么就有人发明了珂朵莉树!
然后珂朵莉是用来维护什么的呢?就像她的另一个名字“颜色端均摊”一样,她的标志在于有一个 区间推平操作。即把原区间内的一段子区间全部赋值为同一个值。
下文中我们把这道题中的数值看做颜色,因为区间推平很像一个 染色操作,不是吗?
首先考虑暴力时的做法,就是一个一个点地存,一个一个点地修改,然后我们会发现这时候有一些连续的、颜色(数值)也相同的点,这些点要重复算很多次,真的很浪费空间时间,那么怎么把这些没用的东西 ban 掉呢?
定义
可以想到,记录连续颜色段为一个 三元组 \((l,r,val)\) 分别表示 左端点、右端点 和 区间颜色类型(数值),那么一段连续的数值就可以被压缩为一个段。
好的现在我们有了一个由 压缩包.zip 组成的序列!
然后我们这些压缩包存到 set 里面,按照左端点为关键字排序,很显然不会有任何重叠(一个点不可能是同时两种颜色啊 除非薛定谔的点),那么它们就自己老老实实地排好了顺序!
就像这样:
Code
#include<bits/stdc++.h>
using namespace std;
struct Node{
multable int l,r;
multable int val;
inline bool operator<(const Node &oth)const{
return l<oth.l;
}
};
set<Node> s;
区间加
考虑下图中这种区间加的操作:

很好懂吧咕~ 这是这一部分的 Code:
分裂 split
用于把上图中两端的区间拆开。
//表示以 k 为界,分裂开 [l,k-1] 和 [k,r] 并返回 [k,r] 的指针。
auto split(int k){
auto it=s.lower_bound({k,0,0});//指针
if(it!=s.end() && (it->l)==k) return it;//发现有一段以 k 为起始点的区间,好说好说,直接用就行
it--;//否则因为找的是比 k 大一些的,所以还要--变成包含 k 的区间。
if((it->r) < k) return s.end();//整个 k 越界。
int v=(it->val), r=(it->r);//存一下
(it->r)=k-1;//缩短另一端变为 [l,k-1]
return s.insert(Node{k,r,v}).first;//加入一个[k,r],返回其指针
}
值得一提的是,因为我们用的是 multiable 关键字,无需删掉重加,于是成功卡常~
区间加 code
最简单的一段。
void Add(int l, int r, int val){
auto id=split(r+1), it=split(l);//分裂两端。
//注意 r+1 才能正确把 [l,r] 整个分离
while(it!=id){
(it->val)+=val;
it++;
}
}
区间推平
然后是这种区间推平操作:

同样很简单呢
Code
void flatten(int l, int r, int val){
auto id=split(r+1), it=split(l);
(it->r)=r;
(it->v)=val;//把左边的延长并赋值,卡掉再次加入的常数
s.erase(++it,id);
}
注意这里我们用之前的区间修改平替,省去了删掉重加的常数。
小细节
还有一点不知道你有没有发现,我们每次分裂区间都是先分裂右边,再分裂左边,这是为什么呢?
假设我们有一段区间 \([1,10]\),现要推平 \([3,5]\) 那么如果先分裂左边得到 \([1,3]\) 和 \([3,10]\),此时我们记下了 \(it=[3,10]\);分裂右边我们得到了 \([3,5]\) 和记录 \(id=[6,10]\)……
这时你要去遍历,发现:
诶我原来的 \(it=[3,10]\) 咋没了?
嗯然后就因为指针不存在 RE 了。
那么如果我们先分裂右边呢?
同样的数据,分裂右边得到 \([1,5]\) 和 \([6,10]\),此时我们只记下了 \(id=[6,10]\);再去分裂左边,得到 \([1,3]\) 和 \(it=[3,5]\) ……
\(it=[3,5]\),\(id=[6,10]\) 真了!好诶!
所以这方面的小细节一定要注意欧,尤其看一看有没有指针互相扰动的问题欧~
Complete Code
\(\text{Speak is cheep, show you the code:}\)
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e5+7,mod=1e9+7;
int n,m,seed,vmax;
//题目中的随机化。
inline rd(){
int ret=seed;
seed=(seed*7+13)%mod;
return ans;
}
//快速幂
inline int ksm(int x, int tmp){
int ans=1;
x%=mod;
while(tmp){
if(tmp&1) ans=ans*x%mod;
x=x*x%mod;
tmp>>=1;
}
return ans;
}
//珂朵莉树已打包~
namespace Cly{
struct str{
mutable int l,r;
mutable int val;//卡常
bool operator<(const str &oth)const{
return l<oth.l;//重载
}
};
set<str> s;//珂朵莉树本体
//分裂,如上
inline auto split(int k){
auto it=s.lower_bound({k,0,0});
if(it!=s.end()&&(it->l)==k) return it;
it--;
if((it->r)<k) return s.end();
int v=(it->val), r=(it->r);
(it->r)=k-1;
return s.insert(str{k,r,v}).first;
}
//区间推平
void flatten(int l, int r, int val){
auto id=split(r+1), it=split(l);
(it->r)=r;//延长、染色,卡常。
(it->val)=val;
s.erase(++it, id);//删掉其他的东西
}
//区间加
void Add(int l, int r, int val){
auto id=split(r+1), it=split(l);
while(it!=id){
(it->val)+=val;//如上
it++;
}
}
//临时记录一下颜色段(数值段)
struct Kthnum{
int val,cnt;
bool operator<(const Kthnum &oth)const{
return val<oth.val;//按数值从小到大排序
}
};
//查找区间第 k 大的值是多少。
int kth(int l, int r, int k){
auto id=split(r+1), it=split(l);
vector<Kthnum> v;
while(it!=id){//很暴力,所以不讲
v.push_back(Kthnum{(it->val), (it->r)-(it->l)+1});
it++;//码面意思,把每一个数值段连值带区长放进去
}
sort(v.begin(), v.end());
for(auto i:v){
//如果现在的数值段已经能够包含第 k 个元素,直接返回这种数值
if(k<=i.cnt) return i.val;
//否则减去这一段长度 cnt,在剩下的序列中查找第 k-cnt 大的数
k-=i.cnt;
}
}
//计算区间幂之和。
int power(int l, int r, int tmp){
auto id=split(r+1), it=split(l);
int ans=0;
while(it!=id){//依旧暴力直接求
ans=(ans+((it->r)-(it->l)+1)*ksm((it->val),tmp)%mod)%mod;
it++;
}
return ans;
}
}
//主函数略去,不想写了,没什么特别的捏
int main(){
return 0;
}
哦那么恭喜你已经能够成为一名珂朵莉树大师了!(柯学家 +1)
关于时间复杂度
珂朵莉树的时间复杂度均摊下来是 \(\mathcal{O}(m\log n)\) 的,是不是很优秀呢~
什么你说这操作难道不是 \(\mathcal{O}(nm)\) 吗?
等会我算一下
第一种修改是最坏 \(\mathcal{O}(n)\),最理想 \(\mathcal{O}(1)\) 的;
第二种修改每次都是稳定 \(\mathcal{O}(\log n)\) 的;
这道题的查询最坏情况是 \(\mathcal{O}(n\log n)\)(因为必要的快速幂),最理想 \(\mathcal{O}(\log n)\) 的。
你说的兑!是O(nm)的没错!
停停停,那不就变成暴力做法了吗?
Emmm... 好吧其实珂朵莉树就是一个暴力数据结构来着 awa。
其实并不会出现这种情况。因为出现这种极端数据 Hack 的话已经说明不能用珂朵莉树了呢(?
是的,珂朵莉树也是一个玄学复杂度,只要允许特殊数据珂朵莉树就会被直接卡掉,但是这题不是指定数据啊!
珂朵莉树一般是用来卡常的……例如一些随机数据的题,有时候 FHQ 等算法会因为常数太大直接被卡,而珂朵莉树就能凭借其优秀的常数神秘通过……
愿世上没有卡常题 (><)
珂朵莉树仅在数据随机的情况下才能保证时间复杂度是均摊下来 \(\mathcal{O}(m\log n)\) 呢,所以一定要小心谨慎,仔细看题欧~
再说了怎么会有人去卡我们可爱的珂朵莉呢 ~
以上。
参考资料:
学长的 cnblogs 有点吓人还是开纯净模式再去看好了…… (逃)
并不想 感谢 Co_led 对本咕的各类问题迫使本咕写下此文。

浙公网安备 33010602011771号