线段树学习笔记

待补:扫描线,可持久化,树套树,李超线段树,线段树分治,势能线段树,树链剖分。

线段树学习笔记

线段树(Segment Tree)是一种基于分治思想的二叉树,能够高效的维护一些区间信息。与树状数组相比,线段树显得更加通用。

线段树是一棵完全二叉树。且根节点是唯一的,代表的区间是整个维护的范围,如 \([1, n]\)。线段树的每一个节点都代表一个区间。对于一个编号为 \(x\) 节点,所代表的区间为 \([l, r]\),并令 \(mid = \lfloor\dfrac{l + r}{2}\rfloor\)。它的左儿子和右儿子的编号分别为 \(2x\)\(2x + 1\),维护的区间为 \([l, mid]\)\([mid + 1, r]\)。线段树的每一个叶节点都维护的是一个元区间,如 \([x, x]\)

线段树有以下性质:
线段树的每个节点维护的区间的大小之和的数量级为 \(\mathcal{O}(n\log n)\)
显然,线段树每向下走一层,区间长度都会缩减一半,则树高为 \(\log n\),而每层节点的并集最多为 \([1, n]\)(有可能最后一层不是)。所以长度为 \(n\log n\)

节点的数量级为 \(\mathcal{O}(n)\)
对于每一层,显然节点个数都是上一层的两倍,则共有 \(\sum\limits_{i = 1}^{\log n} 2 ^ i = 2 ^ {\log (n) + 1} - 1 = 2n\) 个节点,但考虑的一些边界问题,可将数组开至 \(4n\),也方便程序的编写。

Tips:线段树用结构体书写程序效率更高,也更好调试。

例题 \(1\)

给定长度为 \(n\) 的数列 \(a\),需要支持两种操作:

  1. 将下标在 \([l, r]\) 的数加上 \(k\)
  2. 查询区间 \([l, r]\) 的和。

数据范围:\(1\le n\le 10^5\)\(|a_i|\le 10 ^ 9\)\(1\le l\le r\le n\)\(|k|\le 10 ^ 9\)

线段树的建树

既然线段树使用递归定义的,那当然也要用递归实现啦。
先处理子节点,然后再将子节点的信息合并到当前节点上。
示例代码:

void pushup(int u) {
	tr[u].v = tr[u << 1].v + tr[u << 1 | 1].v;
}
void build(int u, int l, int r) {
	if (l == r) {
		tr[u].v = a[l];
		return;
	}
	int mid = (l + r) >> 1;
	build(u << 1, l, mid), build(u << 1 | 1, mid + 1, r);
	pushup(u);
}

线段树的单点修改

操作形如 \(C\; pos\; k\),即将下标为 \(pos\) 的数加上 \(k\)
可先在线段树上找到维护的区间为 \([pos, pos]\) 的节点。然后将它到根节点的左右节点维护的信息更新。
由于每个节点维护的区间是确定的,递归修改即可,别忘了更新当前节点的信息。
示例代码:

void upd(int u, int l, int r, int k) {
	if (l == r) {
		tr[u].v += k;
		return;
	}
	int mid = (l + r) >> 1;
	if (k <= mid) upd(u << 1, l, mid, k);
	else upd(u << 1 | 1, mid + 1, r, k);
	pushup(u);
}

线段树的区间查询

操作形如 \(Q\; l\; r\),即查询 \(\sum\limits_{i = l}^{r} a_i\)

先说过程,向下递归找区间,判断一下再向下走,就不会出现无交的情况了。如果是被完全包含就直接返回,否则继续递归。

一种严谨点的方法是分类讨论,但很麻烦。
考虑到每次向下递归时,那一层都只会有两个节点会继续向下递归,而其它节点要么无交,要么被包含。又因为树高为 \(\mathcal{O}(\log n)\),故时间复杂度也为 \(\mathcal{O}(\log n)\)

还有一种更感性的方法,。将区间在 \(\lfloor \dfrac{n}{2} \rfloor\) 左边和右边的部分分别按照二进制分解,那会被分为 \(\mathcal{O}(\log n)\) 个区间。递归时树的深度又是 \(\mathcal{O}(\log n)\),所以时间复杂度也是 \(\mathcal{O}(\log n)\)
示例代码:

ll query(int u, int l, int r, int L, int R) {
	if (L <= l && r <= R) return tr[u].v;
	int mid = (l + r) >> 1;
	ll res = 0;
	if (L <= mid) res += query(u << 1, l, mid, L, R);
	if (mid < R) res += query(u << ! | 1, mid + 1, r, L, R);
	return res;
}

