树形数据结构5——可持久化线段树(主席树)

本文同时也是可持久化数据结构的第一篇。

在探讨可持久化数据结构之前,先来考虑一类问题:

许多的app(应该是几乎所有),都有一个功能——撤销,回到一个历史版本,然后重新操作。那这是怎么实现的呢?

我们把这个问题抽象一下,先考虑最简单的问题:给定一个数列,现在进行单点修改,每次修改都会产生一个历史版本。我们要能自由的访问任意一个历史版本并且在这个版本上进行修改,怎么办?

一个朴素的思想:逐一维护

一个数列是吧?那就一个数组存;改一个数产生一个版本?那就每一个版本都存下来!每产生一个版本就存储一个新序列。这样要哪个版本就能随时找到并访问对应的元素。

但是这显然是空间无法承受的:如果长度为十万,我进行了十万次修改,那么就空间已经是 1 0 12 10^{12} 1012的量级;同时还要抄写大量的相同数据,时间也是这个量级;百万次修改那更加不敢说了。那么我们有什么方法去处理这个东西呢?

我们想到的是线段树。

肯定会有人说了:线段树本身占的空间就大,一个序列要四倍空间,那这更加没法解决这个问题了!而且这样抄写量更大了。

不错,线段树它本身确实比一个数列占用空间更大,内容也更多,但是涉及修改上,那可就不一定了。

也许你想的是这样去维护的:

在这里插入图片描述

一颗线段树,最下面一排的是待维护的序列,然后 × n \times n ×n

在这里插入图片描述

但是其实它是这样的:

在这里插入图片描述

看起来很吓人是不是?

我们把图拆开来看:

首先是每层的图。

在这里插入图片描述

我们去掉透视的效果,图可以拆分为三层:

第一层一个人畜无害的线段树。

在这里插入图片描述

第二层和第三层都只有一个枝条:

在这里插入图片描述

这代表我们第二次修改了第三个节点;

在这里插入图片描述

这代表了我们修改的是倒数第二个节点。

发现什么规律了?我们好像并没有修改,或者说再抄了一遍整个线段树,而是只修改了这些节点到根上的路径,其他的我们并没有处理。这样做也确实是合理的:一次就改一个节点,何必把剩下的节点全部都抄一遍?只把和这个节点有关的一些节点处理以下就行了,剩下的最好能接入到原来的老版本就行——而这就是它的第二个部分,层之间的边。

我们先考察一下两层之间,也就是一次修改后原层和新加的一层之间我们都干了些啥。

在这里插入图片描述

虚的代表修改后的那一层图,上面实的代表原图。我们在这两层暂时互相没有关联的图上加入了几条边(红色表示):

在这里插入图片描述

发现什么规律没?

由于我们出于节省空间和懒惰大量没有修改的点我们压根就不去处理,但是挡不住人家问这些没有修改的啊!那我们就要让每个版本都能访问到正确的值,只不过这个值在哪一层图那就可以糊弄人了。对于修改过的那一半,我们建立了它到新根的边,访问它没有一点问题;对于没有修改的那一半,我们牵一个边过去指向老的版本那一层的那一半的根节点,表示在这个版本中这一半都和老版本一模一样。那么这样访问它就没有一点问题了。

所以,主席树和线段树不太一样的地方在于:主席树的左右儿子不可以直接推算——毕竟一次加边只加几个就把序号弄乱了,需要人为的记录。

下面来逐段分析代码。

struct node
{
    int value;
    int leftson;
    int rightson;
};
struct node t[20000005];
int root[1000005], a[1000005], tot;

这里是一些必要的声明。node节点内包含元素:左右儿子和当前节点的值value。其中value只在最后一行,即叶节点那里才有意义。root数组非常重要。在上文的分析中,我们注意到每次更新根节点必然要被新建。因而,我们要访问不同版本,就可以从不同版本的根开始。root[i]表示了第i个版本的根的编号,知道这个我们才可以去访问该版本。

首先是建树。这个部分和线段树是一样的,建立第一层树,但是要记忆左右儿子了。

int build(int left,int right)//这里并没有像线段树那样使用place来传递位置,而是用返回值。这是为了和其他的几个函数适配而采取的改进化操作。
{
    int place = ++tot;
    if(left==right)
    {
        t[place].value = a[left];//只有叶节点才有value的意义。因而下文中查询和修改操作必须执行到底left==right
        return place;
    }
    int mid = (left + right) >> 1;
    t[place].leftson = build(left, mid);//build函数返回该子树根节点编号
    t[place].rightson = build(mid + 1, right);
    return place;
}

