1. 程式人生 > 其它 >Chtholly Tree 學習筆記

Chtholly Tree 學習筆記

前言

珂朵莉樹 (Chtholly Tree) 是一種簡單優美的資料結構,就像 Chtholly 一樣可愛。暴力即優美。 適用於一些有區間賦值操作的序列操作題。

Chtholly Tree 的本質是把一個序列分成幾個連續區間,每個區間內的元素的值相同,然後用一個 std::set 維護所有區間。

然後就可以通過一些神奇的操作,做到在資料隨機的情況下 \(O(logn)\) 查詢區間資訊。時間複雜度我不會證QAQ。

當然也可以手寫 std::set ,但是那樣子還不如直接用平衡樹做題。

事實上,Chtholly Tree 的適用範圍很小,只能在某些保證資料隨機且有區間賦值操作的題目中使用,別的情況下就是“你比暴力多個 log ”。而且一般來說出題人沒有不卡 Chtholly Tree 的。雖然有時可以吸氧水過去。

競賽一般不會考 Chtholly Tree ,但是多學一種資料結構也不是壞事嘛。尤其是 Chtholly 這麼可愛。

零. 前置知識

你需要關於 std::set 的基礎知識:

  1. set s; 建立一個型別為 type 的set。

  2. s.insert(x); 向 \(s\) 中插入一個值為 \(x\) 的元素。該函式的返回值為 pair<iterator,bool>,之後會用到這一返回值。

  3. s.erase(itl, itr); 刪除 s 中的一段區間\([itl,itr)\),其中 \(itl\), \(itr\) 的型別為 set::iterator,也就是兩個迭代器。

  4. s.lower_bound(x); 在 s 中二分查詢大於等於 \(x\) 的元素,返回指向第一個大於等於 \(x\) 的元素所在的位置的迭代器。

一. 建樹

用一個結構體表示每一個小區間。在結構體中記錄三個值 \(l\) , \(r\), \(val\) ,分別表示這段區間的左端點、右端點和區間中每個元素的數值。

struct node{
	int l, r;
	mutable ll val;
	node(int L, int R=-1, ll V=0):l(L), r(R), val(V){}
	bool operator<(const node &oth)const{return this->l<oth.l;}
};
set<node> s;

inline void build()
{
	for (int i=1; i<=n; ++i)
    {
        a[i] = read();
        s.insert(node(i, i, a[i]));
    }
    s.insert(node(n+1, n+1, 0));
}

值得一提的地方:

  1. 為了保證區間有序,std::set 中的 node 是按照 \(l\) 來排序的。也可以理解為我們以 \(l\) 作為這個區間的代表。

  2. 由於 std::set 自身的原因,\(val\) 前必須有 mutable,以便支援區間修改等操作。

  3. 在插入所有元素後需要再插入一個虛擬區間,以保證之後在查詢區間時不會出錯。

這樣我們就建好了一棵 Chtholly Tree。

但是這樣就和原序列完全一致了,一共有 \(n\) 個小區間。所以我們需要一些操作來減少區間的數量。

二. 核心操作:split 和 assign

要想維持珂朵莉樹的優秀時間複雜度,這兩個操作必不可少。

1.split(index)

這個操作把 std::set 維護的區間從 \(index\) 分成兩段,且不改變不包含下標 \(index\) 的區間。

步驟如下:

  1. 首先在已有的區間中查詢 \(l=index\) 的區間,如果找到了就直接返回,否則進行下一步操作。

  2. 經過上一步操作,我們要找的 \(index\) 一定已經被包含在一個區間中,所以我們要把包含 \(index\) 的區間分成兩個更小的區間。具體來說,我們找到包含 \(index\) 的區間,然後刪除該區間 \([l,r]\) ,再在 std::set 中插入區間 \([l,index-1]\) 和區間 \([index,r]\)\(val\) 值當然都為原區間的 \(val\)

  3. \(\operatorname{split}\) 操作會增加 std::set 維護的區間數量,但是這對時間複雜度基本不影響。

  4. \(\operatorname{split}(index)\) 操作的返回值是一個指向以 \(index\)\(l\) 的區間的迭代器,理解為指標即可。利用了 std::set 的 insert 操作的返回值。

#define IT set<node>::iterator
  
IT split(int ind)
{
	IT it = s.lower_bound(node(ind));
	if(it != s.end()&&it->l == ind)return it;
	--it;
	int xl = it->l, xr = it->r;
	ll v = it->val;
	s.erase(it);
	s.insert(node(xl, ind-1, v));
	return s.insert(node(ind, xr, v)).first;
} 

