P3372 【模板】线段树 1 题解

P3372 【模板】线段树 1

简化题意

有一个长度为 \(n\) 的数列 \(\{a_i\}\),我们要对其进行 \(m\) 次以下两种操作:

  1. 将某区间每一个数加上 \(k\)。
  2. 求出某区间每一个数的和。

对于所有的操作 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]\) 即可,其余为线段树模版。

posted on 2026-10-16 21:11  Leo_29  阅读(2)  评论(0)    收藏  举报

导航