欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

[OI]主席树小结

程序员文章站 2024-01-29 13:56:10
...

文章转载自http://www.cnblogs.com/zyf0163/p/4749042.html

树状结构之主席树

主席树搞了一个多星期TAT,,,,,,也只是大致领悟而已!!!

主席树又称函数式线段树,顾名思义,也就是通过函数来实现的线段树,至于为什么叫主席树,那是因为是fotile主席创建出来的这个数据结构(其实貌似是当初主席不会划分树而自己想出来的另一个处理方式。。。。是不是很吊呢? ORZ...)不扯了,切入正题。

主席树就是利用函数式编程的思想来使线段树支持询问历史版本、同时充分利用它们之间的共同数据来减少时间和空间消耗的增强版的线段树。

​ 很多问题如果用线段树处理的话需要采用离线思想,若用主席树则可直接在线处理。故很多时候离线线段树求解可以转化为在线主席树求解。注意,主席树本质就是线段树,变化就在其实现可持久化,后一刻可以参考前一刻的状态,二者共同部分很多。一颗线段树的节点维护的是当前节点对应区间的信息,倘若每次区间都不一样,就会给处理带来一些困难。有时可以直接细分区间然后合并,此种情况线段树可以直接搞定;但有时无法通过直接划分区间来求解,如频繁询问区间第k小元素,当然,此问题有比较特殊的数据结构-划分树。其实还有一个叫做归并树,是根据归并排序实现的,每个节点保存的是该区间归并排序后的序列,因此,时间、空间复杂度都及其高, 所以一般不推荐去用。当然,主席树也是可以解决的。

附上归并树代码:

#include <cstdio>
#include <cstring>
#include <vector>
#include <algorithm>
using namespace std;
const int N = 100000 + 5;

vector<int>node[N << 2];

int T, n, q, ql, qr, ans, k, sz;

int a[N], b[N];

inline int read(){//快速读入是*
    char c;
    int ret = 0;
    int sgn = 1;
    do{c = getchar();}while((c < '0' || c > '9') && c != '-');
    if(c == '-') sgn = -1; else ret = c - '0';
    while((c = getchar()) >= '0' && c <= '9') ret = ret * 10 + (c - '0');
    return sgn * ret;
}

void Build(int o, int l, int r){
    node[o].clear();
    if(l == r){
        node[o].push_back(a[l]);
        return ;
    }
    int m = (l + r) >> 1;
    Build(o << 1, l, m);
    Build(o << 1|1, m + 1, r);
    node[o].resize(r - l + 1);
    merge(node[o<<1].begin(), node[o<<1].end(), node[o<<1|1].begin(), node[o<<1|1].end(), node[o].begin());
}

int query(int o, int l, int r, int x){
    //if(ql > r || qr < l) return 0;
    if(ql <= l && qr >= r)  return upper_bound(node[o].begin(), node[o].end(), x) - node[o].begin();
    int m = (l + r) >> 1;
    int ret = 0;
    if(ql <= m)ret += query(o << 1, l, m, x);
    if(qr > m)ret += query(o << 1|1, m + 1, r, x);
    return ret;
}

void work(){
    //ql = read();
    //qr = read();
    //k = read();
    scanf("%d%d%d", &ql, &qr, &k);
    int lt = 1, rt = sz;
   while(lt <= rt){
       int md = (lt + rt) >> 1;
       if(query(1, 1, n, b[md]) >= k)rt = md - 1;
       else lt = md + 1;
   } 
   printf("%d\n", b[rt+1]);
}

int main(){
    scanf("%d", &T);
    while(T--){
        scanf("%d%d", &n, &q);
        //n = read();
        //q = read();
        //for(int i = 1; i <= n; i ++) a[i] = b[i] = read();
        for(int i = 1; i <= n; i ++)scanf("%d", a + i), b[i] = a[i];
        Build(1, 1, n);
        sort(b + 1, b + n + 1);
        sz = unique(b + 1, b + n + 1) - (b + 1);
        while(q --)work();
    }
    return 0;
}

[OI]主席树小结

赤果果的嫌弃,时间居然那么费,,,,,,,(不过挺好理解的)