区间修改
虽然支持单点修改的线段树有点用,但用处还是十分局限,所以出现了延迟标记(也称懒标记或 lazy-tag)。
延迟标记的意思就是修改了过后,只要不再次更新或查询,就不会告诉子节点被修改,但是该维护信息是修改时就更新了的。
考虑到延迟标记,每次非完全包含时都需要下传标记,注意有时候需要开 long long。
用 pushdown 函数完成下传:

void pushdown(int u, int l, int r) {
	if (tr[u].tag) {
		int mid = (l + r) >> 1;
		tr[u << 1].tag += tr[u].tag, tr[u << 1 | 1].tag += tr[u].tag;
		tr[u << 1].v += (mid - l + 1) * tr[u].tag;
		tr[u << 1 | 1].v += (r - mid) * tr[u].tag;
	}
}

现在线段树的基本操作已经结束,模板题自己写去吧。

普通线段树的一些应用:

例题 \(2\)

给定长度为 \(n\) 且初始值均为 \(0\) 的数列,有 \(m\) 次操作。
要求支持:

  1. \([l, r]\) 间的数全部取反,即 \(0\) 变为 \(1\)\(1\) 变为 \(0\)
  2. 查询 \([l, r]\) 内所有数的和。

数据范围:\(1\le n\le 10 ^ 5\)\(1\le m\le 10 ^ 5\)

容易发现,该问题的具有“可合并性”,考虑使用线段树维护。
每次修改时,将区间的值变为 \(r - l + 1 - v\) 即可,其中 \(v\) 为原来区间 \(1\) 的个数。

例题 \(3\)

给定长度为 \(n\) 的序列 \(a\),有 \(m\) 次操作。
要求支持:

  1. \([l, r]\) 的最大子段和。
  2. \(a_p\) 修改为 \(k\)

数据范围:\(1\le n\le 5\times 10 ^ 4\)\(1\le l\le r\le n\)\(1\le m\le 5\times 10 ^ 4\)\(|a_i|, |k| \le 10 ^ 4\),时限 330ms。

\([L, R]\) 的最大子段和指 \(\max\limits_{L\le l\le r\le R}^{} {\sum\limits_{i = l}^{r} a_i}\)
发现最大子段和具有可合并性,容易想到线段树。
每个节点维护的信息除了该区间的答案 \(mx\),还需要包含区间左端点的最大子段和 \(mxl\) 和包含区间右端点的最大子段和 \(mxr\) 以及区间内所有数的和 \(sum\),因为需要这些信息才能将子节点的信息合并到父节点。

每个信息的合并如下:

\[t[u].sum = t[2u].sum + t[2u + 1].sum \]

\[t[u].mxl = \max(t[2u].mxl, t[2u].sum + t[2u + 1].mxl) \]

\[t[u].mxr = \max(t[2u + 1].mxr, t[2u + 1].sum + t[2u].mxr) \]

\[t[u].mx = \max(t[2u].mx, t[2u + 1].mx, t[2u].mxr + t[2u + 1].mxl) \]

修改的过程相似,不加赘述。

例题 \(4\)

给定长度为 \(n\) 的序列 \(a\),有 \(m\) 次操作,要求支持:

  1. 将区间 \([l, r]\) 的数开平方根后向下取整。
  2. 查询下标在 \([l, r]\) 的数的和。

数据范围:\(1\le n, m\le 10 ^ 5\)\(0 < a_i \le 10 ^ {12},1\le l\le r\le n\)

由于我太菜了,所以不讲解势能分析法。
发现不好对区间修改的延迟标记进行操作,但发现一个数最多进行 \(\mathcal{O}(\log n)\) 次操作就会变为 \(1\),具体证明放在后面。
然后就很美妙了,对于每个修改直接向下暴力递归即可,当区间的最大值为 \(1\) 时,可以直接返回。
时间复杂度:\(\mathcal{O}(n\log n\log\log w)\),其中 \(w\)\(\max\limits_{i = 1}^{n} a_i\)

证明:将数 \(x\) 进行质因数分解得到 \(x = p_1 ^ {c_1}\times p_2 ^ {c_2}\times p_3 ^ {c_3}\times \dots\times p_n ^ {c_n}\),发现每次开方,对于任意 \(i\in [1, n]\)\(c_i\) 都会变为 \(\lfloor\frac{c_i}{2}\rfloor\)。那么进行 \(\log (max\{c_i\})\) 次操作后 \(x\) 就会变为 \(1\)。而 \(\max\{c_i\}\) 最多为 \(\log x\),即当 \(x\)\(2\) 的幂次时有最大值。
证毕。

