图解:什么是线段树?

作者简介:黄子桓,华师大三学生一枚,进场参加各类算法竞赛,并获得了挺多荣誉,耗费 3 天时间给大家贡献此文!

图文:黄子桓

编辑:景禹

致谢:感谢子桓的无私贡献,收到您的投稿已有时日,只是我工作时间紧张,未能尽早修改发布,还望理解!祝愿你早日找到自己理想的工作。

简介

线段树是在算法竞赛中常用来维护区间信息的数据结构,同时,线段树的理解难度较树状数组低(当然是在理解递归的前提下)。

线段树可以在 的时间复杂度内实现对数组的单点修改、区间修改、区间查询等操作。

下面以一个简单的例题进行介绍:

给定一个数组 arr[0 ... n-1],执行不超过10万次以下两个操作:

1、输出数组中 [left, right] 区间内的数的和,也就是 a[left] + a[l+1] + ... + a[right]

2、将数组 [left, right] 区间内的所有数更改为 k。

线段树的结构与建树

线段树将每个长度不为1的区间划分为左右两个区间,并递归处理,通过合并左右两个区间的信息来求得该区间的信息。

对于例题来说,我们想要得到的是指定区间里数的和,那么,我们要求的区间信息便是该区间数的和,合并左右两个区间的信息即是将两个区间的值进行相加。

例如对于数组 arr = [ 13 , 99 , 23 , 66 , 7 , 2 , 56 , 45 ] 而言:

数组长度为 8,以中点为分界点,划分为左右子区间:

mid = (left + right) / 2 ,则 [left, mid] 为左子区间,[mid + 1 , right]  为右子区间(如果区间长度是奇数,则左子区间比右子区间大1);接着,对子区间递归划分,直到区间长度为 1。

我们对数组 arr = [ 13, 99, 23 , 66 , 7 , 2 , 56 , 45 ] 执行上面提到的递归划分过程后:

在回溯的过程中,合并子区间的信息,即将子节点的值加起来:

此时一颗线段树其实就已经建成了,你可以将你的手机上下颠倒看一看这个图。

线段树的结构如下:

如何存储这个线段树呢?

最简单的方法是堆式建树

即像下面这样,我们可以用一个数组直接存储线段树的节点,节点在数组中下标如图:

由图,我们可以发现一个规律:

对于一个节点,设其在数组中下标为 ,则该节点的左子节点的下标为  ,右子节点的下标为  ,父节点的下标为  (取下界)。

建树的代码:

int arr[100010];//原数组
int tree[400010]; //线段树数组
//建立线段树,[l, r]为节点代表的范围, p 为节点在线段树数组的下标
void build(int left, int right, int p = 1)
{
    if(left == right)
    {//区间长度为 1, 节点值即为原数组对应位置的值;
        tree[p] = arr[left];
        return;
    }
    int mid = (left + right)/2;
    
    build(left, mid, 2 * p); //递归处理左子区间
    build(mid + right, right, 2 * p + 1); //递归处理右子区间
    
    tree[p] = tree[2 * p] + tree[2 * p + 1]; // 合并左右子区间的信息
}

这里有一点需要注意:堆式建树的线段树的数组大小需要设为原数组大小的4倍,原理可参考

线段树需要开4倍区间大小的数组的原因 ,所以线段树节点数一定小于原区间长度的4倍,所以建树过程的时间复杂度为 量级。

区间查询

回到例题,建好树后,如何查询 [left, right] 区间内数的和?

只需要在线段树中找到一些节点,这些节点刚好能组成查询的区间,再合并这些节点的信息即可

(找到的节点要尽可能少)。

这里先放4张图,看一下查询过程以及我们需要查询的节点。

查询区间 [1, 5] :

查询区间的数的和即是 201+7=208 .

查询区间 [3, 6]:

查询区间的数的和即是 89+9=98

查询区间 [5, 6] :

查询区间的数的和即是 9

查询区间  [1, 7] :

查询区间的数的和即是 201+9+56=266

由图可以看出,在线段树中查询指定的区间,其实就是找到一些节点(也就是图中的粉红色节点),使这些节点恰好能组成查询的区间。

显然,查询操作所遍历的节点数(浅蓝色节点+粉红色节点)与树高成正比,即查询操作的时间复杂度为 .

