浅谈线段树

线段树(Segment Tree),OI 中十分常用的一个数据结构,适用于一个序列的区间修改、区间询问交叉进行的问题。

线段树可以非常高效地维护区间,它可以在 \(\Theta(\log n)\) 的复杂度内完成一次区间修改或者查询。

它的原理非常简单,就是将一个序列投射到一颗平衡二叉树上,树上的每一个节点可以表示一段区间。那么这个序列中的任意一段区间都可以由若干树上的结点表示的区间拼接起来,而这些结点的查询或修改就可以在 \(\Theta(\log n)\) 的复杂度内解决。

那么它如何用代码实现呢?本文中笔者以洛谷模板题的区间加法,区间求和作为示例。

我们设 \(tree_i\) 表示 \(i\) 这个结点所表示的区间和,\(tree_{2i}\) 表示 \(i\) 的左儿子,\(tree_{2i + 1}\) 表示 \(i\) 的右儿子。

假设结点 \(i\) 表示的区间为 \([lt, rt]\)\(mid = \lfloor \dfrac{lt + rt}{2} \rfloor\),那么它的左右儿子表示的区间则分别为 \([lt, mid]\)\([mid + 1, rt]\)

特别的,我们定义根结点 \(tree_1\) 表示整个序列的和。

1. 建立线段树

我们可以考虑用递归的方式来逐层建立线段树。

从根结点开始,逐层递归左右儿子,直到区间的左右端点重合,即为叶子结点,此时区间和应为这个点的值,然后向上返回。非叶子节点的区间和应为它的左右儿子的区间和的总和。

下面的 gif 动图展现了序列 \({1, 1, 4, 5, 1, 4}\) 的建树过程。

void pushup(int cur)
{
	tree[cur] = tree[cur * 2] + tree[cur * 2 + 1];//tree[cur]是它的左右两个子结点tree[cur * 2],tree[cur * 2 + 1]的和
	return;
}
void build(int cur, int lt, int rt)//cur表示结点编号,lt表示这个结点表示的区间的左端点,rt表示右端点
{
	if(lt == rt)//如果是叶子结点(叶子结点只能表示一个点,也就是左右端点相等)
	{
		tree[cur] = a[lt];//那么叶子结点就是这个数
		return;
	}
	int mid = (lt + rt) >> 1;
	build(cur * 2, lt, mid);//递归左子树
	build(cur * 2 + 1, mid + 1, rt);//递归右子树
	pushup(cur);//给此结点赋值
	return;
}

2. 区间查询

同样的,我们可以考虑逐层递归,如果当前节点所覆盖的区间能够被查询区间完全覆盖,说明该节点对答案产生的贡献,返回该节点的区间和。

下面的 gif 动图展现了查询序列 \({1, 1, 4, 5, 1, 4}\) 区间 \([2, 5]\) 的和的过程。

int query(int cur, int lt, int rt, int qx, int qy)//cur表示结点编号,lt表示这个结点表示的区间的左端点,rt表示右端点,qx表示需要查询的区间的左端点,qy表示右端点
{
	if(qx > rt || qy < lt)//如果此结点覆盖的范围不在需要修改的范围内,停止递归,返回0
		return 0;
	if(qx <= lt && qy >= rt)//如果此结点覆盖的范围都在需要查询的范围内,返回这个结点的值
		return tree[cur];
	int mid = (lt + rt) >> 1;
	return query(cur * 2, lt, mid, qx, qy) + query(cur * 2 + 1, mid + 1, rt, qx, qy);//继续往下递归,返回左右两个子结点的和
}

3.区间修改

同样考虑逐层递归。如果当前点为叶子结点,就将其修改,然后返回并不断将它的祖先结点更新为其子节点的区间和的和。

void update(int cur, int lt, int rt, int qx, int qy, int val)//cur表示结点编号,lt表示这个结点表示的区间的左端点,rt表示右端点,qx表示需要修改的区间的左端点,qy表示右端点,val表示需要修改的数
{
	if(qx > rt || qy < lt)//如果此结点覆盖的范围不在需要修改的范围内,停止递归
		return;
	if(lt == rt)//如果是叶子结点
	{
		tree[cur] += val;//加上要修改的值
		return;//停止递归
	}
	int mid = (lt + rt) >> 1;
	update(cur * 2, lt, mid, qx, qy, val);//递归左子树
	update(cur * 2 + 1, mid + 1, rt, qx, qy, val);//递归右子树
	pushup(cur);//因为它的左右两个子结点修改了,所以此结点需要修改为左右两个子结点的和
	return;
}

