珂朵莉树(ODT)学习笔记与刷题路线
珂朵莉树(ODT)学习笔记与刷题路线
一、刷题清单
| 顺序 | 题目 | 训练点 | 建议 |
|---|---|---|---|
| 1 | CF915E Physical Education Lessons | 区间推平、维护总和 | 最适合入门 |
| 2 | CF896C Willem, Chtholly and Seniorious | ODT 模板:加、赋值、k 小、幂和 | 经典模板题 |
| 3 | P2787 语文1(chin1)- 理理思维 | 字符串区间赋值、计数、排序 | 练“按段暴力处理” |
| 4 | CF817F MEX Queries | 区间加入、删除、翻转、维护 MEX | 练区间集合思想 |
| 5 | CF444C DZY Loves Colors | ODT + 树状数组 | 进阶,理解“颜色段均摊” |
ODT 的本质是用 set 维护若干个“值相同的连续段”,适合大量区间赋值、区间推平、数据随机或区间段数量较少的题目。
但是要注意:珂朵莉树不是严格意义上的稳定复杂度数据结构,很多情况下复杂度依赖数据性质。如果没有区间赋值,或者数据可以被恶意构造,直接使用 ODT 很容易被卡。因此它更适合用作“随机数据下的暴力分块式维护”或者“颜色段维护思想”。
二、珂朵莉树核心知识点
1. 它维护的是什么?
原数组:
1 1 1 3 3 2 4 4
ODT 维护成:
[1,3,1] [4,5,3] [6,6,2] [7,8,4]
每个结点表示一段连续且值相同的区间:
struct node{
int l,r;
mutable int v;
node(int L,int R=0,int V=0):l(L),r(R),v(V){}
bool operator<(const node &x)const{return l<x.l;}
};
含义:
l,r:这一段的左右端点;v:这一段的值;mutable:允许在set中直接修改v。
因为 set 中元素默认不能修改,否则会破坏排序结构。但这里排序只和 l 有关,修改 v 不影响排序,所以可以使用 mutable。
2. 核心操作:split(pos)
split(pos) 的作用是:保证 pos 成为某一段的左端点。
例如当前有:
[1,10,5]
执行:
split(4);
之后变成:
[1,3,5] [4,10,5]
所以处理区间 [l,r] 时,一般先写:
auto itr=split(r+1),itl=split(l);
这样 [itl,itr) 中的所有段都完整落在 [l,r] 内。
注意,一般要先 split(r+1),再 split(l),避免迭代器因为分裂和删除而出问题。
模板:
set<node>::iterator split(int pos){
auto it=s.lower_bound(node(pos));
if(it!=s.end()&&it->l==pos)return it;
--it;
int l=it->l,r=it->r,v=it->v;
s.erase(it);
s.insert(node(l,pos-1,v));
return s.insert(node(pos,r,v)).first;
}
为了保证 split(n+1) 合法,常见写法会额外插入一个哨兵:
s.insert(node(n+1,n+1,0));
3. 核心操作:区间赋值 assign
区间赋值 [l,r]=x:
void assign(int l,int r,int x){
auto itr=split(r+1),itl=split(l);
s.erase(itl,itr);
s.insert(node(l,r,x));
}
这一步是 ODT 能均摊跑得快的关键。因为区间赋值会把很多零散的小段合并成一个大段,使得总段数下降。
4. 区间暴力处理
大部分 ODT 题的写法都是:
auto itr=split(r+1),itl=split(l);
for(auto it=itl;it!=itr;it++){
// 处理 [it->l,it->r],值为 it->v
}
例如区间加:
void add(int l,int r,int x){
auto itr=split(r+1),itl=split(l);
for(auto it=itl;it!=itr;it++)it->v+=x;
}
区间求和:
int ask(int l,int r){
int ans=0;
auto itr=split(r+1),itl=split(l);
for(auto it=itl;it!=itr;it++){
ans+=(it->r-it->l+1)*it->v;
}
return ans;
}
区间第 k 小:
int kth(int l,int r,int k){
vector<pair<int,int> >v;
auto itr=split(r+1),itl=split(l);
for(auto it=itl;it!=itr;it++){
v.push_back({it->v,it->r-it->l+1});
}
sort(v.begin(),v.end());
for(auto p:v){
k-=p.second;
if(k<=0)return p.first;
}
return -1;
}
5. 什么时候适合用 ODT?
适合:
- 有大量区间赋值;
- 数据随机;
- 区间段数量不会太多;
- 查询可以对每个连续段暴力处理;
- 题目本身有“颜色段”“相同值连续段”的性质。
不适合:
- 没有区间赋值;
- 只有区间加、区间查询;
- 数据可以恶意构造;
- 需要严格保证复杂度;
- 一次查询要求处理的段数可能达到
O(n)且无法被区间赋值压缩。
一句话总结:
ODT 是“维护连续相同段 + 区间推平”的暴力数据结构。看到大量区间赋值时可以优先考虑;否则优先想线段树、树状数组、平衡树、分块等更稳定的做法。
三、逐题题解与代码
1. CF915E Physical Education Lessons
题意简述
有 1~n 天,初始都是工作日。每次操作把区间 [l,r] 变成非工作日或者工作日,操作后输出当前工作日数量。
可以把每一天看成一个 0/1 值:
1表示工作日;0表示非工作日。
每次操作就是区间赋值,然后查询全局 1 的个数。
思路
用 ODT 维护连续相同状态的区间。
每次区间赋值时:
split(r+1)和split(l);- 遍历
[l,r]中原有段,先从答案中减去旧贡献; - 删除旧段;
- 插入新区间;
- 加上新区间的新贡献。
因为只需要维护全局答案 ans,不需要每次重新遍历整棵树。
代码
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define endl '\n'
struct node{
int l,r;
mutable int v;
node(int L,int R=0,int V=0):l(L),r(R),v(V){}
bool operator<(const node &x)const{return l<x.l;}
};
set<node>s;
int n,q,ans;
set<node>::iterator split(int pos){
auto it=s.lower_bound(node(pos));
if(it!=s.end()&&it->l==pos)return it;
--it;
int l=it->l,r=it->r,v=it->v;
s.erase(it);
s.insert(node(l,pos-1,v));
return s.insert(node(pos,r,v)).first;
}
void assign(int l,int r,int v){
auto itr=split(r+1),itl=split(l);
for(auto it=itl;it!=itr;it++)ans-=(it->r-it->l+1)*it->v;
s.erase(itl,itr);
s.insert(node(l,r,v));
ans+=(r-l+1)*v;
}
signed main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin>>n>>q;
ans=n;
s.insert(node(1,n,1));
s.insert(node(n+1,n+1,0));
while(q--){
int l,r,k;
cin>>l>>r>>k;
if(k==1)assign(l,r,0);
else assign(l,r,1);
cout<<ans<<endl;
}
return 0;
}
2. CF896C Willem, Chtholly and Seniorious
题意简述
维护一个长度为 n 的数列,支持四种操作:
- 区间加;
- 区间赋值;
- 查询区间第
k小; - 查询区间幂和取模。
这是 ODT 最经典的模板题之一。题目数据由随机种子生成,因此非常适合 ODT。
思路
ODT 维护连续相同值的段。
四个操作分别处理:
操作 1:区间加
auto itr=split(r+1),itl=split(l);
for(auto it=itl;it!=itr;it++)it->v+=x;
操作 2:区间赋值
auto itr=split(r+1),itl=split(l);
s.erase(itl,itr);
s.insert(node(l,r,x));
操作 3:区间第 k 小
把每一段的 (值,长度) 拿出来排序,再按长度扣掉 k。
操作 4:区间幂和
对每一段贡献:
长度 * v^x % y
代码
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define endl '\n'
const int P=1000000007;
int n,m,seed,vmax;
struct node{
int l,r;
mutable int v;
node(int L,int R=0,int V=0):l(L),r(R),v(V){}
bool operator<(const node &x)const{return l<x.l;}
};
set<node>s;
int rnd(){
int ret=seed;
seed=(seed*7+13)%P;
return ret;
}
set<node>::iterator split(int pos){
auto it=s.lower_bound(node(pos));
if(it!=s.end()&&it->l==pos)return it;
--it;
int l=it->l,r=it->r,v=it->v;
s.erase(it);
s.insert(node(l,pos-1,v));
return s.insert(node(pos,r,v)).first;
}
void add(int l,int r,int x){
auto itr=split(r+1),itl=split(l);
for(auto it=itl;it!=itr;it++)it->v+=x;
}
void assign(int l,int r,int x){
auto itr=split(r+1),itl=split(l);
s.erase(itl,itr);
s.insert(node(l,r,x));
}
int kth(int l,int r,int k){
vector<pair<int,int> >v;
auto itr=split(r+1),itl=split(l);
for(auto it=itl;it!=itr;it++)v.push_back({it->v,it->r-it->l+1});
sort(v.begin(),v.end());
for(auto p:v){
k-=p.second;
if(k<=0)return p.first;
}
return -1;
}
int ksm(int a,int b,int p){
if(p==1)return 0;
int ans=1%p;
a%=p;
while(b){
if(b&1)ans=ans*a%p;
a=a*a%p;
b>>=1;
}
return ans;
}
int query(int l,int r,int x,int y){
int ans=0;
auto itr=split(r+1),itl=split(l);
for(auto it=itl;it!=itr;it++){
int len=it->r-it->l+1;
ans=(ans+len%y*ksm(it->v,x,y)%y)%y;
}
return ans;
}
signed main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin>>n>>m>>seed>>vmax;
for(int i=1;i<=n;i++){
int x=(rnd()%vmax)+1;
s.insert(node(i,i,x));
}
s.insert(node(n+1,n+1,0));
while(m--){
int op=(rnd()%4)+1;
int 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<<query(l,r,x,y)<<endl;
}
return 0;
}
3. P2787 语文1(chin1)- 理理思维
题意简述
维护一个字符串,支持三种操作:
- 查询
[l,r]中某个字符出现次数; - 将
[l,r]全部赋值为某个字符; - 将
[l,r]按字母升序排序。
思路
用 ODT 维护连续相同字符段。
查询字符次数
遍历 [l,r] 中的所有段,只统计目标字符的长度。
区间赋值
删除旧段,插入新区间。
区间排序
先统计 [l,r] 中每个字符出现次数,然后删除旧段,最后按 a~z 重新插入若干段。
例如 [l,r] 中统计得到:
a: 3
b: 2
c: 4
就重新插入:
[l,l+2,'a'] [l+3,l+4,'b'] [l+5,l+8,'c']
代码
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define endl '\n'
const int N=5e4+5;
int n,m;
string str;
struct node{
int l,r;
mutable int v;
node(int L,int R=0,int V=0):l(L),r(R),v(V){}
bool operator<(const node &x)const{return l<x.l;}
};
set<node>s;
set<node>::iterator split(int pos){
auto it=s.lower_bound(node(pos));
if(it!=s.end()&&it->l==pos)return it;
--it;
int l=it->l,r=it->r,v=it->v;
s.erase(it);
s.insert(node(l,pos-1,v));
return s.insert(node(pos,r,v)).first;
}
void assign(int l,int r,int c){
auto itr=split(r+1),itl=split(l);
s.erase(itl,itr);
s.insert(node(l,r,c));
}
int ask(int l,int r,int c){
int ans=0;
auto itr=split(r+1),itl=split(l);
for(auto it=itl;it!=itr;it++){
if(it->v==c)ans+=it->r-it->l+1;
}
return ans;
}
void Sort(int l,int r){
int cnt[26]={0};
auto itr=split(r+1),itl=split(l);
for(auto it=itl;it!=itr;it++)cnt[it->v]+=it->r-it->l+1;
s.erase(itl,itr);
int now=l;
for(int i=0;i<26;i++){
if(cnt[i]){
s.insert(node(now,now+cnt[i]-1,i));
now+=cnt[i];
}
}
}
signed main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin>>n>>m>>str;
str=" "+str;
for(int i=1;i<=n;i++){
int j=i;
while(j+1<=n&&str[j+1]==str[i])j++;
s.insert(node(i,j,tolower(str[i])-'a'));
i=j;
}
s.insert(node(n+1,n+1,0));
while(m--){
int op,l,r;
char c;
cin>>op>>l>>r;
if(op==1){
cin>>c;
cout<<ask(l,r,tolower(c)-'a')<<endl;
}else if(op==2){
cin>>c;
assign(l,r,tolower(c)-'a');
}else{
Sort(l,r);
}
}
return 0;
}
4. CF817F MEX Queries
题意简述
维护一个整数集合,初始为空。支持三种操作:
- 把
[l,r]加入集合; - 把
[l,r]从集合中删除; - 翻转
[l,r]中每个数的存在状态。
每次操作后输出当前集合的 MEX,也就是最小的未出现正整数。
思路
这道题不维护 0/1 全部区间,而是只维护集合中已经存在的若干区间,也就是只存值为 1 的段。
例如集合:
{1,2,3,7,8}
维护成:
[1,3] [7,8]
加入区间 [l,r]
找到所有和 [l,r] 相交或相邻的区间,合并成一个新区间。
删除区间 [l,r]
找到所有和 [l,r] 相交的区间,把被删除部分切掉,剩下的左右两段保留。
翻转区间 [l,r]
翻转可以拆成两步:
- 找出
[l,r]中原本不存在的空隙; - 删除
[l,r]中原本存在的部分; - 把第一步找出的空隙加入集合。
查询 MEX
如果集合为空,答案是 1。
如果第一个区间不是从 1 开始,答案也是 1。
否则答案是第一个区间右端点 +1。
代码
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define endl '\n'
const int INF=4000000000000000000LL;
struct node{
int l,r;
bool operator<(const node &x)const{return l<x.l;}
};
set<node>s;
set<node>::iterator first(int l){
auto it=s.lower_bound({l,0});
if(it!=s.begin()){
--it;
if(it->r<l)++it;
}
return it;
}
void add(int l,int r){
auto it=s.lower_bound({l,0});
if(it!=s.begin()){
--it;
if(it->r<l-1)++it;
}
while(it!=s.end()&&it->l<=r+1){
l=min(l,it->l);
r=max(r,it->r);
it=s.erase(it);
}
s.insert({l,r});
}
void del(int l,int r){
auto it=first(l);
vector<node>tmp;
while(it!=s.end()&&it->l<=r){
if(it->r<l){
++it;
continue;
}
int L=it->l,R=it->r;
it=s.erase(it);
if(L<l)tmp.push_back({L,l-1});
if(R>r)tmp.push_back({r+1,R});
}
for(auto x:tmp)s.insert(x);
}
void rev(int l,int r){
vector<node>tmp;
int now=l;
auto it=first(l);
while(it!=s.end()&&it->l<=r){
if(it->r<l){
++it;
continue;
}
if(now<it->l)tmp.push_back({now,min(r,it->l-1)});
now=max(now,it->r+1);
if(now>r)break;
++it;
}
if(now<=r)tmp.push_back({now,r});
del(l,r);
for(auto x:tmp)add(x.l,x.r);
}
int mex(){
if(s.empty()||s.begin()->l>1)return 1;
return s.begin()->r+1;
}
signed main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);
int q;
cin>>q;
while(q--){
int op,l,r;
cin>>op>>l>>r;
if(op==1)add(l,r);
else if(op==2)del(l,r);
else rev(l,r);
cout<<mex()<<endl;
}
return 0;
}
5. CF444C DZY Loves Colors
题意简述
有一条长度为 n 的彩带,第 i 个位置初始颜色为 i,色彩度为 0。
支持两种操作:
- 把
[l,r]全部染成颜色x,如果某个位置原颜色为y,则该位置色彩度增加|x-y|; - 查询
[l,r]的色彩度总和。
思路
用 ODT 维护当前颜色段,用树状数组维护色彩度。
ODT 中每段表示:
[l,r,color]
树状数组维护的是每个位置累计色彩度,需要支持:
- 区间加;
- 区间求和。
所以用两个树状数组维护差分。
染色 [l,r] 时:
split(r+1)和split(l);- 遍历
[l,r]中每一段; - 假设旧颜色是
old,新颜色是x,那么这一段的每个位置都增加abs(old-x); - 对这一段做区间加;
- 删除旧颜色段;
- 插入
[l,r,x]。
代码
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define endl '\n'
const int N=1e5+5;
int n,m,c1[N],c2[N];
struct node{
int l,r;
mutable int v;
node(int L,int R=0,int V=0):l(L),r(R),v(V){}
bool operator<(const node &x)const{return l<x.l;}
};
set<node>s;
void add(int c[],int x,int v){
for(;x<=n;x+=x&-x)c[x]+=v;
}
int sum(int c[],int x){
int ans=0;
for(;x;x-=x&-x)ans+=c[x];
return ans;
}
void range_add(int l,int r,int v){
add(c1,l,v);add(c1,r+1,-v);
add(c2,l,v*(l-1));add(c2,r+1,-v*r);
}
int pre(int x){
return sum(c1,x)*x-sum(c2,x);
}
int query(int l,int r){
return pre(r)-pre(l-1);
}
set<node>::iterator split(int pos){
auto it=s.lower_bound(node(pos));
if(it!=s.end()&&it->l==pos)return it;
--it;
int l=it->l,r=it->r,v=it->v;
s.erase(it);
s.insert(node(l,pos-1,v));
return s.insert(node(pos,r,v)).first;
}
void paint(int l,int r,int x){
auto itr=split(r+1),itl=split(l);
for(auto it=itl;it!=itr;it++){
int d=it->v-x;
if(d<0)d=-d;
range_add(it->l,it->r,d);
}
s.erase(itl,itr);
s.insert(node(l,r,x));
}
signed main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin>>n>>m;
for(int i=1;i<=n;i++)s.insert(node(i,i,i));
s.insert(node(n+1,n+1,0));
while(m--){
int op,l,r,x;
cin>>op>>l>>r;
if(op==1){
cin>>x;
paint(l,r,x);
}else{
cout<<query(l,r)<<endl;
}
}
return 0;
}
四、ODT 常用模板总结
1. 标准结点
struct node{
int l,r;
mutable int v;
node(int L,int R=0,int V=0):l(L),r(R),v(V){}
bool operator<(const node &x)const{return l<x.l;}
};
set<node>s;
2. split 模板
set<node>::iterator split(int pos){
auto it=s.lower_bound(node(pos));
if(it!=s.end()&&it->l==pos)return it;
--it;
int l=it->l,r=it->r,v=it->v;
s.erase(it);
s.insert(node(l,pos-1,v));
return s.insert(node(pos,r,v)).first;
}
3. 区间赋值模板
void assign(int l,int r,int x){
auto itr=split(r+1),itl=split(l);
s.erase(itl,itr);
s.insert(node(l,r,x));
}
4. 区间加模板
void add(int l,int r,int x){
auto itr=split(r+1),itl=split(l);
for(auto it=itl;it!=itr;it++)it->v+=x;
}
5. 区间求和模板
int ask(int l,int r){
int ans=0;
auto itr=split(r+1),itl=split(l);
for(auto it=itl;it!=itr;it++){
ans+=(it->r-it->l+1)*it->v;
}
return ans;
}
6. 区间第 k 小模板
int kth(int l,int r,int k){
vector<pair<int,int> >v;
auto itr=split(r+1),itl=split(l);
for(auto it=itl;it!=itr;it++){
v.push_back({it->v,it->r-it->l+1});
}
sort(v.begin(),v.end());
for(auto p:v){
k-=p.second;
if(k<=0)return p.first;
}
return -1;
}
五、学习路线建议
第一阶段:掌握基础模板
先练:
- CF915E
- CF896C
目标:
- 熟悉
set维护区间; - 熟悉
split; - 熟悉区间赋值;
- 能独立写出 ODT 模板。
这一阶段最重要的是把 split 写熟。
第二阶段:理解“按段暴力处理”
再练:
- P2787
- CF817F
目标:
- 能根据题目设计每一段的信息;
- 会统计区间内部的段;
- 会对区间做删除、重建、合并;
- 能理解不是所有 ODT 都必须写成经典
node{l,r,v}。
第三阶段:ODT + 其他数据结构
最后练:
- CF444C
目标:
- 理解 ODT 只负责维护“当前颜色段”;
- 查询和修改的附加信息可以交给树状数组或线段树;
- 学会把 ODT 当作“找到发生变化的连续段”的工具。
六、易错点总结
1. 忘记插入哨兵
很多模板需要:
s.insert(node(n+1,n+1,0));
否则 split(r+1) 在 r=n 时可能出错。
2. split 顺序写反
推荐写法:
auto itr=split(r+1),itl=split(l);
先切右端,再切左端。
3. set 中直接修改排序关键字
不能修改 l,也不能修改会影响排序的字段。
可以修改 v,因为排序只和 l 有关,所以 v 需要写成 mutable。
4. erase 之后迭代器失效
错误写法:
for(auto it=itl;it!=itr;it++){
s.erase(it);
}
正确写法:
s.erase(itl,itr);
或者:
it=s.erase(it);
5. 误以为 ODT 复杂度稳定
ODT 很依赖“区间赋值能减少段数”这个性质。如果题目构造出大量交错段,复杂度可能退化。
七、最终记忆版
ODT 主要记住四句话:
- 用
set维护连续相同段; split(pos)保证pos是某段左端点;- 处理
[l,r]前先split(r+1),split(l); - 区间赋值是 ODT 的核心,能把很多碎段合并。
基础模板:
struct node{
int l,r;
mutable int v;
node(int L,int R=0,int V=0):l(L),r(R),v(V){}
bool operator<(const node &x)const{return l<x.l;}
};
set<node>s;
set<node>::iterator split(int pos){
auto it=s.lower_bound(node(pos));
if(it!=s.end()&&it->l==pos)return it;
--it;
int l=it->l,r=it->r,v=it->v;
s.erase(it);
s.insert(node(l,pos-1,v));
return s.insert(node(pos,r,v)).first;
}
处理区间:
auto itr=split(r+1),itl=split(l);
区间赋值:
s.erase(itl,itr);
s.insert(node(l,r,x));
判断是否适合 ODT:
看到大量区间赋值、区间推平、随机数据、颜色段维护,可以考虑 ODT;否则优先考虑线段树等稳定做法。

浙公网安备 33010602011771号