P3372 【模板】线段树 1 题解
P3372 【模板】线段树 1
简化题意
有一个长度为 \(n\) 的数列 \(\{a_i\}\),我们要对其进行 \(m\) 次以下两种操作:
- 将某区间每一个数加上 \(k\)。
- 求出某区间每一个数的和。
对于所有的操作 2,输出得出的和。
解题思路
这里的数据范围是很大的,直接暴力肯定不可取。想必有的同学肯定想到了前缀和,然而由于有一个区间修改操作,而前缀和无法实现这一点,因此不可取。
这里,我们就要引入算法:线段树。
线段树是一种常用的区间数据结构,用于高效的处理区间查询与区间求和的问题。
线段树采用了分治的思想。通常使用线段树要分为建树与查询这两部分。
1、建树
建树指对于区间 \([1, n]\),建立关于它的线段树。
具体思路为:将区间不断二分,直到无法再分(区间长度为 \(1\))。这样的好处是什么呢?就是我们可以通过合并零散的区间,把我们的答案给“凑”出来。
下面是一个根据区间 \(a = \{1, 4, 2, 3, 5, 7 \}\) 建立的线段树:
[1, 6](22)
/ \
[1, 3](7) [4, 6](15)
/ \ / \
[1, 2](5) [3](2) [4, 5](8) [6](7)
/ \ / \
[1](1) [2](4) [4](3) [5](5)
建树的思路就是将区间从 \([1, n]\) 开始不断二分,直到分到每个区间大小均为 \(1\),然后这个时候把相应区间的大小赋值,然后再把已经合并的区间信息向上推进(自底向上)。
为了方便实现,我们设线段树的根节点为 \(1\),节点 \(i\) 的两个子节点编号分别为 \(2i\) 与 \(2i + 1\)。
实现这个向上推进的操作,我们需要构建一个函数 push_up。push_up 的作用是将子节点的区间和上传到父节点。
需要注意的是,这样操作的话,会使所需的数组大小增加(每个分开的区间都需要记录下来),因此线段数组是典型的以空间换时间。
点击查看代码
void push_up(ll fa)
{
t[fa] = t[fa << 1] + t[fa << 1 | 1];//把小区间的数据向上传递
}
void build(ll fa, ll left, ll right)
{
if(left == right)//区间长度已经为 1
{
t[fa] = a[left];
return;
}
ll mid = (left + right) >> 1;//分左右两半向下传递
build(fa << 1, left, mid);
build(fa << 1 | 1, mid + 1, right);
push_up(fa);//递归向上传输信息
}
/*
t[i] 代表编号为 i 的区间的大小;
a[i] 代表原始区间下标为 i 的位置的数。
*/
2、区间更新
线段树若是只将区间拆分,然后合并“拼”出答案,并在修改时将所有需要更改区间立即一起修改,其实并不能提升多少速度,甚至会比暴力还不合算。
因此,在区间更新时,我们需要使用一些特殊操作,减少不必要的更改。
为此,我们引入新概念——懒标记。
懒标记就要做到懒!我们规定,如果一个区间已经完全被将要更改的区间覆盖,就给它贴上懒标记:标记要增加的大小,但是先不加——因为可能后续这个更改根本没有意义。
如果一个区间并没有被完全覆盖,就把父节点的信息向下推,直到区间被目标区间覆盖。
而实现这个向下推进的操作,我们需要构建一个函数 push_down。push_down 的作用是将父节点的懒标记下放到子节点,然后把区间和更新(加上 \(k\) 乘以区间大小)。最后清空父节点的懒标记。
最后再把子节点的信息重新传上去,只需要利用 push_up 函数即可达成。
点击查看代码
void push_down(ll left, ll right, ll fa)
{
ll mid = (left + right) >> 1;//把懒标记下放、区间和更新
t[fa << 1] += tag[fa] * (mid - left + 1);
tag[fa << 1] += tag[fa];
t[fa << 1 | 1] += tag[fa] * (right - mid);
tag[fa << 1 | 1] += tag[fa];
tag[fa] = 0;//父节点懒标记清空
}
void update(ll ql, ll qr, ll left, ll right, ll fa, ll k)
{
if(ql <= left && qr >= right)//已经被目标区间包含了,直接更新
{
t[fa] += k * (right - left + 1);//区间和更新
tag[fa] += k;//懒标记增加
return;
}
push_down(left, right, fa);//不被目标区间完全包含,下放区间。
ll mid = (left + right) >> 1; //再分割区间,向下递归,直到被目标区间完全包含
if(ql <= mid)
update(ql, qr, left, mid, fa << 1, k);
if(qr > mid)
update(ql, qr, mid + 1, right, fa << 1 | 1, k);
push_up(fa);//把信息推上去
}
/*
tag[i] : 区间 i 目前的懒标记总值
*/
3、区间求和
区间求和简单来说,就是利用我们已经构建完的小区间,把整个区间给拼出来。
由于懒标记的存在,我们需要在查询的过程中,不断利用 push_down 操作来更新当前需要的区间。也正是这样,懒标记节省了将所有区间更新所需的时间(而不是一次性更新完,浪费大量时间)。
每当我们找到一个被目标区间包含的区间,就把它的大小给加上,直到凑出了整个区间。如果当前区间与目标区间有交集(未被完全包含),就继续往下推。
点击查看代码
ll query(ll ql, ll qr, ll left, ll right, ll fa)
{
ll ans = 0;
if(ql <= left && qr >= right)//整个区间被包含
return t[fa];
ll mid = (left + right) >> 1;
push_down(left, right, fa);//利用懒标记更新
if(ql <= mid)//左儿子区间与目标区间有交集
ans += query(ql, qr, left, mid, fa << 1);
if(qr > mid)//右儿子区间与目标区间有交集
ans += query(ql, qr, mid + 1, right, fa << 1 | 1);
return ans;
}
代码部分
#include <bits/stdc++.h>
#define ll long long
using namespace std;
ll a[100010], t[400010], tag[400010];
ll n, m;
void push_up(ll fa)
{
t[fa] = t[fa << 1] + t[fa << 1 | 1];
}
void build(ll fa, ll left, ll right)
{
if(left == right)
{
t[fa] = a[left];
return;
}
ll mid = (left + right) >> 1;
build(fa << 1, left, mid);
build(fa << 1 | 1, mid + 1, right);
push_up(fa);
}
void push_down(ll left, ll right, ll fa)
{
ll mid = (left + right) >> 1;
t[fa << 1] += tag[fa] * (mid - left + 1);
tag[fa << 1] += tag[fa];
t[fa << 1 | 1] += tag[fa] * (right - mid);
tag[fa << 1 | 1] += tag[fa];
tag[fa] = 0;
}
ll query(ll ql, ll qr, ll left, ll right, ll fa)
{
ll ans = 0;
if(ql <= left && qr >= right)
return t[fa];
ll mid = (left + right) >> 1;
push_down(left, right, fa);
if(ql <= mid)
ans += query(ql, qr, left, mid, fa << 1);
if(qr > mid)
ans += query(ql, qr, mid + 1, right, fa << 1 | 1);
return ans;
}
void update(ll ql, ll qr, ll left, ll right, ll fa, ll k)
{
if(ql <= left && qr >= right)
{
t[fa] += k * (right - left + 1);
tag[fa] += k;
return;
}
push_down(left, right, fa);
ll mid = (left + right) >> 1;
if(ql <= mid)
update(ql, qr, left, mid, fa << 1, k);
if(qr > mid)
update(ql, qr, mid + 1, right, fa << 1 | 1, k);
push_up(fa);
}
int main()
{
ll type, left, right, k;
scanf("%lld %lld", &n, &m);
for(int i = 1;i <= n;i ++)
scanf("%lld", &a[i]);
build(1, 1, n);
for(int i = 1;i <= m;i ++)
{
scanf("%lld %lld %lld", &type, &left, &right);
if(type == 2)
printf("%lld\n", query(left, right, 1, n, 1));
else
{
scanf("%lld", &k);
update(left, right, 1, n, 1, k);
}
}
return 0;
}
后记
功利的讲,线段树是 CSP 等一系列赛事中非常重要的算法,建议能够流畅背诵出来。
这里推荐一些新手练习题目,我也快退役了,就留下来记录一下吧。
- P2357 守墓人 把主墓碑看做区间 \([1, 1]\) 即可,其余为线段树模版。
浙公网安备 33010602011771号