学习总结丨数据结构丨主席树

预备知识:

  首先得明白线段树的基本知识。线段树上每个节点代表了一个区间,左右孩子节点代表被二分的区间。叶子节点记录了需要操作的数组里的每一个元素。配合延迟标记可以快速查找并修改区间信息。
  而主席树是一种可持久化线段树,这是为了访问多次更新后之前某个版本的线段树设计的结构,它的优化在于,不记录每个版本完整的线段树,而是记录每次被更新的一条路径(未被更新的节点就用路径节点连回去)这样每次更新只需要存下 logN l o g N 数量的节点,利用根作为每次更新的索引,这样的空间复杂度就是可以接受了。
  根据以上的特性,我们可以先想想如何定义主席树的节点,我们知道简单的线段树只需要一个数组来表示记录,但是由于主席树的节点需要把更改的双亲与“没有更新的孩子”相连接,这就需要记录左右孩子了。(虽然说是可持久化线段树,但是样子并不这么像

Struct treenode{
Int value,lchild,rchild;
}tree[maxn];
//当然,为了简单也可以把它们分开用不同的数组来表示。


两个基本概念:

题给数组:题目给定的一组数列,表示某特定意义,并在这个数组上求算第k大数或者小于等于k的数之类的问题,以下简称数组;
建树序列:用类似桶排序的思想,将数列(可能经过离散),放入到序列中,用于建立主席树和统计,以下简称序列。主席树往往用于不能直接对题给数组操作的情景。


两个基本操作:

Update
(虽然说是更新,其实是不断建立并记录新的节点。)
算法流程:
1. 为当前节点打上编号
2. 判断是否到达叶子节点,如果是则传递更新上个版本这个叶子节点的信息,结束递归。
3. 还未到叶子节点,则传递上个版本左右孩子节点的信息,继续向下更新。
4. 更新过程类似线段树,记得同时传递上个版本对应区间节点的编号。
5. 递归更新结束后,重新计算当前版本的区间节点值。
相关参数:
  每个节点代表一个区间,那么了lr自然必不可少。其次每次更新节点都是相对与上一个版本的,所以需要有precur来表示之前与当前的版本。
  顺带一提,由于我们需要对每个版本的每个节点打上新的编号,而且都是在函数已经递归以后再修改的(也可以麻烦点在递归只前修改当前孩子的编号)我效仿了大佬引用&的办法,这样的好处在于简化代码。
  最后,每次更新操作可能是插入新的元素,那么需要一个参数(例如pos)来传递它。

Query
(其实解决询问和具体问题,但是这里还是比较类似线段树的)
算法流程:
1. 判断[l,r]是否在目标区间内,是的化直接返还这个区间新老版本的差值
2. 否则如果有区间交集,继续询问,最后返回总和;
相关参数:
  表示当前区间的l r和目标区间的L R不用说,注意涉及新老版本的询问,还是需要oldnuw来传递编号。


例题解析:

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-2104HDU-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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值