首先我们先明确两件事情!
1. 线段树他是个二叉搜索树! 2. 线段树是基于一个数组生成的!
线段树常用于统计区间上的信息:
其每个节点存储的是一个区间的信息,每个节点包含三个元素:
线段树的思想就是将数组内所有元素看作是一个区间,将每个区间递归的进行分解,直到区间内只剩下一个元素为止。
按照二叉树的标号对线段树进行编号,如下图:
根节点编号为 1 {1} 1,编号为 x {x} x的节点左节点为 x ∗ 2 {x*2} x∗2,右节点为 x ∗ 2 + 1 {x*2+1} x∗2+1。
可以发现树的最后一层节点在数组中存储,位置是不连续的。理想情况下: n {n} n个节点的满二叉树有 2 ∗ n − 1 {2*n-1} 2∗n−1个节点,但由于最后一层产生空余,为了要保证数组能存储整棵树,最后一层也要开到 2 ∗ n {2*n} 2∗n的空间。
因此一共需要开辟 4 n {4n} 4n的空间存储线段树。
线段树将区间递归分为多个小区间,可以用来解决区间问题。
其最基本的作用有:
线段树的五个常用操作:
pushup
:由子节点向上更新父节点的信息。
pushdown
:把父节点的修改信息下传到子节点,也被称为懒标记(延迟标记)。这个操作比较复杂,一般不涉及到区间修改则不用写。
build
:将一段区间初始化成线段树。
modify
:修改操作。① 单点修改(需要使用pushup)。② 区间修改(需要使用pushdown)。
query
:查询操作。① 单点查询。②区间查询
在建立线段树时,建树方式如下:
#define ls u<<1
#define rs u<<1|1
struct node{
int l, r;
ll sum;
}tr[N << 2];
int a[N];
void build(int u, int l, int r) {
tr[u] = {l, r};
if(l == r) { tr[u].sum = a[l]; return ;}
int mid = l + r >> 1;
build(ls, l, mid), build(rs, mid + 1, r);
pushup(u);
}
建树操作时间复杂度: O ( n ) {O(n)} O(n)
将 a i {a_i} ai的值增加 x {x} x,过程如下:
如图所示:
void modify(int u, int l, int r, int x) {
if (tr[u].l >= l && tr[u].r <= r) { tr[u].sum += x; return ; }
int mid = tr[u].l + tr[u].r >> 1;
if(l <= mid) modify(ls, l, r, x);
else modify(rs, l, r, x);
pushup(u);
}
main():
modify(1, x, x, y); // x,x就是对x单点进行修改
线段树的区间修改也是将区间分成子区间,但是要加一个标记,称作懒标记。
懒标记的含义:
当前节点维护的信息已经根据标记更新过了,但当前节点之下的子节点仍需要更新。
举个例子: 给当前节点 u {u} u维护的区间 [ l , r ] {[l,r]} [l,r]的所有值加上 1 {1} 1,那么实际上并没有走到区间的所有叶子节点上,一个个的加上 1 {1} 1。而是给 u {u} u维护的懒标记 a d d {add} add加上 1 {1} 1,并更新 u {u} u维护的 s u m {sum} sum值。这样就做到了向下延迟修改,但是向上显示的是修改后的信息,所以查询能得到正确的结果。如果要查询 u {u} u下的子节点,那么需要将懒标记下传。
相对标记和绝对标记:
相对标记
是将区间的所有数 + x {+x} +x 之类的操作,标记之间可以共存,跟打标记的顺序无关。
pushup
中,必须考虑本节点的标记。绝对标记
是将区间的所有数变成 x {x} x 之类的操作,打标记的顺序直接影响结果,
注意,有多个标记的时候,标记下推的顺序也很重要,错误的下推顺序可能会导致错误。
void modify(int u, int l, int r, int x) {
if (tr[u].l >= l && tr[u].r <= r) {
tr[u].sum += len(u) * x;
tr[u].add += x;
return ;
}
pushdown(u); // 相对标记可以不在区间修改时下传
int mid = tr[u].l + tr[u].r >> 1;
if(l <= mid) modify(ls, l, r, x);
if(r > mid) modify(rs, l, r, x);
pushup(u);
}
举例说一下相对标记:
初始状态:4号节点有一个延迟标记 + 1 {+1} +1,所有叶子节点值全是1
接下来,对 [ 1 , 1 ] {[1,1]} [1,1]区间修改 + 1 {+1} +1,modify
执行到8号节点后:
由于我们采用的是相对标记,modify完8号点后,其实4号点的懒标记没下传给8、9两点,且8号点自己存在懒标记,值更新为2。
接下来进行回溯操作:
对于4号点,pushup
函数中如果是原先采用绝对标记的写法:tr[u].sum = tr[ls].sum + tr[rs].sum;
,那么显然会出错,因为到这回溯上去4号节点的值应该更新为4+1
。所以我们需要考虑4号点自身的懒标记。
void pushup(int u) {
tr[u].sum = tr[u].add * len(u) + tr[ls].sum + tr[rs].sum;
}
待到询问 [ 1 , 1 ] {[1,1]} [1,1]区间,会在query
中进行懒标记下传:
这样保证了结果无误。
要查询在区间 [ l , r ] {[l,r]} [l,r]上的和,当前节点 u {u} u所表示的区间 [ T l , T r ] {[ T_l,T_r]} [Tl,Tr],则从根节点开始,递归时会遇到三种情况:
T L < = l < = T R < = r {T_L <= l <= T_R <= r} TL<=l<=TR<=r, 分两种情况:
T L < = l < = r < = T R {T_L <= l <= r <= T_R} TL<=l<=r<=TR
ll query(int u, int l, int r) {
// 1.被包含,直接返回
// Tl-----Tr
// L-------------R
if(tr[u].l >= l && tr[u].r <= r) { return tr[u].sum; }
int mid = tr[u].l + tr[u].r >> 1;
// pushdown(u); 有区间修改,懒标记,才需要
ll v = 0;
// 2. 左区间
// Tl----m----Tr
// L-------------R
if(l <= mid) v = query(ls, l, r);
// 3. 右区间
// Tl----m----Tr
// L---------R
if(r > mid) v += query(rs, l, r);
// Tl----m----Tr
// L-----R
//2.3涵盖了这种情况
return v;
}
示例:
该操作时间复杂度大约在: O ( 4 l o g ( n ) ) {O(4log(n))} O(4log(n))