线段树杂谈·普通线段数|乘法线段数|主席树

首先非常感谢涛涛学长的认可与厚爱,让小熊同学有机会在这里和大家共同探讨线段树的问题。
(。・ω・。)

一点点前置知识x<<1相当于 x ∗ 2 x*2 x2,x<<1|1相当于 x ∗ 2 + 1 x*2+1 x2+1

线段数是什么

线段树,“线段”是“树”的修饰词。对于线段树,它首先是一颗树。
顾名思义,它是由线段构成的一颗树。
请看下图:
请添加图片描述
我们知道线段是有长度的,比如对于(1)号节点,它的长度就是L=1,R=4, L − R + 1 = 4 L-R+1=4 LR+1=4。同理,对于(2)号节点,它的长度是L=1,R=2, L − R + 1 = 2 L-R+1=2 LR+1=2
并且,这个树是一颗二叉树,或者说,是一颗完全二叉树。并且,节点的编号个数,约等于 2 ∗ N 2*N 2N。例如上图, N = 4 N=4 N=4,它有7个节点。(嘿,为什么是 4 N 4N 4N,这里请去二叉树复习,简单来说,就是等比数列求和,和log函数,公式如下: 2 l o g 2 ( N ) + 1 − 1 2^{log_2{(N)}+1}-1 2log2(N)+11

问题的引入

朴素的问题

现在,我们有一个数组a = [1, 2, 3, 4]
然后,我们有这样一类问题,那么就是,对于区间进行Add操作,例如,对于区间1-4进行+2的操作。
按照我们最容易想到的算法是,通过一个For循环,然后对元素一个个遍历,然后发现这个元素就是我们要修改的元素,那么我们就对这个元素执行Add。

这样做法当然没错,而且也很直观,这个算法的复杂度是O(N)

优化的火花

现在,让我们来想想,在进行区间修改的时候,我们能不能有一种更快的方法,去修改它,使得它的时间复杂度可以往下降一降。

例如,在,每次修改区间的时候,我为什么要求去对元素进行操作,我可不可以创建一个History历史记录,这个记录记录了我是怎么去修改这个区间的,当我要去查询某一个元素的时候,那么就按照History给出的指南,利用原来的a数组,就可以还原出真正的答案。

这种感觉就好像,对于每次修改,我先不做,当查询一个元素的时候,我再做。这样至少,对于修改操作来说,每次的时间复杂度可以为O(1),因为我只要记录进History即可。

这样想已经很棒了,至少我们已经有了一点点的启发。现在让我们来看看查询操作。对于Query每个点,我们需要还原History的操作,这依赖于,History的长度,当用户修改的越多,那么还原的时候也就更累,时间复杂度更复杂。

让我们再次开动脑筋。

没错,那就是,线段树!

优化的再次进化

现在,我们可以认为,线段树就是一个History,还是原来的图片,原来的a数组(在“朴素的问题”那里)。
对于(1)节点,这个节点L和R表示的区间是,1-4,那么当我们在1-4进行Add操作的时候,完全可以把我们的操作记录在这个节点上。
那,当我们对1-3区间进行操作的时候,我们还可以将操作记录在1-4节点上面嘛?
不正确!首先我们明确,(1)节点记录的是在1-4这个整体区间的操作,1-3区间的操作,并不包含着,第4号节点。所以,这是错误的,对于(2)号节点,它代表着区间1-2,1-2包好在1-3之间,所以(2)号节点是可以进行Add操作的。然后就是(6)号节点,进行Add操作。
请添加图片描述
现在,让我们模拟一种情况:
第一次操作:对于区间[1,4] 加2
(我们定义v是对于本节点区间的修改值)
请添加图片描述

第二次操作:对于区间[1,3] 加1
请添加图片描述
没错,线段树也可以是一个History。
现在让我们来查询,a[0]再修改后是几,对于a[0],它首先经过线段数(1)号节点的修改,它被加了2,然后对于(2)号节点,它又被加了1,然后到(4)号节点,此时,(4)号节点代表着,区间[1,1],对应a[0]

a[0]除了被(1)、(2)、(4)节点修改以外,不可能再被别点节点修改了。
例如,a[0]不会被(3)号节点修改,因为(3)号节点意味着,历史记录于[3,4]的区间。

所以,我们可以得到,a[0]=a[0]+2+1=4。

线段树是一个历史记录,我们根据线段树,还原了最后修改之后,本点的值。

现在,查询和修改,都是 l o g ( N ) log(N) log(N)级别,回忆一下,二叉查找算法的,复杂度原理。对于每次修改查询,我都根据mid来选择进入左子树或右子树,每次砍一半,当然是log级别的啦。

但是,现在我们又有了一个新的问题:
区间查询怎么办,正如您上面所看到的,我们刚刚的是单点查询,而区间查询的话,我们可能要变为N*log(N),那就是,单独计算需要累加的点,然后加在一起算出区间的值。

我们有没有更好的办法?
当然有了,因为我看过别人的教程了,这篇文章也在CSDN,将在末尾给出。

代码指南(1)

在继续深入学习之前,现在有必要敲敲代码。将上文的思路实现出来。

树的构造

struct node {
        int L, R, V;
    } nd[N * 4];

建树

    void build(int p, int L, int R) {
        nd[p] = {L, R, 0};
        if (L == R)
            return;
        int mid = L + R >> 1;
        build(p << 1, L, mid);
        build(p << 1 | 1, mid + 1, R);
    }

注意,在我们建树的时候,我们将mid分配给了左子树。
并且,当L==R,即长度为1的时候,我们就return,当长度为2的时候,再进行mid,从中间砍掉,分为左子树和右子树。

修改

void modify(int p, int L, int R, int v) {
        if (nd[p].L >= L && nd[p].R <= R) {
			nd[p].V += V; //对本节点的V值进行累加,就相当于历史记录一样。
            return;
        }
        int mid = nd[p].L + nd[p].R >> 1;
        if (L <= mid) { //进入左子树
            modify(p << 1, L, R, v);
        }
        if (R > mid) {
            modify(p << 1 | 1, L, R, v);
        }
    }

查询(单点查询)

    void query(int p, int x) {  //计算x节点上的v值
		total += nd[p].V;
        if (nd[p].L == nd[p].R)
            return;
        int mid = nd[p].L + nd[p].R >> 1;
        if (x <= mid) {
            query(p << 1, x);
        } else {
            query(p << 1 | 1, x);
        }
    }

total是一个全局的变量,负责+=查询到最后节点这一路径上的V累加值。
最后a[x-1]+total即答案,total就是一个History历史记录。
和我们上文所说的思想是一致的。

延迟标记

树的结构

现在,为了更高效地支持区间查询,我们不得不使得线段数节点修改一下。
我们修改完毕的线段数节点结构如下:

    struct node {
        int L, R, sum;
    } nd[N * 4];

没错,现在,我们将V修改为sum,意义也开始变得不同,现在,这个节点的sum代表着的是L到R范围的所有元素之和,为sum。

例如:a = [1, 2, 3, 4]
那么现在树的结构如下,例如对(1)号节点,它的范围是1-4所以 s u m = 10 sum=10 sum=10

请添加图片描述
其中,我们有一种关系:即nd[i].sum=nd[i<<1].sum+nd[i<<1|1].sum父节点的sum为子节点sum之和。

这样树的结构,方便我们进行区间查询例如,现在sum就是在进行区间查询这一段总和是多少。

区间查询

我们要查询1-3范围综合是多少,那么就是(2)号节点的sum和(6)号节点的sum。和前文查询的思路是一模一样的,(2)号节点记录了1-2范围的sum是多少,而(6)号节点记录了3-3范围的sum是多少,最后答案是,6。

区间查询结束了。算法大体的思路是这样,代码在后文给出。

区间修改

对于本次的区间修改,例如,我们想修改区间1-3,然后加2。
那么对于(1)号节点来说,它是 10 + 3 ∗ 2 10+3*2 10+32 其中,3指的是1-3区间长度,每个元素加2,所以加了6。
让我们来直接看图。
请添加图片描述
完成上图的代码如下:
(摘自博客:https://blog.csdn.net/weixin_45697774/article/details/104274713)

void add(int i,int l,int r,int k)
{
    if(tree[i].r<=r && tree[i].l>=l)//如果当前区间被完全覆盖在目标区间里,讲这个区间的sum+k*(tree[i].r-tree[i].l+1)
    {
        tree[i].sum+=k*(tree[i].r-tree[i].l+1);
        tree[i].lz+=k;//记录lazytage
        return ;
    }
    push_down(i);//向下传递
    if(tree[i*2].r>=l)
        add(i*2,l,r,k);
    if(tree[i*2+1].l<=r)
        add(i*2+1,l,r,k);
    tree[i].sum=tree[i*2].sum+tree[i*2+1].sum;
    return ;
}

首先请无视poshdown(i)。对于上图,我们会发现,这其中存在着几个错误的节点,例如,节点(4)和节点(5)。他们的sum值并没有被修改成功。这是因为,在add函数之中,当tree[i].r<=r && tree[i].l>=l我们就return了。一种方法是,我们直接把return去掉,当然,其实我们还有另一种方法,引入延迟标记

修正错误,以及何时修正错误

当我们访问到(4)号和(5)节点的时候,我们再进行修复错误。
对,延迟就是差不多这个意思,我们延迟到:当我们会访问到这个节点的时候,我们再修复这个节点的错误。
延迟标记怎么设置呢,改进Node的Struct,新增一个lz变量。

    struct node {
        int L, R, sum, lz;
    } nd[N * 4];

对于访问到某个节点来说,它拥有一个lz,就说明,它需要更新。
对于pushdown来说,它的意义是:
(1)清空本节点的lz标记
(2)更正左右子节点的sum(以本节点的lz标记为准)。
(3)将本节点的lz加入到左右子节点的lz标记上去。(这样可以实现层层传递这个lz标记,访问到哪里,标记传递到哪里)
请看pushdown的代码:
(摘自博客:https://blog.csdn.net/weixin_45697774/article/details/104274713)

void push_down(int i)
{
    if(tree[i].lz!=0)
    {
        tree[i*2].lz+=tree[i].lz;//左右儿子分别加上父亲的lz
        tree[i*2+1].lz+=tree[i].lz;
        int mid=(tree[i].l+tree[i].r)/2;
        tree[i*2].sum+=tree[i].lz*(mid-tree[i*2].l+1);//左右分别求和加起来
        tree[i*2+1].sum+=tree[i].lz*(tree[i*2+1].r-mid);
        tree[i].lz=0;//父亲lz归零
    }
    return ;
}

查询的代码:

inline int search(int i,int l,int r){
    if(tree[i].l>=l && tree[i].r<=r)
        return tree[i].sum;
    if(tree[i].r<l || tree[i].l>r)  return 0;
    push_down(i);
    int s=0;
    if(tree[i*2].r>=l)  s+=search(i*2,l,r);
    if(tree[i*2+1].l<=r)  s+=search(i*2+1,l,r);
    return s;
}

需要注意的是,pushdown的时机,那就是,当访问本节点旗下的左右子节点的时候。
请见谅,由于篇幅限制,您可以访问:https://blog.csdn.net/weixin_45697774/article/details/104274713 大佬的博客,查看完整的代码。

乘法线段树

它的意思是,相较于之前求区间和,只有Add操作的线段树,我们现在引入了Mul乘法。
例如,对区间[1,2]每个元素乘2

简单来说,它的数据结构其实没有什么变化,比如节点中有sum变量。延迟标记稍有不同,它分为2个延迟标记,一个是plz,一个是mlz。前者是加法的延迟标记,后者是乘法的延迟标记。

我们认为,本节点的总和是sum,那么通过延迟标记修改完毕的sum是 s u m = s u m ∗ m l z + p l z sum=sum*mlz+plz sum=summlz+plz

所以维护两个延迟标记就显得非常重要。

我们维护加法的时候,比如Add函数,那么打上plz标记就是一句话的事情plz+=v
那么维护乘法的时候,比如Mul函数,那么这个时候需要同时修改plz和mlz,它们都需要*=v
这就好像我们原来有一个 ( m l z ∗ m + p l z ) ∗ v (mlz*m+plz)*v (mlzm+plz)v一样。展开括号即可知道为什么需要同时修改plz和mlz。

现在给出,pushdown的代码:
(摘自博客:https://blog.csdn.net/weixin_45697774/article/details/104274713)

inline void pushdown(long long i){//注意这种级别的数据一定要开long long
    long long k1=tree[i].mlz,k2=tree[i].plz;
    tree[i<<1].sum=(tree[i<<1].sum*k1+k2*(tree[i<<1].r-tree[i<<1].l+1))%p;//
    tree[i<<1|1].sum=(tree[i<<1|1].sum*k1+k2*(tree[i<<1|1].r-tree[i<<1|1].l+1))%p;
    tree[i<<1].mlz=(tree[i<<1].mlz*k1)%p;
    tree[i<<1|1].mlz=(tree[i<<1|1].mlz*k1)%p;
    tree[i<<1].plz=(tree[i<<1].plz*k1+k2)%p;
    tree[i<<1|1].plz=(tree[i<<1|1].plz*k1+k2)%p;
    tree[i].plz=0;
    tree[i].mlz=1;
    return ;
}

主席树

主席树其实很简单 ,它没有什么特别的地方。它也是一种线段树。
它常常用来区间查询第K大的问题。
请添加图片描述

树的结构

我们想象有这么这么一颗树,它的L,R不再代表着的是区间。而是值域。而这个节点的sum代表着的是,这个值域中,有多少数字。

我们来举个栗子,对于数组a = [1, 3, 4]
请添加图片描述
它的值域是[1,4]所以根据值域进行Dfs建树。
这里要注意的是,因为是按照Dfs建树,和之前的节点编号顺序稍有不同。

建造出这颗树有什么用呢,有了这棵树,我们就可以轻松的来查询第K大到底在哪里。

方法:缩小值域范围

先给出建树的代码:

int build(int l, int r) {  //您可以按照这个建树逻辑学习一下
    int rt = ++tot;        //本节点的编号
    sum[rt] = 0;
    if (l < r) {
        int mid = (l + r) >> 1;
        lson[rt] = build(l, mid);
        rson[rt] = build(mid + 1, r);
    }
    return rt;
}

tot是总共树的节点编号。我们建树的时候采用动态开点,然后lson数组记录节点的左子树的节点编号,rson数组记录本节点的右子树编号。然后现在sum为0,sum记录了当前这个值域我们有多少数。我们以后再加入进去。return rt返回当前节点的编号。那么对于最外层的build函数,它返回的也就是树根编号。这将在下文我们进行讨论。

查询第K大

还是上面的这个线段数的图,我们知道(1)号节点,在值域[1,4]中我们有3个数字。所以查找第2大数字的时候,我们首先知道第二大的这个数字应该在[1,4]这个区间内。
然后对于(2)号节点,我们知道[1,2]区间内有1个数字,所以第2大数字,不应该在区间[1,2]内,因为这个区间只包含一个数字,所以应该在(5)号节点[3,4]区间内。而因为[1,2]内已经有1个数字了,所以查询第2大,就是查询在[3,4]内的第1大数字。进入(5)号节点,开始查询第一大数字,发现(5)号节点的左子树(6)在区间[3,3]有一个数字,所以进入,因为我们现在查询的是第一大数字。

现在值域被缩小为[3,3],所以在a数组[1, 3, 4]中,第二大数字就是3。

根据这颗值域线段树,我们就能查询出第K大问题。

root数组

我们刚刚上面查询的是第K大数字,它查询的区间的全域,也就是在所有范围内的第K大。
还是对于a = [1, 3, 4]在a的区间[2,3]查询第二大数字(答案是4,a区间从1开始),那么这个时候该怎么办呢?

我们使用一个root数组,来保存树根,历史记录。
什么意思呢,也就是,刚刚我们建树的时候,sum是0嘛,那么我们插入a[i]的时候,我们会新建一颗树。然后把这颗新建的树的树根保存在root数组。

比如,对于a数组,我们在值域线段数加入完a[1]之后,我们就把这颗线段树保存在root[1]之中,当我们加入a[2]到这颗线段树,注意这颗线段树现在包含a[1],a[2],我们就把树根保存在root[2]之中,当我们加入a[3]的时候,就把这颗线段树的树根保存在root[3]之中。

代码将在稍后给出。
我们来看看root[1]和root[3]:
请添加图片描述
现在,我们查询a的区间[2,3]那么在这个区间,root[3]中处于值域[1,4]有3个数,而处理完第一个节点也就是a[1]的时候,root[1]中,处于值域[1,4]的有1个数,所以我们可以推测出,a[2],a[3]位于[1,4]的,有2个数。

算法就是 s u m [ r o o t [ 3 ] ] − s u m [ r o o t [ 1 ] ] sum[root[3]]-sum[root[1]] sum[root[3]]sum[root[1]]
也就是{a[1]、a[2]、a[3]}-{a[1]}
那么对于值域[1,2]我们有几个数字呢?现在的情况还是用sum[root[3]->lson]-sum[root[1]->lson]得到0,所以在a的区间[2,3]查询第2大,只能在区间[3,4]里面找,剩下的都一样。

这里只是想向您说明,我们到底是怎么知道在值域之中,我们有多少个数字的。那就是通过root数组查询历史记录,更正线段数节点值域范围即可。然后再把值域区间缩小到L==R。即可找到这个数字是几。

动态开点建树

这里和root数组是结合在一起的,其实这篇博客写的比较口语化,它的真正性质叫做“可持久化”。
在main函数之中,我们有:
main()

        for (int i = 1; i <= n; i++) {
            a[i] = lower_bound(b + 1, b + nn + 1, a[i]) - b;
            root[i] = update(root[i - 1], 1, nn, a[i]);
        }

请先忽视a[i] = lower_bound(b + 1, b + nn + 1, a[i]) - b;这句话将在后文离散化介绍。
update()

int update(int p, int l, int r, int x) {
    int rt = ++tot;
    lson[rt] = lson[p], rson[rt] = rson[p], sum[rt] = sum[p] + 1;
    if (l < r) {
        int mid = (l + r) >> 1;
        if (x <= mid)
            lson[rt] = update(lson[rt], l, mid, x);
        else
            rson[rt] = update(rson[rt], mid + 1, r, x);
    }
    return rt;
}

我个人觉得这个博主讲解的特别好,下文图片粘贴自:https://blog.csdn.net/Foxreign/article/details/118770162
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

然后再进行递归操作,进入左子树或者右子树。
请去大佬博客学习。
简单来说,就是每次Update,都会新开一个根节点,我们把这个根节点保存在了root数组之中。

离散化

下面就是离散化了,我们为什么需要离散化。
比如这是我们原来的a数组:
a = [10, 7, 9, 3]
那我们建树难道值域就要建树成[3,10]嘛?
这,有点开销太大了,因为最后子节点可能会是:[3,3]、[4,4]、[5,5]…[10,10]
有很多节点是用不到的,浪费了。
所以我们可以做一个这样的离散化,那就是a[i]存储的是,我在整个序列中,我是第几大的数字
比如现在离散化完毕之后:
a = [4 2 3 1]
a[1]=10,它是整个序列中第四大数字,所以它是4。
这样的话,a数组离散化之后,它的值域就变成了[1,4]
值域末尾确定,就是我们离散化了几个数字。请看代码来理解。
现在,如果我们查询一个区间的结果是,值域[3,3],我们应该直接输出3嘛?不是,这个3是离散化后的结果,它的意思是第三大的数,所以真正对应非离散化的结果它是9,输出9才对。

好,让我们来看下代码:

  n = read(), m = read(), tot = 0;
        //离散化
        for (int i = 1; i <= n; i++)
            a[i] = b[i] = read();
        sort(b + 1, b + n + 1);  // b是离散化的结果
        int nn = unique(b + 1, b + n + 1) - b - 1;
        root[0] = build(1, nn);
        for (int i = 1; i <= n; i++) {
            a[i] = lower_bound(b + 1, b + nn + 1, a[i]) - b;
            root[i] = update(root[i - 1], 1, nn, a[i]);
        }

读入完成之后,先把a数组的数据保存到b里面,然后对b进行unique函数,最后对查找a[i]在b中是第几大,写回a[i]中,完成离散化。并且将离散化的值,插入到值域线段树中。

这就是一颗,主席树。
下面请看例题,再次想想。

例题

描述:

Give you a sequence and ask you the kth big number of a inteval.

输入:

The first line is the number of the test cases.
For each test case, the first line contain two integer n and m (n, m <= 100000), indicates the number of integers in the sequence and the number of the quaere.
The second line contains n integers, describe the sequence.
Each of following m lines contains three integers s, t, k.
[s, t] indicates the interval and k indicates the kth big number in interval [s, t]

输出:

For each test case, output m lines. Each line contains the kth big number.

不知道A不AC的代码:

//修改自博主:https://blog.csdn.net/Foxreign/article/details/118770162
#include <algorithm>
#include <iostream>
#define rep(i, x, y) for (int i = (x); i <= (y); i++)
#define down(i, x, y) for (int i = (x); i >= (y); i--)
#define MAXN 1001
using namespace std;
int a[MAXN], b[MAXN], root[MAXN], lson[MAXN << 5], rson[MAXN << 5], sum[MAXN << 5];
int st, ed, k;
int n, m, tot = 0;
inline int read() {
    int 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 - '0';
        ch = getchar();
    }
    return x * f;
}

int build(int l, int r) {
    int rt = ++tot;
    sum[rt] = 0;
    if (l < r) {
        int mid = (l + r) >> 1;
        lson[rt] = build(l, mid);
        rson[rt] = build(mid + 1, r);
    }
    return rt;
}

int update(int p, int l, int r, int x) {
    int rt = ++tot;
    lson[rt] = lson[p], rson[rt] = rson[p], sum[rt] = sum[p] + 1;
    if (l < r) {
        int mid = (l + r) >> 1;
        if (x <= mid)
            lson[rt] = update(lson[rt], l, mid, x);
        else
            rson[rt] = update(rson[rt], mid + 1, r, x);
    }
    return rt;
}

int query(int u, int v, int l, int r, int k) {
    if (l == r)
        return l;
    int x = sum[lson[v]] - sum[lson[u]];
    int mid = (l + r) >> 1;
    if (x >= k)
        return query(lson[u], lson[v], l, mid, k);
    else
        return query(rson[u], rson[v], mid + 1, r, k - x);
}

int main(int argc, char const* argv[]) {
    int t = read();
    while (t--) {
        n = read(), m = read(), tot = 0;
        //离散化
        for (int i = 1; i <= n; i++)
            a[i] = b[i] = read();
        sort(b + 1, b + n + 1);  // b是离散化的结果
        int nn = unique(b + 1, b + n + 1) - b - 1;
        root[0] = build(1, nn);
        for (int i = 1; i <= n; i++) {
            a[i] = lower_bound(b + 1, b + nn + 1, a[i]) - b;
            root[i] = update(root[i - 1], 1, nn, a[i]);
        }
        rep(i, 1, m) {
            st = read(), ed = read(), k = read();
            printf("%d\n", b[query(root[st - 1], root[ed], 1, nn, k)]);
        }
    }

    return 0;
}

参考文档与补充说明:

本博客大量参考了下列博客,再次表达由衷地感谢。
线段数详细讲解以及模板例题:https://blog.csdn.net/weixin_45697774/article/details/104274713(包含了除法|根号线段树
主席树清晰讲解:https://blog.csdn.net/Foxreign/article/details/118770162

大量习题:

(下面包含了一道扫描线的例题)
https://blog.csdn.net/weixin_42638946/article/details/115512941

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值
>