​ 主席树的每个节点对应一颗线段树,此处有点抽象。在我们的印象中,每个线段树的节点维护的树左右子树下标以及当前节点对应区间的信息(信息视具体问题定)。对于一个待处理的序列a[1]、a[2]…a[n],有n个前缀。每个前缀可以看做一棵线段树,共有n棵线段树;若不采用可持久化结构,带来的严重后果就是会MLE,即对内存来说很难承受。根据可持久化数据结构的定义,由于相邻线段树即前缀的公共部分很多,可以充分利用,达到优化目的,同时每棵线段树还是保留所有的叶节点只是较之前共用了很多共用节点。主席树很重要的操作就是如何寻找公用的节点信息,这些可能可能出现在根节点也可能出现在叶节点。

下面是某大牛的理解:所谓主席树呢,就是对原来的数列[1..n]的每一个前缀[1..i](1≤i≤n)建立一棵线段树,线段树的每一个节点存某个前缀[1..i]中属于区间[L..R]的数一共有多少个(比如根节点是[1..n],一共i个数,sum[root] = i;根节点的左儿子是[1..(L+R)/2],若不大于(L+R)/2的数有x个,那么sum[root.left] = x)。若要查找[i..j]中第k大数时,设某结点x,那么x.sum[j] - x.sum[i - 1]就是[i..j]中在结点x内的数字总数。而对每一个前缀都建一棵树,会MLE,观察到每个[1..i]和[1..i-1]只有一条路是不一样的,那么其他的结点只要用回前一棵树的结点即可,时空复杂度为O(nlogn)。

