主席樹
主席樹全稱是可持久化權值線段樹,即對權值開線段樹,參見 知乎討論。
引入
先引入一道題目:給定 \(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;
}
}
參考
主席樹詳解——讓你躺著學會主席樹
可持久化線段樹