[C++]可持久化线段树 主席树模型分析及例题详解

本文详细介绍了线段树及其在区间最值和求和问题中的应用,讨论了线段树的缺陷,即无法回溯历史版本。然后引出了可持久化线段树,即主席树,通过动态开点和双指针同步搜索解决了版本回溯的问题。文章提供了两个模板题,分别展示了主席树在单点修改和区间第k小值查询中的实现,以及空间复杂度和时间复杂度分析。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

目录


线段树

模型介绍

线段树的缺陷

线段树的分类


主席树(可持久化线段树)

模型介绍

空间复杂度分析


模板题一:洛谷P3919

原题呈现

题目分析

动态开点

建树

单点修改

查询

整题代码


模板题二:洛谷P3834

原题呈现

题目分析

离散化

建树

插入值

值查询

整题代码


总结和比较

附:线段树算法讲解链接


 

线段树

模型介绍

通过前面的学习我们都对线段树有了一定的了解。线段树是用于解决区间最值问题区间求和类问题的树形数据结构,其特点为:支持多次修改、多次查询且时间复杂度都接近o(logn)。下面,假如我们想对数列arr{8,4,5,3}的每个区间求最小值,我们可以建立如下图的线段树。

不难看出,线段树建树是不断二分当前区间并形成新的子节点,同时通过子节点最小值回溯计算父节点最小值的过程。现在,假如我们将arr[3](假定下标从1开始计数)的值修改为2,那么线段树将会变为如下图所示。

接下来,我们再将arr[1]的值修改为1,线段树最终变为如下图所示。

线段树的缺陷

我们将每一次变换看作是一次version(版本)的改变,那么线段树依次经历了三个版本。可以看出,朴素的线段树改值的方式是将原值覆盖,但这样同样会出现一个问题:假如我们想要获得上一个版本中的区间最值呢?换句话说,假如我们对线段树进行了一次操作,我们无法回撤这一次的操作而获取原本的值。

线段树的分类

朴素线段树大致分为两类:普通线段树权值线段树。对于普通线段树来说,每个节点的区间分别对应了元素在数组中的下标,节点中一般需要维护区间内的最值区间和;而权值线段树的节点区间则对应了数组的值域,同时维护每个值域内数的出现频次


主席树(可持久化线段树)

模型介绍

显然,为了解决上述问题,我们需要多个根节点去对应存储不同版本下的线段树。如果我们要经过m次操作获得m个新版本的线段树,那么最简单的方法无疑是创建m棵线段树去对应存储每一个版本了。但是,每棵线段树都需要4n的空间,那么总共的空间复杂度需要o(4mn),这显然已经超出MLE的警戒线了,所以这必然不是一个很好的方案。

我们不妨观察上文中线段树的两次单点插入操作,可以发现,虽然每次操作都会形成一个新版本的线段树,但是实际修改的节点只有一条链式结构,且最多只有logn+1个结点会被修改。于是,我们就产生了这样一种想法:既然其他的结点都不会被修改,那我们是否可以在新版本的线段树下继承这些不需要修改的旧结点,再另外开辟新的结点去存储需要修改的结点呢?

于是,主席树便由此诞生了。接下来,我们仍然以刚才的数据为例,演示主席树的结构。

现在,我们修改arr[3]的值为2,因此可能被修改的结点有[3,3]对应结点、[3,4]对应结点和[1,4]对应结点。因此,我们需要开辟出三个新结点去存储这三个区间的新值。其他结点都不需要更改,因此我们只需要将新结点连接到旧树上,就形成了一个新的线段树,而这个线段树也同样对应了新的版本。

 同样地,我们将arr[1]的值修改为1,[1,1]对应结点、[1,2]对应结点和[1,4]对应结点可能被修改,按照同样的方式,我们可以建立如下的新树。

这样,我们便通过一个数据结构建立起了对应多个版本的线段树,我们将它称为可持久化线段树,也就是主席树

空间复杂度分析

设数组大小为n,共进行m次改值操作,建立初代线段树需要2n-1个节点,每次改值需要新建logn+1个节点,空间复杂度约为o(2n+m(logn+1)),即o(nlogn+3n)级的空间复杂度。


