解决区间第K大的问题的各种方法
例题: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)
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
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 }
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
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值的个数;
- 如果当前结点部分包含在区间中那就递归到子结点中去。
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
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 }
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
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时刻变化的数,变化的肯定是新插入的数。线段树的加减也没法简单的用代码实现啊。
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
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 }
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
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 }
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
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代码:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
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 }
划分树确实溜啊,速度比前面的几种方法跑起来都快,空间占用也要小。