为了查找这些浅蓝色节点,从根向下遍历,每遇到一个节点,可分为2种情况

1、当前节点代表的区间是询问区间的子集。

例如:询问区间为 [1, 7] ,当前节点代表的区间为 [1, 4] (大圆圈起部分)是询问区间的子集,则直接将此节点的值返回即可,区间 [5, 6][7] 也是一样。

可以发现,情况1的节点即是图中的粉红色节点。

2、除第一种情况外,判断询问节点与子区间是否有交集,若有,则递归处理。

例如:询问区间为 [1, 7] ,当前节点代表区间为  [1, 8] (大圆圈起部分)与询问区间有交集,则递归处理其左右孩子。

左子区间为 [1, 4] ,是询问区间 [1,7] 的子集,所以将其返回 sum = 201;

右子区间为  [5, 8]  ,与询问区间 [1,7] 有交集,递归调用其左右子节点;

[5,8]  的左子区间 [5,6] ,是询问区间 [1,7] 的子集,所以将其返回 sum = 201 + 9 = 210;

[5,8]  的右子区间 [7,8] ,与询问区间 [1,7] 有交集,递归调用其左右子节点;

[7,8] 的左子区间 [7] ,是询问区间的子集,所以将其直接返回 sum = 210 + 56 = 276;

[7,8] 的右子区间 [8] ,与询问区间不存在子集也不存在交集,回溯到根结点结束。

情况2其实就是图中浅蓝色节点,递归完成后合并信息并返回。

那么如何判断两个区间是否有交集?

如果询问区间的 端点 小于等于 当前节点区间的 分界点,则为左子区间相交。

比如当前结点区间为 [1,8],其分界点为 mid = (1+8)/2=4 ,查询区间为 [1,7] ,查询区间的左端点为 1 ,由于 1 < 4 ,所以查询区间与当前结点区间为左子区间相交。

如果询问区间的 端点 大于 当前节点区间的 分界点,则与右子区间相交。

比如当前结点区间为 [1,8],其分界点为 mid = (1+8)/2=4 ,查询区间为 [5,6] ,查询区间的右端点为 6 ,由于 6 > 4 ,所以查询区间与当前结点区间为右子区间相交。

只看两种情况的描述有点抽象,直接看整体的代码:

int query(int nl, int nr, int l, int r, int p = 1){//[nl, nr]为所访问区间,[l, r]为当前结点代表的区间    if(nl <= l && r <= nr)    {        return tree[p];    }

    int mid = (l + r) / 2;    int sum = 0;    if(l <= mid)    {        sum += query(nl, nr, l, mid, 2 * p);    }    if(r > mid)    {        sum += query(nl, nr, mid+1, r, 2 * p + 1);    }    return sum;}

单点修改

看完前两个步骤的代码,相信已经发现了:线段树的一系列操作就是通过递归不断缩小区间,直到找到所需的节点。

那么单点修改的操作就很明显了:找到对应的区间并修改,回溯时对沿途的节点更新。

我们以将数组 arr[4] 的值 66 修改为 8 进行说明,其中 '递归' 的过程就是找到从根节点 [1,8] 到要修改的结点的 [4] 的路径,而 的过程就是对路径上结点值的修改过程。

修改结点 [4] 的值为 8

修改结点 [4] 的父结点 [3,4] 的值为当前 [3] 的值 23 加上 [4] 的值 8 ,即 23 + 8 = 31 :

修改节点 [3,4] 的父结点 [1,4] 的值为当前 [3,4] 节点的值 31 和其兄弟节点[1,2] 的值 112 的和,即 112 + 31 = 143:

最后将根节点 [1,8] 的值修改为当前结点 [1,4] 及其兄弟结点 [5,8] 的和,即 143 + 110 = 253:

实现代码超级简单(这就是递归的魅力,代码简洁):

void change_s(int idx, int k, int l, int r, int p = 1)
{// 将arr[idx] 的值修改为 k, [l, r] 为当前结点代表的区间
    if(l == idx && r == idx)
    {
        tree[p] = k;
        return;
    }
    int mid = (l + r)/2;
    
    if(idx <= mid)
    {
        change_s(idx, k, l, mid, 2 * p);
    }
    else
    {
        change_s(idx, k, mid + 1, r, 2 * p + 1);
    }
    
    tree[p] = tree[2 * p] + tree[2 * p + 1];
}