模板题一:洛谷P3919

原题呈现

[题目描述]

如题,你需要维护这样的一个长度为  N  的数组,支持如下几种操作


1. 在某个历史版本上修改某一个位置上的值

2. 访问某个历史版本上的某一位置的值


此外,每进行一次操作(*对于操作2,即为生成一个完全一样的版本,不作任何改动*),就会生成一个新的版本。版本编号即为当前操作的编号(从1开始编号,版本0表示初始状态数组)

[输入格式]

输入的第一行包含两个正整数  N, M , 分别表示数组的长度和操作的个数。

第二行包含 N 个整数,依次为初始状态下数组各位的值(依次为  ai, 1 ≤ i ≤ N)。

接下来 M 行每行包含3或4个整数,代表两种操作之一( i 为基于的历史版本号):

1. 对于操作1,格式为 vi  1 loci valuei,即为在版本 vi 的基础上,将  aloci 修改为 valuei

2. 对于操作2,格式为 vi  2  loci,即访问版本 vi 中的  aloci 的值,生成一样版本的对象应为vi

[输出格式]

输出包含若干行,依次为每个操作2的结果。

[输入样例]

5 10
59 46 14 87 41
0 2 1
0 1 1 14
0 1 1 57
0 1 1 88
4 2 4
0 2 5
0 2 4
4 2 1
2 2 2
1 1 5 91

[输出样例]

59
87
41
87
88
46

[数据规模]

对于30%的数据: 1 ≤ N, M ≤ 1e3

对于50%的数据: 1 ≤ N, M ≤ 1e4

对于70%的数据: 1 ≤ N, M ≤ 1e5

对于100%的数据: 1 ≤ N, M ≤ 1e6, 1 ≤ loci ≤ N, 0 ≤ vi < i, -1e9 ≤ ai, valuei  ≤ 1e9

**经测试,正常常数的可持久化数组可以通过,请各位放心**

~~数据略微凶残,请注意常数不要过大~~

~~另,此题I/O量较大,如果实在TLE请注意I/O优化~~

询问生成的版本是指你访问的那个版本的复制

[样例说明]

一共11个版本,编号从0-10,依次为:

*0 : 59 46 14 87 41

*1 : 59 46 14 87 41

*2 : 14 46 14 87 41

*3 : 57 46 14 87 41

*4 : 88 46 14 87 41

*5 : 88 46 14 87 41

*6 : 59 46 14 87 41

*7 : 59 46 14 87 41

*8 : 88 46 14 87 41

*9 : 14 46 14 87 41

*10 : 59 46 14 87 91

题目分析

给出一个长度为n的数组,每次单点修改某一个历史版本中某一位置的值或查询某一历史版本中某一位置的值。显然,我们需要维护多个版本的线段树,因此使用主席树来解决此题。

动态开点

我们先来回顾一下之前的线段树是如何获取左右子节点对应坐标的:

由树的性质可知,在存储树的数组中,假定父节点下标为i,那么左结点的下标为2i,右结点的下标为2i+1。因此我们可以通过这样一段代码来获取左右孩子的下标:

inline ll lson(ll fa)
{
    return fa << 1;
}

inline ll rson(ll fa)
{
    return fa << 1 | 1;
}

然而,这个关系式在主席树中是不成立的。现在,我们为主席树的左右结点分别进行标记,如下图所示:

 不难看出,同一个结点可以作为不同父节点的子结点,因此父子结点在数组中的下标是不满足上述关系的,所以我们需要采用动态开点的方式去为每一个结点分别存储其左结点和右结点。

为每个结点定义如下结构体:

struct Node
{
    int ch[2];//存储左右孩子下标
    int n;//存储当前位置对应的值
} tr[maxn * 25];

其中,ch为每个结点分别对应的左右子节点,n为当前位置的数字(由于本题都是单点修改操作,我们直接将值通过结构体传递)。另外开辟tr数组作为存放树结构的数组,这样,我们可以定义宏来直接获取左右孩子的下标,代码如下:

