线段树的构建

本文介绍了线段树的背景,解释了为何在处理区间问题时需要它,详细讲解了线段树的构建过程,以及其在查询和修改操作中的时间和空间复杂度优化,包括递归更新、延迟检测等核心原理。

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

在了解线段树之前,我们先需要知道为什么需要线段树,构建线段树的意义是什么

线段树的意义

在解决区间问题的查询和修改时,通常会遇到查找和修改发生冲突的问题,想要快速查找,就必须选择不宜修改的存储方式,想要快速修改,就必须选择不宜查找的存储方式。

例:

对于数组arr=|1,2,3…n|来说,如果我们使用数组来存储每个元素,如果查询长度为m的区间的所有元素之和,就需要O(m)级别的时间来进行查询,如果要修改其中的一个值,就只需要花费O(1)级别的时间。

为了解决查询时间复杂度过高的问题,我们只需要计算出它的元素和数组就行,这样对于查询任何一个区段的元素之和,时间复杂度都变成了O(1)。

如果我们并不需要修改数组,这种做法就是完美的,但是,如果我们需要修改数组,我们就会发现更新sumarr的时间复杂度是O(n)。

就像一个天平一样,如果注重查询区间,那必然不易维护,如果注重修改,那必然不易查询区间。

这个时候,有没有一种结构,可以让天平变得相对平衡呢?

线段树,就给出了解答。

线段树的构建

从零理解如何构建出线段树

对于元素和数组来说,我们可以将其看成由n个区间和组成的长度为n的数组。

对此,就不难理解为什么维护元素和数组的时间复杂度这么高了。

那么,对于这个数组有什么方式可以改进呢?

对于O(n),我们想要进一步优化的话就要优化成为O(log n)(O(1)在之前已经论证了是行不通的),我们可以很容易联想到二分来进行优化。

例如对数组1,2,3,4,5,6,7,8,9,10

使用二分对区间进行划分,得到:

面对这样的区间划分,我们可以联想到什么?

对,二叉树。

使用二叉树的形式来存储我们使用二分划分后的区间。

对于一个长度为10的数组,我们使用二叉树的形式来进行存储

观察这颗二叉树,不难发现,如果去除最下面一层,这就是一颗满二叉树。

对于满二叉树来说,最适合的存储方式,就是顺序存储。所以,我们可以用顺序存储的二叉树来存储我们划分好的区间。

线段树的空间复杂度分析

节点数

对于长度为n的数列,对其进行二分区间划分,得到n+\frac{n}{2}+\frac{n}{4}+\frac{n}{8}+…+1个节点,约为2n(\leq2n),所以空间复杂度为O(n)。

深度:

除去最后一层以外,可以看做满二叉树,所以深度depth为log_{2}(n-1)+1。

线段树的时间复杂度分析

对于查询操作:

例如对于长度为10的线段树,我们查询最极限的情况,区间[1,9]之和,只需要查询[2,2],[3,3],[4,5],[6,8],[9,9]这五个区间。

对于查询区间[l,r],我们最多沿着线段树从顶层走到底层两遍。例如对于最极限的情况,查询区间[2,n-1],我们最多沿着线段树向下两次2×depth所以时间复杂度为O(log_{2}{n})。

图示:

对于修改操作:

例如对于长度为10的线段树,我们修改[6,6]的值,需要更新的区间是[6,6],[6,7],[6,8],[6,10],[1,10]这五个区间。

对于查询区间[l,r],我们最多沿着线段树从底层走到顶层一遍。例如对于最极限的情况,修改区间[1,1],我们最多沿着线段树向上一次depth所以时间复杂度为O(log_{2}{n})。

图示:

构建线段树代码:

const int N;//N为数组长度
​
ll st[4 * N]{ 0 };//考虑极限情况,最后一层可能有2N个空位,所有要开4N大小的数组来存储线段树
​
void build(int l, int r, int p)//l为当前左边界,r为当前右边界,p为线段树当前编号
{
    //如果l和r相等,表示已经到了叶节点,直接返回。
    if (l == r)
    {
        st[p] = nums[l];
        return;
    }
    //二分递归建树
    int mid = (l + r) / 2;
    build(l, mid, p * 2);
    build(mid + 1, r, p * 2 + 1);
    st[p] = st[p * 2] + st[p * 2 + 1];//递归更新
}

线段树的操作:

1、查询区间和

ll getSum(int x, int y, int l, int r, int p)//x,y为待查询区间[x,y]的左右边界
{
    if (x <= l && r <= y)//如果当前节点被包含,直接返回当前节点的值
        return st[p];
    int mid = (l + r) / 2;
    ll sum = 0;
    if (x <= mid)//中间值大于等于左边界,表示左半界需要查询
        sum += getSum(x, y, l, mid, p * 2);
    if (mid < y)//中间值小于右边界,表示右边界需要查询,等于则表示没有右半界了
        sum += getSum(x, y, mid + 1, r, p * 2 + 1);
    return sum;
}
​

2、修改指定单点