例题 \(5\)

给定长度为 \(n\) 的数列,和模数 \(p\),有 \(m\) 次操作:

  1. 将区间内的每个数加上 \(k\)
  2. 将区间内每个数乘上 \(k\)
  3. 求序列的区间和对 \(p\) 取模的答案。

数据范围:\(1\le n, m\le 10 ^ 5\)\(p = 571373\)

一道简单的题,维护多个标记即可,注意标记下传的顺序。

权值线段树:
即维护值域的线段树。
例题 \(6\)

给定长度为 \(n\) 的数列 \(a\)
求出该 \(a\) 中逆序对的数量。
其中逆序对指满足 \(i < j\)\(a_i > a_j\) 的二元组 \((i, j)\) 的数量。

数据范围:\(1\le n\le 10 ^ 5\)\(|a_i|\le 10 ^ 9\)

既然以值域作为维护区间。肯定要先离散化。
从前往后扫描 \(a\),建立一棵权值线段树(权值树状数组其实也可以)。令当前扫描到的是 \(a_i\),查询树中的小于 \(a_i\) 的数的数量,然后再将 \(a_i\) 插进线段树里面就行了。

动态开点线段树:
比如值域为 \([1, 10 ^ 9]\),每次插入一个数或查询小于这个数的元素个数,强制在线(不能用平衡树)。
如果直接建一棵权值线段树,空间直接起飞。但是发现有很多的节点都是没用的,于是就产生了动态开点线段树。即根据每次所需建立新的节点,只是不满足完全二叉树的编码方式了而已,记录一下左儿子和右儿子的编号即可。
有了动态开点线段树,就能使线段树进行可持久化操作了。

可持久化线段树:

例题 \(7\)

给定长度为 \(n\) 的数列 \(a\),有 \(m\) 次操作,要求支持:
查询区间 \([l, r]\) 的第 \(k\) 小值。

数据范围:\(1\le n, m\le 2\times 10 ^ 5\)\(1\le l\le r\le n\)\(|a_i|\le 10 ^ 9\)\(1\le k\le r - l + 1\)

考虑一个很暴力的想法?对于每一个点都建立一棵前缀的权值线段树,对于操作可以在两棵树上同时递归,因为将两棵树相减就得到了当前区间所构成的权值线段树。但显然,空间无法承受。发现相邻的两颗线段树只多有 \(\log n\) 个节点维护的信息不同,就在原树基础上额外建 \(\log n\) 个节点。

具体地,对于点 \(u\) 的左子节点会改变,\(u\) 所对应的新节点的编号为 \(newnode\),则 \(tr[newnode].rson = tr[u].rson\),先将 \(u\) 的左子节点复制给 \(newnode\) 的左子节点(因为原来的信息是不会变的,所以要先复制,才能进行更改,毕竟直接在原树上修改的话也需要这个信息)。
然后实现就很简单了,注意单次修改要增加 \(\log n\)(大约 \(20\) 个节点),但是空间比较宽裕,可以开成 \(N\times 2 ^ 5\),不然有可能会有奇怪问题?

核心代码:

void change(int ltr, int &x, int l, int r, int k, int v) {
	if (!x) x = ++cnt;
	if (l == r) {
		tr[x].v += v;
		return;
	}
	int mid = l + r >> 1;
	if (k <= mid) {
		tr[x].rs = tr[ltr].rs;
		tr[x].ls = ++cnt;
		tr[tr[x].ls] = tr[tr[ltr].ls];
		change(tr[ltr].ls, tr[x].ls, l, mid, k, v);
	} else {
		tr[x].ls = tr[ltr].ls;
		tr[x].rs = ++cnt;
		tr[tr[x].rs] = tr[tr[ltr].rs];
		change(tr[ltr].rs, tr[x].rs, mid + 1, r, k, v); 
	}
	pushup(x);
}
int query(int x, int y, int l, int r, int k) {
	if (l == r) return l;
	int mid = l + r >> 1, tmp = tr[tr[y].ls].v - tr[tr[x].ls].v;
	if (tmp >= k) return query(tr[x].ls, tr[y].ls, l, mid, k);
	return query(tr[x].rs, tr[y].rs, mid + 1, r, k - tmp);
}

另外:可持久化线段树还有很多其他的功能,比如实现可持久化数组,还有可持久化并查集等等。

咕咕咕。

posted @ 2023-05-15 13:35  Pengzt  阅读(35)  评论(0编辑  收藏  举报