珂朵莉树小结

珂朵莉是世界上最幸福的女孩,不接受任何反驳

学习珂学?请左转此处


1、珂朵莉树简介

珂朵莉树是由某毒瘤在2017年的一场CF比赛中提出的数据结构,原名老司机树(Old Driver Tree,ODT)。由于第一个以珂朵莉树为正解的题目的背景以《末日时在做什么?有没有空?可以来拯救吗?》中的角色——珂朵莉为主角,所以这个数据结构又被称为珂朵莉树(Chtholly Tree)她主要处理区间修改、查询问题,在数据随机的前提下有着优秀的复杂度。

说到区间维护,我就想起明年年初 我们都会想到线段树、树状数组、splay、分块、莫队等数据结构,这些数据结构貌似是无所不能的(毕竟一个不行用两个),无论区间加减乘除、平方开方都能搞定。但如果我们有这么一题:(CF中的起源题)

【CF896C】 Willem, Chtholly and Seniorious

题意:写个数据结构维护长度为\(n\)的序列(\(n≤10^5\)),实现区间赋值、区间加上一个值、求区间第k小、求区间每个数x次方之和模y的值的操作,数据随机生成

我们发现本题的第4个操作涉及每个数字的相关操作,那么线段树、树状数组、分块甚至splay是肯定行不通的,莫队虽然可以维护4操作,但复杂度同样难以承受(要对每个入队出队的数取快速幂)。我们需要一个更为高效的数据结构来维护这些操作,她就是珂朵莉树。

2、珂朵莉树的实现

珂朵莉树基于std::set,我们定义节点为一段连续的值相同的区间,代码如下:

struct node {
    int l, r;
    mutable ll val;
    int operator < (const node &a) const{
        return l < a.l;
    }
    node(int L, int R, ll Val) : l(L), r(R), val(Val) {}
    node(int L) : l(L) {}
};

其中函数node是构造函数,可以理解为定义变量时的初始化函数,具体用法请看下文代码。

mutable修饰符意为“可变的”,这样我们就可以在本不支持直接修改的set中修改它的值辣

\(l\)\(r\)分别代表当前区间的左、右端点,\(val\)表示当前区间每个数字的值。

但是我们查询的时候不能保证查询的区间端点一定与这些节点的端点重合,如果采用分块思想(边角暴力)肯定行不通(会退化成暴力),所以我们要按需把节点分裂一下:

#define sit set<node>::iterator
sit Split(int pos) {
    sit it = s.lower_bound(node(pos));
    if (it != s.end() && it->l == pos) return it;
    --it;
    int l = it->l, r = it->r;
    int val = it->val;
    s.erase(it);
    s.insert(node(l, pos - 1, val));
    return s.insert(node(pos, r, val)).first;
}

这段代码所做的事就是把\(pos\)所在的节点(左右端点分别为\(l\)\(r\))分成 \([l, pos)\)\([pos, r]\) 两块,然后返回后者。返回值所用的是set::insert的返回值,这是一个pair对象(熟悉map的同学应该能熟练运用),它的first是一个迭代器,即插入的东西在set中的迭代器。

这段代码很简单,应该不难打(bei)出(song)

接下来的addkthsum等操作都依赖于Split操作,具体做法就是把区间左右端点所在的节点分裂,使得修改&查询区间能完全对应起来,之后就是暴力地去搞啦qwq

参考代码:

void Add(int l, int r, ll val) {//暴力枚举
    sit it2 = Split(r + 1), it1 = Split(l);
    for (sit it = it1; it != it2; ++it) it->val += val;
}
ll Kth(int l, int r, int k) {//暴力排序
    sit it2 = Split(r + 1), it1 = Split(l);
    vector< pair<ll, int> > aa;
    aa.clear();
    for (sit it = it1; it != it2; ++it) aa.push_back(pair<ll, int>(it->val, it->r - it->l + 1));
    sort(aa.begin(), aa.end());
    for (int i = 0; i < aa.size(); ++i) {
        k -= aa[i].second;
        if (k <= 0) return aa[i].first;
    }
}
ll qpow(ll a, int x, ll y) {
    ll b = 1ll;
    a %= y;//不加这句话WA
    while (x) {
        if (x & 1) b = (b * a) % y;
        a = (a * a) % y;
        x >>= 1;
    }
    return b;
}
ll Query(int l, int r, int x, ll y) {//暴力枚举+快速幂
    sit it2 = Split(r + 1), it1 = Split(l);
    ll res = 0;
    for (sit it = it1; it != it2; ++it) res = (res + (it->r - it->l + 1) * qpow(it->val, x, y)) % y;
    return res;
}

