Chtholly Tree 学习笔记

前言

珂朵莉树 (Chtholly Tree) 是一种简单优美的数据结构,就像 Chtholly 一样可爱。暴力即优美。 适用于一些有区间赋值操作的序列操作题。

Chtholly Tree 的本质是把一个序列分成几个连续区间,每个区间内的元素的值相同,然后用一个 std::set 维护所有区间。

然后就可以通过一些神奇的操作,做到在数据随机的情况下 \(O(logn)\) 查询区间信息。时间复杂度我不会证QAQ。

当然也可以手写 std::set ,但是那样子还不如直接用平衡树做题。

事实上,Chtholly Tree 的适用范围很小,只能在某些保证数据随机且有区间赋值操作的题目中使用,别的情况下就是“你比暴力多个 log ”。而且一般来说出题人没有不卡 Chtholly Tree 的。虽然有时可以吸氧水过去。

竞赛一般不会考 Chtholly Tree ,但是多学一种数据结构也不是坏事嘛。尤其是 Chtholly 这么可爱。

零. 前置知识

你需要关于 std::set 的基础知识:

  1. set s; 建立一个类型为 type 的set。

  2. s.insert(x); 向 \(s\) 中插入一个值为 \(x\) 的元素。该函数的返回值为 pair<iterator,bool>,之后会用到这一返回值。

  3. s.erase(itl, itr); 删除 s 中的一段区间\([itl,itr)\),其中 \(itl\), \(itr\) 的类型为 set::iterator,也就是两个迭代器。

  4. s.lower_bound(x); 在 s 中二分查找大于等于 \(x\) 的元素,返回指向第一个大于等于 \(x\) 的元素所在的位置的迭代器。

一. 建树

用一个结构体表示每一个小区间。在结构体中记录三个值 \(l\) , \(r\), \(val\) ,分别表示这段区间的左端点、右端点和区间中每个元素的数值。

struct node{
	int l, r;
	mutable ll val;
	node(int L, int R=-1, ll V=0):l(L), r(R), val(V){}
	bool operator<(const node &oth)const{return this->l<oth.l;}
};
set<node> s;

inline void build()
{
	for (int i=1; i<=n; ++i)
    {
        a[i] = read();
        s.insert(node(i, i, a[i]));
    }
    s.insert(node(n+1, n+1, 0));
}

值得一提的地方:

  1. 为了保证区间有序,std::set 中的 node 是按照 \(l\) 来排序的。也可以理解为我们以 \(l\) 作为这个区间的代表。

  2. 由于 std::set 自身的原因,\(val\) 前必须有 mutable,以便支持区间修改等操作。

  3. 在插入所有元素后需要再插入一个虚拟区间,以保证之后在查找区间时不会出错。

这样我们就建好了一棵 Chtholly Tree。

但是这样就和原序列完全一致了,一共有 \(n\) 个小区间。所以我们需要一些操作来减少区间的数量。

二. 核心操作:split 和 assign

要想维持珂朵莉树的优秀时间复杂度,这两个操作必不可少。

1.split(index)

这个操作把 std::set 维护的区间从 \(index\) 分成两段,且不改变不包含下标 \(index\) 的区间。

步骤如下:

  1. 首先在已有的区间中查找 \(l=index\) 的区间,如果找到了就直接返回,否则进行下一步操作。

  2. 经过上一步操作,我们要找的 \(index\) 一定已经被包含在一个区间中,所以我们要把包含 \(index\) 的区间分成两个更小的区间。具体来说,我们找到包含 \(index\) 的区间,然后删除该区间 \([l,r]\) ,再在 std::set 中插入区间 \([l,index-1]\) 和区间 \([index,r]\)\(val\) 值当然都为原区间的 \(val\)

  3. \(\operatorname{split}\) 操作会增加 std::set 维护的区间数量,但是这对时间复杂度基本不影响。

  4. \(\operatorname{split}(index)\) 操作的返回值是一个指向以 \(index\)\(l\) 的区间的迭代器,理解为指针即可。利用了 std::set 的 insert 操作的返回值。

#define IT set<node>::iterator
  
IT split(int ind)
{
	IT it = s.lower_bound(node(ind));
	if(it != s.end()&&it->l == ind)return it;
	--it;
	int xl = it->l, xr = it->r;
	ll v = it->val;
	s.erase(it);
	s.insert(node(xl, ind-1, v));
	return s.insert(node(ind, xr, v)).first;
} 

以上就是 Chtholly Tree 的核心操作。

之后如果要对一段区间 \([l,r]\) 进行操作,只需要分离出区间 \([l,r]\),然后用最朴素的方法乱搞即可。

