预备知识:
首先得明白线段树的基本知识。线段树上每个节点代表了一个区间,左右孩子节点代表被二分的区间。叶子节点记录了需要操作的数组里的每一个元素。配合延迟标记可以快速查找并修改区间信息。
而主席树是一种可持久化线段树,这是为了访问多次更新后之前某个版本的线段树设计的结构,它的优化在于,不记录每个版本完整的线段树,而是记录每次被更新的一条路径(未被更新的节点就用路径节点连回去)这样每次更新只需要存下
logN
l
o
g
N
数量的节点,利用根作为每次更新的索引,这样的空间复杂度就是可以接受了。
根据以上的特性,我们可以先想想如何定义主席树的节点,我们知道简单的线段树只需要一个数组来表示记录,但是由于主席树的节点需要把更改的双亲与“没有更新的孩子”相连接,这就需要记录左右孩子了。(虽然说是可持久化线段树,但是样子并不这么像)
Struct treenode{
Int value,lchild,rchild;
}tree[maxn];
//当然,为了简单也可以把它们分开用不同的数组来表示。
两个基本概念:
题给数组:题目给定的一组数列,表示某特定意义,并在这个数组上求算第k大数或者小于等于k的数之类的问题,以下简称数组;
建树序列:用类似桶排序的思想,将数列(可能经过离散),放入到序列中,用于建立主席树和统计,以下简称序列。主席树往往用于不能直接对题给数组操作的情景。
两个基本操作:
Update
(虽然说是更新,其实是不断建立并记录新的节点。)
算法流程:
1. 为当前节点打上编号
2. 判断是否到达叶子节点,如果是则传递更新上个版本这个叶子节点的信息,结束递归。
3. 还未到叶子节点,则传递上个版本左右孩子节点的信息,继续向下更新。
4. 更新过程类似线段树,记得同时传递上个版本对应区间节点的编号。
5. 递归更新结束后,重新计算当前版本的区间节点值。
相关参数:
每个节点代表一个区间,那么了l
和r
自然必不可少。其次每次更新节点都是相对与上一个版本的,所以需要有pre
和cur
来表示之前与当前的版本。
顺带一提,由于我们需要对每个版本的每个节点打上新的编号,而且都是在函数已经递归以后再修改的(也可以麻烦点在递归只前修改当前孩子的编号)我效仿了大佬引用&的办法,这样的好处在于简化代码。
最后,每次更新操作可能是插入新的元素,那么需要一个参数(例如pos
)来传递它。
Query
(其实解决询问和具体问题,但是这里还是比较类似线段树的)
算法流程:
1. 判断[l,r]是否在目标区间内,是的化直接返还这个区间新老版本的差值
2. 否则如果有区间交集,继续询问,最后返回总和;
相关参数:
表示当前区间的l
r
和目标区间的L
R
不用说,注意涉及新老版本的询问,还是需要old
和nuw
来传递编号。
例题解析:
HDU-4417
题意:给定n个数的数组(从0开始)询问m次,每次询问l r k,输出数组在区间[l,r]内小于等于k的数字个数。
思路:直接用线段树不能胡来,因为线段树无法对“小于等于k”数字个数进行加和。除非,我们每次对数组的一个前缀做一棵线段树,这样如果对于求数组[3,8]范围内的答案,我们可以得到一个[1,8]得到的答案,和一个[1,2]的答案,聪明的你肯定知道做个差就可以得到我们需要的了。
不过清醒一点,看看数据范围,上面那样做肯定是会MLE的。既然如此作为主席树的例题,怎么从主席树的角度去优化呢?那就是每次更新保存一个路径,其他未更新的点就连回去,询问的时候,我们只要每次找到符合范围的区间,对新老版本做一次差,就累加得到答案了。
参考代码:
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
#define MAXN 100010
int root[MAXN],tcnt;
int ls[MAXN*17],rs[MAXN*17],sum[MAXN*17];
void update(int l,int r,int pre,int &cur,int pos)
{
cur=++tcnt;
if(l==r)//如果已经到达叶子节点,就在前一个转来上+1
{
sum[cur]=sum[pre]+1;
return ;
}
ls[cur]=ls[pre];//继承前状态双亲的左孩子编号
rs[cur]=rs[pre];//继承前状态双亲的右孩子编号
int mid=l+r>>1;//更新左右孩子,检查数字pos在1-n的序列中哪个区间
if(pos<=mid)update(l,mid,ls[pre],ls[cur],pos);//则该区间应当得到修改
//同时传递lch[pre]是为了传递代表同一个区间节点的前后状态
else update(mid+1,r,rs[pre],rs[cur],pos);
sum[cur]=sum[ls[cur]]+sum[rs[cur]];
//孩子更新完毕后,更新双亲的sum
//得到b所指代区间[l,r]内 出现在a[1~l]的数字个数 在当前状态下的求和
}
int query(int l,int r,int old,int nuw,int h)//new被霸占了QAQ
{//询问原数列在(old,nuw]小于h的数字个数
if(0>h)return 0;
if(0<=l&&r<=h)//当前数字区间[l,r]的点都小于等于h
return sum[nuw]-sum[old];
//nuw状态该区间的数字个数减去old时个数,求得是总和
//也就是原数列(old,nuw]内,符合小于h条件的数字个数
int mid=l+r>>1,ret=0;//如果数字区间与[0,h]有交集,进一步询问
if(0<=mid) ret+=query(l,mid,ls[old],ls[nuw],h);
if(h>mid) ret+=query(mid+1,r,rs[old],rs[nuw],h);
return ret;//返还统计总数
}
int dsc[MAXN],dcnt;//discrete
int a[MAXN];
int main()
{
int n,m,t;
scanf("%d",&t);
for(int kase=1;kase<=t;kase++)
{
printf("Case %d:\n",kase);
scanf("%d%d",&n,&m);
dcnt=0;
for(int i=1;i<=n;i++)
{
scanf("%d",a+i);
dsc[dcnt++]=a[i];//离散化步骤1记录
}
sort(dsc,dsc+dcnt);//离散化步骤2排序
dcnt=unique(dsc,dsc+dcnt)-dsc;//离散化步骤3去重
tcnt=0;
for(int x,i=1;i<=n;i++)
{
x=lower_bound(dsc,dsc+dcnt,a[i])-dsc;
update(0,dcnt-1,root[i-1],root[i],x);
}
int old,nuw,h;
while(m--)
{
scanf("%d%d%d",&old,&nuw,&h);
old++,nuw++;
h=upper_bound(dsc,dsc+dcnt,h)-dsc-1;
//这里找到第一个大于h的数的下标,减去1,就是最大的小于等于h的下标
//利用离散化下标,去查找个数(注意有可能找不到大于的位置,h就是-1了)
printf("%d\n",query(0,dcnt-1,root[old-1],root[nuw],h));
}
}
return 0;
}
POJ-2104(HDU-2665除了多组输入其他都一样)
题意:给n个数的数组,m次询问,每次询问[l,r]区间内第k大的数
思路:这道题的解法是非常巧妙的,但是很多人都是只着重讲了主席树的部分。我想换个角度来想,同样的,我们先来想想用线段树会怎么做?依然不可能胡来,直接去维护区间最大值。我们不妨把数字抽离出来,做一个桶排序来统计他们。逐次把数组的元素加入到这个桶,用线段树维护这个桶里的数字个数。
这样做之后,我们每次寻找子区间,都可以把这个区间内,包含a3-a8的数字个数求出来,如果区间数字个数多于k,说明答案还要在这个区间里去找,反之,如果少于k,说明在k另外一个区间里,这个时候我们需要处理一下,缩小k去查找。具体见代码。
另外,这道题的数据结构和离散化我用了另外一种写法,和上题做对照。
参考代码:
#include <cstdio>
#include <vector>
#include <cstring>
#include <algorithm>
#include <iostream>
using namespace std;
const int maxn = 1e5+5;
int n,m,cnt,root[maxn],a[maxn],x,y,k;
struct treenode {int l,r,sum;}T[maxn*20];
vector<int> v;
int getid(int x)//另一种离散化方式,利用了STL
{
return lower_bound(v.begin(),v.end(),x)-v.begin()+1;
}
void update(int l,int r,int pre,int &cur,int pos)
{
T[++cnt]=T[pre];//传递前版本的信息
T[cnt].sum++;//直接计算这个序列的区间里包含了a1到ai的数字个数
cur=cnt;
if(l==r)return ;
int mid=(l+r)/2;
if(mid>=pos)update(l,mid,T[pre].l,T[cur].l,pos);
else update(mid+1,r,T[pre].r,T[cur].r,pos);
}
int query(int l,int r,int old,int nuw,int k)
{
//寻找ai到aj之间第k大的数
if(l==r)return l;//如果找到叶子节点,返回
int mid=(l+r)/2;
int sum=T[T[nuw].l].sum-T[T[old].l].sum;
//当前节点,左孩子节点的区间内包含ai到aj的数字数量
//点睛之笔:如果数量比k多,说明第k大的数在左边这个区间向左询问
if(sum>=k)return query(l,mid,T[old].l,T[nuw].l,k);
//反之向右询问,同时k要减去sum,等价于求右孩子区间里第k-sum大的数
else return query(mid+1,r,T[old].r,T[nuw].r,k-sum);
//如此一来最后找到的叶子节点,就是第k大的数字了!妙呀
}
int main()
{
scanf("%d %d",&n,&m);
for(int i=1;i<=n;i++)
scanf("%d",&a[i]),v.push_back(a[i]);
sort(v.begin(),v.end());
v.erase(unique(v.begin(),v.end()),v.end());
for(int i=1;i<=n;i++)//选取这种离散化方式,也是由题目决定的
update(1,n,root[i-1],root[i],getid(a[i]));
for(int i=1;i<=m;i++)
{
scanf("%d%d%d",&x,&y,&k);
printf("%d\n",v[query(1,n,root[x-1],root[y],k)-1]);
}
return 0;
}
后记
之前刚接触的时候感觉主席树好难啊,完全摸不透,现在我觉得算是有些入门了吧。也看了很多优秀的博客,学到很多新的东西。再接再厉吧。下面贴我参考的博客和视频。
http://www.cnblogs.com/zyf0163/p/4749042.html
https://www.bilibili.com/video/av4619406