浅论 珂朵莉树/ODT 复习

引入

珂朵莉呜呜呜——

珂朵莉树是一个神奇的数据结构,她起源于 CF896C

嗯对,珂朵莉树是一个十分年轻而可爱的数据结构。虽然叫珂朵莉树,但她其实是一种线性数据结构。

为什么叫珂朵莉树呢?
大概率是因为 发明出珂朵莉树的那位 真的很喜欢珂朵莉吧

\(\small \texttt{准备开始学习珂朵莉❤} \sout{\texttt{\scriptsize 树}}\)

wait a minute, 这里有一些你必须会的前置知识要讲...

前置知识

1. 指针前置知识

指针,或称迭代器,顾名思义,是表示一个指向某一个东西的东西(?)

(其实可以当做数组下标用啦)

//在 C++11 及以上版本中,可以使用 auto 作为关键字存储任意类型的数据,因此常被用作通用指针。
vector<int> v;//以 vector 为例
v.push_back(4);
v.push_back(2);
v.push_back(1);
v.push_back(3);
auto it=v.begin();
cout<<*it<<endl; //输出:4
it++; //注:指针不支持 +1 或 -1 操作,但支持自增自减,可以看做数组下标的加减。
cout<<*it<<endl; //输出:2

对于结构体:

struct Node{
	int x,y;
};
vector<Node> v;
v.push_back({1,2});
v.push_back({1717,406});
v.push_back({233,233});
auto it=v.begin();
cout<<(*it.x)<<endl; //输出 1
cout<<(it->x)<<endl; //输出 1,与上面完全等价
//这种形式更形象:-> 就是一个箭头,此处 "it->x" 可以看做 "it 指向的那个 x 的值"。

2. set 前置知识

std::set 是一个非常好的数据类型!一般用于维护不可重集合。

它是一个 stl 标准模板库中的数据类型,其底层逻辑是 红黑树 这个高级平衡树算法,因此时间复杂度十分优秀!

下文要用到的一些内置函数:

using namespace std;

set<int> s; //定义一个整数类型集合,空间大小和 vector 一样是动态的。
//set 维护的内容需要按照从小到大的顺序自动排序,因此如果套用结构体记得重载运算符。

int x;
pair<auto,bool> it=s.insert(x); //插入一个 x,若已有该元素则认为插入失败,时间复杂度 O(log n)。
//返回一个二元组,第一个为插入元素(或相同元素)的迭代器(指针),第二个为布尔类型,表示插入是否成功。

s.erase(x); //删除 s 中所有值为 x 的元素,要求该值必须存在。时间复杂度 O(log n)
auto it=s.lower_bound(x) //返回集合中大于等于 x 的最小值,时间复杂度 O(log n)

auto a=s.begin() //返回 s 的起始(最小值)迭代器,或称指针。
auto b=s.end() //返回 s 的终止迭代器(指针),这个指针指向的是最后一个未越界的值。
s.erase(a); //O(log n) 删除迭代器为 a 的元素,需要保证 a 有效。
s.erase(a,b); //O(log n) 删除迭代器在 a ~ b 范围内的所有元素

需要注意的是,std::set 是会自动去重的集合,如果不想让他自动去重可以使用 std::multiset

还有,std::set 维护的值是不可以更改的。如果要更改有两种办法:

  1. 结构体定义时在类型前加入 mutable 关键字,表示可修。
  2. 直接删掉旧值,重新加入新值。

下面是源题:

CF896C Willem, Chtholly and Seniorious

题目描述

——威廉……

——怎么了?

——好像瑟尼欧里斯出问题了……

——我去看看……

瑟尼欧里斯是通过特定顺序连接特殊护符制成的。

经过了 500 多年,这个圣剑现在状况不太好,所以威廉决定彻底检查它。

瑟尼欧里斯有 \(n\) 片护符。威廉将它们排成一行,第 \(i\) 片护符是一个整数 \(a _ i\)

为了维护它,威廉需要执行 \(m\) 次操作。

有四种操作类型:

  • \(1\ l\ r\ x\) :对于 \(i\) 满足 \(l \le i \le r\),将 \(a _ i + x\) 赋值给 \(a _ i\)
  • \(2\ l\ r\ x\) :对于 \(i\) 满足 \(l \le i \le r\),将 \(x\) 赋值给 \(a _ i\)
  • \(3\ l\ r\ x\) :输出范围 \([l, r]\) 中第 \(x\) 小的数字,即在所有满足 \(l \le i \le r\)\(a _ i\) 排序后第 \(x\) 小的数字。保证 \(1 \le x \le r - l + 1\)
  • \(4\ l\ r\ x\ y\) :输出范围 \([l, r]\) 中所有 \(a _ i\)\(x\) 次幂之和模 \(y\),即 \(\left( \sum _ {i = l} ^ r {a _ i} ^ x \right) \bmod y\)

输入格式

第一行包含四个整数 \(n, m, seed, v _ {max}\)\(1 \le n, m \le 10 ^ 5\)\(0 \le seed < 10 ^ 9 + 7\)\(1 \le v _ {max} \le 10 ^ 9\))。

