珂朵莉树/颜色段均摊

由来

珂朵莉树( Chtholly Tree \text{Chtholly Tree} Chtholly Tree),又称老司机树( Old Driver Tree \text{Old Driver Tree} Old Driver Tree ODT \textbf{ODT} ODT)。

源于 Codeforces Round 449 (Div. 1) 的 C 题 CF896C

具体实现

注意:珂朵莉树不是数据结构,而是一种颜色段均摊的思想。

核心思想

对于一个区间中值相同的一个子串使用一个结点存储。

结点存储

首先,一个结点需要存储其左右端点,故定义 size_t l, r

其次,由于将值相同的子串使用一个结点存储,那么结点就需要存储其维护的值,故定义 mutable _Tp w,其中 _Tp 是数据类型。


应该如何将大量结点存储呢?

可以开一个 std::set 存储结点,在看了下文的两个操作后,就可以发现使用 std::set 是最优的,std::vector 更劣。

但是听说 std::vector 因为常数小模板题更快?


代码见后。

一些解释

  • mutable 意为可变的,这样在遍历结点时可以 O ( 1 ) O(1) O(1) 修改迭代器所对应的值,而不用花费 O ( log ⁡ n ) O(\log n) O(logn)​ 的时间取出结点,修改后重新插入。
  • 将小于号如此定义是为了让结点在 std::set 中按照原数组中的次序,即按照左右端点次序排列,方便遍历。

结点分裂 / split \textbf{split} split

split \textbf{split} split 是珂朵莉树中一个重要的操作,对于给定的一个位置 x   ( x ∈ [ l , r ] ) x\,(x\in[l,r]) x(x[l,r]),可以将 [ l , r ] [l,r] [l,r] 分为 [ l , x ) [l,x) [l,x) [ x , r ] [x,r] [x,r] 两个区间,并且返回 [ x , r ] [x,r] [x,r] 区间在 std::set 中的迭代器。

有了这个操作,当对一个结点维护的区间不同位置赋值为不同的值时,就可以将原结点快速拆分了。

例如对区间 [ l , r ] [l,r] [l,r] 的操作,在珂朵莉树上就可以转化成对结点 split(l) ∼ \sim split(r+1) 的操作。


应该如何实现 split \textbf{split} split 操作呢?

  1. std::set 中找到包含 x x x 的区间对应的结点。

    可以在 std::set 中二分找到结点。根据小于号的定义,可以先使用 std::upper_bound 找到第一个左端点大于 x x x 的结点,那么一般前一个结点就是 x ∈ [ l , r ] x\in[l,r] x[l,r] 的区间了。

    但是存在特殊情况:如果 x x x 大于区间的右端点,那么说明找不到包含 x x x 的区间,返回 end()

  2. 如果 x = l x=l x=l,那么就无需分裂,直接返回结点的迭代器即可。

  3. 记录下当前结点的 l, r, w,然后删除当前节点。

  4. 添加 [ l , x ) [l,x) [l,x),即 insert({l, x - 1, w})

  5. 添加 [ x , r ] [x,r] [x,r],即 insert({x, r, w})

    由于 split \textbf{split} split 操作需要返回 [ x , r ] [x,r] [x,r] 区间的迭代器,所以返回 insert() 返回值的 first

    冷知识:insert() 函数是由返回值的,且返回值是一个 std::pair<std::set<_Tp>::iterator, bool>,第一维表示当前插入数据在集合中的迭代器,第二维表示是否插入成功,即原本是否存在相同的数据。

这样一个结点就成功分裂了。


这里就体现出了 std::set 的优势:插入只需要 O ( log ⁡ n ) O(\log n) O(logn) 的时间,而线性数据结构需要 O ( n ) O(n) O(n) 的时间(包括链表)。

区间推平 / assign \textbf{assign} assign

assign \textbf{assign} assign​ 也是一个非常重要的操作,是珂朵莉树时间复杂度正确的保证。

assign \textbf{assign} assign 对于给定的区间 [ l , r ] [l,r] [l,r],将其全部数赋值为 x x x

首先先 split(r + 1),然后再 split(l),分别记录下其迭代器。

Q:为什么要先 split(r + 1),再 split(l)

A:如果先 split(l),那么返回的迭代器可能会在 split(r + 1) 的时候失效。

然后删除两个迭代器之间的所有结点(不包括 split(r + 1) 返回的迭代器),然后再将其合并为一个新节点插入 std::set

复杂度

时间复杂度

如果保证数据随机,那么单次操作的时间复杂度均摊是 O ( log ⁡ log ⁡ n ) O(\log\log n) O(loglogn) 的。

但是如果构造数据,那么珂朵莉树的时间复杂度会卡到 O ( n 2 ) O(n^2) O(n2)

所以珂朵莉树一般用于骗分

我的一位同学在一道好像没有区间推平的题目中用珂朵莉树得了 70 分!

空间复杂度

由于 std::set 最多存储 n n n 个结点( n n n 为序列长度),所以空间复杂度为 O ( n ) O(n) O(n)

