例題:http://poj.org/problem?id=2104
最近可能是念念不忘,必有回響吧,總是看到區間第k大的問題,第一次看到是在知乎上有人面試被弄懵了后來又多次在比賽中看到。以前大概是知道怎么解決但是沒有實際操作過。直到昨天看到了POJ上的2104題,一個標准的區間第K大詢問,然后好好總結了一下區間第K大的問題。
普通人要是沒想過這個問題,突然被問到第一個反應肯定和知乎上面試的哥們兒一樣,把區間里面的所有數拎出來,排序,找第K個,但是這樣時間復雜度是很大的,如果m次詢問,時間復雜度是O( m×(n + n×logn) )要是詢問次數m非常大時間復雜度很恐怖。
要是優化就有很多種方法,第一種就是利用分治的思維,分塊。將n個數分成√n × logn 塊,然后對每個塊進行排序。既然是區間第K大,那假設N是區間內第K大的數,那么不大於N的數至少有K個。這樣對N值進行二分枚舉,每枚舉出一個N值,然后去區間中找不大於N的數。因為對於每個塊都是排好序的,所以如果該塊完全包含在區間內,就直接對塊進行二分查找不大於N的數有多少個。塊部分包含在區間內的就直接暴力查找(過程如圖1.1所示)。根據查找的值再擴大或者縮小N值。
圖1.1
這個時候時間復雜度就是O( n×logn + m√nlog1.5n)
1 void init() { 2 scanf("%d%d",&n,&m);//n個數m次詢問 3 unit = 1000;//分塊大小 4 for(ll i=0;i<n;i++) { 5 scanf("%d",&num[i]); 6 OdrArr[i] = num[i]; 7 ve[i/unit].push_back(num[i]);//分別裝入塊中 8 } 9 for(ll i=0;i<n/unit;i++)//最后一個塊不用排序 10 sort(ve[i].begin(),ve[i].end());//對每個塊排序 11 sort(OdrArr, OdrArr+n);//二分枚舉N值 12 }
1 int query(int L, int R, int k) {//詢問區間L,R內的第k大 2 int l = -1, r = n-1; 3 while(r - l > 1) {//結束狀態為l + 1 = r, r取閉 4 int cnt = 0; 5 int mid = (l + r) >> 1; 6 int temp_l = L, temp_r = R+1;//設定區間為左閉右開 7 int va = OdrArr[mid];//二分枚舉N值 8 9 //不完全包含在區間的部分 10 while(temp_l < temp_r && temp_l%unit) 11 if(num[temp_l++] <= va) 12 cnt++; 13 while(temp_l < temp_r && temp_r%unit) 14 if(num[--temp_r] <= va) 15 cnt++; 16 17 //完全包含在區間中的塊 18 while(temp_l < temp_r) { 19 int b = temp_l/unit; 20 cnt += upper_bound(ve[b].begin(), ve[b].end(), va) - ve[b].begin(); 21 temp_l += unit; 22 } 23 if(cnt >= k) 24 r = mid; 25 else 26 l = mid; 27 } 28 return OdrArr[r]; 29 }
但是就分塊我花了各種姿勢都過不了例題,直接TLE。哭唧唧。
第二種方法就是參考歸並排序的姿勢,用一棵線段樹來維護歸並排序的過程。線段樹的每一個葉子結點為一個數,然后父結點就是兩個兒子結點歸並排序。這個時候線段樹的每一個結點代表的就是一個區域,這種線段樹也叫區域樹(Range Tree)。
在查詢區間第k大的時候也需要和分塊一樣二分枚舉N值,但是在找不大於N值個數的時候可以直接在線段樹的中相應的結點上二分查找。這種算法的主要優勢都是用線段樹來模擬歸並排序的過程,那么線段樹結點上代表的那一段區域內的數一定是有序的,二分查找直接上。
具體過程是:
- 如果區間和當前結點完全沒有交集,直接返回0;
- 如果當前結點完全包含在區間內直接二分查找不小於N值的個數;
- 如果當前結點部分包含在區間中那就遞歸到子結點中去。
1 vector <int> Tree[maxn]; 2 void build_tree(int root,int l,int r) { 3 if(l == r) { 4 Tree[root].push_back(num[l]); 5 return ; 6 } 7 int chl = root<<1; 8 int chr = root<<1|1; 9 int mid = l + r >> 1; 10 build_tree(chl, l, mid); 11 build_tree(chr, mid+1, r); 12 Tree[root].resize(r-l+1);//開線段樹這個結點上區域的大小 13 merge(Tree[chl].begin(), Tree[chl].end(), Tree[chr].begin(), Tree[chr].end(), Tree[root].begin());//用自帶的merge函數 14 }
1 int Sum(int root, int ql, int qr, int l, int r, int va) {//查詢值為va的數有幾個 2 if(ql == l && qr == r) {//結點完全包含在區域內 3 return upper_bound(Tree[root].begin(), Tree[root].end(), va) - Tree[root].begin(); 4 } 5 int chl = root<<1; 6 int chr = root<<1|1; 7 int mid = l + r >> 1; 8 if(qr <= mid) { 9 return Sum(chl, ql, qr, l, mid, va); 10 } else if(ql > mid) { 11 return Sum(chr, ql, qr, mid+1, r, va); 12 } else {//部分包含遞歸下去 13 return Sum(chl, ql, mid, l, mid, va) + Sum(chr, mid+1, qr, mid+1, r, va); 14 } 15 } 16 17 int query(int ql, int qr, int va) { 18 int l = 0, r = n; 19 while(r - l > 1) { 20 int mid = l + r >> 1; 21 int x = OdrArr[mid];//枚舉N值 22 int cnt = Sum(1, ql, qr, 1, n, x); 23 if(cnt >= va)//r端為閉 24 r = mid; 25 else 26 l = mid; 27 } 28 return OdrArr[r]; 29 }
用這種方法就可以過例題了,只不過跑的比較慢,6266ms。
上面說的兩種方法其實思想都是很簡單的,就是想辦法排序,然后二分查找。因為只有二分查找的時候能夠減少一個量級(從O(n) 到 O(logn))的復雜度。下面說的第三種方法就和前兩種有一些不一樣。第三種方法就是可持久化線段樹,先不說可持久化線段樹,就說線段樹。如果線段樹需要修改,但是要保留每一次修改之后線段樹的模樣,不能覆蓋掉,咋辦?創立多棵線段樹,每一棵線段樹表示一個時刻該線段樹的狀態。這樣我們在解決區間第K大問題的時候就可以這樣。我們把每一個數按時刻插入到線段樹中,例如第一個數就在第一刻插入線段樹中,第二個數就先將第一棵線段樹復制下來,然后再插入,第三個數就將第二棵線段樹復制下來,然后插入第三個數。
這樣如果查找l到r區間內的某個數,那么第r棵線段樹比第l-1棵線段樹多出來的數就是區間l到r內的數。我們在將數字插入線段樹的時候就可以按照大小順序插入,並且維護線段樹每個結點上數的個數。在找第K大的時候就是r這棵線段樹從左往右開始數比l-1這個線段樹多出的第K個數,不懂得看后面的例子。例如第l-1棵線段樹的葉子節點(-1代表這個數不存在)是1,2,-1,-1,4,5,-1,-1。第r棵線段樹的葉子節點是1,2,3,4,4,5,6,-1,那么多出來的數就是3,4,6,第2大的數就是4。維護每個節點數的個數就可以在查找的時候logn復雜度內解決。
但是算一算空間復雜度,n個數就是n棵線段樹,空間爆炸啊。這個時候就是可持久化線段樹的實現方式了,我們每一次修改線段樹一個葉子節點上的值對於整棵樹來說需要修改多少個節點,logn個,這么一算其實需要修改的節點並不多,那就可以在建立下一個線段樹的時候需要修改的節點我們開辟新的空間來儲存,沒有改變的節點共用就行了,反正都沒變還分什么你的我的。
為了能夠更好的理解可持久化線段樹為什么能夠解決區間第k大的問題,以上的描述是用n棵不同的線段樹去記錄,但是可持久化線段樹表示的是同一個線段樹在不同的時間節點的不同形態,所以不能看成線段樹的加減。r樹比l-1樹多出的數為啥是l到r區間的數,因為r節點比l-1節點多出的數是在l到r時刻變化的數,變化的肯定是新插入的數。線段樹的加減也沒法簡單的用代碼實現啊。
1 void build_tree(int &root, int l, int r) {//開始是一棵空樹 2 root = ++cnt; 3 node[root].sum = 0; 4 if(l == r) { 5 return ; 6 } 7 int mid = l + r >> 1; 8 build_tree(node[root].l, l, mid); 9 build_tree(node[root].r, mid+1, r); 10 }
1 void insert(int &root, int pre, int pos, int l, int r) { 2 root = ++cnt; 3 node[root] = node[pre]; 4 if(l == r) { 5 node[root].sum++; 6 return ; 7 } 8 int mid = (l + r) >>1 ; 9 if(pos <= mid) 10 insert(node[root].l, node[pre].l, pos, l, mid); 11 else 12 insert(node[root].r, node[pre].r, pos, mid+1, r); 13 updata(root); 14 } 15 16 void insert() { 17 for(int i=1;i<=n;i++) { 18 int pos = (int)(lower_bound(ve.begin(), ve.end(), num[i]) - ve.begin()) + 1;//離散化 19 insert(rt[Time], rt[Time-1], pos, 1, n);//rt存儲每一個根結點的編號 20 Time++; 21 } 22 }
int query(int i, int j, int k, int l, int r) {//在區間i,j中找第k大 if(l == r) { return ve[l-1]; } int mid = l + r >> 1; int Sum = node[node[j].l].sum - node[node[i].l].sum; if(Sum >= k) return query(node[i].l, node[j].l, k, l, mid); else return query(node[i].r, node[j].r, k-Sum, mid+1, r);//進入右節點的時候要將左邊已經找到的刪除 }
可持久化線段樹過2104題還是過得很快的,大概跑了1688ms。
第四種方法是利用划分樹,說實話我也是第一次使用划分樹,划分樹較為復雜,寫的時候也不容易扯清楚。自己搞了半天搞過了,然而我也有點好奇為啥專門有一個划分樹用來解決第K大的問題,除此之外沒發現什么地方還需要使用划分樹,后來在網上發現有別人寫文章把划分樹說得十分清楚,這就不多贅述了。需要了解划分樹的傳送:https://www.cnblogs.com/hchlqlz-oj-mrj/p/5744308.html 以及https://blog.csdn.net/luomingjun12315/article/details/51253205
AC代碼:
1 #include <stdio.h> 2 #include <algorithm> 3 #include <vector> 4 using namespace std; 5 const int maxn = 1e5+100; 6 7 int num[20][maxn], va[20][maxn], odr[maxn]; 8 int n, m; 9 10 void build_tree(int row, int l, int r) { 11 if(l == r) 12 return ; 13 int mid = l + r >> 1; 14 int cnt_l = l, cnt_r = mid + 1; 15 int sameMid = 0; 16 for(int i=l;i<=r;i++) { 17 if (odr[i] == odr[mid]) 18 sameMid++; 19 else if (odr[i] > odr[mid]) 20 break; 21 } 22 for(int i=l;i<=r;i++) { 23 if(i == l) 24 num[row][i] = 0; 25 else 26 num[row][i] = num[row][i-1]; 27 if(va[row][i] < odr[mid] || (sameMid > 0 && va[row][i] == odr[mid])) { 28 va[row+1][cnt_l++] = va[row][i]; 29 num[row][i]++; 30 if(va[row][i] == odr[mid]) 31 sameMid--; 32 } else { 33 va[row+1][cnt_r++] = va[row][i]; 34 } 35 } 36 build_tree(row+1, l, mid); 37 build_tree(row+1, mid+1, r); 38 } 39 40 void init() { 41 scanf("%d%d",&n,&m); 42 for(int i=1;i<=n;i++) { 43 scanf("%d", &odr[i]); 44 va[0][i] = odr[i]; 45 } 46 sort(odr+1, odr+1+n); 47 build_tree(0, 1, n); 48 } 49 50 int query(int row, int ql, int qr, int l, int r, int k) { 51 int mid = l + r >> 1; 52 if(l == r) { 53 return va[row][l]; 54 } 55 int fromLeft = 0;//來自ql之前的數進入左孩子有幾個 56 if(l != ql) 57 fromLeft = num[row][ql-1]; 58 int cnt = num[row][qr]-fromLeft;//qr之前的進入左孩子個數減去(ql-1)之前的個數就是ql-qr的個數 59 if(cnt >= k) {//如果ql到qr進入左孩子的個數大於等於k,那么k就在左孩子里面 60 return query(row+1, l+fromLeft, l+num[row][qr]-1, l, mid, k);//進入左孩子中,從大於ql之前進入左孩子的開始找第k個 61 } else {//否則進入右兒子尋找 62 int pos_r = mid+1+(ql - l - fromLeft);//ql-l-formLeft是ql之前進入右兒子的個數 63 return query(row+1, pos_r, pos_r+qr-ql+1-cnt-1, mid+1, r, k-cnt);//從pos_r找qr-ql+1個但是要減去在左兒子找到的cnt個 64 } 65 } 66 67 int main() { 68 init(); 69 while(m--) { 70 int ql, qr , k; 71 scanf("%d%d%d",&ql, &qr, &k); 72 printf("%d\n", query(0, ql, qr, 1, n, k)); 73 } 74 return 0; 75 }
划分樹確實溜啊,速度比前面的幾種方法跑起來都快,空間占用也要小。
