线段树的代替算法——珂朵莉树

大家好,我是梁唐。

今天我们继续来聊前两天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)。这是因为lr两个点可以在同一个区间上,如果我们先split(l)再执行split(r)时可能会导致split(l)得到的迭代器被释放,这会导致空指针的错误。

在知乎上有大佬对珂朵莉树的复杂度进行了严谨的证明,基于set实现的珂朵莉树的均摊复杂度为 O ( n log log n ) O(n \log \log n) ,基于链表实现的为 O ( n log n ) O(n \log n) 。虽然基于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;
    }
};
复制代码

好了,关于珂朵莉树这个算法就先聊到这里,感谢大家的阅读。

猜你喜欢

转载自juejin.im/post/7077923187740131364
今日推荐