#define lc(x) tr[x].ch[0]
#define rc(x) tr[x].ch[1]

通过这种方式,我们便实现了动态开点,下一步,我们就要开始实现主席树了。

建树

由于我们后续会建立多个版本的线段树,因此需要一个专门的数组root来存放所有版本线段树的根节点。同时,我们需要为线段树的每个节点分配一个下标,这一步我们通过变量idx来实现。

首先,让我们看一下建树部分的代码:

int arr[maxn], root[maxn], idx = 0;

//建树:传入结点下标root、区间左端点l、区间右端点r
void build(int &root, int l, int r)
{
    //为当前结点分配在数组中的下标并通过引用返回给其父节点
    root = ++idx;

    //左端点==右端点,直接赋值
    if (l == r)
    {
        tr[root].n = arr[l];
        return;
    }

    //二分区间建树
    int mid = l + r >> 1;
    build(lc(root), l, mid);
    build(rc(root), mid + 1, r);
}

这里比较特殊的地方在于结点root的传入是以传引用的方式进行的,这样,我们可以通过为子节点分配下标的方式直接修改父节点中左右孩子的值。

其他部分与线段树的建树基本类似:当区间左端点l等于右端点r时,将数组的值赋给结点;否则通过二分区间的方式去建立子结点。

单点修改

当我们对某个值进行修改,就意味着线段树形成了一个新的版本,因此,我们需要进行旧结点的继承和新结点的建立

单点修改的代码如下:

//修改pos位置的值为val 另需传入旧版本的根节点下标pre、新版本的根节点下标cur、区间左端点l、区间右端点r
void modify(int pre, int &cur, int l, int r, int pos, int val)
{
    cur = ++idx;//新结点的申请
    tr[cur] = tr[pre];//将旧结点的信息进行拷贝,包括左右孩子和值

    //左端点==右端点,我们对该结点的值进行修改
    if (l == r)
    {
        tr[cur].n = val;
        return;
    }

    //二分区间查找pos位置的结点
    int mid = l + r >> 1;
    if (pos <= mid)
        modify(lc(pre), lc(cur), l, mid, pos, val);
    else
        modify(rc(pre), rc(cur), mid + 1, r, pos, val);
}

可以发现,在这里我们新版本的根节点是以引用传递的形式传入来进行修改的,而旧版本则是值传递的形式传入。同时,我们将旧结点的信息完全拷贝给新的结点,并在搜索到pos位置时更改新结点的值,这样就可以实现主席树的修改了。由于这道题并没有涉及区间最值和区间和,所以这里并不需要将值回溯给父结点,否则务必记住将子结点的值回溯。另外,在调用递归进入子结点时,我们需要双指针同步搜索,即旧结点和新结点同步进入各自的左右孩子。毕竟只有这样,我们才能将部分旧的结点继承给新树。

查询

查询函数的实现较为简单,只需要不断二分区间查找pos位置即可,其代码如下:

//查询
int query(int root, int l, int r, int pos)
{
    //当左端点==右端点,说明找到了pos位置,返回当前位置的值
    if (l == r)
        return tr[root].n;

    //二分区间进行搜索
    int mid = l + r >> 1;
    if (pos <= mid)
        return query(lc(root), l, mid, pos);
    else
        return query(rc(root), mid + 1, r, pos);
}

整题代码

#include <iostream>
#define lc(x) tr[x].ch[0]
#define rc(x) tr[x].ch[1]
using namespace std;
const int maxn = 1e6 + 50;

//快读
template <class T>
inline T read()
{
    T x = 0, f = 1;
    char ch = getchar();
    while (ch < '0' || ch > '9')
    {
        if (ch == '-')
            f = -1;
        ch = getchar();
    }
    while (ch >= '0' && ch <= '9')
    {
        x = x * 10 + ch - 48;
        ch = getchar();
    }
    return x * f;
}

struct Node
{
    int ch[2];//存储左右孩子下标
    int n;//存储当前位置对应的值
} tr[maxn * 25];

int arr[maxn], root[maxn], idx = 0;

