珂朵莉树(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?

适合:

  1. 有大量区间赋值;
  2. 数据随机;
  3. 区间段数量不会太多;
  4. 查询可以对每个连续段暴力处理;
  5. 题目本身有“颜色段”“相同值连续段”的性质。

不适合:

  1. 没有区间赋值;
  2. 只有区间加、区间查询;
  3. 数据可以恶意构造;
  4. 需要严格保证复杂度;
  5. 一次查询要求处理的段数可能达到 O(n) 且无法被区间赋值压缩。

一句话总结:

ODT 是“维护连续相同段 + 区间推平”的暴力数据结构。看到大量区间赋值时可以优先考虑;否则优先想线段树、树状数组、平衡树、分块等更稳定的做法。


三、逐题题解与代码


1. CF915E Physical Education Lessons

题意简述

1~n 天,初始都是工作日。每次操作把区间 [l,r] 变成非工作日或者工作日,操作后输出当前工作日数量。

可以把每一天看成一个 0/1 值:

  • 1 表示工作日;
  • 0 表示非工作日。

每次操作就是区间赋值,然后查询全局 1 的个数。


思路

用 ODT 维护连续相同状态的区间。

每次区间赋值时:

  1. split(r+1)split(l)
  2. 遍历 [l,r] 中原有段,先从答案中减去旧贡献;
  3. 删除旧段;
  4. 插入新区间;
  5. 加上新区间的新贡献。

因为只需要维护全局答案 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 的数列,支持四种操作:

  1. 区间加;
  2. 区间赋值;
  3. 查询区间第 k 小;
  4. 查询区间幂和取模。

这是 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)- 理理思维

题意简述

维护一个字符串,支持三种操作:

  1. 查询 [l,r] 中某个字符出现次数;
  2. [l,r] 全部赋值为某个字符;
  3. [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

题意简述

维护一个整数集合,初始为空。支持三种操作:

  1. [l,r] 加入集合;
  2. [l,r] 从集合中删除;
  3. 翻转 [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]

翻转可以拆成两步:

  1. 找出 [l,r] 中原本不存在的空隙;
  2. 删除 [l,r] 中原本存在的部分;
  3. 把第一步找出的空隙加入集合。

查询 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

支持两种操作:

  1. [l,r] 全部染成颜色 x,如果某个位置原颜色为 y,则该位置色彩度增加 |x-y|
  2. 查询 [l,r] 的色彩度总和。

思路

用 ODT 维护当前颜色段,用树状数组维护色彩度。

ODT 中每段表示:

[l,r,color]

树状数组维护的是每个位置累计色彩度,需要支持:

  • 区间加;
  • 区间求和。

所以用两个树状数组维护差分。

染色 [l,r] 时:

  1. split(r+1)split(l)
  2. 遍历 [l,r] 中每一段;
  3. 假设旧颜色是 old,新颜色是 x,那么这一段的每个位置都增加 abs(old-x)
  4. 对这一段做区间加;
  5. 删除旧颜色段;
  6. 插入 [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;
}

五、学习路线建议

第一阶段:掌握基础模板

先练:

  1. CF915E
  2. CF896C

目标:

  • 熟悉 set 维护区间;
  • 熟悉 split
  • 熟悉区间赋值;
  • 能独立写出 ODT 模板。

这一阶段最重要的是把 split 写熟。


第二阶段:理解“按段暴力处理”

再练:

  1. P2787
  2. CF817F

目标:

  • 能根据题目设计每一段的信息;
  • 会统计区间内部的段;
  • 会对区间做删除、重建、合并;
  • 能理解不是所有 ODT 都必须写成经典 node{l,r,v}

第三阶段:ODT + 其他数据结构

最后练:

  1. 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 主要记住四句话:

  1. set 维护连续相同段;
  2. split(pos) 保证 pos 是某段左端点;
  3. 处理 [l,r] 前先 split(r+1),split(l)
  4. 区间赋值是 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;否则优先考虑线段树等稳定做法。

posted @ 2026-05-01 15:23  艾拉别哭  阅读(34)  评论(0)    收藏  举报