1. 程式人生 > >珂朵莉樹小結

珂朵莉樹小結

珂朵莉是世界上最幸福的女孩,不接受任何反駁

學習珂學?請左轉此處


1、珂朵莉樹簡介

珂朵莉樹是由某毒瘤在2017年的一場CF比賽中提出的資料結構,原名老司機樹(Old Driver Tree,ODT)。由於第一個以珂朵莉樹為正解的題目的背景以《末日時在做什麼?有沒有空?可以來拯救嗎?》中的角色——珂朵莉為主角,所以這個資料結構又被稱為珂朵莉樹(Chtholly Tree)她主要處理區間修改、查詢問題,在資料隨機的前提下有著優秀的複雜度。

說到區間維護,我就想起明年年初 我們都會想到線段樹、樹狀陣列、splay、分塊、莫隊等資料結構,這些資料結構貌似是無所不能的(畢竟一個不行用兩個)

,無論區間加減乘除、平方開方都能搞定。但如果我們有這麼一題:(CF中的起源題)

【CF896C】 Willem, Chtholly and Seniorious

題意:寫個資料結構維護長度為\(n\)的序列(\(n≤10^5\)),實現區間賦值、區間加上一個值、求區間第k小、求區間每個數x次方之和模y的值的操作,資料隨機生成

我們發現本題的第4個操作涉及每個數字的相關操作,那麼線段樹、樹狀陣列、分塊甚至splay是肯定行不通的,莫隊雖然可以維護4操作,但複雜度同樣難以承受(要對每個入隊出隊的數取快速冪)。我們需要一個更為高效的資料結構來維護這些操作,她就是珂朵莉樹。

2、珂朵莉樹的實現

珂朵莉樹基於std::set,我們定義節點為一段連續的值相同的區間,程式碼如下:

struct node {
    int l, r;
    mutable ll val;
    int operator < (const node &a) const{
        return l < a.l;
    }
    node(int L, int R, ll Val) : l(L), r(R), val(Val) {}
    node(int L) : l(L) {}
};

其中函式node是建構函式,可以理解為定義變數時的初始化函式,具體用法請看下文程式碼。

mutable修飾符意為“可變的”,這樣我們就可以在本不支援直接修改的set中修改它的值辣

\(l\)\(r\)分別代表當前區間的左、右端點,\(val\)表示當前區間每個數字的值。

但是我們查詢的時候不能保證查詢的區間端點一定與這些節點的端點重合,如果採用分塊思想(邊角暴力)肯定行不通(會退化成暴力),所以我們要按需把節點分裂一下:

#define sit set<node>::iterator
sit Split(int pos) {
    sit it = s.lower_bound(node(pos));
    if (it != s.end() && it->l == pos) return it;
    --it;
    int l = it->l, r = it->r;
    int val = it->val;
    s.erase(it);
    s.insert(node(l, pos - 1, val));
    return s.insert(node(pos, r, val)).first;
}

這段程式碼所做的事就是把\(pos\)所在的節點(左右端點分別為\(l\)\(r\))分成 \([l, pos)\)\([pos, r]\) 兩塊,然後返回後者。返回值所用的是set::insert的返回值,這是一個pair物件(熟悉map的同學應該能熟練運用),它的first是一個迭代器,即插入的東西在set中的迭代器。

這段程式碼很簡單,應該不難打(bei)出(song)

接下來的addkthsum等操作都依賴於Split操作,具體做法就是把區間左右端點所在的節點分裂,使得修改&查詢區間能完全對應起來,之後就是暴力地去搞啦qwq

參考程式碼:

void Add(int l, int r, ll val) {//暴力列舉
    sit it2 = Split(r + 1), it1 = Split(l);
    for (sit it = it1; it != it2; ++it) it->val += val;
}
ll Kth(int l, int r, int k) {//暴力排序
    sit it2 = Split(r + 1), it1 = Split(l);
    vector< pair<ll, int> > aa;
    aa.clear();
    for (sit it = it1; it != it2; ++it) aa.push_back(pair<ll, int>(it->val, it->r - it->l + 1));
    sort(aa.begin(), aa.end());
    for (int i = 0; i < aa.size(); ++i) {
        k -= aa[i].second;
        if (k <= 0) return aa[i].first;
    }
}
ll qpow(ll a, int x, ll y) {
    ll b = 1ll;
    a %= y;//不加這句話WA
    while (x) {
        if (x & 1) b = (b * a) % y;
        a = (a * a) % y;
        x >>= 1;
    }
    return b;
}
ll Query(int l, int r, int x, ll y) {//暴力列舉+快速冪
    sit it2 = Split(r + 1), it1 = Split(l);
    ll res = 0;
    for (sit it = it1; it != it2; ++it) res = (res + (it->r - it->l + 1) * qpow(it->val, x, y)) % y;
    return res;
}

