「学习笔记」线段树

零.前言

由于作者仍然不断地学习关于线段树的知识,所以该学习笔记的内容将不断填充。

这里给出一份洛谷题单供大家参考,为该学习笔记的例题。

欢迎点赞关注收藏。


一.概念

线段树是一种二叉搜索树,每个节点最多有两颗子树,通常称为左、右子树。

这个数据结构将一个区间分成若干个区间,每一个节点存储一个区间 \([L,R]\) 的信息,叶子结点的 \(L\) 等于 \(R\)

也就是说,我们将 \([1,n]\) 平均分为两个区间,然后一直执行这个操作,分到无法再分为止。

线段树的思想和分治很像:将大区间的信息分为若干个小区间的信息进行操作。

二.建树

首先我们要有一个结构体封装节点信息:

struct SegMentTree {
	int l;//区间左端点。
	int r;//区间右端点。
	int sum;//区间和。
	int siz;//区间长度。
	//Do other things.
}S[N];

我们有一个数组 \([1,2,3,4,5]\),线段树的搭建如下。

image

可以发现,对于一个节点 \(u\),它的左子节点和右子节点的编号分别为 \(2u\)\(2u+1\),这样我们就能从根节点一直递归下去建树。

显然,节点 \(u\) 的区间和为它的左子节点和右子节点的区间和之和。

这样,就可以写出建树代码了:

#define ls (x) (x * 2)//左子节点。
#define rs (x) (x * 2 + 1)//右子节点。

void build (int nd, int l, int r) {
	s[nd].l = l, s[nd].r = r, s[nd].siz = r - l + 1;
	//记录信息。
	if (l == r) {
		s[nd].sum = a[l];//a数组为最初给定的数列。
		
		return ;
	}
	
	int mid = l + r >> 1;
	
	build (ls(nd), l, mid);
	build (rs(nd), mid + 1, r);
	//递归左子树和右子树。
	
	s[nd].sum = s[ls(nd)].sum + s[rs(nd)].sum;
	//合并信息。
}

三.区间查询

其实单点查询没什么必要学,区间查询可以实现单点查询。

那么这里就只讲解区间查询。

对于一个区间 \([L,R]\),我们要查询它的区间之和。

同样需要递归记录和。

考虑一个节点 \(u\), 设它的左端点为 \(u_l\),右端点为 \(u_l\),询问区间为 \([L,R]\)

如果 \(L\le u_l\) 并且 \(R \ge u_r\),那么直接返回当前节点的区间和即可。

也就是说当当前节点是询问范围的子集时,就直接返回当前节点的区间和。

如果不满足上述条件,那么就需要递归两个子树,通过合并两个区间的信息获得该区间的答案。

这样很容易得出代码了:

ll query (int nd, int l, int r) {
	if (l <= s[nd].l && r >= s[nd].r) {
		return s[nd].sum;
		//当前节点是询问范围的子集时,就直接返回当前节点的区间和。
	}	
	
	int mid = s[nd].l + s[nd].r >> 1;
	ll res = 0;
	
	if (l <= mid) {
		res += query (ls(nd), l, r);
		//左子树与询问区间有交集,那么递归左子树。
	}
	
	if (r > mid) {
		res += query (rs(nd), l, r);
		//右子树与询问区间有交集,那么递归右子树。
	}
	
	return res;
}

当然,如果需要单点查询节点 \(u\),那么直接将询问区间左右端点都设为 \(u\) 即可。


四.区间加法

给定一个区间 \([l,r]\),要求给每个区间中的数增加 \(k\)

这时候暴力的想法冒出来了。但是暴力遍历区间进行操作的话,时间难以承受。

这里给一个小故事来启发大家的思路:

同学 \(u\) 很喜欢借别人的钱,但是他觉得每借 \(1\) 次就还别人 \(1\) 次钱实在是太麻烦了!于是,他想到了一个好方法。

一天,他对最经常借他钱的同学 \(v\) 说:"前几天我一共借了你 \(76\) 元,今天我再借你 \(15\) 元。但是我先不还给你,等到你需要用钱的时候我再还给你。"

