把博客园图标替换成自己的图标
把博客园图标替换成自己的图标end

算法 - 线段树学习笔记

前言

此文章为线段树基础知识可供学习参考


咳咳,进入正题:

我们在做题的时候可能会遇到 给定一个数组 同时给出一个值进行修改 或是 区间性的操作

这里以单点修改和区间查询为例子:

我们每次枚举一遍答案可能会非常的慢,时间分别可能为 \(O(1)\)\(O(n)\)

或许会有人说 用前缀和 区间求和就会降为 \(O(1)\) 但是区间求改 变为了\(O(n)\) 那有没有可能平均一下 将两个情况都成 \(O(\log n)\)

答案肯定是有的 : 那就是线段树———— Segment tree

线段树学习分为 \(3\) 个阶段 --------- 自我总结 \(4\) 个知识点如下:

1.1: 建树:

对于线段树的编写 第一步的重点就是建树 此后所有进行的更改均需第一步建树

顾名思义: 建树从字面意义上来讲就是 数组以树的方式存储下来。

举个例子:

\(a_{n} = \left\{1,3,5,7,9,11\right\}\)

同时再开一个数组 \(tree[4 \cdot n]\) (这里对于线段树来说一般需要开 \(4 \cdot n\) 的空间 \(n\) 为原数组大小)

核心思想:每次进行对数组二分 分为 L R 两个分叉 每个叶子的父节点为其子节点的和

下面我将以图的形式把 \(a\) 数组所对应的 \(tree\) 展现出来 方便理解;

P.s.图为b站某up主的,仅为参考

此图即为 \(a\) 数组所对应的 \(tree\) 在这颗树上每个父节点即为其右上角所对应的区间和

相应代码如下:

void build_tree(int node, int start, int end)//node为本节点
{
	if (start == end)//start为区间的左端 , end即为右端
		tree[node] = arr[start];//如果start == end 说明已经遍历到本棵树的最深节点 我们就将其存储下来
	else
	{
		int mid = (start + end) / 2;//每次将其二分
		int left_node = 2 * node + 1;//这里对应着其节点的左儿子
		int right_node = 2 * node + 2;//则这里为右儿子
		build_tree(left_node, start, mid);
		build_tree(right_node, mid + 1, end);//递归进行求解
		tree[node] = tree[left_node] + tree[right_node];//这里说明本node的节点为其左右儿子的和
	}
}

最后,我们只需按照 层序遍历 将其输出, 答案即为 \(a\) 数组 的 组:

    a数组    1  3  5  7  9  11
对应下标:     0  1  2  3  4  5

  tree数组   36  9  27  4  5  16  11  1  3  0  0  7  9  0  0
对应下标 :    0   1  2   3  4  5   6   7  8  9  10 11 12 13 14

(正常来说可能会出现非满二叉树的情况我们只需要 其余空节点记为 \(0\) 即可方便查询)

以上为建树的所有内容


2.1 单点修改

单点修改分为:

          1.单点更改。
          2.单点增值。

什么是单点更改呢? 举个栗子:

给出一组数 1 3 5 7 9 11
下标       0 1 2 3 4 5 
请将下标为 3 的数 变为 9

这里输出的是: 1 3 5 9 9 11

根据常理来说 从头开始遍历 一个一个枚举 时间复杂度为 O(n)。
若有时可以为 O(1) 的情况 不过出题人不会那么良心的。

因此 我们需要加速 便可以用线段树来做。

大体的思路 : 从最高父节点遍历 通过给定的下标确定数所在的区间 进行深层搜索 直到找到为止

我们还是以图的形式论述,更加直观:

对于 \(3\) 这个下标 首先找到对应区间 发现 :

\(2 < 3 < 5\)

所以 区间便到了 \([ 3 \sim 5 ]\)

此时我们可以发现 继续往下搜索 通过比对 :

$ 3 < 4 < 5$。

因此 区间便到了 \([ 3 \sim 4 ]\)

发现依然没有找到答案 所以继续往下;直到节点 \([ 3 ]\) 停止搜索 将其数值更新 。

可是更新之后 此棵树的左半部分所有父节点收到了影响 违背了线段树的原理:

每个叶子的父节点为其子节点的和。

所有 最后我们还需自下而上进行更新节点操作。

代码如下:

void arr_tree(int node, int start, int end, int X, int K)//将 X 的值变为 K
{
    if (start == end)//如果找到
    {
        arr[X] = K;//更新原数组
        tree[node] = arr[X];同时更新树
    }
    else
    {
        int mid = (start + end) / 2;
        int left_node = 2 * node + 1;
        int right_node = 2 * node + 2;
        if (X >= start && X <= mid)//如果 X 比中值小则遍历左区间
            arr_tree(left_node, start, mid, X, K);
        else //反之则遍历右区间
            arr_tree(right_node, mid + 1, end, X, K);

        tree[node] = tree[left_node] + tree[right_node];//更新每个更改后的父节点
    }
}

