大家好,我是梁唐。
今天我们继续来聊前两天LeetCode的周赛,在昨天的题解文章当中,我提到最后一题有一种不使用线段树的取巧的办法,今天就来聊聊这个方法。本文始发于个人公众号:Coder梁,欢迎关注
这个算法用到一个全新的数据结构——珂朵莉树。
看到全新的数据结构估计很多同学都会很慌,其实完全不必,因为珂朵莉树与其说是全新的数据结构,倒不如是一种取巧骗分的方法。所以它自从被发明出来之后就有了一个别名——老司机树,缩写成ODT(old driver tree)。
珂朵莉树的核心思想很简单,使用set
代替线段树来维护区间,我们直接对区间进行操作。
简单来说我们将一个区间对应的状态或者是值打包成为一个结构体,并且放入set
当中。在更新和维护的时候,通过对于set
进行增删改查来实现。
我们先来看一下结构体的定义:
struct Node_t {
int l, r;
mutable int v;
Node_t(const int &il, const int &ir, const int &iv) : l(il), r(ir), v(iv) {}
inline bool operator<(const Node_t &o) const { return l < o.l; }
};
复制代码
结构体当中定义了三个int
,其中l和r很好理解,分别表示区间的左右端点。除了这两个值之外,还有一个v,这个v就是我们要对区间维护的值,当然我们也可以存一些别的变量,根据题目的需求来定。
这里的v有一个mutable
的修饰符,表示可修改的。之所以加上这个修饰符是因为set
中的元素都会加上const
修饰符,被 mutable
修饰的变量(mutable
只能用于修饰类中的非静态数据成员),将永远处于可变的状态,即使在一个 const
函数中。
简单来说,有了这个修饰符之后,我们可以直接通过迭代器修改set
节点中的v
,而不用先删除再插入。
看完了结构体定义之后,我们来真正看一下算法的原理。