\(v\) 说:"这样是挺好,能减少你的还钱的操作,方便。但是你和我如果都不记得你借了我多少钱怎么办呢?"

\(u\) 说:"你放心,我拿一个小本本记着的,你需要钱的时候我就会还给你滴!"

于是,小 \(u\) 和小 \(v\) 一言为定,双方都达成了自己的愿望。

看到这里,大家应该都有想法了。

对于一个节点,我们需要一个变量记录改变的数量,通常我们称为懒惰标记

如果当前节点的懒惰标记为 \(0\),也就是说无需下传标记了。

对于下传懒惰标记,我们可以单独写一个函数:

inline void pushdown (int nd) {
	s[ls(nd)].add += s[nd].add; 
	s[rs(nd)].add += s[nd].add;
	//记录懒惰标记
	
	s[ls(nd)].sum += (s[ls(nd)].siz * s[nd].add);
	s[rs(nd)].sum += (s[rs(nd)].siz * s[nd].add);
	//左右子树的每个数都需要加上父节点的懒惰标记,那么区间和只需要乘上区间长度即可。 
	
	s[nd].add = 0;//下传过了就无需下传啦! 
}

对于区间修改,按照上文区间查询一样的思路,对于三种情况进行分类讨论:

void Add (int nd, int l, int r, int k) {
	//k为增加的数字,l和r是询问区间。 
	if (l <= s[nd].l && r >= s[nd].r) {
		//当前区间是询问区间的子集时。 
		s[nd].sum += s[nd].siz * k;
		s[nd].add += k;
		
		return ;
	}
	
	if (s[nd].add) {
		pushdown (nd);
	} 

	int mid = s[nd].l + s[nd].r >> 1;
	
	if (l <= mid) {
		Add (ls(nd), l, r, k);
		//递归修改左子树。 
	}
	
	if (r > mid) {
		Add (rs(nd), l, r, k);
		//递归修改右子树。 
	}
	
	s[nd].sum = s[ls(nd)].sum + s[rs(nd)].sum;  
	//合并左右区间。 
}

当然,在有区间修改的时候,区间查询时,也是需要下传懒惰标记的。


五.基础运用

P3372 【模板】线段树 1
单点修改,区间查询。

P2357 守墓人
单点修改,区间查询。

P2068 统计和
区间修改,区间查询。

P2846 [USACO08NOV]Light Switching G
区间异或,区间查询。下传懒惰标记和修改的时候稍微改改即可。

P3870 [TJOI2009]开关
同上题。

SP7259 LITE - Light Switching
同上题。

P5057 [CQOI2006]简单题
同上题。

P2574 XOR的艺术

五倍经验!

因为之前写的代码太丑就不放了qwq。


六.区间乘法

上文我们提到过,对于区间加法时,我们创造出一个懒惰标记记录。

同样,对于区间乘法,我们也需要一个懒惰标记进行标记。

我们知道,乘法和加法的优先级不同,也就是说,我们不能将乘法和加法的信息混在一起,所以需要两个标记进行处理。

思想透彻了,其实代码实现也很简单。

下传标记如下:

inline void pushdown (int nd) {
	s[ls(nd)].mul = s[nd].mul * s[ls(nd)].mul;
	s[rs(nd)].mul = s[nd].mul * s[rs(nd)].mul;
	//维护乘法懒惰标记。 
	
	s[ls(nd)].add = s[nd].mul * s[ls(nd)].add + s[nd].add;
	s[rs(nd)].add = s[nd].mul * s[rs(nd)].add + s[nd].add;
	//需要乘上乘法懒惰标记。 
	
	s[ls(nd)].sum = s[ls(nd)].siz * s[nd].add + s[nd].mul * s[ls(nd)].sum;
	s[rs(nd)].sum = s[rs(nd)].siz * s[nd].add + s[nd].mul * s[rs(nd)].sum; 
	
	s[nd].add = 0;
	s[nd].mul = 1;
} 

对于区间乘法,我们的操作像区间加法一样,递归左右子树进行修改。

