這是即將推送到 OI-wiki 的,對於原 OI-wiki 珂朵莉樹頁面的重構。作者是我。感謝上一位維護這玩意的人。
簡介
珂朵莉樹(Chtholly Tree),又名老司機樹 ODT(Old Driver Tree)。起源自 CF896C。
這個名稱指代的是一種“使用平衡樹(std::set
、std::map
等)或連結串列(std::list
、手寫連結串列等)維護顏色段均攤”的技巧,而不是一種特定的資料結構。其核心思想是將值相同的一段區間合併成一個結點處理。相較於傳統的線段樹等資料結構,對於含有區間覆蓋的操作的問題,珂朵莉樹可以更加方便地維護每個被覆蓋區間的值。
實現(std::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) {}
bool operator<(const Node_t &o) const { return l < o.l; }
};
其中,int v
是你自己指定的附加資料。
???+ note "mutable
關鍵字的含義是什麼?"
mutable
的意思是「可變的」,讓我們可以在後面的操作中修改 v
的值。在 C++ 中,mutable 是為了突破 const 的限制而設定的。被 mutable 修飾的變數(mutable 只能用於修飾類中的非靜態資料成員),將永遠處於可變的狀態,即使在一個 const 函式中。
這意味著,我們可以直接修改已經插入 `set` 的元素的 `v` 值,而不用將該元素取出後重新加入 `set`。
結點儲存
我們希望維護所有結點,使得這些結點所代表的區間左端點單調增加且兩兩不交,最好可以保證所有區間的並是一個極大的連續範圍。此處以 std::set
為例,用一個 set<Node_t> odt;
維護所有結點。
初始化時。向珂朵莉樹中插入一個極長區間(如題目要求維護位置 \(1\) 到 \(n\) 的資訊,插入區間 \([1,n+1]\))。
split 操作
split
操作是珂朵莉樹的核心。它接受一個位置 \(x\),將原本包含點 \(x\) 的區間(設為 \([l, r]\))分裂為兩個區間 \([l, x)\) 和 \([x, r]\),並返回指向後者的迭代器。
參考程式碼如下:
auto split(int x) {
auto it = odt.lower_bound(Node_t(x, 0, 0));
if (it != odt.end() && it->l == x) return it;
--it;
int l = it->l, r = it->r, v = it->v;
odt.erase(it);
odt.insert(Node_t(l, x - 1, v));
return odt.insert(Node_t(x, r, v)).first;
}
assign 操作
另外一個重要的操作:assign
。用於對一段區間進行賦值。設將要對區間 \([l,r]\) 賦值為 \(v\)。
首先,將區間 \([l, r]\) 擷取出來。依次呼叫 \(split(r+1), split(l)\),將此兩者返回的迭代器記作 \(itr, itl\),那麼 \([itl, itr)\) 這個迭代器範圍就指向了珂朵莉樹中 \([l,r]\) 包含的所有區間。
然後,將原有的資訊刪除。std::set
有成員方法 erase
,簽名如同 iterator erase( const_iterator first, const_iterator last );
,可以移除範圍 [first; last)
中的元素。於是我們呼叫 odt.erase(itl, itr);
以刪除原有的資訊。
最後,插入區間 \([l,r]\) 的新值。呼叫 odt.insert(Node_t(l, r, v))
即可。
參考程式碼如下:
void assign(int l, int r, int v) {
auto itr = split(r + 1), itl = split(l);
odt.erase(itl, itr);
odt.insert(Node_t(l, r, v));
}
??? 為什麼需要先 split(r + 1)
再 split(l)
?
1. `std::set::erase` 方法將使指向被擦除元素的引用和迭代器失效。而其他引用和迭代器不受影響。
2. `std::set::insert` 方法不會使任何迭代器或引用失效。
3. `split` 操作會將區間拆開。呼叫 `split(r + 1)` 之後 $r + 1$ 會成為兩個新區間中右邊區間的左端點,此時 `split` 左區間,必然不會訪問到 $r + 1$ 為左端點的那個區間,也就不會將其拆開,刪去 $r + 1$ 為左端點的區間,使迭代器失效。反之,先 `split(l)`,再 `split(r + 1)`,可能會把 $l$ 為左端點的區間刪去,使迭代器失效。
perform 操作
將珂朵莉樹上的一段區間提取出來並進行操作。與 assign
操作類似,只不過是將刪除區間改為遍歷區間。
參考程式碼如下:
void perform(int l, int r) {
auto itr = split(r + 1), itl = split(l);
for (; itl != itr; ++itl) {
// Perform Operations here
}
}
注意不應該濫用這樣的提取操作,可能使得時間複雜度錯誤。見下文“複雜度分析”一欄。
實現(std::map)
相較於 std::set
的實現,std::map
的實現的 split
操作寫法更簡單。除此之外,其餘操作與 std::set
並無二異。
結點儲存
由於珂朵莉樹儲存的區間是連續的,我們不一定要記下右端點是什麼。不妨使用一個 map<int, int> mp;
儲存所有區間,其鍵維護左端點,其值維護其對應的左端點到下一個左端點之前的值。
初始化時,如題目要求維護位置 \(1\) 到 \(n\) 的資訊,則呼叫 mp[1] = -1, mp[n + 1] = -1
表示將 \([1,n+1)\) 即 \([1, n]\) 都設為特殊值 \(-1\),\([n+1, +\infty)\) 這個區間當作哨兵使用,也可以對它進行初始化。
split 操作
參考程式碼:(第一份)
void split(int x) {
auto it = prev(mp.upper_bound(x)); // 找到左端點小於等於 x 的區間。
mp[x] = it->second; // 設立新的區間,並將上一個區間儲存的值複製給本區間。
}
參考程式碼:(第二份)
auto split(int pos) {
auto it = prev(mp.upper_bound(pos)); // 找到左端點小於等於 x 的區間。
return mp.insert(it, make_pair(pos, it->second)); // 設立新的區間,並將上一個區間儲存的值複製給本區間。
}
這裡使用了 std::map::insert
的過載 iterator insert( const_iterator pos, const value_type& value );
,其插入 value
到儘可能接近正好在 pos
之前的位置。如果插入恰好發生在正好在 pos
之前的位置,那麼複雜度是均攤常數,否則複雜度與容器大小成對數。
其餘操作與 std::set
並無二異。
實現(連結串列)
可參考 題解 CF896C 【Willem, Chtholly and Seniorious】 - 洛谷專欄 (luogu.com.cn)。
複雜度分析
perform 以後立即對同一區間呼叫 assign
此時觀察發現,兩次 split
操作至多增加兩個區間;一次 assign
將刪除範圍內的所有區間並增加一個區間,同時遍歷所刪除的區間。所以,我們所遍歷的區間與所刪除的區間數量成線性,而每次操作都只會增加 \(O(1)\) 個區間,所以我們操作的區間數量關於操作次數(包括初始化)成線性,時間複雜度為均攤 \(O(m\log n)\),其中記 \(m\) 為操作次數,\(n\) 為珂朵莉樹中最大區間個數(可以認為 \(n\leq m\))。
perform 以後不進行 assign
如果允許特殊構造資料,這樣一定是能被卡掉的,只需要使珂朵莉樹中有足夠多的不同區間並反覆遍歷,就能使珂朵莉樹的複雜度達到甚至高於平方級別。
如果要保證複雜度正確,必須保證資料隨機。詳見 Codeforces 上關於珂朵莉樹的複雜度的證明。更詳細的嚴格證明見 珂朵莉樹的複雜度分析。證明的結論是:用 std::set
實現的珂朵莉樹的複雜度為 \(O(n \log \log n)\),而用連結串列實現的複雜度為 \(O(n \log n)\)。
習題
「SCOI2010」序列操作(該題目來源已新增 Hack 資料)- 「SHOI2015」腦洞治療儀
- 「Luogu 4979」礦洞:坍塌
- 「Luogu 8146」risrqnis
擴充套件閱讀
ODT的對映思想的推廣 - 洛谷專欄 (luogu.com.cn)