可持久化线段树(主席树)浅析及图解(附没有使用结构体、链表、指针的标准新手模板及详细注释)

论坛 期权论坛 脚本     
匿名网站用户   2020-12-19 21:56   44   0

你的每一个赞,我都当作了喜欢

印子:

先看一个问题:

给出n个数,m个询问每次询问l,r,k,求[l..r]区间内第k小的数是什么?

一种简单的思路是每次把[l..r]区间的数取出来,做一次快排,得到第k小。

时间复杂度是O(mnlog(n))的,如果n,m稍微大一点,就会超时。

我们再来想,

假设题目变成了只有一次询问求[1..n]区间的第k小值,我们怎么用log的时间得到询问的答案呢?

一种方法是先对每个数进行离散化(因为只要大小关系不变),然后建立一棵权值线段树,表示的是数列[1..n]区间内的数(就是读入的那个数组)在数值l..r这个范围中有多少个数,然后就可以通过线段树上的二分找出第k小值了。(如果没学过权值线段树的,请先查看相关资料并认真学习)

那如果是有n次询问,每次询问[1..i]区间的第k小值呢?

按照上面的方法,我们可以建立n棵权值线段树,每棵权值线段树表示的是数列[1..i]区间内的数数值l..r这个范围中有多少个数。然后每次询问[1..i]区间,就找到对应的那棵权值线段树去求。

那么,时间是O(nlog(n))的,空间却是n^2以上的。

我们如何想办法优化空间呢?

这个待会再说,我们先把这个问题再转化成文章开头提出的那个问题。

推导:

上面我们已经知道了每次询问[1..i]区间的做法,那我们现在每次询问是[l..r]区间呢?

我们还是和上面一样先建立n棵这样的权值线段树。

不难发现,对于某一个数值a..b这个数值范围

数列[1..r]区间内的数在这个数值范围内的个数-数列[1..l-1]区间内的数在这个数值范围内的个数,

就等于数值[l..r]区间内的数在这个数值范围内的个数。

利用这一个性质,又因为两棵权值线段树的结构是相同的,

我们就可以对于每次询问的[l..r]区间,找到[1..l]区间对应的权值线段树和[1..r]区间对应的权值线段树([1..i]区间对应的树当然是在建权值线段树的时候就要记录好了),

然后按照性质就可以得出树上每个节点所对应的数值a..b这个范围内,数值[l..r]区间内的数在这个数值范围内的个数。

于是就可以像上一个问题那样来做了。

空间问题及主席树思想:

再说说那个空间的问题,

上面的做法空间都是n^2以上的,很容易会炸掉。

如何减小空间呢?

我们想想,每一棵数列[1..i]区间内的数构成的权值线段树,和数列[1..i-1]区间内的数构成的权值线段树有什么不同呢?

因为数列新增加了一个数,所以权值线段树的某个叶子结点加了1,导致修改了一条从根节点到叶子结点的链,对吧?

所以它们不同的地方只有一条链。

所以我们可以只要新开一条链,而不是新开一个权值线段树。

除了新开的那条链上的节点,其它节点都沿用上一棵权值线段树的。

这样就可以大大节省空间了。

这就是主席树的思想了。

实现及图解:

那怎么沿用上一棵权值线段树呢?

因为树的结构都是一样的,

所以直接把儿子节点连过去就好了。

例如像下图:

这是一棵根节点为1的树

假设我们现在要修改1-3-7这条链:

那么,修改后就变成了一棵根节点为8的树(就是用棕色边连起来的)。

其中,以2和6为根的子树我们直接沿用,我们新增8-9-10(红色的点)这条链来替换1-3-7(黄色的点)这条链。

这样,我们就可以只新增一条链了,还可以记录下每次修改后的版本(例如第一次是以1为根的树,第二次是以8为根的树)。

值得注意的是,这样的线段树不再满足左儿子是2x,右儿子的2x+1的性质,需要单独记录每个点的左右儿子

空间复杂度:

那么,空间复杂度是多少呢?

理论上是nlog(n)的,但实际线段树操作时很容易越界,建议开成30n(也就是说n=100000时就开到3000000)。

时间复杂度:

O(nlog(n))

模板及详细注释:

这是本文最开始那个经典的求区间第k小问题的模板。

#include<cstdio>
#include<algorithm>
using namespace std;
int a[100010];//输入的数列 
int sum[3000010];//以i为根的子树的那个数值范围内有sum[i]个数
int lson[3000010];//线段树上每个点的左儿子
int rson[3000010];//线段树上每个点的有儿子
int root[100010];//root[i]表示第i棵树的根节点位置
int cnt; //当前新建到第几个节点
int maxl=-1000000000; 
int maxr=1000000000; //输入数的范围 (动态开点)
void update(int rt,int num,int l,int r)//rt表示当前这棵权值线段树的当前节点所要沿用上一棵权值线段树的对应节点rt,num表示加入的这个数的数值,l..r表示当前节点的数值范围
{
 cnt++; //总节点数+1 (新建一个节点)
 lson[cnt]=lson[rt]; // 沿用左儿子
 rson[cnt]=rson[rt]; // 沿用右儿子
 sum[cnt]=sum[rt]+1; //总个数等于所沿用的那个点的+1
 if (l==r) return;
 rt=cnt; //cnt在进入递归后会变,因此要记录下来在这一层里的cnt,刚好rt又没用了,就拿rt来记录cnt
 int mid=(l+r)/2;
 if (num<=mid) //如果新增数值在左儿子
 {
  update(lson[rt],num,l,mid); //右儿子保持沿用不变,不用再递归,左儿子不再整体沿用,继续递归下去(部分可沿用,部分要修改) 
  lson[rt]=rt+1; //左儿子不再整体沿用,所以修改左儿子
 } else //那么就是新增数值在右儿子了
 {
  update(rson[rt],num,mid+1,r);//左儿子保持沿用不变,不用再递归,右儿子不再整体沿用,继续递归下去(部分可沿用,部分要修改) 
  rson[rt]=rt+1; //右儿子不再整体沿用,所以修改右儿子
 }
}
int query(int i,int j,int num,int l,int r)//i,j表示两颗权值线段树现在所到的节点位置
{
 if (l==r) return l;
 int d=sum[lson[j]]-sum[lson[i]]; //根据前面讲的性质
 int mid=(l+r)/2;
 if (num<=d) return query(lson[i],lson[j],num,l,mid);
 else return query(rson[i],rson[j],num-d,mid+1,r);
}
int main()
{
 freopen("a.in","r",stdin);
 int n,m;
 scanf("%d%d",&n,&m); //n个数,m个询问
 int i;
 for (i=1;i<=n;i++) scanf("%d",&a[i]);
 root[0]=1; //第0棵树的根节点是1(原始树)
 cnt=1;
        for (i=1;i<=n;i++)
 {
  root[i]=cnt+1; //第i棵树的根节点是新建的下一个节点
  update(root[i-1],a[i],maxl,maxr);//加入b[i]这个数后更新权值线段树
 }
 int x,y,k;
 for (i=1;i<=m;i++)
 {
  scanf("%d%d%d",&x,&y,&k);
  printf("%d\n",query(root[x-1],root[y],k,maxl,maxr));//query()返回的是查询的值离散化后是多少(排第几)
 }
 return 0;
}

你的每一个赞,我都当作了喜欢

分享到 :
0 人收藏
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

积分:1136255
帖子:227251
精华:0
期权论坛 期权论坛
发布
内容

下载期权论坛手机APP