珂朵莉树(odt)学习笔记

代码中可能带了一些个人缺省源里的东西。

前置知识

std::set

简述

珂朵莉树(Chtholly Tree),又名老司机树(Old Driver Tree, ODT),是一种非常暴力的维护序列信息的数据结构。

其通过维护值相同的连续段来保证效率,在特殊构造的数据下会退化为普通暴力算法。

其起源于CF896C

引入

CF896C

你需要维护一个序列,并支持如下几种操作:

  1. 给区间 \([l,r]\) 内的所有数字加上 \(x\)
  2. 将区间 \([l,r]\) 内的所有数字赋值为 \(x\)
  3. 求区间 \([l,r]\) 内所有数字中第 \(x\) 小的数字(重复数字多次计算)。
  4. 求 \(\sum \limits_{i=l}^r a^{x}_i \bmod y\) 的值。

题目保证数据随机。

前三个操作使用主席树可以解决,但并不能解决第四个。于是就有了珂朵莉树。

基本操作

定义

珂朵莉树会把序列分为若干连续的段。

struct node
{
	int l,r;
	mutable long long val;
	//mutable修饰之后,就可以直接在set中修改该变量的值而不是取出修改后再重新插入
	node (int L,int R=-1,long long v=0)
	{
		l=L,r=R,val=v;
	}
	bool operator < (const node&a) const
	{
		return l<a.l;
	}
};
sd set<node> odt;

分割(split)

给出区间 \([l,r]\)\(pos\in [l,r]\),将区间分割为 \([l,pos-1]\)\([pos,r]\)

实现过程:

  1. 找到含有 \(pos\) 位置的区间。
  2. 如果 \(pos=l\),则无需分割。否则删除原区间,插入两个新区间。

复杂度单 \(\log\)

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,val=it->val;
	odt.erase(it);//删除原来的区间
	odt.insert(node(l,pos-1,val));
	return odt.insert(node(pos,r,val)).X;//插入两个新区间
	//这里的返回值是后半段区间对应的迭代器
}

分割操作后保证两个区间无交,这是保证正确性的前提。

合并(assign)

给出区间 \([l,r]\) 以及 \(val\),将 \([l,r]\) 覆盖为 \(val\),即将 \([l,r]\) 合并成一个区间。

实现过程:

  1. 找到 \(l,r\) 所在区间。分裂掉。
  2. 删除 \([l,r]\) 原本的若干个小区间。
  3. 插入一个大区间。

注意,分裂时应先分裂右端点再分裂左端点,否则可能导致 RE。

原因?

因为如果先 split(l),返回的迭代器会位于所对应的区间以 \(l\) 为左端点,此时如果 \(r\) 也在这个节点内,就会导致 split(l) 返回的迭代器被 erase 掉,导致 RE。

void assign(int l,int r,int val)
{
	auto itr=split(r+1),itl=split(l);
	odt.erase(itl,itr);//删除[itl,itr)区间内的所有元素(注意左闭右开区间)
	odt.insert(node(l,r,val));//将原来的诸多小区间用一个大区间代替
}

因为odt树的本质是暴力处理每个段,所以合并操作是用来保证复杂度的。

其他操作

所有区间操作都可以套这样的一个模板:

  1. 先 split 右端点,再 split 左端点,获得两个端点(左闭右开)的迭代器。
  2. 对两个端点之间的所有区间暴力更改。

代码差不多长这样:

void update(int l,int r)
{
	auto itr=split(r+1),itl=split(l);
	for(auto it=itl;it!=itr;it++) ;//do sth.
}

比如区间加:

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;
}

比如区间第 \(k\) 小,暴力取出所有数排序:

long long kth(int l,int r,int k)
{
	sd 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.Y;
		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+ksm(*it.val,x,y)*((*it.r)-(*it.l)+1))%y;
	return ans;
}

效率

ODT 的做法看起来非常暴力,但是在随机数据的情况下,它的表现其实非常优秀。

可以证明,一个有 \(n\) 个数的序列,在经过 \(k\) 次随机的区间赋值操作后,期望段数大约在 \(O(\frac{n}{k}​)\) 的级别。

证明可以点击 这里 查看,这里不再展开。

在模板题中,因为数据随机,有 \(\dfrac{1}{4}\)​ 的操作均为随机的区间赋值操作,因此 ODT 的实际段数很低,效率当然不错。

参考资料

珂朵莉树学习笔记 - 洛谷专栏

ODT 学习笔记 - 博客园

posted @ 2025-04-06 17:18  _E_M_T  阅读(75)  评论(0)    收藏  举报