但是这份代码有一个很大的漏洞。在区间修改时,每一次都需要递归到叶子结点,如果需要修改整个序列,那就需要访问线段树中的每一个点,也就是 \(2^n-1\) 个点,那么区间修改的最坏时间复杂度就变成了 \(\Theta(2^n)\)

考虑优化上面的代码。不难发现,修改的意义在于需要查询,如果不需要查询,那么修改自然就没有意义了。

举一个很简单却直观的例子:老师布置了一堆作业,但是不需要检查,那么就没人写作业了。即使需要检查,那么大部分人也都会留到检查之前再写作业。

于是,根据上面的例子的启发,我们就可以对代码进行优化。

对于每一次修改,只要还没有查询到当前结点,就将修改存起来,直到查询到当前结点,就将这个点修改,然后将修改操作往下面传。我们把这个操作称为打懒标记

\(tag_i\) 表示点 \(i\) 的懒标记,只要点 \(i\) 被查询,就将点 \(i\) 修改,并将 \(tag_i\) 下传到它的子结点.

1. 标记下传

\(tag_{i}\) 打给左右子结点,\(tag_{ls} \gets tag_{ls} + tag_{i}\)\(tag_{rs} \gets tag_{rs} + tag_{i}\),并且清空 \(tag_{i}\),如果不清空的话只要重复下传就会重复加上标记。

void addtag(int cur, int lt, int rt, int val)//加上标记,cur表示结点编号,lt表示这个结点覆盖的区间的左端点,rt表示右端点
{
	tag[cur] += val;//此结点的标记加上它的父结点的标记
	tree[cur] += (rt - lt + 1) * val;//此结点的值加上它的父结点的值乘上它覆盖的结点数
	return;
}
void pushdown(int cur, int lt, int rt)//标记下传,cur表示结点编号,lt表示这个结点覆盖的区间的左端点,rt表示右端点
{
	if(tag[cur] == 0)//如果标记本来就是0,那么return
		return;
	int mid = (lt + rt) >> 1;
	addtag(cur * 2, lt, mid, tag[cur]);//给左孩子加上标记
	addtag(cur * 2 + 1, mid + 1, rt, tag[cur]);//给右孩子加上标记
	tag[cur] = 0;//清空标记
	return;
}

2. 修改区间修改操作

只要 \(cur\) 这个点所覆盖的范围都需要修改,就将点 \(cur\) 打上懒标记,不进行修改。然后标记下传。

void update(int cur, int lt, int rt, int qx, int qy, int val)//cur表示结点编号,lt表示这个结点表示的区间的左端点,rt表示右端点,qx表示需要修改的区间的左端点,qy表示右端点,val表示需要修改的数
{
	if(qx > rt || qy < lt)//如果此结点覆盖的范围不在需要修改的范围内,停止递归
		return;
	if(qx <= lt && qy >= rt)//如果此结点覆盖的范围都在需要修改的范围内,增加标记
	{
		addtag(cur, lt, rt, val);
		return;
	}
	pushdown(cur, lt, rt);//标记下传
	int mid = (lt + rt) >> 1;
	update(cur * 2, lt, mid, qx, qy, val);//递归左子树
	update(cur * 2 + 1, mid + 1, rt, qx, qy, val);//递归右子树
	pushup(cur);//因为它的左右两个子结点修改了,所以此结点需要修改为左右两个子结点的和
	return;
}

3. 修改区间查询操作

我们只需要在查询左右子结点前标记下传,使得其被修改即可。

int query(int cur, int lt, int rt, int qx, int qy)//cur表示结点编号,lt表示这个结点表示的区间的左端点,rt表示右端点,qx表示需要查询的区间的左端点,qy表示右端点
{
	if(qx > rt || qy < lt)//如果此结点覆盖的范围不在需要修改的范围内,停止递归,返回0
		return 0;
	if(qx <= lt && qy >= rt)//如果此结点覆盖的范围都在需要查询的范围内,返回这个结点的值
		return tree[cur];
	pushdown(cur, lt, rt);//标记下传
	int mid = (lt + rt) >> 1;
	return query(cur * 2, lt, mid, qx, qy) + query(cur * 2 + 1, mid + 1, rt, qx, qy);//继续往下递归,返回左右两个子结点的和
}

