主席樹

Violet_fan發表於2024-08-29

主席樹

主席樹全稱是可持久化權值線段樹,即對權值開線段樹,參見 知乎討論

引入

先引入一道題目:給定 \(n\) 個整數構成的序列 \(a\),將對於指定的閉區間 \([l, r]\) 查詢其區間內的第 \(k\) 小值。

你該如何解決?

一種可行的方案是:使用主席樹。
主席樹的主要思想就是:儲存每次插入操作時的歷史版本,以便查詢區間第 \(k\) 小。

怎麼儲存呢?簡單暴力一點,每次開一棵線段樹唄。
那空間還不爆掉?

例如我們上面開了兩個線段樹,現在我們要在4的位置加上1:

發現,我們每次只修改了一條鏈上的值,

所以我們只需要多記錄一條鏈就能代表歷史的版本了:

解釋

我們分析一下,發現每次修改操作修改的點的個數是一樣的。
(例如下圖,修改了 \([1,8]\) 中對應權值為 1 的結點,紅色的點即為更改的點)

只更改了 \(O(\log{n})\) 個結點,形成一條鏈,也就是說每次更改的結點數 = 樹的高度。
注意主席樹不能使用堆式儲存法,就是說不能用 \(x\times 2\)\(x\times 2+1\) 來表示左右兒子,而是應該動態開點,並儲存每個節點的左右兒子編號。
所以我們只要在記錄左右兒子的基礎上,儲存插入每個數的時候的根節點就可以實現持久化了。

我們把問題簡化一下:每次求 \([1,r]\) 區間內的 \(k\) 小值。
怎麼做呢?只需要找到插入 r 時的根節點版本,然後用普通權值線段樹(有的叫鍵值線段樹/值域線段樹)做就行了。

這個相信大家都能理解,回到原問題——求 \([l,r]\) 區間 \(k\) 小值。
這裡我們再聯絡另外一個知識:字首和
這個小東西巧妙運用了區間減法的性質,透過預處理從而達到 \(O(1)\) 回答每個詢問。

我們可以發現,主席樹統計的資訊也滿足這個性質。
所以……如果需要得到 \([l,r]\) 的統計資訊,只需要用 \([1,r]\) 的資訊減去 \([1,l - 1]\) 的資訊就行了。

至此,該問題解決!

關於空間問題,我們分析一下:由於我們是動態開點的,所以一棵線段樹只會出現 \(2n-1\) 個結點。
然後,有 \(n\) 次修改,每次至多增加 \(\lceil\log_2{n}\rceil+1\) 個結點。因此,最壞情況下 \(n\) 次修改後的結點總數會達到 \(2n-1+n(\lceil\log_2{n}\rceil+1)\)
此題的 \(n \leq 10^5\),單次修改至多增加 \(\lceil\log_2{10^5}\rceil+1 = 18\) 個結點,故 \(n\) 次修改後的結點總數為 \(2\times 10^5-1+18\times 10^5\),忽略掉 \(-1\),大概就是 \(20\times 10^5\)

最後給一個忠告:千萬不要吝嗇空間(大多數題目中空間限制都較為寬鬆,因此一般不用擔心空間超限的問題)!大膽一點,直接上個 \(2^5\times 10^5\),接近原空間的兩倍(即 n << 5)。

實現

#include<bits/stdc++.h>
#define int long long
#define endl "\n"
using namespace std;

const int N=2e5+3;
using i64 = long long;

int n,m,q;
struct node
{
	int val,ls,rs,sum;
}tr[2*N+N*18];
int a[N];
vector<int> vy;
int rt[N],tot;
int getid(int x)  // 離散化
{
	return lower_bound(vy.begin(),vy.end(),x)-vy.begin()+1;
}
int build(int l,int r)  //建樹,最初只有2*n-1個節點
{
	int p=++tot;
	if(l==r) return p;
	int mid=(l+r)/2;
	tr[p].ls = build(l,mid);
	tr[p].rs = build(mid+1,r);
	return p;
}
// pos為要插入的位置,[l,r]為操作的範圍,f為該節點的歷史節點
int update(int pos,int l,int r,int f)  
{
	int p=++tot;
	tr[p].ls=tr[f].ls,tr[p].rs=tr[f].rs,tr[p].sum=tr[f].sum+1;
	if(l==r) return p;
	int mid=(l+r)/2;
	if(pos<=mid) tr[p].ls=update(pos,l,mid,tr[p].ls);
	else tr[p].rs=update(pos,mid+1,r,tr[p].rs);
	return p;
}
//查詢區間[l,r]的第k小數,u為前一次版本的根節點,v為當前版本的根節點
int query(int u,int v,int l,int r,int k)  
{
	int mid=(l+r)/2;
	int x=tr[tr[v].ls].sum-tr[tr[u].ls].sum; //左子樹的新增個數
	if(l==r) return l;
	if(k<=x) return query(tr[u].ls,tr[v].ls,l,mid,k);  //說明左子樹新增個數大於k,一定在左子樹
	else return query(tr[u].rs,tr[v].rs,mid+1,r,k-x);
}
signed main()
{
	cin>>n>>m;
	for(int i=1;i<=n;i++)
	{
		cin>>a[i];
		vy.push_back(a[i]);
	}
	sort(vy.begin(),vy.end());
	vy.erase(unique(vy.begin(),vy.end()),vy.end());
	int len=vy.size();
	rt[0]=build(1,len);
	for(int i=1;i<=n;i++)
	{
		int id=getid(a[i]);
		rt[i]=update(id,1,len,rt[i-1]);  //記錄每個歷史版本的根節點
	}
	for(int i=1;i<=m;i++)
	{
		int l,r,k;
		cin>>l>>r>>k;
		int id=query(rt[l-1],rt[r],1,len,k); //字首和思想
		//cout<<id<<endl;
		cout<<vy[id-1]<<endl;
	}
}

參考

主席樹詳解——讓你躺著學會主席樹

可持久化線段樹

相關文章