1、引入
有这么一个问题:一共有n个数,q次操作,每次操作为询问区间[l, r]的和。
在算法基础篇中,我们知道了这种问题可以用前缀和来解决。
现在我们在原有问题的基础上进行如下进阶:问题1: 有n个数,q次操作,操作有两种:a、查询区间[l,r]的和;b、将第i个数修改成x。问题2: 有n个数,q次操作,操作有两种:a、查询区间[l,r]的和;b、将区间[l,r]的数全部修改成x。**问题3:**有n个数,q次操作,每次操作询问区间[l,r]的最大值或者最小值(这个其实是RMQ问题)。
这些问题虽然都是子问题的变形,但是你会发现前缀和一个都解决不了,用暴力解法又肯定会超时。因此,我们需要一个新的数据结构来帮助我们解决以上几类问题。
2、线段树的构建
线段树是基于分治思想的二叉树,树中每一个节点都会维护一段区间的信息。其中叶子节点存储元素本身,非叶子节点维护区间内元素的信息。
我们以数组a = [5,1,3,0,2,2,7,4,5,8]为例,如果要查询区间和,我们会先从下标中间位置将数组一分为二,直到区间长度为1。如下图所示:

而区间[1,1]的元素和就是数组中下标为1的元素,区间[2,2]的和就是数组下标为2的元素。此时我们就可以更新结果了:

根据上面的思路,就可以写出代码了:
cpp
#define lc p << 1
#define rc p << 1 | 1
typedef long long LL;
const int N = 1e5 + 10;
int n, m;
LL a[N];
// 线段树
struct node
{
LL l, r, sum;
}tr[N << 2];
// 整合左右孩子的信息
void pushup(int p)
{
tr[p].sum = tr[lc].sum + tr[rc].sum;
}
// 构建线段树
void build(int p, int l, int r)
{
tr[p] = {l, r, 0}; // 初始化
if(l == r)
{
tr[p].sum = a[l];
return;
}
int mid = (l + r) >> 1; // 一分为二
build(lc, l, mid); // 构建左子树
build(rc, mid + 1, r); // 构建右子树
pushup(p);
}
时间复杂度:O(n)
3、区间查询
对于一个待查询的区间,用拆分+拼凑的思想,在线段树的节点中收集结果。具体流程:
1、从根节点出发,向下递归;
2、如果当前节点维护的区间信息包含在待查询的区间内,直接返回节点维护的信息(递归出口)
3、如果左区间有重叠,去左子树上找结果;
4、如果右区间有重叠,去右子树上找结果。
具体流程如下图所示:

代码实现:
cpp
// 区间查询
LL query(int p, int x, int y)
{
LL l = tr[p].l, r = tr[p].r; // 当前节点维护的信息
if(x <= l && r <= y)
return tr[p].sum;
LL sum = 0;
LL mid = (l + r) >> 1;
if(x <= mid)
sum += query(lc, x, y);
if(y >= mid + 1)
sum += query(rc, x, y);
return sum;
}
时间复杂度:O(logN)
4、单点修改
将下标为6的位置上的数加上3(如果是对单个位置上的数执行减、乘、除的操作都是这个思路)
1、递归找到叶子结点,并且维护修改后的信息;
2、然后一路向上回溯,修改所有路径上的节点信息,使得维护的信息为修改之后的信息。
流程图如下:

代码实现:
cpp
// 整合左右孩子的信息
void pushup(int p)
{
tr[p].sum = tr[lc].sum + tr[rc].sum;
}
// 单点修改
void modify(int p, int x, LL k)
{
int l = tr[p].l, r = tr[p].r;
if(x == l && x == r) // 叶子结点
{
tr[p].sum += k;
return;
}
int mid = (l + r) >> 1;
if(x <= mid)
modify(lc, x, k);
else
modify(rc, x, k);
pushup(p);
}
5、区间修改
例如,对区间[4, 9]上每个元素增加2。如果按照单点修改的方式把该区间内所有的点全部修改,时间复杂度是O(n)。
但是,如果某个节点维护的区间[l, r]被要修改的区间[x, y]完全覆盖时,它的左右子树可以没必要修改,等到下次遇到时再去处理。
借助这一思想,我们会在每个节点中维护一个额外的懒标记:
1、如果当前区间[l, r]被待查询区间[x, y]完全覆盖时,停止递归,根据区间长度维护出增加元素之后的和,打上一个懒标记,不去处理左右孩子。
2.等到下次修改或者查询操作,遇到该节点时,再把懒标记下放给左右孩子。
以数组a = [5,1,3,0,2,2,7,4,5,8]为例,如果对区间[4,9]上每个元素加2,维护信息如下:

再执行查询操作:查询区间[5,7]上所有元素的和,维护信息如下:

代码实现:
cpp
struct node
{
int l, r;
LL sum, add;
}tr[N * 4];
void pushup(int p)
{
tr[p].sum = tr[lc].sum + tr[rc].sum;
}
void build(int p, int l, int r)
{
tr[p] = {l, r, 0, 0};
if(l == r)
{
tr[p].sum = a[l];
return;
}
int mid = (l + r) / 2;
build(lc, l, mid);
build(rc, mid + 1, r);
pushup(p);
}
// 接收到修改任务,修改完毕之后,把信息懒下来
void lazy(int p, LL add)
{
int l = tr[p].l, r = tr[p].r;
tr[p].sum += (r - l + 1) * add;
tr[p].add += add;
}
void pushdown(int p)
{
lazy(lc, tr[p].add); // 懒标记分配给左孩子
lazy(rc, tr[p].add); // 懒标记分配给右孩子
tr[p].add = 0;
}
void modify(int p, int x, int y, LL k)
{
int l = tr[p].l, r = tr[p].r;
if(x <= l && r <= y)
{
// 修改之后打上标记
tr[p].sum += (r - l + 1) * k;
tr[p].add += k;
return;
}
pushdown(p); // 懒标记下放
int mid = (l + r) / 2;
if(x <= mid)
modify(lc, x, y, k);
if(y >= mid + 1)
modify(rc, x, y, k);
pushup(p); // 更新父节点
}
LL query(int p, int x, int y)
{
int l = tr[p].l, r = tr[p].r;
LL sum = 0;
if(x <= l && r <= y)
{
return tr[p].sum;
}
pushdown(p);
int mid = (l + r) / 2;
if(x <= mid)
sum += query(lc, x, y);
if(y >= mid + 1)
sum += query(rc, x, y);
return sum;
}
由于加上了懒标记,所有的操作与区间查询过程一致,所以整体的时间复杂度为O(logN)。