线段树是一种二叉搜索树,常用于统计区间上的信息;
其每个节点存储的是一个区间的信息,每个节点包含三个元素:
线段树的思想就是将数组内所有元素看作是一个区间,将每个区间递归的进行分解,直到区间内只剩下一个元素为止;
按照二叉树的标号方法对线段树进行编号,如图所示:
根节点编号为 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 个节点。最后还有一层产生了空余,所以要保证数组长度要不小于 4 ∗ n 4 * n 4∗n 才不会越界;
线段树将区间递归分为多个小区间,可以用来解决区间问题;
其最基本的作用有:
在建立线段树时,建树方式如下:
时间复杂度为 O ( l o g 2 n ) O(log_2n) O(log2n) ;
代码如下:
int n, a[MAXN];
struct segment_tree {
int l, r, tot; // 建立存储线段树的的结构体,l表示左端点,r表示右端点,tot表示线段树维护的值
} t[4 * MAXN];
void build(int p, int l, int r) { // p 当前 [l, r] 的区间编号
t[p].l = l, t[p].r = r; // 存储当前区间的左右端点
if (l == r) {
t[p].tot = a[l]; // 当递归回到叶节点时在存储值
return;
}
int lc = p * 2, rc = p * 2 + 1, mid = (l + r) / 2;
build(lc, l, mid); // 递归建立左子树
build(rc, mid + 1, r); // 递归建立右子树
t[p].tot = t[lc].tot + t[rc].tot; // 左右子树建立完后,从下往上更新信息
return;
}
build(1, 1, n); // 调用入口
若要将 a i a_i ai 的值增加 x x x ,过程如下:
如图所示:
时间复杂度为 O ( l o g 2 n ) O(log_2n) O(log2n) ;
代码如下:
void add(int p, int i, int x) { // 将 a[i] 的值增加 x
if (t[p].l == t[p].r) {
t[p].tot += x; // 找到存储 [i, i] 信息的节点,跟新其的值
return;
}
int lc = p * 2, rc = p * 2 + 1, mid = (t[p].l + t[p].r) / 2;
if (i <= mid) add(lc, i, x); // 当 i 在当前点左子树时,更新左子树
else add(rc, i, x); // 当 i 在当前点右子树时,更新右子树
t[p].tot = t[lc].tot + t[rc].tot; // 从下往上更新信息
return;
}
add(1, i, x); // 调用入口
要查询在区间 [ l , r ] [l, r] [l,r] 上的和,则从根节点开始,递归时会遇到三种情况:
若当前的节点 p p p 所表示的节点区间 [ l , r ] [l, r] [l,r] 完全覆盖了查询空间,则立即回溯,并且该节点 t o t [ p ] tot[p] tot[p] 为查询答案的一部分;
如图:红色表示查询的区间 [ l , r ] [l, r] [l,r] ,蓝色表示节点 p p p 表示的区间;
若当前的节点 p p p 所表示的节点区间 [ l , r ] [l, r] [l,r] 只覆盖了一部分查询空间,则递归访问左右子节点:
如图:红色表示查询的区间 [ l , r ] [l, r] [l,r] ,蓝色表示节点 p p p 表示的区间;
时间复杂度为 O ( l o g 2 n ) O(log_2n) O(log2n) ;
代码如下:
int query(int p, int l, int r) {
if (l <= t[p].l && t[p].r <= r) { // 情况1
return t[p].tot;
}
int lc = p * 2, rc = p * 2 + 1, mid = (t[p].l + t[p].r) / 2, sum = 0;
if (l <= mid) sum += query(lc, l, r); // 情况2
if (r > mid) sum += query(rc, l, r); // 情况3
return sum;
}
printf("%d\n", query(1, l, r)); // 调用出口
在区间修改中,如果某个节点代表的区间被修改区间 [ l , r ] [l,r] [l,r] 完全覆盖,那么以该节点为根节点的整棵子树中的所有节点存储的信息都需要发生变化,若逐一进行更新,将使得一次区间修改指令的时间复杂度增加到 O ( n ) O(n) O(n) ;
所以如果再修改中发现某个节点 p p p 代表的区间 [ p l , p r ] [p_l, p_r] [pl,pr] 被修改区间 [ l , r ] [l,r] [l,r] 完全覆盖,并且逐一更新了子树 p p p 中的所有节点,但是在之后的查询指令中却没有用到 [ l , r ] [l, r] [l,r] 的子区间作为候选答案,那么更新 p p p 的整棵字数是无用的;
只需要在执行修改指令时,只修改到满足 l ≤ p l ≤ p r ≤ r l \leq p_l \leq p_r \leq r l≤pl≤pr≤r 的节点后停止递归修改,但在回溯之前型节点 p p p 增加一个标记,表示“该节点曾经被修改,但其子节点尚未被修改”。该标记的作用如下:
所以,对任意节点的修改都延迟到“在后续操作中递归进入它的父节点时”在执行。这样,每条区间修改的时间复杂度降低到了 O ( l o g 2 n ) O(log_2n) O(log2n) 。这些标记则为“延迟标记”;
向下传递延迟标记:
void down(int p) {
if (t[p].target) { // 节点 p 有延迟标记
int lc = 2 * p, rc = 2 * p + 1;
t[lc].target += t[p].target; // 更新左子节点的信息
t[rc].target += t[p].target; // 更新右子节点的信息
t[lc].tot += (t[lc].r - t[lc].l + 1) * t[p].target; // 左左子节点打上延迟标记
t[rc].tot += (t[rc].r - t[rc].l + 1) * t[p].target; // 右子节点打上延迟标记
t[p].target = 0; // 清除 p 的标记
}
return ;
}
区间修改:
void add(int p, int l, int r, int x) {
if (l <= t[p].l && t[p].r <= r) { // 完全包含
t[p].tot += x * (t[p].r - t[p].l + 1); // 更新节点信息
t[p].target += x; // 更新延迟标记
return;
}
down(p); // 需要递归向下时就传递延迟标记
int lc = p * 2, rc = p * 2 + 1, mid = (t[p].l + t[p].r) / 2;
if (l <= mid) add(lc, l, r, x); // 当 i 在当前点左子树时,更新左子树
if (r > mid) add(rc, l, r, x); // 当 i 在当前点右子树时,更新右子树
t[p].tot = t[lc].tot + t[rc].tot; // 从下往上更新信息
return;
}
区间查询:
int query(int p, int l, int r) {
if (l <= t[p].l && t[p].r <= r) return t[p].tot; // 完全包含
down(p); // 需要递归向下时就传递延迟标记
int lc = p * 2, rc = p * 2 + 1, mid = (t[p].l + t[p].r) / 2, sum = 0;
if (l <= mid) sum += query(lc, l, r); // 左子节点有重叠
if (r > mid) sum += query(rc, l, r); // 右子节点有重叠
return sum;
}