线段树

何谓线段树

又是一个长时间没用的数据结构,复杂而又简单的线段树,简单是思路简单,复杂是调起来复杂(起步一个下午)

线段树及其强大的东西,可以在O(4logn)时间内完成,区间修改(增删改减),区间查询的操作 ,即所有关于区间的操作,它都能以O(4logn)的时间完成。相当于大砍刀,什么都能用。

线段树内每一个节点都存有一个区间[l, r]的信息,只有一个root根节点即全区间,每个父节点后有两个子节点,分别存有两个子区间的信息[l, mid][mid + 1, r],这两个子区间严丝合缝,且占满整个父区间。
一颗线段树大体长这样
![[Pasted image 20240427171918.png |400]]

一颗合格的线段树,应支持,建立修改查询等基本操作

存储线段树

一般线段树的节点我们用结构体来存储,比较方便使用。

如何建立父子关系
  1. 分析线段树,可以发现是一颗满二叉树,那么可以沿用的存储方式,设父节点为u,则左右儿子分别为u * 2u * 2 + 1,也可以写成u << 1u << 1 | 1的形式,这样通过儿子通过除2就可以找到父节点,父节点可以通过上面的u << 1u << 1 | 1找到子节点
  2. 我们可以通过记录父子关系,在结构体内直接记录左右儿子的下标,直接查询。
    一般结构体长这样
struct Node
{
	int l, r; // 左右端点
	区间信息
	// int ls, rs; // 左右儿子下标(用第一种存储方式的话,不用这句话)
}tr[N * 4];

线段树一般开4倍空间。因为线段树是一个满二叉树,所以这个二叉树第N - 1行一定是满的,考虑所有叶节点最多有n个,而第N - 1行可以放叶节点,所以第N - 1行最多有n个节点,那么从1~N - 1行一共2n - 1个节点(二叉树性质),那么第N行最多就是2n个,那么总共大约是4n个节点,也就是四倍空间。

建立线段树(build)

首先要认识一点,线段树不能扩增,建完了就是建完了,不能扩增。
意思是大小是一开始定义好的,比如存1~1000的区间,那么以后就只能在这个区间内进行。
那我们要添加东西怎么办?直接建好一个大的,把添加操作当成修改就行了。
所以建立线段树,上来就要全部建好。

代码

void build(int u, int l, int r) // 初始化
{
    if (l == r) tr[u] = {l, r, ... }; // 如果建立到单个元素,则直接赋值
    else
    {
        int mid = l + r >> 1;
        tr[u] = {l, r}; // 先给这个点赋值l, r, 也可以更多
        build(u << 1, l, mid); // 建立左儿子
        build(u << 1 | 1, mid + 1, r); // 建立右儿子
        pushup(u); // 用儿子更新u点
    }
}

上面出现了pushup后面会写到

利用子节点更新父节点(pushup)

pushup操作相比pushdown(利用父节点更新子节点)更简单些。
用于建树和修改中,且都应当进行pushup操作(有特例)。

这东西随着题目不同,用法不同,但亘古不变的是它是用来更新父节点信息的。

代码

常见的如这样(记录区间最大值)

void pushup(int u)
{
    tr[u].v = max(tr[u << 1].v, tr[u << 1 | 1]. v);   
    // 由子节点的信息,来计算父节点的信息
    // tr[u].l = tr[u << 1].l;
    // tr[u].r = tr[u << 1 | 1].r;
}

常用用法

这里列举一下常用用法

多个pushup

有的时候我们厌烦些tr[u].l等,也为了增加代码的实用性,pushup有时候会写两个。
像这样(下面代码是计算区间最大连续字段和的代码)

void pushup(Node &u, Node &l, Node &r)
{
    u.ssum = max({l.ssum, r.ssum, l.rsum + r.lsum});
    u.l = l.l; 
    u.r = r.r;
    u.sum = l.sum + r.sum;
    u.lsum = max(l.lsum, l.sum + r.lsum);
    u.rsum = max(r.rsum, r.sum + l.rsum);
}

void pushup(int u)
{
    pushup(tr[u], tr[u << 1], tr[u << 1 | 1]);
}

利用C++的重载函数,写两个pushup,方便使用,像在build()中我直接pushup(u),即可
而在其他函数中,我可能要合并两个节点信息,这时候调用第一个pushup即可,可以很好的减少码量和错误率。

利用父节点更新子节点(pushdown)

pushdown又名懒标记。思想非常nb,对于区间修改如果我们每个都修改,那么时间太大了,
这时候可以在一个区间上打上一个标记,表示它的子区间修改状态为,这里以区间加为例。
如果想在[1, 6]内加上d,那么我们可以给满足在这个区间内的节点都加上add的懒标记,并修改sum等参数,注意如果一个节点被当前d标记过了,那么它的子节点不用现在标记,
等到要用子区间时再下传标记即可。这样本来O(n)的算法就优化为了O(logn)