单点修改的时间复杂度也为 .

区间修改、懒标记

接下来是线段树的重头戏:区间修改。显然,不可能通过一个个的单点修改来实现区间修改,这样时间效率还不如直接暴力修改。

区间修改的操作与之前的区间查询很相似,都是找到若干的区间能组成寻找区间

这里先引入一个新的概念:

懒标记

直接看栗子,我们现在想把数组中 [1,4] 中所有的数修改为 16

那么,我们通过递归找到了需要的节点(下图粉红色带圆圈的节点)

这时,我们知道,这个节点的值应该被修改为 64 (16*4=64)(16乘区间长度)。

这时,我们不需要继续往下递归,而是给这个节点打上一个标记。我们可以把这个标记设为16,意为这个区间的数已经被修改为16,如图所示:

然后回溯的过程中再更新结点的值:

从作用可以体会到为什么叫懒标记(就像别人给你家长一些钱说是给你和弟弟的压岁钱,然后家长就说先不给你们,等需要用的时候再给你们)

现在,我们再进行下一次修改,要将 [3, 8] 区间内的数修改为 7:

还是一样的操作,先找到需要的节点,但是,我们在找所需节点时遇到了带标记的节点(刚刚的区间修改操作打的标记),这时,我们把标记下传:

下传完毕后,继续查找,访问节点 [3,4] :

找到这些节点后,也是像刚刚一样,修改节点的值,并打标记。

注意:这里代表区间 [3,4] 的节点原来标记为 16 ,意为该区间已经被修改为16,现在我们想把这个区间更改为7,直接将标记修改即可。

我们想将 [3, 8] 区间内的数修改为 7 :

[3,4] 区间长度为2,所以节点值应改为 7 * 2 = 14,标记为 7;

[5,8] 区间长度为4,所以节点值应改为 7 * 4 = 28,标记为 7;

别忘记回溯时修改沿途节点:

在这里我猜可能有人会有疑惑:为什么加了标记以后就能保证区间修改时间复杂度为 .

其实很简单:很显然区间修改时遍历过的节点数(浅蓝色+粉红色)与区间查询时遍历的节点是一样的;那么,只有这 个节点需要下传标记,所以区间修改时间复杂度为   .

代码如下,当然,加入了懒标记后,之前说到的几个操作都有少许的变动,这里先给出区间修改的代码再将全部操作代码一起给出:

全部代码

线段树节点的构造

struct seg{    int val, tag;    bool have_tag;}tree[400010]; //线段树数组

int arr[100010]; // 原数组

下传标记的函数

void push_tag_down(int p, int l, int r)
{
    if(tree[p].hava_tag)
    { //如果当前结点的标记不为 0 ,将当前结点的标记下传
        int mid = (l + r) / 2;
        seg root = tree[p], ls = tree[2 * p], rs = tree[2 * p + 1];
        //结点值为 标记值 * 区间长度
        ls.val = root.tag * (mid -l + 1);
        ls.tag = root.tag, ls.have_tag = true;
        rs.val = root.tag * (r - mid);
        rs.tag = root.tag, rs.hava_tag = true;
        
        root.hava_tag = false;
    }
}

区间修改的函数