以上就是 Chtholly Tree 的核心操作。

之後如果要對一段區間 \([l,r]\) 進行操作,只需要分離出區間 \([l,r]\),然後用最樸素的方法亂搞即可。

2.assign(x, y, z)

\(\operatorname{assign}(x, y, z)\):把一段區間 \([x,y]\) 的值全部賦成一個數 \(z\)

能使用 Chtholly Tree 的題目都會有這個操作。足夠的 \(\operatorname{assign}\) 操作是 Chtholly Tree 時間複雜度的保障。

事實上 \(\operatorname{assign}(x,y,z)\) 操作很好實現,我們只需要分離出左端點為 \(x\) 的區間,再分離出左端點為 \(y+1\) 的區間,用一個元素值都為 \(z\) 值區間 \([x,y]\) 替換掉這兩個區間中的所有區間即可。

void assign(int l ,int r, ll v)
{
	IT itr = split(r+1), itl = split(l);
	s.erase(itl, itr);
	s.insert(node(l, r, v));
}

值得注意的地方:

  1. \(\operatorname{split}\) 的順序最好按照程式碼中的順序,否則可能會有玄學錯誤。

事實上,Chtholly Tree 的基礎操作只有以上的 \(\operatorname{split}\)\(\operatorname{assign}\)。只要掌握了這兩個操作,任何能用 Chtholly Tree 求解的題目就都不難做了。

另外,以上的操作不會使 Chtholly Tree 維護的區間產生重複或遺漏的情況。

三. 一道最經典的題目:CF896C

CF896C Willem, Chtholly and Seniorious

題意:給定一個長度為 \(n\) 的序列 \(a\) ,一共有 \(m\) 個操作,包含以下四種:

  • \((1,l,r,x)\) : 給定一段區間 \([l, r]\) ,把這段區間內的每一個元素都加上 \(x\)

  • \((2,l,r,x)\) : 給定一段區間 \([l, r]\)把這段區間內的每一個元素都變成 \(x\)

  • \((3,l,r,x)\) : 給定一段區間 \([l, r]\) ,求這段區間內排名為 \(x\) 的元素。

  • \((1,l,r,x,y)\) : 給定一段區間 \([l, r]\) ,求這段區間內的所有元素的 \(x\) 次方和 \(\bmod\ y\) 的值,即求\(\sum_{i=l}^r({a_i}^x)\ \bmod\ y\)

  • $n \leq 10^5 $, $m \leq 10^5 $

保證資料隨機生成。

以上標黑的字型就是本題可以使用 Chtholly Tree 的關鍵:區間賦值操作和資料隨機生成。

接下來我們考慮如何用 Chtholly Tree 實現本題的四個操作。

首先可以發現操作2就是 \(\operatorname{assign}\) 操作,直接套用即可。

void assign(int l ,int r, ll v)
{
	IT itr = split(r+1), itl = split(l);
	s.erase(itl, itr);
	s.insert(node(l, r, v));
}

接下來考慮操作1。我們可以分離出區間 \([l,r]\) ,然後暴力把這段區間內的每一個結點的 \(val\) 都加上 \(x\)

這樣就行了。

void add(int l, int r, ll v)
{
	IT itr = split(r+1), itl = split(l);
	for(;itl!=itr;++itl)
		itl->val += v;
}

就是這麼暴力,所以 Chtholly Tree 才優美。

關於時間複雜度的問題,之後會再做討論。

然後考慮操作3。我們先把區間 \([l,r]\) 中的所有結點暴力取出來,放進一個 std::vector 裡,按照 \(val\) 排序,然後暴力列舉 vector 中的元素,每次記錄當前已經列舉過的元素的數量,直到找到第 \(x\) 大的元素即可返回。

注意 Chtholly Tree 的結點中存的是一段區間,記錄當前列舉過元素的數量時要加上這段區間的長度。

ll krank(int l ,int r, ll k)
{
	vp.clear();
	IT itr = split(r+1), itl = split(l);
	for(;itl!=itr;++itl)
		vp.push_back(make_pair(itl->val, itl->r-itl->l+1));
	sort(vp.begin(), vp.end());
	for(vector<pair<ll, int> >::iterator it = vp.begin();it!=vp.end();++it)
	{
		k -= it->second;
		if(k<=0) return it->first;
	}
	return -1ll;
}

最後是操作4。還是暴力列舉區間中的結點,然後快速冪計算每個元素 \(x\) 次方和即可。

還是要注意Chtholly Tree 的結點中存的是一段區間,所以每一個結點對答案的貢獻要乘上區間長度,另外注意取模。