FAQ:

\(1.Q:\)為什麼要Split(r + 1)
\(A:\)便於取出 \([l, r + 1)\) 的部分,即 \([l,r]\)

\(2.Q:\)為什麼要先Split(r + 1),再Split(l)
\(A:\)因為如果先Split(l),返回的迭代器會位於所對應的區間以\(l\)為左端點,此時如果\(r\)也在這個節點內,就會導致Split(l)返回的迭代器被erase掉,導致RE。

\(3.Q:\)這些操作裡面全是Split,複雜度理論上會退化成暴力(不斷分裂直到無法再分),怎麼讓它不退化?

這便涉及到珂朵莉樹不可或缺的操作:\(Assign\)

Assign操作也很簡單,Split之後暴力刪點,然後加上一個代表當前區間的點即可。程式碼如下:

void Assign(int l, int r, int val) {
    sit it2 = Split(r + 1), it1 = Split(l);
    s.erase(it1, it2);
    s.insert(node(l, r, val));
}

不難看出,這個操作把多個節點減成一個,但這樣就能使複雜度優化了嗎?

\(Assign\)的珂朵莉樹究竟有多快

由於題目資料隨機,所以Assign操作很多,約佔所有操作的\(\frac{1}{4}\)。其他所有操作都有兩個Split,我們可以用一下程式模擬一下珂朵莉樹在資料隨機情況下節點的個數:

#include <cstdio>
#include <cstring>
#include <cmath>
#include <algorithm>
#include <set> 
#include <vector>
#include <cstdlib>
#include <ctime>
using namespace std;
struct node {
    int l, r;
    mutable int val;
    int operator < (const node &a) const {
        return l < a.l;
    }
    node(int L, int R = -1, int Val = 0) : l(L), r(R), val(Val) {} 
};
set<node> s;
#define sit set<node>::iterator
sit Split(int pos) {
    sit it = s.lower_bound(node(pos));
    if (it != s.end() && it->l == pos) return it;
    --it;
    int l = it->l, r = it->r, val = it->val;
    s.erase(it);
    s.insert(node(l, pos - 1, val));
    return s.insert(node(pos, r, val)).first;
}
void Assign(int l, int r, int val) {
    sit it2 = Split(r + 1), it1 = Split(l);
    s.erase(it1, it2);
    s.insert(node(l, r, val));
}
int main() {
    int n;
    scanf("%d", &n);
    for (int i = 1; i <= n + 1; ++i) s.insert(node(i, i, 0));
    srand((unsigned)time(0));
    srand(rand());
    for (int t = 1; t <= n; ++t) {
        int a = rand() * rand() % n + 1, b = rand() * rand() % n + 1;
        if (a > b) swap(a, b);
        if (rand() % 4 == 0) {
            Assign(a, b, 0);
        }
        else Split(a), Split(b + 1);
    }
    printf("%d", s.size());
    return 0;
}

本人機子上實驗資料如下(\(n\)表示序列長度,\(f(n)\)表示節點個數)

\(n\) \(f(n)\)
10 7
100 24
1000 33
10000 47
100000 67
1000000 95

可見,加了Assign的珂朵莉樹在隨機資料下跑得飛快,節點數達到了\(\log\)級別。也就是說,珂朵莉樹的高效是由隨機分配的Assign保證的。如果一個題目沒有區間賦值操作或者有資料點沒有賦值操作,或者資料很不隨機,請不要把珂朵莉樹當正解打。

珂朵莉樹目前的應用還很狹窄,各位dalao還是用她來騙分吧qwq

關於詳盡的複雜度證明,我數學太差證不出,這裡貼一下@Blaze dalao的證明:


例題程式碼:

#include <cstdio>
#include <cstring>
#include <cmath>
#include <algorithm>
#include <set>
#include <vector>
#include <utility>
using namespace std;
#define ll long long

struct node {
    int l, r;
    mutable ll val;
    int operator < (const node &a) const{
        return l < a.l;
    }
    node(int L, int R, ll Val) : l(L), r(R), val(Val) {}
    node(int L) : l(L) {}
};

set<node> s;
#define sit set<node>::iterator
sit Split(int pos) {
    sit it = s.lower_bound(node(pos));
    if (it != s.end() && it->l == pos) return it;
    --it;
    int l = it->l, r = it->r;
    ll val = it->val;
    s.erase(it);
    s.insert(node(l, pos - 1, val));
    return s.insert(node(pos, r, val)).first;
}
sit Assign(int l, int r, ll val) {
    sit it2 = Split(r + 1), it1 = Split(l);
    s.erase(it1, it2);
    s.insert(node(l, r, val));
}
void Add(int l, int r, ll val) {
    sit it2 = Split(r + 1), it1 = Split(l);
    for (sit it = it1; it != it2; ++it) it->val += val;
}
ll Kth(int l, int r, int k) {
    sit it2 = Split(r + 1), it1 = Split(l);
    vector< pair<ll, int> > aa;
    aa.clear();
    for (sit it = it1; it != it2; ++it) aa.push_back(pair<ll, int>(it->val, it->r - it->l + 1));
    sort(aa.begin(), aa.end());
    for (int i = 0; i < aa.size(); ++i) {
        k -= aa[i].second;
        if (k <= 0) return aa[i].first;
    }
}
ll qpow(ll a, int x, ll y) {
    ll b = 1ll;
    a %= y;
    while (x) {
        if (x & 1) b = (b * a) % y;
        a = (a * a) % y;
        x >>= 1;
    }
    return b;
}
ll Query(int l, int r, int x, ll y) {
    sit it2 = Split(r + 1), it1 = Split(l);
    ll res = 0;
    for (sit it = it1; it != it2; ++it) res = (res + (it->r - it->l + 1) * qpow(it->val, x, y)) % y;
    return res;
}
int n, m, vmax;
ll seed;
int rnd() {
    int ret = (int)seed;
    seed = (seed * 7 + 13) % 1000000007;
    return ret; 
}
int main() {
    scanf("%d%d%lld%d", &n, &m, &seed, &vmax);
    for (int i = 1; i <= n; ++i) {
        int a = rnd() % vmax + 1;
        s.insert(node(i, i, (ll)a));
    } 
    s.insert(node(n + 1, n + 1, 0));
    for (int i = 1; i <= m; ++i) {
        int l, r, x, y;
        int op = rnd() % 4 + 1;
        l = rnd() % n + 1, r = rnd() % n + 1;
        if (l > r) swap(l, r);
        if (op == 3) x = rnd() % (r - l + 1) + 1;
        else x = rnd() % vmax + 1;
        if (op == 4) y = rnd() % vmax + 1;
        if (op == 1) Add(l, r, (ll)x);
        else if (op == 2) Assign(l, r, (ll)x);
        else if (op == 3) printf("%lld\n", Kth(l, r, x));
        else if (op == 4) printf("%lld\n", Query(l, r, x, (ll)y));
    }
    return 0;
}

題目:

珂朵莉樹的經典題目大都非正解(且正解大多是線段樹),不過在有的題目中吊打正解

不過有些題目做起來還是挺有意思的qwq

1.【Luogu P2787】語文1(chin1)- 理理思維

標算線段樹(貌似?)

不過珂朵莉樹隨隨便便跑進最優解,這才是重點。

2.【CF915E】Physical Education Lessons

同上,珂朵莉樹甩線段樹幾條街

3.【Luogu P2572】[SCOI2010] 序列操作

資料不純隨機,但珂朵莉樹也能無壓力跑過~~

4.【Luogu P4344】[SHOI2015] 腦洞治療儀

毒瘤題目,治療腦洞操作的時候注意題面的規則,不然RE65或75。其餘沒什麼難度。

5.【Luogu P2146】[NOI2015] 軟體包管理器

樹剖+珂朵莉樹+O2,其實和線段樹一樣的做法qwq

完結撒花✿✿ヽ(°▽°)ノ✿