代码如下:

void Mul (int nd, int l, int r, int k) {
	if (l <= s[nd].l && r >= s[nd].r) {
		s[nd].mul *= k;
		s[nd].add *= k;
		s[nd].sum *= k;
		//处理叶子节点信息。
		
		return ;
	}
	
	pushdown (nd);
	//下传标记。
	
	int mid = s[nd].l + s[nd].r >> 1;
	
	if (l <= mid) {
		Mul (ls(nd), l, r, k);
	}
	
	if (r > mid) {
		Mul (rs(nd), l, r, k);
	}
	
	//递归左右子树。
	
	pushup (nd);
	//合并区间信息。
}

七.区间最值

其实思想都是一样的,和查询一样,分三种情况进行处理,递归左右子树。

查询区间最小值如下:

int Query_Min (int nd, int l, int r) {
	if (l <= s[nd].l && r >= s[nd].r) {
		return s[nd].mn;
		//直接返回。
	}
	
	int mid = s[nd].l + s[nd].r >>1, ans = inf;
	
	if (l <= mid) {
		ans = min (ans, Query_Min (ls(nd), l, r));
		//左子树找最小值。
	}
	
	if (r > mid) {
		ans = min (ans, Query_Min (rs(nd), l, r));
		//右子树找最小值。
	}
	
	return ans;
}

查询最大值将符号换一下即可。


八.基础运用

P3373 【模板】线段树 2
区间加法,乘法,询问。

完整代码

P2023 [AHOI2009] 维护序列
同上题。

P5097 [USACO04OPEN]Cave Cows 2

区间查询最小值,懒惰标记都不需要。

P2880 [USACO07JAN]Balanced Lineup G

求极差,区间查询最大值以及最小值即可。

P2251 质量检测

区间查询最小值。

P1816 忠诚

区间查询最小值。


九.区间开方

SP2713 GSS4 - Can you answer these queries IV

P4145 上帝造题的七分钟 2 / 花神游历各国

区间开方的模板题。

首先我们发现一个性质:\(10^{18}\) 经过 \(6\) 次开方(向下取整)后就变成了 \(1\)

也就是说,对于任意一个区间,最多只可能被开方(向下取整)\(6\) 次。因为 \(1\) 继续开方(向下取整)后一直都是 \(1\)

于是,我们只需要记录每个区间的最大值,当该区间最大值为 \(1\) 时,就可以退出,否则直接使用 \(sqrt\) 函数暴力修改即可。

void Sqrt (int nd, int l, int r, int tl, int tr) {
	if (maxx[nd] <= 1) {
		return ;
	}
	if (l == r) {
		s[nd] = sqrt (s[nd]);
		maxx[nd] = s[nd];
		
		//暴力修改。
		return ;
	}
	
	int mid = l + r >> 1;
	
	if (tl <= mid) {
		Sqrt (ls(nd), l, mid, tl, tr);
	}
	
	if (tr > mid) {
		Sqrt (rs(nd), mid + 1, r, tl, tr);
	}
	
	pushup (nd);
}

十.区间取模

CF438D The Child and Sequence

给出一个很显然的结论:

\[x\bmod p < \frac{x}{2} \]

我们可以参考区间开方的思想,也就是说,一个区间最多被取模 \(\log x\) 次。

所以,只需要记录当前区间的最大值,如果最大值小于 \(p\) 就可以直接退出。

否则暴力取模。

void Mod (int nd, int l, int r, int tl, int tr, int p) {
	if (mx[nd] < p) {
		return ;
	}
	
	if (l == r) {
		sum[nd] %= p;
		mx[nd] %= p;
		
		return ;
	}
	
	int mid = l + r >> 1;
	
	if (tl <= mid) {
		Mod (ls(nd), l, mid, tl, tr, p);
	}
	
	if (tr > mid) {
		Mod (rs(nd), mid + 1, r, tl, tr, p);
	} 
	
	pushup (nd);
}
posted @ 2021-08-05 16:20  cyhyyds  阅读(86)  评论(0编辑  收藏  举报