初始值和操作通过如下伪代码生成:

def rnd():

    ret = seed
    seed = (seed * 7 + 13) mod 1000000007
    return ret

for i = 1 to n:

    a[i] = (rnd() mod vmax) + 1

for i = 1 to m:

    op = (rnd() mod 4) + 1
    l = (rnd() mod n) + 1
    r = (rnd() mod n) + 1

    if (l > r): 
         swap(l, r)

    if (op == 3):
        x = (rnd() mod (r - l + 1)) + 1
    else:
        x = (rnd() mod vmax) + 1

    if (op == 4):
        y = (rnd() mod vmax) + 1

这里的 \(op\) 是题目中提到的操作类型。

输出格式

对于每个类型为 \(3\)\(4\) 的操作,输出答案。


题目分析 & 正文

很好现在你已经了解了所有情景,现在你可以自己发明珂朵莉树了呢!%%%
终于可以开始 珂朵莉大学习 珂朵莉树大学习了!

实际上,单看前三个操作就会发现这其实是一道平衡树板子题,但是加上了第四种就变成了一种神秘的闻所未闻的不知可不可做题……

那么就有人发明了珂朵莉树!

然后珂朵莉是用来维护什么的呢?就像她的另一个名字“颜色端均摊”一样,她的标志在于有一个 区间推平操作。即把原区间内的一段子区间全部赋值为同一个值。

下文中我们把这道题中的数值看做颜色,因为区间推平很像一个 染色操作,不是吗?

首先考虑暴力时的做法,就是一个一个点地存,一个一个点地修改,然后我们会发现这时候有一些连续的、颜色(数值)也相同的点,这些点要重复算很多次,真的很浪费空间时间,那么怎么把这些没用的东西 ban 掉呢?

定义

可以想到,记录连续颜色段为一个 三元组 \((l,r,val)\) 分别表示 左端点、右端点 和 区间颜色类型(数值),那么一段连续的数值就可以被压缩为一个段。

好的现在我们有了一个由 压缩包.zip 组成的序列!

然后我们这些压缩包存到 set 里面,按照左端点为关键字排序,很显然不会有任何重叠(一个点不可能是同时两种颜色啊 除非薛定谔的点),那么它们就自己老老实实地排好了顺序!

就像这样:

Code
#include<bits/stdc++.h>
using namespace std;
struct Node{
	multable int l,r;
	multable int val;
	inline bool operator<(const Node &oth)const{
		return  l<oth.l;
	}
};
set<Node> s;

区间加

考虑下图中这种区间加的操作:

快找我

很好懂吧咕~ 这是这一部分的 Code:

分裂 split

用于把上图中两端的区间拆开。


//表示以 k 为界,分裂开 [l,k-1] 和 [k,r] 并返回 [k,r] 的指针。
auto split(int k){
	auto it=s.lower_bound({k,0,0});//指针
    if(it!=s.end() && (it->l)==k) return it;//发现有一段以 k 为起始点的区间,好说好说,直接用就行
    it--;//否则因为找的是比 k 大一些的,所以还要--变成包含 k 的区间。
    if((it->r) < k) return s.end();//整个 k 越界。
	int v=(it->val), r=(it->r);//存一下
	(it->r)=k-1;//缩短另一端变为 [l,k-1]
	return s.insert(Node{k,r,v}).first;//加入一个[k,r],返回其指针
}

值得一提的是,因为我们用的是 multiable 关键字,无需删掉重加,于是成功卡常~

区间加 code

最简单的一段。


void Add(int l, int r, int val){
	auto id=split(r+1), it=split(l);//分裂两端。
    //注意 r+1 才能正确把 [l,r] 整个分离
	while(it!=id){
        (it->val)+=val;
        it++;
    }
}

区间推平

然后是这种区间推平操作:

Call me for help

同样很简单呢

Code


void flatten(int l, int r, int val){
    auto id=split(r+1), it=split(l);
    (it->r)=r;
    (it->v)=val;//把左边的延长并赋值,卡掉再次加入的常数
    s.erase(++it,id);
}

注意这里我们用之前的区间修改平替,省去了删掉重加的常数。

小细节

还有一点不知道你有没有发现,我们每次分裂区间都是先分裂右边,再分裂左边,这是为什么呢?

假设我们有一段区间 \([1,10]\),现要推平 \([3,5]\) 那么如果先分裂左边得到 \([1,3]\)\([3,10]\),此时我们记下了 \(it=[3,10]\);分裂右边我们得到了 \([3,5]\) 和记录 \(id=[6,10]\)……

这时你要去遍历,发现:

诶我原来的 \(it=[3,10]\) 咋没了

嗯然后就因为指针不存在 RE 了。

那么如果我们先分裂右边呢?

同样的数据,分裂右边得到 \([1,5]\)\([6,10]\),此时我们只记下了 \(id=[6,10]\);再去分裂左边,得到 \([1,3]\)\(it=[3,5]\) ……

\(it=[3,5]\)\(id=[6,10]\) 真了!好诶!