//建树:传入结点下标root、区间左端点l、区间右端点r
void build(int &root, int l, int r)
{
    //为当前结点分配在数组中的下标并通过引用返回给其父节点
    root = ++idx;

    //左端点==右端点,直接赋值
    if (l == r)
    {
        tr[root].n = arr[l];
        return;
    }

    //二分区间建树
    int mid = l + r >> 1;
    build(lc(root), l, mid);
    build(rc(root), mid + 1, r);
}

//修改pos位置的值为val 另需传入旧版本的根节点下标pre、新版本的根节点下标cur、区间左端点l、区间右端点r
void modify(int pre, int &cur, int l, int r, int pos, int val)
{
    cur = ++idx;//新结点的申请
    tr[cur] = tr[pre];//将旧结点的信息进行拷贝,包括左右孩子和值

    //左端点==右端点,我们对该结点的值进行修改
    if (l == r)
    {
        tr[cur].n = val;
        return;
    }

    //二分区间查找pos位置的结点
    int mid = l + r >> 1;
    if (pos <= mid)
        modify(lc(pre), lc(cur), l, mid, pos, val);
    else
        modify(rc(pre), rc(cur), mid + 1, r, pos, val);
}

//查询
int query(int root, int l, int r, int pos)
{
    //当左端点==右端点,说明找到了pos位置,返回当前位置的值
    if (l == r)
        return tr[root].n;

    //二分区间进行搜索
    int mid = l + r >> 1;
    if (pos <= mid)
        return query(lc(root), l, mid, pos);
    else
        return query(rc(root), mid + 1, r, pos);
}

int main()
{
    //数据读取
    int n = read<int>(), m = read<int>();
    for (int i = 1; i <= n; i++)
    {
        arr[i] = read<int>();
    }

    //建树,传入root[0]作为初始版本根节点
    build(root[0], 1, n);

    //改值和查询
    for (int i = 1; i <= m; i++)
    {
        int v = read<int>(), op = read<int>(), p = read<int>();
        if (op == 1)
        {
            int val = read<int>();
            //题目要求每一步操作生成一个当前操作编号的版本,因此传入旧版本root[v]和新版本root[i]
            modify(root[v], root[i], 1, n, p, val);
        }
        else
        {
            printf("%d\n", query(root[v], 1, n, p));
            //同样,查询操作生成一个与旧版本完全一样的新版本,直接拷贝即可
            root[i] = root[v];
        }
    }
    return 0;
}

模板题二:洛谷P3834

原题呈现

[题目描述]

如题,给定 n 个整数构成的序列 a,将对于指定的闭区间 [l, r] 查询其区间内的第 k 小值。

[输入格式]

第一行包含两个整数,分别表示序列的长度 n 和查询的个数 m。  
第二行包含 n 个整数,第 i 个整数表示序列的第 i 个元素 ai。   
接下来 m 行每行包含三个整数 l, r, k, 表示查询区间 [l, r] 内的第 k 小值。

[输出格式]

对于每次询问,输出一行一个整数表示答案。

[输入样例]

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

[输出样例]

6405
15770
26287
25957
26287

[样例 1 解释]

n=5,数列长度为 5,数列从第一项开始依次为{25957, 6405, 15770, 26287, 26465}。

- 第一次查询为 [2, 2] 区间内的第一小值,即为 6405。
- 第二次查询为 [3, 4] 区间内的第一小值,即为 15770。
- 第三次查询为 [4, 5] 区间内的第一小值,即为 26287。
- 第四次查询为 [1, 2] 区间内的第二小值,即为 25957。
- 第五次查询为 [4, 4] 区间内的第一小值,即为 26287。


[数据规模与约定]

- 对于 20% 的数据,满足 1 ≤ n,m ≤ 10。
- 对于 50% 的数据,满足 1 ≤ n,m ≤ 1e3。
- 对于 80% 的数据,满足 1 ≤ n,m ≤ 1e5。
- 对于 100% 的数据,满足 1 ≤ n,m ≤ 2e5,|ai| ≤ 1e9,1 ≤ l ≤ r ≤ n,1 ≤ k ≤ r - l + 1。

