线段树 学习笔记
前言
蒟蒻终于学会线段树(指【模板】线段树
线段树思想
我们先来考虑 P3372(基础线段树模板题)给的操作:
- 区间修改(增加)/弱化版:单点修改
- 区间查询(求和)
很重要的一点是,两者是交叉的,要不然可以使用好写时间复杂度又优秀又好吃的前缀和秒了。
- 一个线段是
[0,2^{k-1}) - 一个线段是
[2^{k-1},2 \times 2^{k-1}) - 一个线段是
[0,2^{k-2}) - 一个线段是
[2^{k-2},2 \times 2^{k-2}) - 一个线段是
[2^{k-1},3 \times 2^{k-2}) - 一个线段是
[3 \times 2^{k-2}, 4 \times 2^{k-2}) - ......
我们把它以树的形式组织起来:
-
[0,2^k) -
[0,2^{k-1}) -
[0,2^{k-2}) - ...
- ...
-
[2^{k-2},2 \times 2^{k-2}) - ...
- ...
-
[2^{k-1},2 \times 2^{k-1}) -
[2^{k-1},3 \times 2^{k-2}) - ...
- ...
-
[3 \times 2^{k-2}, 4 \times 2^{k-2}) - ...
- ...
-
或者,使用 Graph Editor?为了不让文字太长,这里令

空间复杂度
这里,我们每一个节点不仅记录
单点修改复杂度
显然,会影响到
区间修改复杂度
假设整个区间为
- 把区间
[0,8) 的S 加3 ; - 把区间
[0,4) 的S 加1 ; - 把区间
[2,4) 的S 加1 ; - 把区间
[3,4) 的S 加1 ; - 把区间
[4,8) 的S 加2 ; - 把区间
[4,6) 的S 加2 ; - 把区间
[4,5) 的S 加1 ; - 把区间
[5,6) 的S 加1 。
看起来加了很多,复杂度真的是正确的吗?
很不幸,把区间内所有数字加上某个数字的时间复杂度为
那么,这个思路就不行了吗?当然不是,我们刚才已经看到过这个思路的一次绝处逢生,为什么这一次就不行?
LtR:随手练练文笔,简称随笔。
小
\alpha :其实你(指小\beta )文笔不怎么样。小
\beta :你**。
我们注意到,把
疑似小 trick
所以,我们有一个优化的小 trick(至于为什么要加双引号,待会儿就知道了):在每次区间加的时候,如果是把整个区间加某个值,则先记录下来要加,并不把下面的整个子树都加上某一个值。
事实上,专门用来记录这个值的变量叫做 ((使用 \text
内怎么加粗啊?/fn)\textbf
即可)。
那么,加上这个
- 把区间
[0,8) 的S 加3 ; - 把区间
[0,4) 的S 加1 ; - 把区间
[2,4) 的S 加1 ; - 把区间
[3,4) 的S 加1 ; - 把区间
[4,8) 的S 加2 ; - 把区间
[4,6) 的S 加2 ; - 把区间
[4,6) 的\text{tag} 加1 。
看起来还是很多,但是我们惊喜的发现,如果在整个区间中都加上
接下来进入 hack 模式。
hack
hack 1 :左边少一个元素
也就是
分成两部分:
第一部分分成两部分:
第一部分分成两部分:
第一部分分成两部分:
以此类推,时间复杂度:
完美接招。
hack 2 :右边少一个元素
完美接招,把左边的稍微改改,变成每次第一部分直接
hack 3 :左右边都少一个元素
本来以为可以成功 hack 的,但是分成两半之后我们发现:
左边:相当于 hack
右边:相当于 hack
然后
如何查询
显然,只能修改还不行,还得查询。
查询也就不难了,按照相似的方式,在树上递归求解,遇到和原本序列完全一样的直接加。
时间复杂度?我们不乱 hack 了,直接求时间复杂度吧。
时间复杂度
首先感性理解一下,层数是
有几种情况:
- 序列和当前节点负责区间完全重合,直接返回,时间复杂度
\Theta(1) ,不会继续递归。 - 序列左端点就是目前节点负责的区间左端点,右端点不到区间中点。此时递归可能会变成情况
1,2,3,4 。 - 序列左端点就是目前节点负责的区间左端点,右端点恰好为区间中点。此时递归可能会变成情况
1 ,也就是时间复杂度还是\Theta(1) 。 - 序列左端点就是目前节点负责的区间左端点,右端点超过区间中点。此时左边递归可能会变成情况
1 ,右边递归可能变成情况2,3,4 。 - 序列右端点就是目前节点负责的区间右端点,左端点不到区间中点。此时递归可能会变成情况
1,5,6,7 。 - 序列右端点就是目前节点负责的区间右端点,左端点恰好为区间中点。此时递归可能会变成情况
1 ,也就是时间复杂度还是\Theta(1) 。 - 序列右端点就是目前节点负责的区间右端点,左端点超过区间中点。此时右边递归可能会变成情况
1 ,左边递归可能变成情况5,6,7 。 - 区间左端点和右端点都不是目前节点负责的左右端点,但是范围符合,左边可能递归成
5,6,7 ,右边可能是2,3,4 。 - 区间左端点和右端点都在左半部分,可能递归为
8,9,10 - 区间左端点和右端点都在右半部分,可能递归为
8,9,10
是不是看的有点晕?配合图片理解一下,黑色代表节点负责的区间,黑色中间的线代表中点,蓝色代表查询的点。
第一种:
第二种:
第三种:
第四种:
第五种:
第六种:
第七种:
第八种:
第九种:
第十种:
看个文章怎么跟个打音游一样
我们发现:只有第八种需要担心——有两个递归,可能导致时间复杂度变为
我们发现,一旦经过一次第
查询时间复杂度也是
修改时间复杂度
我们发现和查询几乎没有区别,原本时间复杂度假掉的原因只是第一种情况会变成和第八种一样的
时间复杂度证明和上述相同,是
这真的只是一个小 trick 吗?当然不是,这就是线段树的精髓,可以说,上面讲的思想就是线段树的灵魂,而这个 lazytag 就是线段树的骨架,缺一不可。
tag 的细节
在查询和修改时,如果一个标记已经有了 tag,那么这个 tag 可能会被使用,则需要把子树的标记加上本身的标记,然后修改
代码
动态分配内存,然而静态开点。
这就是最朴素的线段树了。
// 需要 C++20 的 <bits> 头文件中的 bit_ceil 函数,作用是找到最小的 >= x 的 2 的幂,如果没有 C++20 也可以手写。
// 线段/区间
template<typename T>
class segment
{
public:
T l, r; // [l, r)
size_t size() { return r - l; } // 长度
bool fail() { return l >= r; } // 是否不合规
template<typename Han_Si_Ying>
friend bool operator==(const segment<Han_Si_Ying>& x, const segment<Han_Si_Ying>& y) { return x.l == y.l && x.r == y.r; } // Han_Si_Ying:?
};
// 区间交
template<typename T>
segment<T> seg_and(segment<T> l1, segment<T> l2)
{
return { max(l1.l, l2.l), min(l1.r, l2.r) };
}
template<typename _Valt>
class segtree
{
class segnode
{
public:
segment<size_t> seg; // 负责区间
_Valt s, tag; // S, lazytag
segnode* lp = nullptr, * rp = nullptr; // 左右子树
void pushdown() // 下放标记
{
s += tag * seg.size();
if (lp) lp->tag += tag;
if (rp) rp->tag += tag;
tag = 0;
}
};
segnode* rt;
void init(segnode* root, size_t l, size_t r) // 初始化
{
root->seg = { l,r };
root->s = root->tag = _Valt();
if (l + 1 == r) return;
else
{
init(root->lp = new segnode, l, (l + r) >> 1);
init(root->rp = new segnode, (l + r) >> 1, r);
}
}
_Valt inn_query(segnode* root, segment<size_t> seg) // 区间查询
{
root->pushdown(); // 下放懒标记
if (root->seg == seg) return root->s;
segment<size_t> segl = seg_and(root->lp->seg, seg),
segr = seg_and(root->rp->seg, seg); // 区间交
_Valt sum = 0; // 和
if (!segl.fail()) sum += inn_query(root->lp, segl);
if (!segr.fail()) sum += inn_query(root->rp, segr);
return sum;
}
void inn_add(segnode* root, segment<size_t> seg, _Valt val) // 区间修改
{
root->pushdown();
if (root->seg == seg)
{
root->tag += val;
return;
}
root->s += val * seg.size();
segment<size_t> segl = seg_and(root->lp->seg, seg),
segr = seg_and(root->rp->seg, seg);
if (!segr.fail()) inn_add(root->rp, segr, val);
if (!segl.fail()) inn_add(root->lp, segl, val);
}
public:
segtree(size_t sz)
{
sz = bit_ceil(sz); // 需要 2 的幂
rt = new segnode{ 0, sz };
init(rt, 0, sz);
}
_Valt query(size_t l, size_t r)
{
return inn_query(rt, { l, r + 1 });
}
void add(size_t l, size_t r, _Valt x)
{
inn_add(rt, { l, r + 1 }, x);
}
};
通用化
我们注意到,上面的线段树的实现要求长度为
如果我们要通用化,那么我们可以让线段树的形态不是满二叉树,而是完全二叉树。
代码几乎不需要进行任何改动。只需要把 segtree
构造函数中 sz = bit_ceil(sz);
删去即可。这都得益于 C++ 中 size_t
的除法是向下取整,而右移也是。
动态开点
其实也是一种 lazy 的技巧。
我们注意到在构造函数中刚开始就把节点建出来了,但是我们不需要这么做。我们可以使用的时候再建。
板子题。其实 sky 和 aqua 点开上面的板子题都会很高兴的。