然后是修改。大体的操作并没有在上面作图说明,其实很简单:如果是待修改区间,就新立节点,反之则连向老点。说明:pre是老版本,即从这个版本出发进行的修改。

nt update(int pre,int left,int right,int ask,int x)//返回值为子树的根节点编号。ask为待修改的数列位置,x为待修改值
{
    int place = ++tot;//先新立节点
    if(left==right)//到底了,叶子节点,存储新的value
    {
        t[place].value = x;
        return place;
    }
    int mid = (left + right) >> 1;
    if(ask<=mid)//待查区间在左
    {
        t[place].rightson = t[pre].rightson;//此时右节点为老节点
        t[place].leftson = update(t[pre].leftson, left, mid, ask, x);//这里有必要说明一下pre的重要性:如果没有pre就无法找到老版本的对应位置。主席树必须要求节点位置一一对应,即每一个版本在[l,r]区间的节点必须出于同一位置
    }
    else//待查区间在右
    {
        t[place].leftson = t[pre].leftson;
        t[place].rightson=update(t[pre].rightson, mid + 1, right, ask, x);//pre也要相应更新。往左则pre变成它的左儿子编号,往右变成它右儿子编号
    }
    return place;//不要忘记返回当前节点位置了,用于上一层的左右儿子更新
}

最后是查询工作。此部分就比较简单了。

int query(int place, int left,int right,int ask)
{
    if(left==right && left==ask)//不到叶节点不能停
        return t[place].value;
    int mid = (left + right) >> 1;
    if(ask<=mid)
        return query(t[place].leftson, left, mid, ask);//待查在左,去左边查
    else
        return query(t[place].rightson, mid + 1, right, ask);
}

当然上面的代码是不够完善的,(至少你没法那这去A题)。主函数中还有一些相配套的操作。

第一个:建树的时候注意

    root[0] = build(1, n);

老版本的root也得记!

第二个:查询的时候,由于【模板】的要求,查询也算版本,因而该版本的root直接为待查版本的root。所以,一个版本的root直接代表了一个版本的访问能力

第三个:修改的时候不要忘记把这个版本的根也给存下来了。

这就是朴素的主席树。

主席树的第二个应用,是静态查询区间的第k小

那这个怎么转化成主席树呢?

现在,这个主席树中间每个节点就排上用场了——每个节点要维护的,是当前覆盖区间内有多少个数

这是啥意思?

假定我现在有以下的一些数(排过序):

2 , 5 , 14 , 27 , 107 , 255 , 307 , 4000 2,5,14,27,107,255,307,4000 2,5,14,27,107,255,307,4000

这些数显然天然将整个数轴分成了若干块(不考虑两端): [ 2 , 5 ) , [ 5 , 14 ) , [ 14 , 27 ) , [ 27 , 107 ) , [ 107 , 255 ) , [ 255 , 307 ) , [ 307 , 400 ) , [ 400 , + inf ⁡ ) [2,5),[5,14),[14,27),[27,107),[107,255),[255,307),[307,400),[400,+\inf) [2,5),[5,14),[14,27),[27,107),[107,255),[255,307),[307,400),[400,+inf)。我们就考虑这几个区间内到底各有几个数。而这正是主席树维护的内容。

例如,区间 [ 27 , 255 ) [27,255) [27,255)内就有两个数 27 , 107 27,107 27,107,因而该节点记录2;区间 [ 2 , 4000 ) [2,4000) [2,4000)就有7个数,以此类推。

叶节点仅单个区间,而父节点开始合并两个区间,同时也合并的子节点的区间内数的个数,因而可以考虑像线段树那样合并。这就提供了线段树、主席树的使用和维护条件。(当然此处也可以使用离散化,代码就是这个维护方法)

该序列可能无序,排成 400 , 27 , 107 , 2 , 14 , 5 , 307 , 255 400,27,107,2,14,5,307,255 400,27,107,2,14,5,307,255。这个时候如何定义历史版本呢?

考虑插入一个数就是新增一个历史版本。注意到我们维护的不是数,而是区间内数的个数,因而根据下标从前到后新增添一个数相当于是让区间内数的个数发生了变化。具体的操作如下:

(图挖坑)

那么为什么要这么做呢?其实这里利用了前缀和的思想。

插入一个数建立了一个历史版本,其实本质是维护了区间 [ 1 , i ] [1,i] [1,i]内各个区段的数的个数。那么,如果我要访问任意的区间 [ l , r ] [l,r] [l,r],只需要将两个历史版本 [ 1 , l − 1 ] [1,l-1] [1,l1] [ 1 , r ] [1,r] [1,r]对应节点内的元素个数做差即可。这样,下标为 [ 1 , l − 1 ] [1,l-1] [1,l1]的数就会被减掉,只剩下 [ l , r ] [l,r] [l,r]的数还留在这个主席树上。