模板代码

只要掌握了这个模板,题目具体分析一下然后暴力就行啦!

template <typename _Tp>
struct ODT_node { // 珂朵莉树的结点
  size_t l, r; // 左右端点
  mutable _Tp w;

  ODT_node(const size_t &_l, const size_t &_r, const _Tp &_w) // 构造函数
  : l(_l), r(_r), w(_w) {}

  bool operator<(const ODT_node &x) const { // 给结构体定义小于号
    if (l != x.l) return l < x.l; // 左端点为第一关键字
    if (r != x.r) return r < x.r; // 右端点为第二关键字
    return w < x.w; // 存储的值为第三关键字
  }
};

template <typename _Tp>
class ODT : public std::set<ODT_node<_Tp>> { // 珂朵莉树直接继承了 std::set,拥有 std::set 的所有 public 的性质以及函数
  const size_t INF = 2e9; // 将一个不可能的值作为右端点
public:

  auto split(const size_t &x) { // 结点分裂
    auto it = --this->upper_bound(ODT_node<_Tp>(x, INF, 0)); // 找到可能包含 x 的区间所对应的结点
    if (x > it->r) return this->end(); // 不存在 x
    if (it->l == x) return it; // 无需分裂
    size_t l = it->l, r = it->r; 
    _Tp w = it->w; // 记录左右端点以及存储的值
    this->erase(it); // 删除当前结点
    this->insert(ODT_node<_Tp>(l, x - 1, w)); // 加入 [l,x) 结点
    return this->insert(ODT_node<_Tp>(x, r, w)).first; // 加入 [x,r] 结点,并且返回其迭代器
  }

  void assign(const size_t l, const size_t &r, const _Tp &w) { // 区间推平
    auto itr = split(r + 1), itl = split(l); // 得到左右结点
    this->erase(itl, itr); // 删除
    this->insert(ODT_node<_Tp>(l, r, w)); // 合并为一个结点后插入
  }
};

例题分析

Willem, Chtholly and Seniorious

Description \textbf{Description} Description

要求实现一种数据结构,维护一个序列 { a n } \{a_n\} {an},维护如下的操作,操作共 m m m 次。

  • 1 l r x:将 [ l , r ] [l,r] [l,r] 区间所有数加上 x x x
  • 2 l r x:将 [ l , r ] [l,r] [l,r] 区间所有数改成 x x x
  • 3 l r x:输出将 [ l , r ] [l,r] [l,r] 区间从小到大排序后的第 x x x 个数是的多少(不去重)。
  • 4 l r x y:输出 ∑ i = l r a i x   m o d   y \sum^r_{i=l}a_i^x\bmod y i=lraixmody

n , m ≤ 1 0 5 n,m\le10^5 n,m105,保证数据随机。

Solution \textbf{Solution} Solution

由于数据随机并且有区间推平操作,故考虑使用珂朵莉树解决。

定义 ODT<lint> tree

PS:在下文讨论中,默认已经获得了两边的迭代器,即 itlitr。此外定义了 using lint = long long

操作 1

遍历每个结点,并将其 w 加上给定的值。

void add(size_t l, size_t r, lint w) { // 区间加
  auto itr = tree.split(r + 1), itl = tree.split(l);
  for (auto it = itl; it != itr; ++it) // 遍历每个结点
    it->w += w;
}
操作 2

直接使用 assign \textbf{assign} assign 操作即可。

操作 3

可以开一个数组记录下区间内每个数的出现次数。

具体的,定义一个 std::vector,第一维存储数字大小,第二维存储该数字结点维护的区间长度,以方便排序。

遍历节点,然后加入 {it->w, it->r - it->l + 1}

然后排序。

定义一个 cnt,记录当前已经访问了多少数字。

从前往后遍历 std::vector,然后使 cnt 加上当前的区间长度。

第一次发现 cnt 不小于 x x x​ 时,即这个数落在了当前的区间内,返回即可。

lint select(size_t l, size_t r, size_t x) {
  auto itr = tree.split(r + 1), itl = tree.split(l);
  std::vector<std::pair<lint, size_t>> vec;
  for (auto it = itl; it != itr; ++it)
    vec.push_back({it->w, it->r - it->l + 1});
  std::sort(vec.begin(), vec.end());
  size_t cnt = 0;
  for (auto it = vec.begin(); it != vec.end(); ++it) {
    cnt += it->second;
    if (cnt >= x) return it->first;
  }
  return 1145141919810;
}
操作 4

首先需要实现一个快速幂。

遍历结点,然后结果加上区间长度乘上当前值的 x x x 次方即可。

lint sum(size_t l, size_t r, lint x, lint y) {
  lint res = 0;
  auto itr = tree.split(r + 1), itl = tree.split(l);
  for (auto it = itl; it != itr; ++it)
    (res += (it->r - it->l + 1) * qpow(it->w, x, y)) %= y;
  return res;
}

接下来按照题意模拟即可。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值