本作品采用知识共享署名-相同方式共享 4.0 国际许可协议进行许可。
好吧为什么我突然想起来要去写主席树呢?因为在做codeforces 787E
这题的时候,我用了二分+剪枝的算法莫名其妙的过了,然而时间复杂度算出来感觉不对。网上一查,也有这么过的,但是主要还是写了主席树,所以回来想到是不是应该学一下主席树。
主席树最经典的应用就是在求区间第
k
k
k大的问题了。我们来看一下例题POJ 2104
。题目大意就是求区间第
k
k
k大,数据范围
n
≤
1
0
5
,
m
≤
5
×
1
0
3
n\le 10^5, m\le 5\times 10^3
n≤105,m≤5×103。
题目描述
You are working for Macrohard company in data structures department. After failing your previous task about key insertion you were asked to write a new data structure that would be able to return quickly k-th order statistics in the array segment.
That is, given an array
a
[
1...
n
]
a[1...n]
a[1...n] of different integer numbers, your program must answer a series of questions
Q
(
i
,
j
,
k
)
Q(i, j, k)
Q(i,j,k) in the form: “What would be the k-th number in
a
[
i
.
.
.
j
]
a[i...j]
a[i...j] segment, if this segment was sorted?”
For example, consider the array
a
=
(
1
,
5
,
2
,
6
,
3
,
7
,
4
)
a = (1, 5, 2, 6, 3, 7, 4)
a=(1,5,2,6,3,7,4). Let the question be
Q
(
2
,
5
,
3
)
Q(2, 5, 3)
Q(2,5,3). The segment
a
[
2...5
]
a[2...5]
a[2...5] is
(
5
,
2
,
6
,
3
)
(5, 2, 6, 3)
(5,2,6,3). If we sort this segment, we get
(
2
,
3
,
5
,
6
)
(2, 3, 5, 6)
(2,3,5,6), the third number is
5
5
5, and therefore the answer to the question is
5
5
5.
样例
Input | Ouput |
---|---|
7 31 5 2 6 3 7 42 5 34 4 11 7 3 | 563 |
解题思路
我们首先考虑如何暴力地维护这个东西。
我们知道,对于一个给定的数列
a
[
1...
n
]
a[1...n]
a[1...n]的第
k
k
k大,我们可以将其离散化,也就是每个数对应的编号是它在数列里从大到小排的第
i
i
i个。然后建立一个线段树来维护这个离散后的区间
o
r
d
[
1...
n
]
ord[1...n]
ord[1...n]。
简单来说,比如数列
{
1
,
1
,
4
,
5
,
1
,
4
}
\{1,1,4,5,1,4\}
{1,1,4,5,1,4},我们注意到
1
1
1出现
3
3
3次,
4
4
4出现
2
2
2次,
5
5
5出现
1
1
1次,那么我们离散化之后,
1
1
1就对应编号
1
1
1,
4
4
4对应编号
2
2
2,
5
5
5对应编号
3
3
3,其中编号为
1
1
1的出现了
3
3
3次(以此类推),得到数组
b
=
{
3
,
2
,
1
}
b=\{3,2,1\}
b={3,2,1}。数组
b
b
b就表示出现的次数。然后建一个线段树维护数组
b
b
b。这个操作实际上是每次对于一个叶节点,将该叶节点到根路径上的点全部权值加
1
1
1。
接下来怎么查找第
k
k
k大呢?我们可以在整颗线段树上二分,对应当前节点
o
o
o,如果
o
o
o控制的区间里出现的次数大于当前查的
r
a
n
k
rank
rank,那么我们就在
o
o
o的左儿子里继续二分,查找第
r
a
n
k
rank
rank大的位置;否则在右儿子里二分,查第
r
a
n
k
−
s
z
rank-sz
rank−sz大的位置,其中
s
z
sz
sz表示
o
o
o左儿子控制的大小。这个递归原理比较简单,这里不再多说。
但是这个算法对于给定区间
[
1
,
x
]
[1,x]
[1,x]查询的复杂度是
O
(
n
log
2
n
)
O(n \log_2 n)
O(nlog2n)。对于任意区间,我们需要维护
n
n
n棵线段树,第
i
i
i棵维护
[
1
,
i
]
[1,i]
[1,i]的区间第
k
k
k大,这样当查
[
l
,
r
]
[l,r]
[l,r]时,我们就可以用
[
1
,
r
]
−
[
1
,
l
)
[1,r]-[1,l)
[1,r]−[1,l)来求答案了。复杂度变成
O
(
n
2
⋅
log
2
n
+
q
⋅
log
n
)
O(n^2 \cdot \log_2 n + q\cdot \log n)
O(n2⋅log2n+q⋅logn)。太大了。
于是我们考虑优化一下这个线段树。
我们知道,我们在维护第
i
i
i个线段树的时候,对于第
i
+
1
i+1
i+1个线段树,我们只修改了其中的一条链——就是
a
[
i
+
1
]
a[i+1]
a[i+1]对应的那条,那么我们是不是可以不用特意建一棵树呢?于是我们找到了一个办法——只把路径上的那一串点全部用新的点替代,剩下的不变!于是就变成了下面这样的图。注意,每次修改完之后,我们要把
o
r
d
ord
ord数组对应的点(就是离散化的编号)换成新的。
每一次的操作都是
O
(
log
2
n
)
O(\log_2 n)
O(log2n)的,空间每次也只增加了
O
(
log
2
n
)
O(\log_2 n)
O(log2n)。于是我们
n
n
n棵线段树总的维护时间就是
O
(
n
log
2
n
)
O(n\log_2 n)
O(nlog2n),空间
O
(
n
log
2
n
)
O(n\log_2 n)
O(nlog2n),查询是
O
(
q
log
2
n
)
O(q\log_2 n)
O(qlog2n)的。我们就很愉快的解决了这个问题。
代码
#include<cstdio>
#include<cstring>
#include<algorithm>
const int MAXN=100010;
const int MAXM=MAXN<<6;
struct node{
int sum,l,r;
}tr[MAXM];
int n,m;
int a[MAXN],ord[MAXN],num[MAXN];
int top=0,rt[MAXN];
int find_pos(int k){
int l=1,r=n;
while(l+1<r){
int mid=(l+r)>>1;
if(ord[mid]>=k)
r=mid;
else
l=mid;
}
if(k==ord[l])
return l;
else
return r;
}
void addt(int num,int o,int l,int r){//修改一条链
tr[++top].sum=tr[o].sum+1;//new node
if(l==r){
tr[top].l=tr[top].r=tr[o].l;
return;
}
int mid=(l+r)>>1;
if(num<=mid){
tr[top].l=top+1;tr[top].r=tr[o].r;
addt(num,tr[o].l,l,mid);
}
else{
tr[top].l=tr[o].l;tr[top].r=top+1;
addt(num,tr[o].r,mid+1,r);
}
}
int query(int lt,int rt,int k,int l,int r){
if(l==r)
return l;
int mid=(l+r)>>1;
int rk=tr[tr[rt].l].sum-tr[tr[lt].l].sum;//[1,r]-[1,l)得出左儿子的大小
if(k<=rk)
return query(tr[lt].l,tr[rt].l,k,l,mid);
else
return query(tr[lt].r,tr[rt].r,k-rk,mid+1,r);
}
int main(){
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++){
scanf("%d",&a[i]);
ord[i]=a[i];
}
std::sort(ord+1,ord+1+n);
for(int i=1;i<=n;i++)
num[i]=find_pos(a[i]);
for(int i=1;i<=n;i++){
rt[i]=top+1;//实际对应结点编号
addt(num[i],rt[i-1],1,n);
}
for(int i=1;i<=m;i++){
int l,r,k;scanf("%d%d%d",&l,&r,&k);
printf("%d\n",ord[query(rt[l-1],rt[r],k,1,n)]);
}
return 0;
}
总结
主席树一般可以用来求解区间第
k
k
k大问题,是线段树的一种拓展,融合了树上二分、等效替代等多种思想。
对于主席树其实还有一个名称叫可持久化线段树,他们之间有什么联系呢?
可持久化,就是要查询历史版本。想一下我们每次更新的时候是怎么做的吗?加入新点,扔掉旧点——我想你应该明白了,如果把这些旧点回收利用,那么我们就可以知道它以前长什么样。
还是感觉有点复杂的。