珂朵莉树
Preface
珂朵莉树(Chtholly Tree)起源于CF896C,那道题要求我们实现一种数据结构,可以较快地实现:
区间加、区间赋值、求区间第k大值、求区间n次方和
就此需求来说,普通的树状数组、线段树等显然难以胜任,看上去需要一种相当复杂的数据结构。
然而,在出题人的题解中,实现的数据结构却很简单,最初用自己的 ID 把这种数据结构命名为 ODT,后又改名为珂朵莉树。
珂朵莉树的适用范围是有区间赋值操作且数据随机的题目。其实珂朵莉树看上去并不像是树状数据结构,
但因为一般要用到有序集合,而有序集合是用红黑树实现的,所以也不算名不副实。
在随机数据下,珂朵莉树可以达到 O(nloglogn) 的复杂度,参见这篇文章(这篇文章博主也是非常厉害的)。
而我接触这个特殊的数据结构是因为同学推荐力扣每日一题用这种方法实现,起到一个抛砖引玉的效果。
- C++ 实现参考:https://zhuanlan.zhihu.com/p/106353082
- Rust 实现参考:http://heavensheep.xyz/?p=276
本篇后续实现方式为 Java,利用的数据结构是 TreeSet,底层实现也是红黑树。
Details
有序集合
在介绍 ODT 之前,先按照朴素做法取实现,采用有序集合,去判断各种情况的发生以及如何处理,
并且利用底层插入寻找结点的时间复杂度为 O(logn) 来解决这道问题,但是实现起来非常复杂,细节很多,
我在按照这种方法去实现时,一直在调试,详情参考: LeetCode 官方题解
我在具体实现时候也优化了一些代码逻辑并且添加了 comment,便于理解,参考代码如下:
1 |
|
玛朵莉树
ODT 核心函数只有两个,split()
和 assign()
,前者用于分割区间,后者用于区间赋值,也是其精髓。
思路如下:
- 用一个结构体或者类存储区间信息:左端点,右端点以及区间值
split()
函数用于分割区间,因此需要在开始初始化一个涵盖数据范围的区间assign()
函数用于区间赋值以及区间合并,使得零散的区间变得整齐
可以参考下图例子,加以理解具体操作:
具体代码如下:
1 |
|
Final
思考
保持算法高效的核心在于哪里?
除了有序集合本身红黑树高效的插入查询之外,还在于
assign
函数清理掉细碎的小区间,合并成大区间,这样提高了
query
也就是查询操作的效率,有点 GC 的感觉为什么 C++ 实现
assign
中需要先split right
?这是因为迭代器的原因,先左再右可能会导致迭代器失效,因为修改了
left iterator
,上述 Java 代码中,就不需要考虑此问题,有兴趣可以自行更换下顺序,看看代码执行结果,
这里我本身理解也是浅尝则止,是以后需要学习跟进的地方,有关红黑树的实现
最开始初始化一个区间是否必须?
必须,因为后续操作需要操作区间,并且必须是涵盖所有测试数据范围的区间
为什么和官方解法的运行时间差距很大?
因为官方题解是遇到一个新区间或者删除时才会去处理结点,而玛朵莉树是相当于在分裂和合并,
随着操作的增加,结点数也在增加,固然时间就增加,并且后者有很多
new()
操作,附上学到的一个术语:
ad-hoc
:指先用低成本实现一个简单易理解的算法,如果后续发现瓶颈再去优化Ad hoc is a Latin phrase meaning literally ‘to this’. In English, it typically signifies a solution for a specific purpose, problem, or task rather than a generalized solution adaptable to collateral instances
拓展
既然抛砖引玉入门了这道题目,再回到原本的出处CF896C,还剩下三个操作没有实现,具体实现就是暴力,
附上 C++ 伪代码,实现步骤很简单,如果对于上述过程都理解了,自然也就明白了。
区间加
l r x : For each i such that l ≤ i ≤ r, assign ai + x to ai.
1
2
3
4
5
6void add(ll l, ll r, ll v)
{
auto end = split(r + 1);
for (auto it = split(l); it != end; it++)
it->v += v;
}区间第 K 小值:
l r x : Print the x-th smallest number in the index range [l, r], i.e.
the element at the x-th position if all the elements ai such that l ≤ i ≤ r are taken
and sorted into an array of non-decreasing integers. It’s guaranteed that 1 ≤ x ≤ r - l + 1.
1
2
3
4
5
6
7
8
9
10
11
12
13
14ll kth(ll l, ll r, ll k)
{
auto end = split(r + 1);
vector<pair<ll, ll>> v; // 这个pair里存节点的值和区间长度
for (auto it = split(l); it != end; it++)
v.push_back(make_pair(it->v, it->r - it->l + 1));
sort(v.begin(), v.end()); // 直接按节点的值的大小排下序
for (int i = 0; i < v.size(); i++) // 然后挨个丢出来,直到丢出k个元素为止
{
k -= v[i].second;
if (k <= 0)
return v[i].first;
}
}区间值求幂的和
l r x y : Print the sum of the x-th power of ai such that l ≤ i ≤ r, modulo y, i.e.
1
2
3
4
5
6
7
8ll sum_of_pow(ll l, ll r, ll x, ll y)
{
ll tot = 0;
auto end = split(r + 1);
for (auto it = split(l); it != end; it++)
tot = (tot + qmi(it->v, x, y) * (it->r - it->l + 1)) % y; // qmi 快速幂
return tot;
}
补充
在学习过程中,也接触到一对名词:在线算法(online algorithm)和离线算法(offline algorithm)
维基百科举了这样一个例子,选择排序是离线算法,而插入排序是在线算法。
那就从这两个算法来看看在线算法和离线算法的区别:
选择排序是不断地从未排序的元素中找到最大(小)的元素放到排序序列的起始位置,
插入排序是不断将未排序的序列插入到有序的序列中,有序序列中的元素相对位置会在一定程度上被改变,
两种排序的本质区别在于,后面的操作对前面操作的结果是否有影响。
更直观地讲,就是离线算法途中拿出来的结果就是最终结果的一部分,
而在线算法可能到了最后一步才能得到需要的结果,
而过程中产生中间结果都是为最后结果的输出而服务的。