我自己对主席树的理解,是一个线段树在修改一个值的时候,它只要修改logn个节点就可以了,那么我们只要每次增加logn个节点就可以记录它原来的状态了, 即你在更新一个值的时候仅仅只是更新了一条链,其他的节点都相同,即达到共用。由于主席树每棵节点保存的是一颗线段树,维护的区间相同,结构相同,保存的信息不同,因此具有了加减性。(这是主席树关键所在,当除笔者理解了很久很久,才相通的),所以在求区间的时候,若要处区间[l, r], 只需要处理rt[r] - rt[l-1]就可以了,(rt[l-1]处理的是[1,l-1]的数,rt[r]处理的是[1,r]的数,相减即为[l, r]这个区间里面的数。

比如说(以区间第k大为例hdu2665题目戳这里http://acm.hdu.edu.cn/showproblem.php?pid=2665):

设n = 4,q= 1;

4个数分别为4, 1, 3 ,2;

ql = 1, qr = 3, k = 2;

1.建树

首先需要建立一棵空的线段树,也是最原始的主席树,此时主席树只含一个空节点,此时设根节点为rt[0],表示刚开始的初值状态,然后依次对原序列按某种顺序更新,即将原序列加入到对应位置。此过程与线段树一样,时间复杂度为O(nlogn),空间复杂度O(nlog(n))(笔者目前没有完全搞清究竟是多少, 不过保守情况下,线段树不会超过4*n)

[OI]主席树小结

2.更新

我们知道,更新一个叶节点只会影响根节点到该叶节点的一条路径,故只需修改该路径上的信息即可。每个主席树的节点即每棵线段树的结构完全相同,只是对应信息(可以理解为线段树的结构完全一样,只是对应叶子节点取值不同,从而有些节点的信息不同,本质是节点不同),此时可以利用历史状态,即利用相邻的上一棵线段树的信息。相邻两颗线段树只有当前待处理的元素不同,其余位置完全一样。因此,如果待处理的元素进入线段树的左子树的话,右子树是完全一样的,可以共用,即直接让当前线段树节点的右子树指向相邻的上一棵线段树的右子树;若进入右子树,情况可以类比。此过程容易推出时间复杂度为O(logn),空间复杂度为 O(logn)。如图:

[OI]主席树小结

3.查询

先附上处理好之后的主席树, 如图:

[OI]主席树小结

是不是看着很晕。。。。。。笔者其实也晕了,我们把共用的节点拆开来,看下图:

[OI]主席树小结

啊, 这下清爽多了,一眼看下去就知道每个节点维护的是哪棵线段树了,TAT,如果早就这样写估计很快就明白了,rt[i]表示处理完前i个数之后所形成的线段树,即具有了前缀和的性质,那么rt[r] - rt[l-1]即表示处理的[l, r]区间喽。当要查询区间[1,3]的时候,我们只要将rt[3] 和 rt[0]节点相减即可得到。如图:

[OI]主席树小结

这样我们得到区间[l, r]的数要查询第k大便很容易了,设左节点中存的个数为cnt,当k<=cnt时,我们直接查询左儿子中第k小的数即可,如果k>cnt,我们只要去查右儿子中第k-cnt小的数即可,这边是一道很简单的线段树了。就如查找[1, 3]的第2小数(图上为了方便,重新给节点标号),从根节点1向下搜,发现左儿子2的个数为1,1<2,所有去右儿子3中搜第2-1级第1小的数,然后再往下搜,发现左儿子6便可以了,此时已经搜到底端,所以直接返回节点6维护的值3即可就可以了。

附上代码:

#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 100000 + 5;

int a[N], b[N], rt[N * 20], ls[N * 20], rs[N * 20], sum[N * 20];

int n, k, tot, sz, ql, qr, x, q, T;

void Build(int& o, int l, int r){
    o = ++ tot;
    sum[o] = 0;
    if(l == r) return;
    int m = (l + r) >> 1;
    Build(ls[o], l, m);
    Build(rs[o], m + 1, r);
}

void update(int& o, int l, int r, int last, int p){
    o = ++ tot;
    ls[o] = ls[last];
    rs[o] = rs[last];
    sum[o] = sum[last] + 1;
    if(l == r) return;
    int m = (l + r) >> 1;
    if(p <= m)  update(ls[o], l, m, ls[last], p);
    else update(rs[o], m + 1, r, rs[last], p);
}

int query(int ss, int tt, int l, int r, int k){
    if(l == r) return l;
    int m = (l + r) >> 1;
    int cnt = sum[ls[tt]] - sum[ls[ss]];
    if(k <= cnt) return query(ls[ss], ls[tt], l, m, k);
    else return query(rs[ss], rs[tt], m + 1, r, k - cnt);
}

void work(){
    scanf("%d%d%d", &ql, &qr, &x);
    int ans = query(rt[ql - 1], rt[qr], 1, sz, x);
    printf("%d\n", b[ans]);
}

int main(){
    scanf("%d", &T);
    while(T--){
        scanf("%d%d", &n, &q);
        for(int i = 1; i <= n; i ++) scanf("%d", a + i), b[i] = a[i];
        sort(b + 1, b + n + 1);
        sz = unique(b + 1, b + n + 1) - (b + 1);
        tot = 0;
        Build(rt[0],1, sz);
        //for(int i = 0; i <= 4 * n; i ++)printf("%d,rt =  %d,ls =  %d, rs = %d, sum = %d\n", i, rt[i], ls[i], rs[i], sum[i]);
        for(int i = 1; i <= n; i ++)a[i] = lower_bound(b + 1, b + sz + 1, a[i]) - b;
        for(int i = 1; i <= n; i ++)update(rt[i], 1, sz, rt[i - 1], a[i]);
        for(int i = 0; i <= 5 * n; i ++)printf("%d,rt =  %d,ls =  %d, rs = %d, sum = %d\n", i, rt[i], ls[i], rs[i], sum[i]);
        while(q --)work();
    }
    return 0;
}

[OI]主席树小结

看着这个时间的复杂度和归并树一比,从此对归并树无爱了,估计不会再用了。。。。ORZ~~

4.总结

由以上可知,主席树是一种特殊的线段树集,他几乎具有所有线段树的所有优势,并且可以保存历史状态,以便以后加以利用,主席树查找和更新时时间空间复杂度均为O(logn), 且空间复杂度约为O(nlogn + nlogn)前者为空树的空间复杂度,后者为更新n次的空间复杂度,主席树的缺点就是空间耗损巨大,但还是可以接受的。当然主席树不止这点应用,他可以处理许多区间问题,例如求区间[l, r]中的值介于[x,y]的值。总之应用多多。


以上是大佬对主席树的讲解。很好懂有木有!

蒟蒻这里只简单说明一下自己对代码的理解。

P3834 【模板】可持久化线段树 1(主席树)

题目背景

这是个非常经典的主席树入门题——静态区间第K小

数据已经过加强,请使用主席树。同时请注意常数优化

题目描述

如题,给定N个正整数构成的序列,将对于指定的闭区间查询其区间内的第K小值。

输入输出格式

输入格式:

第一行包含两个正整数N、M,分别表示序列的长度和查询的个数。

第二行包含N个正整数,表示这个序列各项的数字。

接下来M行每行包含三个整数l,r,k , 表示查询区间\([l,r]\)内的第k小值。

输出格式:

输出包含k行,每行1个正整数,依次表示每一次查询的结果

输入输出样例

输入样例#1:

5 5
25957 6405 15770 26287 26465 
2 2 1
3 4 1
4 5 1
1 2 2
4 4 1

输出样例#1:

6405
15770
26287
25957
26287

说明

数据范围

对于20%的数据满足:\(1≤N,M≤10\)

对于50%的数据满足 \(1\leq N, M \leq 10^3\)

对于80%的数据满足:\(1\leq N, M \leq 10^5\)

对于100%的数据满足:\(1\leq N, M \leq 2*10^5\)

对于数列中的所有数\(a_i\),均满足\(-{10}^9 \leq a_i \leq {10}^9\)


code

#include <algorithm>
#include <cmath>
#include <cstring>
#include <cstdlib>
#include <cstdio>
#include <iostream>
#include <map>
#include <queue>
#define R register

typedef long long ll;
typedef double db;

const int MAXN = 200010;
int N, M, a[MAXN], b[MAXN];

struct PTree{
    //主席树第i棵树存的是a[i]-a[0]的树(前缀和) 
    //l表示该节点的左儿子,r表示该节点的右儿子,
    //id表示当前树的根节点编号,sum表示当前区间所包含的数的个数
    int l, r, id, sum;
}pt[MAXN*10];
int cnt;

inline int read() {
    int num = 0, f = 1; char ch = getchar();
    while (!isdigit(ch)) { if (ch == '-') f = -1; ch = getchar(); }
    while (isdigit(ch)) { num = num * 10 + ch - '0'; ch = getchar(); }
    return num * f; 
}

void Build (int &now, int l ,int r) {
    //当前节点没有在前面出现过时,用普通的建树方法
    //now在这里代指当前节点编号
    //类似与线段树的建树方法,但是主席树不满足线段树的节点与左儿子右儿子的关系
    //每次加入只是单纯的按计数器分配给当前节点一个值。
    now = ++ cnt;
    pt[now].sum = 0;
    if (l == r) return ;
    int mid = (l + r) >> 1;
    Build(pt[now].l, l, mid);
    Build(pt[now].r, mid + 1, r);
}

void update(int &now, int l, int r, int last, int x) {
    //为了防止MLE,若当前节点在之前出现过,直接从之前节点那里继承过来
    //update是主席树实现可持久化的函数
    //last是上个节点的编号
    now = ++ cnt;
    //继承前面节点的儿子
    pt[now].l = pt[last].l;
    pt[now].r = pt[last].r;
    //继承前面节点的sum并将当前的数加入,相当于sum+1
    pt[now].sum = pt[last].sum + 1;
    if (l == r) return ;
    int mid = (l + r) >> 1;
    //如果x<=mid,可知待处理元素在左子树中,右子树可以完全共用,可以直接由祖先节点访问到
    //只需要处理左子树即可。反之亦然
    if (x <= mid) update(pt[now].l, l, mid, pt[now].l, x);
    else update(pt[now].r, mid + 1, r, pt[now].r, x);
}

int query(int s, int t, int l, int r, int x) {
    //实际上是一个递归二分过程
    //pt[i]表示处理完前i个数之后形成的线段树
    if (l == r) return l;
    int mid = (l + r) >> 1;
    //若当前查询值小于左子树中存的个数,直接继续查询左子树中第x小的数
    int cnt = pt[pt[t].l].sum - pt[pt[s].l].sum;
    if (x <= cnt) return query(pt[s].l, pt[t].l, l, mid, x);
    //不然就向右子树中查询,显然问题变成在右子树中找x-cnt大的数
    else return query(pt[s].r, pt[t].r, mid + 1, r, x - cnt);
}

int main() {
    N = read(), M = read();
    for (R int i = 1; i <= N; ++ i) a[i] = read(), b[i] = a[i];
    //b[i]是a[i]的副本,下面是类似于离散化的东西,确定一共有多少不同的数出现过,确定树的大小
    std::sort(b + 1, b + N + 1);
    int Siz = std::unique(b + 1, b + N + 1) - b - 1;
    //先将原始的主席树建立出来
    Build(pt[0].id, 1, Siz);
    //用每个元素在b中的坐标重置a数组(离散化)
    for (R int i = 1; i <= N; ++ i) 
        a[i] = std::lower_bound(b + 1, b + Siz + 1, a[i]) - b;
    //可持久化操作,对于每个a[i]都往主席树中update
    for (R int i = 1; i <= N; ++ i) 
        update(pt[i].id, 1, Siz, pt[i - 1].id, a[i]);
    int x, y, z;
    while (M -- ) {
        x = read(), y = read(), z = read();
        //其实和一维前缀和很类似(pt[].sum就是前缀和)
        printf("%d\n", b[query(pt[x - 1].id, pt[y].id, 1, Siz, z)]);
    }
    return 0;
}