void change(int nl, int nr, int k, int l, int r, int p = 1){// k为区间[l, r]内每个数的变化量   if(nl <=l && r <= nr)   { // 当该节点的区间是访问区间的子集       tree[p].val = (r - l + 1) * k;       tree[p].tag = k;       tree[p].hava_tag = true;       return;   }   push_tag_down(p, l, r); //访问到带标记的结点就下传标记

   int mid = (l + r) / 2;   if(nl <= mid) // 与左子区间相交   {       change(nl, nr, k, l, mid, 2 * p);   }   if(nr > mid)   {       change(nl, nr, k, mid + 1, r, 2 * p + 1);   }

   tree[p].val = tree[2 * p].val + tree[2 * p + 1].val;//回溯时更新}

加入懒标记后的建树函数

void build(int l, int r, int p = 1)
{//建立线段树,[l, r]为节点代表的范围 p 为节点在线段树数组下标
    tree[p].tag = 0;
    tree[p].hava_tag = false;
    
    if(l == r)
    {
        tree[p].val = arr[l];
        return;
    }
    int mid = (l - r)/2 ;
    build(l, mid, 2 * p); // 递归处理左子区间
    build(mid + 1, r, 2 * p + 1); //递归处理右子区间
    
    tree[p].val = tree[2 * p].val + tree[2 * p + 1].val; //合并左右子区间的信息
}

加入懒标记后的查询函数

注意,查询函数中同样需要下放标记!

int query(int nl, int nr, int l, int r, int p = 1){//[nl, nr]为所访问区间, [l, r]为当前节点代表的区间    if(nl <= l && r <= nr) // 当该节点的区间是访问区间的子集    {        return tree[p].val;    }

    push_tag_down(p, l, r); // 访问到就下传标记

    int mid = (l + r) / 2;    int sum = 0;    if(l <= mid) //与左子区间相交    {        sum += query(nl, nr, l, mid, 2 * p);    }    if(r > mid) // 与右子区间相交    {        sum += query(nl, nr, mid + 1, r, 2 * p + 1);    }

    return sum;}

注:这里没有单点修改的代码

因为单点修改可以通过调用区间修改函数来修改一个长度为1的区间来实现。且纯单点修改的代码跟区间修改的几乎相同(懒)。

主函数中的调用

int main()
{
    int n, m, opt, l, r, k;
    cin>>n>>m;
    for(int i = 1; i <= n; i++) cin >> arr[i];
    build(1, n);
    for(int i = 1; i <= m; i++)
    {
        cin>>opt>>l>>r;
        if(opt==1)
        {
            cin>>k;
            change(l,r,k,1,n);
        }
        else{
            cout<<query(l,r,1,n);
        }
    }
    return 0;
}

区间合并操作与懒标记的运用

介绍完3个主要操作,线段树的主体已经介绍完了,大家不要被这100多行的代码吓到,我这里是为了打注释留了很多空位,大家实际打的过程中每个函数只有15行左右!

接下来是一些小的进阶。

通过上面的几个操作,相信大家已经发现了线段树的核心:

通过对小区间信息的合并来得到大区间的信息,查询时将几个区间合并即可得到指定区间的信息。

同时,对懒标记的巧妙运用可以对很多信息进行维护,此处给出几个例题,熟悉线段树的使用

基础:

https://www.luogu.com.cn/problem/P3372(例题的变形)

https://www.luogu.com.cn/problem/P3373(懒标记的使用)

进阶:

https://www.luogu.com.cn/problem/P6242

对于区间信息合并的进一步讨论

对于例题而言,合并两信息只需要把两个子区间得到的数相加就可以了,但是实际情况中不可能只让我们求出一堆数的和,比如下面这道题:

https://www.luogu.com.cn/problem/P4198

这个就留作思考题啦,这里不作展开(我也是看了题解才会的qwq)

权值线段树

顾名思义,权值线段树就是统计权值的线段树!

上一道简单的例题

相信接触过平衡树的同学直接就看出来了:这不就是平衡树的模板题嘛

但是在考场上写动不动就一两百行的平衡树未免太折磨人了!

下面来介绍权值线段树的写法

在一开始的例题中,线段树一个节点记录的是这个节点对应的区间的数的和。在权值树里,节点记录的则是对应区间的数出现了几次!

例如,对于集合 [1,3,3,5,4,8] 权值线段树的结构为这样:

节点记录的是节点所代表的区间的数的出现次数,比如 [1,3,3,5,4,8]  2 中 1 出现 1次,2、6 和 7 出现 0 次,3 出现 2 次 , 4 出现 1 次,5 出现 1 次,8 出现 1 次,所以叶子结点中表示的就是对应数组出现次数,其他结点代表区间内所包含数的出现次数。

那么,题目中的插入的数的值域可是有 啊,怎么能开下这么多的空间?

所以,在这里不能使用堆式建树

动态开点

从上面的图里可以看出有一些点是不需要用到的

夸张一点:

这些黑色节点我们一开始不需要,等到需要用到的时候再开辟就可以了!

这里我先介绍竞赛中比较常见的,较为容易的实现方法:

我们可以声明一个足够大的线段树数组,再声明一个变量 cnt(记录用过的节点数),当我们访问到一个没访问过的节点时,令 cnt++,则线段树数组中下标为 cnt 的节点即为新节点,只需要想办法让新节点跟以前的节点联系上就好了

struct seg {    int ls, rs, val;    seg(int v=0,int l=0, int r=0):val(v),ls(l),rs(r){};};seg t[MAX_N];int cnt;

inline void pushup(int o){    t[o].val = t[t[o].ls].val + t[t[o].rs].val;}

void change(int &o, int l, int r, int k, int v){    // 将 k 的出现次数改变 v,[l, r]为当前节点代表的区间    if(!o) o = ++cnt;    if(l == r)    {        t[o].val += v;        return;    }    int mid = (l + r) / 2;    if(k <= mid) change(t[o].ls, l, mid, k, v);    else change(t[o].rs, mid + 1, r, k, v);    pushup(o);}

这里的change 函数与前面的逻辑一致,除了这句话

if(!o) o = ++cnt;

而且这里的 o 是以引用的方式传进来的,其实这里的o就是节点在线段树数组中的下标

回顾代码,在线段树数组被声明时,每一个节点的左右子节点都为0,那么,我们在递归的过程中遇到了 o 为零,就可以确定这个节点未被使用。

而以引用的方式传入,则可确保原值被赋值,即 t[o].lst[o].rs

即实现了将新开辟的节点与其父节点相联系,理解了change函数,插入与删除操作就很简单了

如果要插入 x ,则执行 change ( 1 , -1e9 , 1e9 , x , 1 )

如果要删除 x,则执行 change ( 1 , -1e9 , 1e9 , x , -1 )

设区间长度为 n,则线段树的树高是 ,在这里区间长度即是值域的大小 ,每次操作最多产生新节点数即是 ,30个左右,所以空间是绝对足够的。

如果想要追求效率的话,可以用离线算法,将所有询问到的数记录下来并离散化,再使用权值线段树,感兴趣的话可以自己实现一下,接下来的操作如果理解了一开始的线段树的介绍后就是很简单的了!

查找排名第k的数

访问到一个节点时,记录左子区间的数的次数为 num,如果 k <= num,则向左子节点递归;否则,将k减去num,再向右子节点递归 。

int query_kth(int o, int l, int r, int k)
{
    if(l == r){
        return l;
    }
    int mid = (l + r)/2;
    int num = t[t[o].ls].val;
    if(k <= num)
    {
        return query_kth(t[o].ls, l, mid, k);
    }
    else
    {
        return query_kth(t[o].rs, mid + 1, r, k - num);
    }
}

为什么向右子节点递归时k要减去 num ?

这个函数其实是求以 o 为根的树中第 k 的数,又 num 代表的是左子区间的数的次数,那么右子区间的第 k-num 的数就是以 o 为根的子树的第 k 的数了 .

查询数 k 的排名

设当前节点代表的区间的中点为 mid ,当 k <= mid 时,向左子节点递归;否则,设左子区间的数的次数为num,向右子节点递归,再将得到的数加上 num 并返回 。

int query_rk(int o, int l, int r, int k){    if(l == r){        return 1;    }    int mid = (l + r)/2;    if(k <= mid)    {        return query_rk(t[o].ls, l, mid, k);    }    else    {        return t[t[o].ls].val + query_rk(t[o].rs, mid+1, r, k);    }}

查询前驱和后继的操作很简单,这里就留作作业啦! 一开始时说到,这是竞赛时可用的,较为简短的版本!

对于动态开点,vector、指针,都是可行的方法;用指针的话,可以避免类似 t[t[o].ls].val 这种写法,但是也会带来较大的调试难度!这里就不作更多的展开了,大家可以自己尝试不同的写法!

写在结尾:

本来的计划最后面还有zkw线段树,和可持久化的介绍,但是突然发现已在 word 中写了 20 多页。。。

那么本文就先这样结束啦,如果我讲解的有些地方不太周全欢迎大家指点,如果喜欢这个文章的话欢迎点个赞啦嘻嘻~~

(0)

相关推荐

  • 2021icpc网络赛A题ac代码及思路

    思路读完题后了解题的大意:有k个节点,编号为0 - k-1,每个节点可以在空闲时间处理请求,每个请求的有一个到达时间和处理该请求的持续时间:节点空闲表示当一个请求在达到时间时,该节点未处理任何一个其他 ...

  • 刷题--DFS--823.排列

    DFS 823.排列 给定一个整数n,将数字1~n排成一排,将会有很多种排列方法. 现在,请你按照字典序将所有的排列方法输出. 输入格式 共一行,包含一个整数n. 输出格式 按字典序输出所有排列方案, ...

  • leetcode算法总结 —— DFS深度优先搜索

    DFS 模板 dfs(TreeNode* root, int path) { //父节点要传给子节点值,则放到递归的形参中.`void dfs(TreeNode* root, int path)` i ...

  • 七大排序算法总结

    以下所有动图均来源于一像素博客园 以下代码均使用C 编写 完整代码请到这里下载 稳定排序算法:冒泡排序.插入排序.归并排序 时间复杂度不受数据影响:选择排序.归并排序.堆排序 时间复杂度基本小于n2: ...

  • 贺天健 图解历代名家画树技法

    贺天健(1891-1977),江苏无锡人.原名贺骏,又名贺炳南,字健叟,别署健父.阿难等.幼年喜欢绘画,早年通过实地写生,领悟画理,精研传统诸法而自创新境.其画以"师法五代两宋山水画法度与精 ...

  • 贺天健 图解历代名家画树技法要诀

    贺天健(1891-1977),江苏无锡人.原名贺骏,又名贺炳南,字健叟,别署健父.阿难等. 贺天健善用水墨,设色讲究层次,多用复色,尤长于青绿山水,并演变而自成一格,风格豪放跌宕,富有时代气息. 树 ...

  • 贺天健图解历代名家画树技法要诀

    贺天健(1891-1977),江苏无锡人.原名贺骏,又名贺炳南,字健叟,别署健父.阿难等.幼年喜欢绘画,早年通过实地写生,领悟画理,精研传统诸法而自创新境.其画以"师法五代两宋山水画法度与精 ...

  • Codeforces 446C - DZY Loves Fibonacci Numbers(斐波那契数列 线段树)

    Codeforces 题目传送门 & 洛谷题目传送门 你可能会疑惑我为什么要写 *2400 的题的题解 首先一个很明显的想法是,看到斐波那契数列和 \(10^9 9\) 就想到通项公式,\(F ...

  • 图解历代名家画树技法要诀!

    初学者主要解决两个问题,一是结体方法的练习,二是笔墨训练的方法.这两种训练按理不应分开,但初学者先侧重于结体训练,并同时研习书法,很多运笔运墨的方法,可在书法中体会. 树  法 画树先练习画枯树,然后 ...

  • 【图解】“有棵树”母公司天泽信息中报:净利增长823.84%

    导读:8月30日,天泽信息(300209)发布了2019年半年度报告,报告显示,2019上半年公司实现营收11.25亿元,同比增长242.42%:归属于上市公司股东的净利润6505.2万元,同比增长8 ...

  • 图解 | 怎么解读一个树状图

    使用范围 树状图主要是用来展示不同的对象之间的相似度大小(习惯上称之为距离关系远近)的一个图形.一般最常用到的是对层次聚类结果的可视化.但是不仅限于此,我们只要是可以衡量不同对象之间的相似度,都可以通 ...

  • CF460CPresent(二分答案 线段树)题解

    思路 显然,这题正着不太好推,那么就考虑二分答案,有一个很大的问题,我们需要在\(O(n\log n)\)或者\(O(n)\)的时间内判断我们二分答案的可行性.首先肯定想到贪心,但是你会发现每一个元素 ...

  • 庭院葡萄树剪枝养护图解

    在庭院里面搭上一个葡萄藤真的是一件想想都感到心里甜丝丝的事儿,很多人老家农村院子里都有葡萄藤.爷爷坐在旁边的摇椅上摇着,我们偷偷的摘着葡萄藤下面的葡萄品尝着,简直是人生的一大乐事.你想在你家庭院中重塑 ...