然后,去做一个类似BST的查找第k大即可——子树大小都已知了。

这样做的优点在于:不必每次都要建立一个全新的二叉树,而是用前缀和提前预处理好。

下面是代码部分。

稍微说明一下:因为成文时间不同,下面的主席树代码和上面的风格不太一样,并不是说这两个问题有什么不同(所需数据结构)。

struct node
{
    int leftson;//左右儿子编号
    int rightson;
    int sum = 0;//该段区间中有几个数
};
struct node t[4000005];
struct trans//离散化所需结构体
{
    int value;//值
    int id;//在原数列中的位置
    bool operator <(const trans &b)
    {
        return value < b.value;
    }
};
struct trans que[200005];
int rk[200005], root[200005], cnt;//rk为排位为i的数的值

这是初定义部分。

void change(int &place,int left,int right,int start)//修改——插入一个新的数,等于建立了一个新版本。下文详细讲述&place
//start:待修改的区间
{
    cnt++;//新建节点
    t[cnt] = t[place];
    t[cnt].sum++;//区间内由于新增了一个数因而变多了一个
    place=cnt;//与&place配套食用
    if(left==right)//到叶节点了
        return;
    int mid = (left + right) >> 1;
    if(start<=mid)
        change(t[cnt].leftson, left, mid, start);
    else
        change(t[cnt].rightson, mid + 1, right, start);
}

此处需要说明的是&place,本质是给root[i]赋值。


关于此处的语法:这是一个引用,相当于给一个变量起另外一个名字,但是在内存中这两个占用同一个空间,修改引用本质还是在修改本身。此处这么使用的原因在于:在函数调用的时候,会复制一份,无论是地址还是变量本身,这就会造成一个问题:函数内被修改了但是外边没有动。这个例子在C语言的时候经常被提及。

void swap(int a,int b)
{
	int t=a;
	a=b;
	b=t;
}
int main()
{
	int x=3,y=4;
	swap(x,y);
	return 0;
}

例如上述程序中,由于这个特性,导致主函数调用swap函数时x和y的值并不会被交换。在课堂上通常会说使用指针,这样传递地址的话就可以直接操作地址内存放的值,但是也可以像下面这样使用引用:

void swap(int &a,int &b)
{
	int t=a;
	a=b;
	b=a;
}

这样a和b传递进来的就是x和y而非新建的一对,swap也可以直接对这两个数进行交换。

这里使用这个的目的在于:由于change函数没有返回值,因而也就无法让root[i]被正确赋值——它需要对应的cnt才行。于是我们就采用引用,让root[i]在语句place=cnt时被赋值,避免了这个问题,同时代码较为简洁没有特判了。


下面是查询。

int query(int former, int latter, int left, int right, int k)//former是前面版本,对应插入[1,l-1];latter是后面版本,对应插入[1,r]。此处采用的前缀和思想在上文中提到过。
{
    int d = t[t[latter].leftson].sum - t[t[former].leftson].sum;
    //每个位置对应的值要相减,一定要对应。d指[l,r]中左侧区间的数个数
    if(left == right)//查到叶节点了
        return left;
    int mid = (left + right) >> 1;
    if(k <= d)//如果左子树茂盛,k的范围在左子树,则直接去左边找
        return query(t[former].leftson, t[latter].leftson, left, mid, k);
    else
        return query(t[former].rightson, t[latter].rightson, mid + 1, right, k - d);//一定要减d!因为此处的k是指在当前区间的第k位,而非总的第k位,而d代表了左侧区间的个数。
}

配合最后的主函数食用

int main()
{

    int n, m;
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++)
    {
        que[i].id = i;
        scanf("%d", &que[i].value);
    }
    sort(que + 1, que + n + 1);//离散化的基本操作:排序+重编号
    for (int i = 1; i <= n;i++)
        rk[que[i].id] = i;
    
    for (int i = 1; i <= n; i++)
    {
        root[i] = root[i - 1];//因为一定是从插入上一个节点这个状态进行本轮修改——插入,因而先抄上一个的root[i],然后再进行更新
        change(root[i], 1, n, rk[i]);
    }
    int a, b, k;
    for(int i = 1; i <= m; i++)
    {
        scanf("%d%d%d", &a, &b, &k);
        printf("%d\n", que[query(root[a - 1], root[b], 1, n, k)].value);
    }
    return 0;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值