ll power(ll x, ll p, ll mod)
{
	ll res = 1, base = x%mod;
	while(p)
	{
		if(p&1)res = res*base%mod;
		base = base*base%mod;
		p>>=1;
	}
	return res%mod;
}
ll sum(int l, int r, ll p, ll mod)
{
	IT itr = split(r+1), itl = split(l);
	ll res = 0;
	for(;itl!=itr;++itl)
		res = 1ll*(1ll*res+1ll*(itl->r-itl->l+1)*power(itl->val, (ll)p, (ll)mod))%mod;
	return res;
}

就這樣,我們以近乎純暴力的解法完成了這道題的所有操作。

時間複雜度可以感性理解一下:足夠隨機的 \(\operatorname{assign}\) 操作保證了 std::set 中的結點數量不會太多,所以每次區間操作都是跑不滿 \(n\) 的。單次操作(不算快速冪)的期望時間複雜度應該是在 \(O(logn)\) 左右,足以通過本題。

完整程式碼如下:

#include<cstdio>
#include<algorithm>
#include<iostream>
#include<set>
#include<vector>
#include<cstring>
#define IT set<node>::iterator
using namespace std;
typedef long long ll;
const int MAXN = 100100;
const int MOD7 = 1e9 + 7;
const int MOD9 = 1e9 + 9;
struct node{
	int l, r;
	mutable ll val;
	node(int L, int R=-1, ll V=0):l(L), r(R), val(V){}
	bool operator<(const node &oth)const{return this->l<oth.l;}
};
set<node> s;
vector<pair<ll, int> > vp;
IT split(int ind)
{
	IT it = s.lower_bound(node(ind));
	if(it != s.end()&&it->l == ind)return it;
	--it;
	int xl = it->l, xr = it->r;
	ll v = it->val;
	s.erase(it);
	s.insert(node(xl, ind-1, v));
	return s.insert(node(ind, xr, v)).first;
}
void assign(int l ,int r, ll v)
{
	IT itr = split(r+1), itl = split(l);
	s.erase(itl, itr);
	s.insert(node(l, r, v));
}
void add(int l, int r, ll v)
{
	IT itr = split(r+1), itl = split(l);
	for(;itl!=itr;++itl)
		itl->val += v;
}
ll krank(int l ,int r, ll k)
{
	vp.clear();
	IT itr = split(r+1), itl = split(l);
	for(;itl!=itr;++itl)
		vp.push_back(make_pair(itl->val, itl->r-itl->l+1));
	sort(vp.begin(), vp.end());
	for(vector<pair<ll, int> >::iterator it = vp.begin();it!=vp.end();++it)
	{
		k -= it->second;
		if(k<=0)return it->first;
	}
	return -1ll;
}
ll power(ll x, ll p, ll mod)
{
	ll res = 1, base = x%mod;
	while(p)
	{
		if(p&1)res = res*base%mod;
		base = base*base%mod;
		p>>=1;
	}
	return res%mod;
}
ll sum(int l, int r, ll p, ll mod)
{
	IT itr = split(r+1), itl = split(l);
	ll res = 0;
	for(;itl!=itr;++itl)
		res = 1ll*(res+1ll*(itl->r-itl->l+1)*power(itl->val, (ll)p, (ll)mod))%mod;
	return res;
}
ll n,m,seed,vmax,a[MAXN];
ll rnd()
{
    ll ret = seed;
    seed = (seed * 7 + 13) % MOD7;
    return ret;
}

int main()
{
    scanf("%d %d %lld %lld",&n,&m,&seed,&vmax);
    for (int i=1; i<=n; ++i)
    {
        a[i] = (rnd() % vmax) + 1;
        s.insert(node(i,i,a[i]));
    }
    s.insert(node(n+1, n+1, 0));
    for (int i =1; i <= m; ++i)
    {
        int op = int(rnd() % 4) + 1;
        int l = int(rnd() % n) + 1;
        int r = int(rnd() % n) + 1;
        if (l > r)
            std::swap(l,r);
        int x, y;
        if (op == 3)
            x = int(rnd() % (r-l+1)) + 1;
        else
            x = int(rnd() % vmax) +1;
        if (op == 4)
            y = int(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",krank(l, r, ll(x)));
        else
            printf("%lld\n",sum(l, r, ll(x), ll(y)));
    }
    return 0;
}

四.其他題目(隨時更新)

另外還有一道可以用 Chtholly Tree 吸氧做的題:P2146 [NOI2015]軟體包管理器

正解是重鏈剖分+線段樹,但是用 Chtholly Tree 吸氧也能過。