这里说几个常用懒标记的写法

区间加 add

void pushdown(Node &u, int add)
{
	u.sum += (u.r - u.l + 1) * add;
	u.add += add;
}

void pushdown(int u) //可以写两个pushdown,上面的那个还可以用于区间修改,增加代码灵活性
{
	pushdown(tr[u << 1], tr[u].add);
	pushdown(tr[u << 1 | 1], tr[u].add);
	tr[u].add = 0;
}

首先下传完标记后,要清空当前标记。对于区间加,我们设sum为区间和,因为这一个区间都加上了add,一共加了r - l + 1个数,所以sum增加应为(r - l + 1) * add

区间乘

void pushdown(Node &u, int mul)
{
	u.sum = u.sum * mul;
	u.mul = u.mul * mul;
}

void pushdown(int u) 
{
	pushdown(tr[u << 1], tr[u].add);
	pushdown(tr[u << 1 | 1], tr[u].add);
	tr[u].mul = 1;
}

注意一点,区间乘mul初始化和清空都为1。因为是区间乘,直接乘整个区间即可。

对于有多个懒标记的情况要分析下传先后顺序

如区间乘和区间加同时存在时要先遵循先乘后加,这是由分析得来的。
建议这部分自己想想

当懒加(add)和懒乘(mul)同时存在, 我们先计算懒乘后懒加
因为 sum * mul + add == sma // sma == 's'um * 'm'ul + 'a'dd
sma + c = sum * mul + (add + c); 
sma * k = sum * mul *k + add * k;
mul * k -> mul', add * k -> add' 
sma = sum * mul' + add'
我们发现是可行的
如果sam == (sum + add) * mul 
sam + c == (sum + add) * mul + c
这时候我们发现没法记录先乘还是先加没法化成 sam形式
所以使用sma形式

修改(modify)

你做题可能会遇到区间修改单点修改两种情况,but,单点修改不就是区间修改的特殊情况吗?所以记住一个区间修改即可。不必写两个函数,实际上,大部分情况,我们写一个修改函数就可以完成任何类型的修改操作。

注意,modify时pushdown和pushup应当都进行

代码大概长这样

void modify(int u, int l, int r, int add) // 这里的l, r指修改区间, add为添加的数
{
	if (l <= tr[u].l && tr[u].r <= r) pushdown(tr[u], add); // 修改tr[u]值
	else        // 实际上这里的pushdown是前面的重载的写法是为了减少码量增加代码灵活性
	{
		pushdown(u);
		int mid = tr[u].l + tr[u].r >> 1;
		if (l <= mid) modify(u << 1, l, r, add); // 如果左区间有,我就去修改左区间
		if (r > mid) modify(u << 1 | 1, l, r, add); // 同理搞右区间
		pushup(u);
	}
}

注意这里面的l, r和build函数里的l, r含有不一样,是目标区间。
一般线段树的修改函数的结构都是一样的,只不过是pushdown和pushup的不同罢了

modify中pushdown的原因
如果不下传那么当modify时会用子节点来更新父节点的值(pushup),如此,因为懒标记没下传,子节点的值不是真实值,因此更新的父节点的值就会错误,导致修改的错误

常用技巧

这是一个奇技淫巧
有时候很会遇到仅区间加的情况,这时候可以不用懒标记,通过套一个差分数组,就可以实现区间修改,当然查询的时间复杂度也会增加,但对于某些,如区间最值等,这东西非常好用了

查询(query)

查询操作,线段树有且仅有的使用方式,你可以区间查询也可以单点,怎么玩都行

关于返回值,还是比较建议直接返回一个节点,这样你一个询问函数,就可以利用这个节点查出所有你想要的东西。

一般长这样

Node query(int u, int l, int r)
{
	if (l <= tr[u].l && tr[u].r <= r) return tr[u];
	else
	{
		pushdown(u); // 注意查询的时候是需要pushdown的原因写在下方
		int mid = tr[u].l + tr[u].r >> 1;
		if (r <= mid) return query(u << 1, l, r);
		else if (l > mid) retrun query(u << 1 | 1, l, r);
		else
		{
			Node ls = query(u << 1, l, r); 
			Node rs = query(u << 1 | 1, l, r); 
			Node res;
			pushup(res, ls, rs); // 合并节点
			return res;
		} 
	}
}

query中pushdown的原因
如果不下传标记,那么子节点的值就不是真实值,查出来的也就不是正确答案,因此要pushdown

更多技巧

比如用线段树建图?
Legacy - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
这题虽然是紫色,但是就是一个线段树建图的模版

posted @ 2024-04-30 10:08  blind5883  阅读(4)  评论(0编辑  收藏  举报