FAQ:

\(1.Q:\)为什么要Split(r + 1)
\(A:\)便于取出 \([l, r + 1)\) 的部分,即 \([l,r]\)

\(2.Q:\)为什么要先Split(r + 1),再Split(l)
\(A:\)因为如果先Split(l),返回的迭代器会位于所对应的区间以\(l\)为左端点,此时如果\(r\)也在这个节点内,就会导致Split(l)返回的迭代器被erase掉,导致RE。

\(3.Q:\)这些操作里面全是Split,复杂度理论上会退化成暴力(不断分裂直到无法再分),怎么让它不退化?

这便涉及到珂朵莉树不可或缺的操作:\(Assign\)

Assign操作也很简单,Split之后暴力删点,然后加上一个代表当前区间的点即可。代码如下:

void Assign(int l, int r, int val) {
	sit it2 = Split(r + 1), it1 = Split(l);
	s.erase(it1, it2);
	s.insert(node(l, r, val));
}

不难看出,这个操作把多个节点减成一个,但这样就能使复杂度优化了吗?

\(Assign\)的珂朵莉树究竟有多快

由于题目数据随机,所以Assign操作很多,约占所有操作的\(\frac{1}{4}\)。其他所有操作都有两个Split,我们可以用一下程序模拟一下珂朵莉树在数据随机情况下节点的个数:

#include <cstdio>
#include <cstring>
#include <cmath>
#include <algorithm>
#include <set> 
#include <vector>
#include <cstdlib>
#include <ctime>
using namespace std;
struct node {
	int l, r;
	mutable int val;
	int operator < (const node &a) const {
		return l < a.l;
	}
	node(int L, int R = -1, int Val = 0) : l(L), r(R), val(Val) {} 
};
set<node> s;
#define sit set<node>::iterator
sit Split(int pos) {
	sit it = s.lower_bound(node(pos));
	if (it != s.end() && it->l == pos) return it;
	--it;
	int l = it->l, r = it->r, val = it->val;
	s.erase(it);
	s.insert(node(l, pos - 1, val));
	return s.insert(node(pos, r, val)).first;
}
void Assign(int l, int r, int val) {
	sit it2 = Split(r + 1), it1 = Split(l);
	s.erase(it1, it2);
	s.insert(node(l, r, val));
}
int main() {
	int n;
	scanf("%d", &n);
	for (int i = 1; i <= n + 1; ++i) s.insert(node(i, i, 0));
	srand((unsigned)time(0));
	srand(rand());
	for (int t = 1; t <= n; ++t) {
		int a = rand() * rand() % n + 1, b = rand() * rand() % n + 1;
		if (a > b) swap(a, b);
		if (rand() % 4 == 0) {
			Assign(a, b, 0);
		}
		else Split(a), Split(b + 1);
	}
	printf("%d", s.size());
	return 0;
}

本人机子上实验数据如下(\(n\)表示序列长度,\(f(n)\)表示节点个数)

\(n\) \(f(n)\)
10 7
100 24
1000 33
10000 47
100000 67
1000000 95

可见,加了Assign的珂朵莉树在随机数据下跑得飞快,节点数达到了\(\log\)级别。也就是说,珂朵莉树的高效是由随机分配的Assign保证的。如果一个题目没有区间赋值操作或者有数据点没有赋值操作,或者数据很不随机,请不要把珂朵莉树当正解打。

珂朵莉树目前的应用还很狭窄,各位dalao还是用她来骗分吧qwq

关于详尽的复杂度证明,我数学太差证不出,这里贴一下@Blaze dalao的证明:


例题代码:

#include <cstdio>
#include <cstring>
#include <cmath>
#include <algorithm>
#include <set>
#include <vector>
#include <utility>
using namespace std;
#define ll long long

struct node {
	int l, r;
	mutable ll val;
	int operator < (const node &a) const{
		return l < a.l;
	}
	node(int L, int R, ll Val) : l(L), r(R), val(Val) {}
	node(int L) : l(L) {}
};

set<node> s;
#define sit set<node>::iterator
sit Split(int pos) {
	sit it = s.lower_bound(node(pos));
	if (it != s.end() && it->l == pos) return it;
	--it;
	int l = it->l, r = it->r;
	ll val = it->val;
	s.erase(it);
	s.insert(node(l, pos - 1, val));
	return s.insert(node(pos, r, val)).first;
}
sit Assign(int l, int r, ll val) {
	sit it2 = Split(r + 1), it1 = Split(l);
	s.erase(it1, it2);
	s.insert(node(l, r, val));
}
void Add(int l, int r, ll val) {
	sit it2 = Split(r + 1), it1 = Split(l);
	for (sit it = it1; it != it2; ++it) it->val += val;
}
ll Kth(int l, int r, int k) {
	sit it2 = Split(r + 1), it1 = Split(l);
	vector< pair<ll, int> > aa;
	aa.clear();
	for (sit it = it1; it != it2; ++it) aa.push_back(pair<ll, int>(it->val, it->r - it->l + 1));
	sort(aa.begin(), aa.end());
	for (int i = 0; i < aa.size(); ++i) {
		k -= aa[i].second;
		if (k <= 0) return aa[i].first;
	}
}
ll qpow(ll a, int x, ll y) {
	ll b = 1ll;
	a %= y;
	while (x) {
		if (x & 1) b = (b * a) % y;
		a = (a * a) % y;
		x >>= 1;
	}
	return b;
}
ll Query(int l, int r, int x, ll y) {
	sit it2 = Split(r + 1), it1 = Split(l);
	ll res = 0;
	for (sit it = it1; it != it2; ++it) res = (res + (it->r - it->l + 1) * qpow(it->val, x, y)) % y;
	return res;
}
int n, m, vmax;
ll seed;
int rnd() {
	int ret = (int)seed;
	seed = (seed * 7 + 13) % 1000000007;
	return ret; 
}
int main() {
	scanf("%d%d%lld%d", &n, &m, &seed, &vmax);
	for (int i = 1; i <= n; ++i) {
		int a = rnd() % vmax + 1;
		s.insert(node(i, i, (ll)a));
	} 
	s.insert(node(n + 1, n + 1, 0));
	for (int i = 1; i <= m; ++i) {
		int l, r, x, y;
		int op = rnd() % 4 + 1;
		l = rnd() % n + 1, r = rnd() % n + 1;
		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, (ll)x);
		else if (op == 2) Assign(l, r, (ll)x);
		else if (op == 3) printf("%lld\n", Kth(l, r, x));
		else if (op == 4) printf("%lld\n", Query(l, r, x, (ll)y));
	}
	return 0;
}

题目:

珂朵莉树的经典题目大都非正解(且正解大多是线段树),不过在有的题目中吊打正解

不过有些题目做起来还是挺有意思的qwq

1.【Luogu P2787】语文1(chin1)- 理理思维

标算线段树(貌似?)

不过珂朵莉树随随便便跑进最优解,这才是重点。

2.【CF915E】Physical Education Lessons

同上,珂朵莉树甩线段树几条街

3.【Luogu P2572】[SCOI2010] 序列操作

数据不纯随机,但珂朵莉树也能无压力跑过~~

4.【Luogu P4344】[SHOI2015] 脑洞治疗仪

毒瘤题目,治疗脑洞操作的时候注意题面的规则,不然RE65或75。其余没什么难度。

5.【Luogu P2146】[NOI2015] 软件包管理器

树剖+珂朵莉树+O2,其实和线段树一样的做法qwq

完结撒花✿✿ヽ(°▽°)ノ✿

posted @ 2018-12-27 10:16  WAMonster  阅读(8005)  评论(9编辑  收藏  举报