拋出問題
給定\(N\)個數(\(int\)范圍內),一共\(M\)次詢問,每次都要詢問區間\([l,r]\)的第\(k\)大的數。
其中\(N,M,l,r\)均不超過\(2\times 10^5\),保證詢問有答案。
點我去模板題
解決問題
暴力法
顯而易見,最暴力的辦法就是區間排序然后輸出排序后第\(k\)個數。最壞情況的時間復雜度是\(O(nm\lg n)\),不超時才怪。
主席樹(可持久化線段樹)法
於是針對這個問題,新的數據結構誕生了,也就是主席樹。
主席樹本名可持久化線段樹,也就是說,主席樹是基於線段樹發展而來的一種數據結構。其前綴"可持久化"意在給線段樹增加一些歷史點來維護歷史數據,使得我們能在較短時間內查詢歷史數據,圖示如下。
圖中的橙色節點為歷史節點,其右邊多出來的節點是新節點(修改節點)。
下面我們來講怎么構建這個數據結構。
主席樹教程
- 要求:掌握線段樹這個數據結構。
- 注意:一般主席樹一類的題目,難的不是寫主席樹,而是主席樹的運用。
主席樹的點修改
不同於普通線段樹的是主席樹的左右子樹節點編號並不能夠用計算得到,所以我們需要記錄下來,但是對應的區間還是沒問題的。
//節點o表示區間[l,r],修改點為p,修改值根據題意設定(此處我們先不談題目,只談數據結構)
int modify(int o, int l, int r, int p)
{
int oo = ++node_cnt;
lc[oo] = lc[o]; rc[oo] = rc[o]; sum[oo] = sum[o] + 1;//新節點,這里是根據模板題來的
if(l == r)//遞歸底層返回新節點編號,修改父節點的兒子指向
{
//sum[oo] = t;如果題目要求sum是加t的再這樣弄,然后上面的+1就去掉
return oo;
}
int mid = (l + r) >> 1;
if(p <= mid) lc[oo] = modify(lc[oo], l, mid);
else rc[oo] = modify(rc[oo], mid+1, r);
//sum[oo] = sum[lc[oo]] + sum[rc[oo]];在該題中,不需要這樣做,但是很多情況下是要這樣更新的
return oo;
}
至於主席樹的區間修改,其實也不難,但是復雜度有點高,簡單點的題目一般只有點修改,有時候區間修改可以轉化為點修改(比如NOIP2012借教室,有區間修改的解法也有點修改的解法)。
主席樹的詢問(歷史區間和)
int ql, qr;//查詢區間[l,r]
int query(int o, int l, int r)//節點o代表區間[l,r]
{
int ans = 0, mid = ((l + r) >> 1);
if(!o) return 0;//不存在的子樹
if(ql <= l && r <= qr) return sum[o];//區間包含返回區間值
//都是線段樹標准操作,只不過是左右子樹多了一個記錄而已
if(ql <= mid) ans += query(lc[o], l, mid);
if(qr > mid) ans += query(rc[o], mid+1, r);
return ans;
//點操作就不用說了
}
主席樹復雜度分析
如果只按照上述做法去做的話,每次修改的時間復雜度是\(O(\lg n)\),每次詢問的復雜度也是\(O(\lg n)\)。
模板題教程
模板題就是主席樹的典型例題,詢問區間第\(k\)大。先不說區間\([l,r]\)吧,就說說\([1,r]\)怎么做。
模板題的[1,r]情況
由題意知道我們肯定要對區間進行排序,但是我們的排序不是每次詢問才排序,是初始化就排序離散化——針對數字較大但數據較小的情況(具體見方法)。排序離散化完畢后,以離散化數組建主席樹,設\(i\)屬於區間\([1,n]\),對原數組的\([1,i]\)區間的數做統計(例如下圖,區間中按離散化數組順序統計\(1\)的個數、\(2\)的個數、\(3\)的個數、\(4\)的個數、\(8\)的個數、\(9\)的個數),有序地插入節點到離散化數組的主席樹中,記錄好原數組每個節點對應的線段樹起點,針對樣例有幾個示意圖。注意,這里的橙色節點是新節點,與之前出現的那個圖不一樣。
- \([1,1]\)的情況
- \([1,4]\)的情況
情況以此類推。
我們按照上面的做法構建的主席樹是為了方便我們查找第\(k\)小值。因為我們是以離散數組構建的主席樹,那么從根節點出發,左子樹部分的數必定不大於右子樹部分的數。於是就可以將左兒子的節點個數\(x\)與\(k\)做比較,若\(k\leq x\),則第\(k\)小值一定在左子樹里面,若\(x\leq k\),則第\(k\)小值一定在右子樹里面,然后遞歸往下走,縮小范圍。值得注意的是,前者遞歸時,\(k\)直接傳下去即可,后者遞歸時,需要將\(k\)減去左子樹的數的個數再傳遞這個\(k\)值。
例如我們查找\([1,4]\)中第\(2\)小的值,圖示如下,綠色節點為該值存在的區間位置。
需要注意的是,第二個綠色節點才是綠色根節點的左子樹,因為左子樹表示的區間是靠前的那一半。
方法總結如下:
- 將原始數組復制一份,然后排序好,然后去掉多余的數,即將數據離散化。推薦使用C++的STL中的
unique
函數; - 以離散化數組為基礎,建一個全\(0\)的線段樹,稱作基礎主席樹;
- 對原數據中每一個\([1,i]\)區間統計,有序地插入新節點(題目中\(i\)每增加\(1\)就會多一個數,僅需對主席樹對應的節點增加\(1\)即可);
- 對於查詢\([1,r]\)中第\(k\)小值的操作,找到\([1,r]\)對應的根節點,我們按照線段樹的方法操作即可(這個根節點及其子孫構成的必定是一顆線段樹)。
模板題的解決
現在我們真正來解決區間詢問\([l,r]\)的問題。
構建主席樹的方法是沒有問題的,問題正在於區間詢問怎么寫。其實,解決方案就是將主席樹\([1,r]\)減去主席樹\([1,l-1]\)就行了。其實這個原因並不難想,首先看到主席樹的底層,全部是對數的統計。當主席樹\([1,r]\)減去主席樹\([1,l-1]\)時,統計也跟着減了,也就是說,現在統計記錄的是\([l,r]\)區間。
而我們不需要單獨減,只需要邊遞歸查詢邊減,具體見查詢部分代碼。
//初始的u和v分別代表的是點l-1和點r,l和r分別表示線段樹點代表的區間,初始的k如題
int query(int u, int v, int l, int r, int k)
{
int ans, mid = ((l + r) >> 1), x = sum[lc[v]] - sum[lc[u]];
//因為主席樹是區間統計好了的,只要減一下即可,無需遞歸到葉子再處理
if(l == r)//找到目標位置
return l;
if(x >= k) ans = query(lc[u], lc[v], l, mid, k);
else ans = query(rc[u], rc[v], mid+1, r, k-x);//右子樹記得改變k的值
return ans;
}
模板題完整代碼
至此,模板題也就解決了,下面是完整代碼。注意,修改點定義為了全局變量。
#include <cstdio>
#include <algorithm>
#define M 200010
using namespace std;
int node_cnt, n, m;
int sum[M<<5], rt[M], lc[M<<5], rc[M<<5];//線段樹相關
int a[M], b[M];//原序列和離散序列
int p;//修改點
void build(int &t, int l, int r)
{
t = ++node_cnt;
if(l == r)
return;
int mid = (l + r) >> 1;
build(lc[t], l, mid);
build(rc[t], mid+1, r);
}
int modify(int o, int l, int r)
{
int oo = ++node_cnt;
lc[oo] = lc[o]; rc[oo] = rc[o]; sum[oo] = sum[o] + 1;
if(l == r)
return oo;
int mid = (l + r) >> 1;
if(p <= mid) lc[oo] = modify(lc[oo], l, mid);
else rc[oo] = modify(rc[oo], mid+1, r);
return oo;
}
int query(int u, int v, int l, int r, int k)
{
int ans, mid = ((l + r) >> 1), x = sum[lc[v]] - sum[lc[u]];
if(l == r)
return l;
if(x >= k) ans = query(lc[u], lc[v], l, mid, k);
else ans = query(rc[u], rc[v], mid+1, r, k-x);
return ans;
}
int main()
{
int l, r, k, q, ans;
scanf("%d%d", &n, &m);
for(register int i = 1; i <= n; i += 1)
scanf("%d", &a[i]), b[i] = a[i];
sort(b+1, b+n+1);
q = unique(b+1, b+n+1) - b - 1;
build(rt[0], 1, q);
for(register int i = 1; i <= n; i += 1)
{
p = lower_bound(b+1, b+q+1, a[i])-b;//可以視為查找最小下標的匹配值,核心算法是二分查找
rt[i] = modify(rt[i-1], 1, q);
}
while(m--)
{
scanf("%d%d%d", &l, &r, &k);
ans = query(rt[l-1], rt[r], 1, q, k);
printf("%d\n", b[ans]);
}
return 0;
}
題目復雜度分析
題目一開始的離散化復雜度為\(O(n\lg n)\),構建基礎主席樹復雜度為\(O(n\lg n)\),統計並插入的復雜度是\(O(n\lg n + n\lg n)=O(n\lg n)\),詢問的復雜度是\(O(m\lg n)\)。復雜度總和就是\(O((m+n)\lg n)\)。
尾注
至今還不知道為什么叫主席樹。。。
這道題目是離線的,也就是使用的靜態主席樹。在線修改的一類題目也不難,在此不作講解,但是以后可能會另寫博客。
主席樹這個數據結構還是非常棒的,這也提醒我們應該學會創造性思維。
- 感謝LMH大佬的幫助;
- 感謝洛谷平台的幫助;
- 感謝那些寫題解的大佬的幫助。
寫在最后
感謝大家的關注和閱讀。
本文章借鑒了少許思路,最后經過本人思考獨立撰寫此文章,如需轉載,請注明出處。