void changeNode(int pos, int v, int l, int r, int p)//pos为目标位置,v为目标值
{
    if (l == r)
    {
        st[p] = v;
        return;
    }
    int mid = (l + r) / 2;
    if (pos <= mid)//pos在左子树
        changeNode(pos, v, l, mid, p * 2);
    else//pos在右子树
        changeNode(pos, v, mid + 1, r, p * 2 + 1);
    st[p] = st[p * 2] + st[p * 2 + 1];//递归回溯更新
}

3、修改指定区间

如果使用单点修改的方式,还不如模拟,所以需要使用延迟修改的方法。

只要不查询[4,4],[4,4],[4,7],[8,8]就不对它们进行修改。

那怎么保证查询到[4,4],[4,4],[4,7],[8,8]时同时对它们进行修改呢?我们可以维护一个tag数组,表示当前节点含有延迟修改的值。

void checkDelay(int l, int r, int p)//检测函数,检查当前节点是否有延迟标记,有则传递给子树
{
    if (!tag[p])
        return;
    int mid = (l + r) / 2;
    tag[p * 2] = tag[p * 2 + 1] = tag[p];
    st[p * 2] = (mid - l + 1) * tag[p];
    st[p * 2 + 1] = (r - mid) * tag[p];
    tag[p] = 0;//清空tag
}
​
void changeInterval(int x, int y, int v, int l, int r, int p)//区间修改
{
    if (x <= l && r <= y)
    {
        tag[p] = v;
        st[p] = v * (r - l + 1);
        return;
    }
    checkDelay(l, r, p);//检测是否有延迟标记
    int mid = (l + r) / 2;
    if (x <= mid)
        changeInterval(x, y, v, l, mid, p * 2);
    if (mid < y)
        changeInterval(x, y, v, mid + 1, r, p * 2 + 1);
    st[p] = st[p * 2] + st[p * 2 + 1];
}

注:

如果使用了延迟修改的话,所有查询工作都需要加上延迟标记检测

4、指定区间加值

需要额外维护一个tag数组来表示乘法

//使用宏定义减少书写
#define ls p*2//左子树所在位置
#define rs p*2+1//右子树所在位置
#define mid (l+r)>>1
​
struct Node
{
    ll v;//区间和的值
    ll add;//加法tag
    ll mul;//减法tag
}sumST[4 * N];
​
void checkTag(int l, int r, int p)//要注意优先级的统一,这里设置的是加法优先结算和运算
{
    int m = mid;
    //更新区间和的值
    sumST[ls].v = (sumST[ls].v * sumST[p].mul + sumST[p].add * (m - l + 1));
    sumST[rs].v = (sumST[rs].v * sumST[p].mul + sumST[p].add * (r - m));
    
    //更新tag
    sumST[ls].mul = (sumST[ls].mul * sumST[p].mul);
    sumST[rs].mul = (sumST[rs].mul * sumST[p].mul);
    sumST[ls].add = (sumST[ls].add * sumST[p].mul + sumST[p].add);//注:这里代表加法优先结算,后面也需要同样先结算
    sumST[rs].add = (sumST[rs].add * sumST[p].mul + sumST[p].add);
    
    //清空tag
    sumST[p].mul = 1;
    sumST[p].add = 0;
}
​
void intervalMultiply(int x, int y, int k, int l, int r, int p)
{
    if (r < x || y < l)
        return;
    if (x <= l && r <= y)
    {
        sumST[p].mul = (sumST[p].mul * k);
        sumST[p].add = (sumST[p].add * k);//加法优先结算
        sumST[p].v = (sumST[p].v * k);
        return;
    }
    checkTag(l, r, p);
    int m = mid;
    if (x <= m)
        intervalMultiply(x, y, k, l, m, ls);
    if (mid < y)
        intervalMultiply(x, y, k, m + 1, r, rs);
    sumST[p].v = (sumST[ls].v + sumST[rs].v);//更新线段树
}

5、查询指定区间的最小值

我们就只要维护一个区间最小值的线段树就行

//建一个区间最小值的线段树
void buildMinNum(int l, int r, int p)//l为当前左边界,r为当前右边界,p为线段树当前节点的索引
{
    //如果l和r相等,表示已经到了叶节点,直接返回。
    if (l == r)
    {
        sum[p] = nums[l];
        return;
    }
    //二分递归建树
    int mid = (l + r) / 2;
    buildMinNum(l, mid, p * 2);
    buildMinNum(mid + 1, r, p * 2 + 1);
    sum[p] = min(sum[p * 2] , sum[p * 2 + 1]);//维护最小值
}
​
int getMin(int x, int y, int l, int r, int p)//x,y为待查询区间[x,y]的左右边界
{
    if (x <= l && r <= y)//如果当前节点被包含,直接返回当前节点的值
        return minNum[p];
    checkDelay(l, r, p);
    int mid = (l + r) / 2;
    int mn = 0;
    if (x <= mid)//中间值大于等于左边界,表示左半界需要查询
        mn = min(mn, getMin(x, y, l, mid, p * 2));
    if (mid < y)//中间值小于右边界,表示右边界需要查询,等于则表示没有右半界了
        mn = min(mn,getMin(x, y, mid + 1, r, p * 2 + 1));
    return mn;
}

总结

观察上面五种操作可以发现,线段树各种操作的核心在于递归更新和延迟检测,所有的操作都是基于这两个核心修改而来

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值