所以这方面的小细节一定要注意欧,尤其看一看有没有指针互相扰动的问题欧~


Complete Code

\(\text{Speak is cheep, show you the code:}\)

#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e5+7,mod=1e9+7;
int n,m,seed,vmax;

//题目中的随机化。
inline rd(){
    int ret=seed;
    seed=(seed*7+13)%mod;
    return ans;
}

//快速幂
inline int ksm(int x, int tmp){
    int ans=1;
    x%=mod;
    while(tmp){
        if(tmp&1) ans=ans*x%mod;
        x=x*x%mod;
        tmp>>=1;
    }
    return ans;
}

//珂朵莉树已打包~
namespace Cly{
    struct str{
        mutable int l,r;
        mutable int val;//卡常
        bool operator<(const str &oth)const{
            return l<oth.l;//重载
        }
    };
    set<str> s;//珂朵莉树本体

    //分裂,如上
    inline auto split(int k){
        auto it=s.lower_bound({k,0,0});
        if(it!=s.end()&&(it->l)==k) return it;
        it--;
        if((it->r)<k) return s.end();
        int v=(it->val), r=(it->r);
        (it->r)=k-1;
        return s.insert(str{k,r,v}).first;
    }

    //区间推平
    void flatten(int l, int r, int val){
        auto id=split(r+1), it=split(l);
        (it->r)=r;//延长、染色,卡常。
        (it->val)=val;
        s.erase(++it, id);//删掉其他的东西
    }

    //区间加
    void Add(int l, int r, int val){
        auto id=split(r+1), it=split(l);
        while(it!=id){
            (it->val)+=val;//如上
            it++;
        }
    }

    //临时记录一下颜色段(数值段)
    struct Kthnum{ 
        int val,cnt; 
        bool operator<(const Kthnum &oth)const{
            return val<oth.val;//按数值从小到大排序
        }
    };

    //查找区间第 k 大的值是多少。
    int kth(int l,  int r, int k){
        auto id=split(r+1), it=split(l);
        vector<Kthnum> v;
        while(it!=id){//很暴力,所以不讲
            v.push_back(Kthnum{(it->val), (it->r)-(it->l)+1});
            it++;//码面意思,把每一个数值段连值带区长放进去
        }
        sort(v.begin(), v.end());
        for(auto i:v){
            //如果现在的数值段已经能够包含第 k 个元素,直接返回这种数值
            if(k<=i.cnt) return i.val;
            //否则减去这一段长度 cnt,在剩下的序列中查找第 k-cnt 大的数
            k-=i.cnt;
        }
    }

    //计算区间幂之和。
    int power(int l, int r, int tmp){
        auto id=split(r+1), it=split(l);
        int ans=0;
        while(it!=id){//依旧暴力直接求
            ans=(ans+((it->r)-(it->l)+1)*ksm((it->val),tmp)%mod)%mod;
            it++;
        }
        return ans;
    }
}
//主函数略去,不想写了,没什么特别的捏
int main(){
    return 0;
}


哦那么恭喜你已经能够成为一名珂朵莉树大师了!(柯学家 +1)

关于时间复杂度

珂朵莉树的时间复杂度均摊下来是 \(\mathcal{O}(m\log n)\) 的,是不是很优秀呢~

什么你说这操作难道不是 \(\mathcal{O}(nm)\) 吗?

等会我算一下


第一种修改是最坏 \(\mathcal{O}(n)\),最理想 \(\mathcal{O}(1)\) 的;

第二种修改每次都是稳定 \(\mathcal{O}(\log n)\) 的;

这道题的查询最坏情况是 \(\mathcal{O}(n\log n)\)(因为必要的快速幂),最理想 \(\mathcal{O}(\log n)\) 的。

你说的兑!是O(nm)的没错!

停停停,那不就变成暴力做法了吗?

Emmm... 好吧其实珂朵莉树就是一个暴力数据结构来着 awa。

其实并不会出现这种情况。因为出现这种极端数据 Hack 的话已经说明不能用珂朵莉树了呢(?

是的,珂朵莉树也是一个玄学复杂度,只要允许特殊数据珂朵莉树就会被直接卡掉,但是这题不是指定数据啊!

珂朵莉树一般是用来卡常的……例如一些随机数据的题,有时候 FHQ 等算法会因为常数太大直接被卡,而珂朵莉树就能凭借其优秀的常数神秘通过……

愿世上没有卡常题 (><)

珂朵莉树仅在数据随机的情况下才能保证时间复杂度是均摊下来 \(\mathcal{O}(m\log n)\) 呢,所以一定要小心谨慎,仔细看题欧~

再说了怎么会有人去卡我们可爱的珂朵莉呢 ~


以上。

参考资料:

学长的 cnblogs 有点吓人还是开纯净模式再去看好了…… (逃)

oiwiki 珂朵莉树/颜色段均摊

并不想 感谢 Co_led 对本咕的各类问题迫使本咕写下此文。

推荐习题:

posted @ 2026-08-21 16:07  听凭风引adj  阅读(12)  评论(0)    收藏  举报