题目分析

给出一个长为n的序列a,求出[l,r]区间内的第k小值。显然,这道题既然放在这里,那肯定是用主席树去做。与之前不同的是,题目所求的是区间的第k小值,而且这个k值并不固定,因此用普通线段树很难实现这个功能。这个时候就需要可持久化权值线段树登场了。

那么为什么要使用权值线段树呢?如下图,每个区间内所标数字即为该区间内存在数字的个数,假定我们需要找出[0,10000]值域内的第10小值,由于[0,4000]范围内共有9个数字,因此我们只需要找出[4000,6000]范围内的第1小值即为所求。同样,可以对区间再度进行细分,最终找到答案。权值线段树便是运用了类似的思想,唯一不同点在于权值线段树是二分区间进行搜索。

其次,虽然题目没有明显的“版本”分界,但是我们可以进行这样一个构造:假设原树中没有任何元素,依次将数组中的元素插入主席树中,每一次插入形成一个新的版本。然而,想求出[l,r]区间内的第k小值并不好求,因为我们使用的是权值线段树,每一个结点代表的是一个值域,而并非是数组中的区间。但是由于我们分成了n次插入数字,也形成了n个版本的线段树,假如我们运用前缀和的思想,令值域[a,b]内第r个版本的数字总和减去值域[a,b]第l-1个版本的数字总和,那不恰恰就是数组区间[l,r]中落在值域[a,b]内的数字个数吗?

通过这种方式进行二分查找,便可以轻松找到最终的答案了。

离散化

观察一下数据范围:|ai| ≤ 1e9这意味着要想建立一棵权值线段树,我们需要整整1e9的空间,这显然是不可取的。

于是,我们需要将分散的大数据进行集中储存,为每一个数据分别取一个“代号”。离散化处理的步骤可以概括为“排序-去重-二分查找”三个步骤,接下来让我们来看一下这个过程吧。

整个过程我们可以将所有数字压入一个vector容器来实现。首先,通过sort使数组有序,接着通过unique和erase将重复元素删去,这样,我们就可以得到一个有序的集合,于是便可以用数组下标去替代当前的元素。

如上图,经过这一个过程,我们便可以用0去替代1316、用1去替代6405......这样,就可以将数字的值域压缩在[0,n]这个范围中。

离散化处理的代码如下:

vector<int> id;

//离散化
void init_id()
{
    sort(id.begin(), id.end());                       //排序
    id.erase(unique(id.begin(), id.end()), id.end()); //去重
}

当然,我们还要获取每个元素在数组中的下标,这里我们使用lower_bound二分查找元素,代码如下:

//获取离散化下标
int get_id(int x)
{
    return lower_bound(id.begin(), id.end(), x) - id.begin() + 1; //二分查找
}

在树中,我们使用元素的“代号”去实现操作,最后输出结果时我们仍需将“代号”带入到离散化处理的容器中进行逆运算获取原数。

建树

该部分与模板题一中基本一致,其代码如下:

struct Node
{
    int ch[2];
    int cnt;//该值域内元素的个数
} tr[maxn * 22];

int n, m;
int arr[maxn], root[maxn], idx = 0;

//建树
void build(int &root, int l, int r) // l和r为值域
{
    root = ++idx;
    if (l == r)
        return;
    int mid = l + r >> 1;
    build(lc(root), l, mid);
    build(rc(root), mid + 1, r);
}

值得一提的是,由于一开始树中什么元素也没有,这段代码事实上可以省略,直接用一个空结点去取代这个过程。

插入值

由于我们需要统计每个值域范围内的元素个数,因此每次插入时需要将每个经过的结点的cnt值加一。其代码如下:

//插入值
void insert(int pre, int &cur, int l, int r, int val)
{
    cur = ++idx;
    tr[cur] = tr[pre];
    tr[cur].cnt++;//该值域内数量+1
    if (l == r)
        return;
    int mid = l + r >> 1;
    if (val <= mid)
        insert(lc(pre), lc(cur), l, mid, val);
    else
        insert(rc(pre), rc(cur), mid + 1, r, val);
}

