线段树学习笔记
待补:扫描线,可持久化,树套树,李超线段树,线段树分治,势能线段树,树链剖分。
线段树学习笔记
线段树(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\),需要支持两种操作:
- 将下标在 \([l, r]\) 的数加上 \(k\)
- 查询区间 \([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\) 次操作。
要求支持:
- 将 \([l, r]\) 间的数全部取反,即 \(0\) 变为 \(1\),\(1\) 变为 \(0\)。
- 查询 \([l, r]\) 内所有数的和。
数据范围:\(1\le n\le 10 ^ 5\),\(1\le m\le 10 ^ 5\)。
容易发现,该问题的具有“可合并性”,考虑使用线段树维护。
每次修改时,将区间的值变为 \(r - l + 1 - v\) 即可,其中 \(v\) 为原来区间 \(1\) 的个数。
例题 \(3\):
给定长度为 \(n\) 的序列 \(a\),有 \(m\) 次操作。
要求支持:
- 求 \([l, r]\) 的最大子段和。
- 将 \(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\),因为需要这些信息才能将子节点的信息合并到父节点。
每个信息的合并如下:
修改的过程相似,不加赘述。
例题 \(4\):
给定长度为 \(n\) 的序列 \(a\),有 \(m\) 次操作,要求支持:
- 将区间 \([l, r]\) 的数开平方根后向下取整。
- 查询下标在 \([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\) 次操作:
- 将区间内的每个数加上 \(k\)。
- 将区间内每个数乘上 \(k\)。
- 求序列的区间和对 \(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);
}
另外:可持久化线段树还有很多其他的功能,比如实现可持久化数组,还有可持久化并查集等等。
咕咕咕。