我们要把多个值打包成结构体放入set
,这很好理解,但这里面有一个问题,就是我们要进行操作的区间并不一定刚好在set
中存在,如果set
中不存在怎么办呢?
我们举个简单的例子,假设我们有一个[0, 10)
的区间,现在分成了两个部分,分别是[0, 3)
和[3, 10)
。现在我们要将区间[5, 8)
对应的状态全部修改成某个值,问题来了,我们当前set
里只有两个区间,[5, 8)
并不在set
里,我们怎么办呢?
珂朵莉树的做法非常简单粗暴,要修改的区间不存在没有关系,我们可以硬拆。我们虽然没有[5, 8)
但是我们有[3, 10)
,我们可以把[3, 10)
强行拆分成[3, 5), [5, 8), [8, 10)
。一拆分,原本没有的区间顿时就有了。
除了拆分之外,同样还可以合并,比如我们要修改[3, 100)
,假设在这个范围当中我们有若干区间:[1, 4), [4, 10), [10, 19)...[98, 103), [103, 109)...
我们也是一样的做法,首先找到头和尾[1, 4)
和[98, 103)
。强行凑成[1, 3), [3, 4), [4, 10)...[98, 100)
,然后把中间这些区间全部删除,最后插入[3, 100)
。
怎么样,是不是非常简单粗暴?
我们简单观察一下会发现,在上面这段逻辑当中,拆分是核心。珂朵莉树的做法非常巧妙,它接收一个整数x
,会找到包含x
的区间,将其拆分成[l, x)
和[x, r)
两个区间,并且返回指向后者的迭代器。
auto split(int x) {
if (x > n) return odt.end();
// 二分搜索包含x的节点
auto it = --odt.upper_bound((Node_t){x, 0, 0});
if (it->l == x) return it;
int l = it->l, r = it->r, v = it->v;
// 先删除[l, r)
odt.erase(it);
// 插入[l, x)
odt.insert(Node_t(l, x, v));
// 插入[x, r)
return odt.insert(Node_t(x, r, v)).first;
}
复制代码
有了split
函数之后,我们可以把所有在区间[l, r)
上的操作转化成在[split(l), split(r))
上。
这么说可能有点抽象,其实结合set
的特性很好理解,因为split(l)
和split(r)
返回的都是set
中的迭代器。在C++当中,set
基于红黑树实现,我们可以非常高效地批量删除当中的元素。
我们把这些节点全部删除,再插入新的节点,就相当于我们更新了整个区间的值。
并且代码写出来也非常简单:
void assign(int l, int r, int v) {
auto itr = split(r), itl = split(l);
// 批量删除[split(l), split(r))
odt.erase(itl, itr);
// 插入[l, r)
odt.insert(Node_t(l, r, v));
}
复制代码
这里有一个小细节需要注意,我们在assign
的时候,需要先split(r)
再执行split(l)
。这是因为l
和r
两个点可以在同一个区间上,如果我们先split(l)
再执行split(r)
时可能会导致split(l)
得到的迭代器被释放,这会导致空指针的错误。
在知乎上有大佬对珂朵莉树的复杂度进行了严谨的证明,基于set
实现的珂朵莉树的均摊复杂度为
,基于链表实现的为
。虽然基于set
实现的版本比线段树性能略差,但胜在编码和思路简单。
最后,我们看一下使用珂朵莉树如何AC掉本题。
介于有些同学没有阅读上一篇文章,我们再对题目进行简单的回顾:
给你一个下标从 0 开始的字符串 s
。另给你一个下标从 0 开始、长度为 k
的字符串 queryCharacters
,一个下标从 0
开始、长度也是 k
的整数 下标 数组 queryIndices
,这两个都用来描述 k
个查询。
第 i
个查询会将 s
中位于下标 queryIndices[i]
的字符更新为 queryCharacters[i]
。
返回一个长度为 k
的数组 lengths
,其中 lengths[i]
是在执行第 i
个查询 之后 s
中仅由 单个字符重复 组成的 最长子字符串 的 长度 。
思路非常简单,对于每次将idx
位置的字符改成c
的操作。我们先找到idx
所在的区间,将它分成三个部分:[l, idx), [idx, idx+1), [idx+1, r)
。
如果s[idx-1] == c
说明idx
可以和[l, idx)
合并,如果s[idx+1] == c
,说明idx
可以和[idx+1, r)
合并,依次操作区间合并即可。
由于我们要求最大长度,可以使用multiset
来维护所有区间的长度,由于multiset
天然有序,我们每次返回末尾位置的值即可。
因为本题是单点更新,所以用不到模板中的assign
函数。
class Solution {
public:
struct P {
int l, r;
mutable int v;
P(const int& _l, const int& _r, const int &_v): l(_l), r(_r), v(_v) {}
bool operator<(const P& p) const{
return l < p.l;
}
};
int n;
set<P> st;
multiset<int> ms;
// 珂朵莉树模板
auto split(int x) {
if (x > n) return st.end();
auto it = --st.upper_bound((P){x, 0, 0});
if (it->l == x) return it;
int l = it->l, r = it->r, v = it->v;
st.erase(it);
// 删除区间时也删除长度
ms.extract(v);
st.insert(P(l, x, x-l));
// 插入区间时也插入长度
ms.emplace(x-l);
ms.emplace(r-x);
return st.insert(P(x, r, r-x)).first;
}
void update(string& s, int idx, int c) {
s[idx] = c;
auto rig = split(idx+1);
auto pos = split(idx);
ms.emplace(1);
if (idx > 0 && s[idx-1] == c) {
// 如果能和[l, idx)合并
auto il = --st.upper_bound((P){idx-1, 0, 0});
int l = il->l;
ms.extract(il->v);
ms.extract(1);
// 删除[l, idx), [idx, idx+1)
st.erase(pos);
st.erase(il);
// 插入[l, idx+1)
st.insert(P(l, idx+1, idx+1-l));
ms.emplace(idx+1 - l);
}
if (idx+1 < n && s[idx+1] == c) {
// 如果能和[idx+1, r)合并
int rs = rig->r;
auto il = --st.upper_bound((P){idx, 0, 0});
int ls = il->l;
ms.extract(rig->v);
ms.extract(il->v);
// 删除[l, idx+1), [idx+1, r)
st.erase(rig);
st.erase(il);
// 插入[l, r)
st.insert(P(ls, rs, rs - ls));
ms.emplace(rs - ls);
}
}
vector<int> longestRepeating(string s, string query, vector<int>& queryIdx) {
n = s.length();
vector<int> ret;
int last = 0;
for (int i = 0; i < n; i++) {
if (s[i] != s[last]) {
st.insert(P(last, i, i - last));
ms.emplace(i - last);
last = i;
}
}
st.insert(P(last, n, n - last));
ms.emplace(n - last);
for (int i = 0; i < query.length(); i++) {
update(s, queryIdx[i], query[i]);
ret.push_back(*ms.rbegin());
}
return ret;
}
};
复制代码
好了,关于珂朵莉树这个算法就先聊到这里,感谢大家的阅读。