2.assign(x, y, z)

\(\operatorname{assign}(x, y, z)\):把一段区间 \([x,y]\) 的值全部赋成一个数 \(z\)

能使用 Chtholly Tree 的题目都会有这个操作。足够的 \(\operatorname{assign}\) 操作是 Chtholly Tree 时间复杂度的保障。

事实上 \(\operatorname{assign}(x,y,z)\) 操作很好实现,我们只需要分离出左端点为 \(x\) 的区间,再分离出左端点为 \(y+1\) 的区间,用一个元素值都为 \(z\) 值区间 \([x,y]\) 替换掉这两个区间中的所有区间即可。

void assign(int l ,int r, ll v)
{
	IT itr = split(r+1), itl = split(l);
	s.erase(itl, itr);
	s.insert(node(l, r, v));
}

值得注意的地方:

  1. \(\operatorname{split}\) 的顺序最好按照代码中的顺序,否则可能会有玄学错误。

事实上,Chtholly Tree 的基础操作只有以上的 \(\operatorname{split}\)\(\operatorname{assign}\)。只要掌握了这两个操作,任何能用 Chtholly Tree 求解的题目就都不难做了。

另外,以上的操作不会使 Chtholly Tree 维护的区间产生重复或遗漏的情况。

三. 一道最经典的题目:CF896C

CF896C Willem, Chtholly and Seniorious

题意:给定一个长度为 \(n\) 的序列 \(a\) ,一共有 \(m\) 个操作,包含以下四种:

  • \((1,l,r,x)\) : 给定一段区间 \([l, r]\) ,把这段区间内的每一个元素都加上 \(x\)

  • \((2,l,r,x)\) : 给定一段区间 \([l, r]\)把这段区间内的每一个元素都变成 \(x\)

  • \((3,l,r,x)\) : 给定一段区间 \([l, r]\) ,求这段区间内排名为 \(x\) 的元素。

  • \((1,l,r,x,y)\) : 给定一段区间 \([l, r]\) ,求这段区间内的所有元素的 \(x\) 次方和 \(\bmod\ y\) 的值,即求\(\sum_{i=l}^r({a_i}^x)\ \bmod\ y\)

  • $n \leq 10^5 $, $m \leq 10^5 $

保证数据随机生成。

以上标黑的字体就是本题可以使用 Chtholly Tree 的关键:区间赋值操作和数据随机生成。

接下来我们考虑如何用 Chtholly Tree 实现本题的四个操作。

首先可以发现操作2就是 \(\operatorname{assign}\) 操作,直接套用即可。

void assign(int l ,int r, ll v)
{
	IT itr = split(r+1), itl = split(l);
	s.erase(itl, itr);
	s.insert(node(l, r, v));
}

接下来考虑操作1。我们可以分离出区间 \([l,r]\) ,然后暴力把这段区间内的每一个结点的 \(val\) 都加上 \(x\)

这样就行了。

void add(int l, int r, ll v)
{
	IT itr = split(r+1), itl = split(l);
	for(;itl!=itr;++itl)
		itl->val += v;
}

就是这么暴力,所以 Chtholly Tree 才优美。

关于时间复杂度的问题,之后会再做讨论。

然后考虑操作3。我们先把区间 \([l,r]\) 中的所有结点暴力取出来,放进一个 std::vector 里,按照 \(val\) 排序,然后暴力枚举 vector 中的元素,每次记录当前已经枚举过的元素的数量,直到找到第 \(x\) 大的元素即可返回。

注意 Chtholly Tree 的结点中存的是一段区间,记录当前枚举过元素的数量时要加上这段区间的长度。

ll krank(int l ,int r, ll k)
{
	vp.clear();
	IT itr = split(r+1), itl = split(l);
	for(;itl!=itr;++itl)
		vp.push_back(make_pair(itl->val, itl->r-itl->l+1));
	sort(vp.begin(), vp.end());
	for(vector<pair<ll, int> >::iterator it = vp.begin();it!=vp.end();++it)
	{
		k -= it->second;
		if(k<=0) return it->first;
	}
	return -1ll;
}

最后是操作4。还是暴力枚举区间中的结点,然后快速幂计算每个元素 \(x\) 次方和即可。

还是要注意Chtholly Tree 的结点中存的是一段区间,所以每一个结点对答案的贡献要乘上区间长度,另外注意取模。