以上则为单点修改所有内容。


2.1.1 单点增值

这里 单点增值 指的是给定一个下标 使该下标上的数增加 \(n\)

其实原理单点修改完全一样 不多论述,将其代码改一处地方即可:

if (start == end)
    {
        arr[X] += K;
        tree[node] += K;
    }

P.s: 每一次增值后需将 \(lazy\) 下放 ( \(lazy\) 数组具体后面说

2.1 整节 结束


3.1 区间性问题


3.1.1 : 区间求和

大意为 :给定一个左端点 给定一个右端点 求得左右端点之间所有数的和。

继续接着线段树的思路说 :

在建完树之后 我们可以得知 父节点即为 --- 左儿子 \(+\) 右儿子

因此只需要判断一下 \(\text{L}\), \(\text{R}\) 所包含的区间在哪即可 从最底层遍历 直到 $start = L $, \(end = R\)

代码如下:

int query_tree(int node, int start, int end, int L, int R)
{
	if (R < start || L > end)//若区间不覆盖此数组之间返回0
		return 0;
	else if (L <= start && end <= R)//剪枝
		return tree[node];
	else if (start == end)
		return tree[node];//若遍历到最深节同样返回值
	else
	{
		int mid = (start + end) / 2;
		int left_node = 2 * node + 1;
		int right_node = 2 * node + 2;
		int sum_left = query_tree(left_node, start, mid, L, R);
		int sum_right = query_tree(right_node, mid + 1, end, L, R);//分别遍历左右子树 进行求和
		return sum_left + sum_right;//返回左右子树相加结果
	}
}

3.1.2: 区间增值 ( 雾

意义: 给定 \(\text{L}\), \(\text{R}\) 将其所有值相加。

如果按照线段树进行之间修改 时间复杂度会飞起QAQ

所以,这里提前引入一个新东西 : lazy标记

\(lazy\) 标记顾名思义:懒标记..

思路 : 在每次遍历子树时 若找到 \(\text{L}\), \(\text{R}\) 所在的区间 则在当前父节点打上标记 将其值更新为:$val + val \cdot up $ 即为更新后的值。 每次遍历时将标记下放 , 取消先前标记 以后遍历时标记过的点直接跳过即可 直至更新的最深层节点。

这样子操作之后 大大降低了时间复杂度。

代码如下:

void push_down(int node, int m)
{
    if (add[node])
    {
        add[2 * node + 1] += add[node];
        add[2 * node + 2] += add[node];
        tree[2 * node + 1] += (m - (m >> 1)) * add[node];
        tree[2 * node + 2] += (m >> 1) * add[node];
        add[node] = 0;
    }
}

这里 \(add\)\(lazy\) 数组 这里包含了下放操作 由此可知没此更新 \(lazy\) 数组之后 重新赋值为 \(0\) (删除标记)

$ m $ 为此修改区间长度


3.1.2: 区间增值

讲完了 \(lazy\) 数组之后其实区间增值就很好理解了。

和区间求和一样 分左右子树根据区间进行遍历 每一次递归后再更新其父节点的值

代码如下:

void arr_tree(int node, int start, int end, int L, int R)
{
    if (L <= start && end <= R)
    {
        add[node] += 1;
        tree[node] = end - start + 1 - tree[node];
        return;
    }
    push_down(node, end - start + 1);
    //本节点进行标记 并且做下放操作 长度即为 end - start + 1
    int mid = (start + end) / 2;
    int left_node = 2 * node + 1;
    int right_node = 2 * node + 2;
    if (L <= mid)
        arr_tree(left_node, start, mid, L, R);
    if (R > mid)
        arr_tree(right_node, mid + 1, end, L, R);
    tree[node] = tree[left_node] + tree[right_node];//更新节点
}

这里代码给出的是 将 \(L \sim R\) 这个区间所有数加一 ( 非常容易理解是不是qwq


3.1.3: 区间查询

我想 看到这里 大家对线段树已经 有了很深的理解了 区间查询我就不多说了 和所有区间操作一样。

口糊一下思路: 遍历子树 直到相对应的节点 输出即可 若直接有树上修改操作 则每次遍历需 pushdown


结语

好啦 线段树的学习就到这了 以上所有的乘除操作均可以换成位运算加快效率

最后给出一个洛谷官方的 线段树练习题单:

戳这里哦

posted @ 2022-10-20 02:08  S1gMa  阅读(67)  评论(0编辑  收藏  举报
浏览器标题切换
浏览器标题切换end