值查询

通过r和l-1两个版本的左结点的数量作差,计算落在[l,r]落在该值域内的数字个数来判断下一步进入左结点还是右结点。其代码如下:

//值查询
int query(int pre, int cur, int l, int r, int k)
{
    if (l == r)
        return l - 1;
    int mid = l + r >> 1;
    int s = tr[lc(cur)].cnt - tr[lc(pre)].cnt;//计算两个版本之间值域内的数量差,即[l,r]内落在该值域的数字个数
    if (k <= s)
        return query(lc(pre), lc(cur), l, mid, k);
    else
        return query(rc(pre), rc(cur), mid + 1, r, k - s);
}

整题代码

#include <iostream>
#include <vector>
#include <algorithm>
#define INF 0x3f3f3f3f
#define lc(x) tr[x].ch[0]
#define rc(x) tr[x].ch[1]
using namespace std;
const int maxn = 2e5 + 50;

//快读
template <class T>
inline T read()
{
    T x = 0, f = 1;
    char ch = getchar();
    while (ch < '0' || ch > '9')
    {
        if (ch == '-')
            f = -1;
        ch = getchar();
    }
    while (ch >= '0' && ch <= '9')
    {
        x = x * 10 + ch - 48;
        ch = getchar();
    }
    return x * f;
}

struct Node
{
    int ch[2];
    int cnt;//该值域内元素的个数
} tr[maxn * 22];

int n, m;
int arr[maxn], root[maxn], idx = 0;
vector<int> id;

//离散化
void init_id()
{
    sort(id.begin(), id.end());                       //排序
    id.erase(unique(id.begin(), id.end()), id.end()); //去重
}

//获取离散化下标
int get_id(int x)
{
    return lower_bound(id.begin(), id.end(), x) - id.begin() + 1; //二分查找
}

//建树
void build(int &root, int l, int r) // l和r为值域
{
    root = ++idx;
    if (l == r)
        return;
    int mid = l + r >> 1;
    build(lc(root), l, mid);
    build(rc(root), mid + 1, r);
}

//插入值
void insert(int pre, int &cur, int l, int r, int val)
{
    cur = ++idx;
    tr[cur] = tr[pre];
    tr[cur].cnt++;//该值域内数量+1
    if (l == r)
        return;
    int mid = l + r >> 1;
    if (val <= mid)
        insert(lc(pre), lc(cur), l, mid, val);
    else
        insert(rc(pre), rc(cur), mid + 1, r, val);
}

//值查询
int query(int pre, int cur, int l, int r, int k)
{
    if (l == r)
        return l - 1;
    int mid = l + r >> 1;
    int s = tr[lc(cur)].cnt - tr[lc(pre)].cnt;//计算两个版本之间值域内的数量差,即[l,r]内落在该值域的数字个数
    if (k <= s)
        return query(lc(pre), lc(cur), l, mid, k);
    else
        return query(rc(pre), rc(cur), mid + 1, r, k - s);
}

int main()
{
    n = read<int>(), m = read<int>();
    for (int i = 1; i <= n; i++)
    {
        arr[i] = read<int>();
        id.push_back(arr[i]);
    }
    init_id();
    build(root[0], 0, n);
    for (int i = 1; i <= n; i++)
    {
        insert(root[i - 1], root[i], 0, n, get_id(arr[i]));//将id传入树中
    }
    while (m--)
    {
        int l = read<int>(), r = read<int>(), k = read<int>();
        printf("%d\n", id[query(root[l - 1], root[r], 0, n, k)]);//传入l-1和r处的根节点,同时需要进行离散化处理的逆运算
    }
    return 0;
}

总结和比较

主席树实质上就是多棵线段树的叠加,在线段树的基础上支持操作的回撤,形成了多个版本的线段树。在实现上,主席树采用了动态开点的存储形式和双指针同步搜索的遍历形式。经过优化,主席树的空间复杂度约为o(nlogn+3n),时间复杂度约为o(n(logn)^2)


附:线段树算法讲解链接

[C++]洛谷 【模板】线段树1 详解+lazy标志优化

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值