ll power(ll x, ll p, ll mod)
{
	ll res = 1, base = x%mod;
	while(p)
	{
		if(p&1)res = res*base%mod;
		base = base*base%mod;
		p>>=1;
	}
	return res%mod;
}
ll sum(int l, int r, ll p, ll mod)
{
	IT itr = split(r+1), itl = split(l);
	ll res = 0;
	for(;itl!=itr;++itl)
		res = 1ll*(1ll*res+1ll*(itl->r-itl->l+1)*power(itl->val, (ll)p, (ll)mod))%mod;
	return res;
}

就这样,我们以近乎纯暴力的解法完成了这道题的所有操作。

时间复杂度可以感性理解一下:足够随机的 \(\operatorname{assign}\) 操作保证了 std::set 中的结点数量不会太多,所以每次区间操作都是跑不满 \(n\) 的。单次操作(不算快速幂)的期望时间复杂度应该是在 \(O(logn)\) 左右,足以通过本题。

完整代码如下:

#include<cstdio>
#include<algorithm>
#include<iostream>
#include<set>
#include<vector>
#include<cstring>
#define IT set<node>::iterator
using namespace std;
typedef long long ll;
const int MAXN = 100100;
const int MOD7 = 1e9 + 7;
const int MOD9 = 1e9 + 9;
struct node{
	int l, r;
	mutable ll val;
	node(int L, int R=-1, ll V=0):l(L), r(R), val(V){}
	bool operator<(const node &oth)const{return this->l<oth.l;}
};
set<node> s;
vector<pair<ll, int> > vp;
IT split(int ind)
{
	IT it = s.lower_bound(node(ind));
	if(it != s.end()&&it->l == ind)return it;
	--it;
	int xl = it->l, xr = it->r;
	ll v = it->val;
	s.erase(it);
	s.insert(node(xl, ind-1, v));
	return s.insert(node(ind, xr, v)).first;
}
void assign(int l ,int r, ll v)
{
	IT itr = split(r+1), itl = split(l);
	s.erase(itl, itr);
	s.insert(node(l, r, v));
}
void add(int l, int r, ll v)
{
	IT itr = split(r+1), itl = split(l);
	for(;itl!=itr;++itl)
		itl->val += v;
}
ll krank(int l ,int r, ll k)
{
	vp.clear();
	IT itr = split(r+1), itl = split(l);
	for(;itl!=itr;++itl)
		vp.push_back(make_pair(itl->val, itl->r-itl->l+1));
	sort(vp.begin(), vp.end());
	for(vector<pair<ll, int> >::iterator it = vp.begin();it!=vp.end();++it)
	{
		k -= it->second;
		if(k<=0)return it->first;
	}
	return -1ll;
}
ll power(ll x, ll p, ll mod)
{
	ll res = 1, base = x%mod;
	while(p)
	{
		if(p&1)res = res*base%mod;
		base = base*base%mod;
		p>>=1;
	}
	return res%mod;
}
ll sum(int l, int r, ll p, ll mod)
{
	IT itr = split(r+1), itl = split(l);
	ll res = 0;
	for(;itl!=itr;++itl)
		res = 1ll*(res+1ll*(itl->r-itl->l+1)*power(itl->val, (ll)p, (ll)mod))%mod;
	return res;
}
ll n,m,seed,vmax,a[MAXN];
ll rnd()
{
    ll ret = seed;
    seed = (seed * 7 + 13) % MOD7;
    return ret;
}

int main()
{
    scanf("%d %d %lld %lld",&n,&m,&seed,&vmax);
    for (int i=1; i<=n; ++i)
    {
        a[i] = (rnd() % vmax) + 1;
        s.insert(node(i,i,a[i]));
    }
    s.insert(node(n+1, n+1, 0));
    for (int i =1; i <= m; ++i)
    {
        int op = int(rnd() % 4) + 1;
        int l = int(rnd() % n) + 1;
        int r = int(rnd() % n) + 1;
        if (l > r)
            std::swap(l,r);
        int x, y;
        if (op == 3)
            x = int(rnd() % (r-l+1)) + 1;
        else
            x = int(rnd() % vmax) +1;
        if (op == 4)
            y = int(rnd() % vmax) + 1;
        if (op == 1)
            add(l, r, ll(x));
        else if (op == 2)
            assign(l, r, ll(x));
        else if (op == 3)
            printf("%lld\n",krank(l, r, ll(x)));
        else
            printf("%lld\n",sum(l, r, ll(x), ll(y)));
    }
    return 0;
}

四.其他题目(随时更新)

另外还有一道可以用 Chtholly Tree 吸氧做的题:P2146 [NOI2015]软件包管理器

正解是重链剖分+线段树,但是用 Chtholly Tree 吸氧也能过。

posted @ 2022-03-26 10:14  Aphrosia  阅读(70)  评论(0编辑  收藏  举报