下面的 gif 动图展现了将序列 \(1, 1, 4, 5, 1, 4\) 区间 \([2, 5]\) 修改为 \(3\) 的过程,其中紫色点为打上了懒标记。

至此,基本的线段树我们就已经学完了,下面附上模板题的完整代码。

#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N = 1e5 + 5;
int n, m, a[N], tree[4 * N], tag[4 * N];
void pushup(int cur)
{
	tree[cur] = tree[cur * 2] + tree[cur * 2 + 1];//tree[cur]是它的左右两个子结点tree[cur * 2],tree[cur * 2 + 1]的和
	return;
}
void build(int cur, int lt, int rt)//cur表示结点编号,lt表示这个结点覆盖的区间的左端点,rt表示右端点
{
	if(lt == rt)//如果是叶子结点(叶子结点只能表示一个点,也就是左右端点相等)
	{
		tree[cur] = a[lt];//那么叶子结点就是这个数
		return;
	}
	int mid = (lt + rt) >> 1;
	build(cur * 2, lt, mid);//递归左子树
	build(cur * 2 + 1, mid + 1, rt);//递归右子树
	pushup(cur);//给此结点赋值
	return;
}
void addtag(int cur, int lt, int rt, int val)//加上标记,cur表示结点编号,lt表示这个结点覆盖的区间的左端点,rt表示右端点
{
	tag[cur] += val;//此结点的标记加上它的父结点的标记
	tree[cur] += (rt - lt + 1) * val;//此结点的值加上它的父结点的值乘上它覆盖的结点数
	return;
}
void pushdown(int cur, int lt, int rt)//标记下传,cur表示结点编号,lt表示这个结点覆盖的区间的左端点,rt表示右端点
{
	if(tag[cur] == 0)//如果标记本来就是0,那么return
		return;
	int mid = (lt + rt) >> 1;
	addtag(cur * 2, lt, mid, tag[cur]);//给左孩子加上标记
	addtag(cur * 2 + 1, mid + 1, rt, tag[cur]);//给右孩子加上标记
	tag[cur] = 0;//清空标记
	return;
}
int query(int cur, int lt, int rt, int qx, int qy)//cur表示结点编号,lt表示这个结点表示的区间的左端点,rt表示右端点,qx表示需要查询的区间的左端点,qy表示右端点
{
	if(qx > rt || qy < lt)//如果此结点覆盖的范围不在需要修改的范围内,停止递归,返回0
		return 0;
	if(qx <= lt && qy >= rt)//如果此结点覆盖的范围都在需要查询的范围内,返回这个结点的值
		return tree[cur];
	pushdown(cur, lt, rt);//标记下传
	int mid = (lt + rt) >> 1;
	return query(cur * 2, lt, mid, qx, qy) + query(cur * 2 + 1, mid + 1, rt, qx, qy);//继续往下递归,返回左右两个子结点的和
}
void update(int cur, int lt, int rt, int qx, int qy, int val)//cur表示结点编号,lt表示这个结点表示的区间的左端点,rt表示右端点,qx表示需要修改的区间的左端点,qy表示右端点,val表示需要修改的数
{
	if(qx > rt || qy < lt)//如果此结点覆盖的范围不在需要修改的范围内,停止递归
		return;
	if(qx <= lt && qy >= rt)//如果此结点覆盖的范围都在需要修改的范围内,增加标记
	{
		addtag(cur, lt, rt, val);
		return;
	}
	pushdown(cur, lt, rt);//标记下传
	int mid = (lt + rt) >> 1;
	update(cur * 2, lt, mid, qx, qy, val);//递归左子树
	update(cur * 2 + 1, mid + 1, rt, qx, qy, val);//递归右子树
	pushup(cur);//因为它的左右两个子结点修改了,所以此结点需要修改为左右两个子结点的和
	return;
}
signed main()
{
	cin >> n >> m;
	for(int i = 1; i <= n; i++)
		cin >> a[i];
	build(1, 1, n);
	for(int i = 1; i <= m; i++)
	{
		int opt, x, y, val;
		cin >> pt >> x >> y;
		if(opt == 1)
		{
			cin >> val;
			update(1, 1, n, x, y, val);
		}
		else
			cout << query(1, 1, n, x, y) << "\n";
	}
	return 0;
}
posted @ 2023-03-05 18:54  Luckies  阅读(82)  评论(0)    收藏  举报