线段树综合
线段树作为一种扩展性极强、复杂度优秀的数据结构,除了常规用法外还被开发出了很多其他的用途。本文将列举几种扩展用途及其应用。
0 基本操作
0.1 权值线段树
正常的线段树是维护区间上每一个点的值,而权值线段树则是维护每一个数字出现的次数(可以类比为桶)。
例如原本的 \(1-4\) 表示区间 \([1,4]\) 上数字的和(或差、最大值等等),现在就表示数字 \(1-4\) 的出现次数之和。
基本的权值线段树可以实现如下操作:
- 添加一个数(对应单点修改)
- 查找一个数出现的次数(对应单点查询)
- 查找一段区间内数字出现的次数(对应区间查询)
- 寻找第 \(k\) 小(大)元素
前三个操作都很简单,下面着重来看第四个操作。实际上这个操作的实现已经用到了一些线段树二分的思想,如下:
int kth(int p, int l, int r, int k) {
if(t[p].sum < k) return -1;//不足 k 个
if(l == r) {
return l;
}
int mid = (l + r) >> 1, res = t[lp].sum;//左区间元素个数
if(res < k) return kth(rp, mid + 1, r, k - res);//第 k 小元素在右区间
else return kth(lp, l, mid, k);//第 k 小元素在左区间
}
0.2 动态开点线段树
在权值线段树中,我们的值域可能在 \([0,10^9]\),如果在线段树上提前把每一个点都开好,那是必然的 MLE。
于是我们便有了一种新的东西:动态开点。对于每一个线段树上的节点,记录下他的左儿子与右儿子编号。
如此,每一次只需要再使用一个节点时判断该节点是否存在即可,如果不存在就新建节点,同时记录儿子即可。
所以树的结构体定义如下:
struct node{
int l, r, sum;//注意这里的 l,r 不是区间 [l,r] 而是左右儿子编号
}t[Maxn];
1 线段树合并 / 分裂
1.1 概念
顾名思义,线段树合并就是将多颗线段树的信息合并起来,用一颗线段树保存。
常有两种方式实现,一种是新建一颗线段树来存储,另一种是将一颗线段树直接合并到另一个上面去(相当于 c=a+b 和 a+=b),第二种方法则更节省空间,缺点是丢失了一颗线段树的原始信息。
那么采用第二种方法的代码如下,第一种是类似的:
//a 是第一棵树的节点,b 是第二棵树的节点
int merge(int a, int b, int l, int r) {
if(!a) return b;
if(!b) return a;//如果有一颗线段树该位置是空的,那就返回另一个节点,然后用动态开点存储左右儿子
if(l == r) {//叶子节点
t[a].sum += t[b].sum;//合并
return a;
}
int mid = (l + r) >> 1;
t[a].l = merge(t[a].l, t[b].l, l, mid);
t[a].r = merge(t[a].r, t[b].r, mid + 1, r);//动态开点
update(a);
return a;
}
线段树分裂一般是在权值线段树上进行的,它的操作是将权值线段树前 \(k\) 小的保留,而将剩下的树分裂出去成为一颗新的线段树。所以分裂出去的两个线段树再合并起来就是原树。
现在考虑怎样实现,实际上可以参考 \(\text{FHQ-Treap}\) 的分裂方式,我们设函数 \(\text{split}(x,y,k)\) 表示当前遍历到原树上 \(x\) 节点,另一颗分裂出去的线段树节点为 \(y\)。那么此时我们去关注 \(x\) 的左儿子的权值 \(v\),作如下分类讨论:
- \(v<k\) 时,左边不会被分裂出去,递归到右儿子,同时 \(k\) 变成 \(k-v\)。
- \(v=k\) 时,此时左子树正好保留了前 \(k\) 小的值,所以直接把右子树归给 \(y\) 即可。
- \(v>k\) 时,此时右子树仍然全部归给 \(y\),然后继续递归左子树求解。
代码如下:
void split(int x, int &y, int k) {
if(!x) return ;
y = newnode();//建新节点
int val = t[t[x].l].sum;
if(val < k) split(t[x].r, t[y].r, k - val);//递归右子树
else swap(t[x].r, t[y].r);//右子树全部归给 y
if(val > k) split(t[x].l, t[y].l, k);//递归左子树
t[y].sum = t[x].sum - k;//更新权值
t[x].sum = k;
}
1.2 例题
例 1 【模板】线段树分裂
考虑每一个操作怎样完成:
- 对于操作 \(1\),我们做两次分裂,然后将两端区间合并即可。由于我们的代码是按排名分裂而不是按值分裂,所以要先求出区间内有多少个数然后在分裂。
- 对于操作 \(2\),直接线段树合并即可。
- 对于操作 \(3,4,5\),朴素的线段树操作即可。
值得注意的是由于我们在合并的时候会删除节点,会导致我们出现一些无用的空节点。所以可以利用一个垃圾桶把它们存起来,分配新节点时优先用垃圾桶内的节点,可以优化空间复杂度。时间复杂度 \(O(n\log n)\)。
代码如下:
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int Maxn = 2e5 + 5;
const int Inf = 2e9;
int n, m;
int cnt, rt[Maxn];
namespace Sgt {
struct node {
int l, r, sum;
}t[Maxn * 20];
#define lp t[p].l
#define rp t[p].r
int tot = 0;
int trs[Maxn * 20], top;
int newnode() {
return top ? trs[top--] : ++tot;
}
void del(int x) {
trs[++top] = x;
t[x].l = t[x].r = t[x].sum = 0;
}
void pushup(int p) {
t[p].sum = t[lp].sum + t[rp].sum;
}
void mdf(int &p, int l, int r, int x, int val) {
if(!p) p = newnode();
if(l == r) {
t[p].sum += val;
return ;
}
int mid = (l + r) >> 1;
if(x <= mid) mdf(lp, l, mid, x, val);
else mdf(rp, mid + 1, r, x, val);
pushup(p);
}
int query(int p, int l, int r, int pl, int pr) {
if(!p || l > r) return 0;
if(pl <= l && r <= pr) {
return t[p].sum;
}
int mid = (l + r) >> 1, res = 0;
if(pl <= mid) res += query(lp, l, mid, pl, pr);
if(pr > mid) res += query(rp, mid + 1, r, pl, pr);
return res;
}
int kth(int p, int l, int r, int k) {
if(!p) return -1;
if(l == r) return l;
int mid = (l + r) >> 1, val = t[lp].sum;
if(val < k) return kth(rp, mid + 1, r, k - val);
else return kth(lp, l, mid, k);
}
int merge(int x, int y, int l, int r) {
if(!x || !y) {
return x + y;
}
if(l == r) {
t[x].sum += t[y].sum;
del(y);
return x;
}
int mid = (l + r) >> 1;
t[x].l = merge(t[x].l, t[y].l, l, mid);
t[x].r = merge(t[x].r, t[y].r, mid + 1, r);
del(y);
pushup(x);
return x;
}
void split(int x, int &y, int k) {
if(!x) return ;
y = newnode();
int val = t[t[x].l].sum;
if(val < k) split(t[x].r, t[y].r, k - val);
else swap(t[x].r, t[y].r);
if(val > k) split(t[x].l, t[y].l, k);
t[y].sum = t[x].sum - k;
t[x].sum = k;
}
}
signed main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> n >> m;
cnt = 1;
for(int i = 1; i <= n; i++) {
int x; cin >> x;
Sgt::mdf(rt[cnt], 1, n, i, x);
}
while(m--) {
int opt, p, x, y;
cin >> opt;
switch(opt) {
case 0: {
cin >> p >> x >> y;
int res1 = Sgt::query(rt[p], 1, n, 1, y), res2 = Sgt::query(rt[p], 1, n, 1, x - 1);
int ret = 0;
Sgt::split(rt[p], ret, res1);
Sgt::split(rt[p], rt[++cnt], res2);
rt[p] = Sgt::merge(rt[p], ret, 1, n);
break;
}
case 1: {
cin >> x >> y;
rt[x] = Sgt::merge(rt[x], rt[y], 1, n);
break;
}
case 2: {
cin >> p >> x >> y;
Sgt::mdf(rt[p], 1, n, y, x);
break;
}
case 3: {
cin >> p >> x >> y;
cout << Sgt::query(rt[p], 1, n, x, y) << '\n';
break;
}
case 4: {
cin >> p >> x;
cout << Sgt::kth(rt[p], 1, n, x) << '\n';
break;
}
}
}
return 0;
}
例 2 [HEOI2016 / TJOI2016] 排序
发现这个题只有排序操作而没有任何修改操作,所以会想到每一次排序后会形成一段递增 / 递减的连续段,而题目最后的目标就是维护出每个连续段内的数字有谁。
那么直接考虑颜色段均摊,用珂朵莉树维护出递增 / 递减的连续段,用权值线段树维护每个连续段内出现的数字。在珂朵莉树需要分裂的时候就同时进行线段树分裂,合并的时候同时进行线段树合并即可。注意分类讨论递增和递减的情况。
容易发现,每一次操作最多会分裂两次,所以分裂次数最多 \(O(m)\) 次;而最开始只有 \(n\) 个小区间,全部合并起来的复杂度也是 \(O(n)\) 级别的,所以总复杂度 \(O((n+m)\log n)\)。
2 线段树优化建图
2.1 概念
线段树优化建图实际上就是利用线段树维护的区间信息,来达到减少连边数量的目的。例如最典型的就是题目给出的边的形式是一个点 \(x\) 连向一段区间 \([l,r]\),或者是一个区间 \([l,r]\) 连向一个点 \(x\)。显然此时暴力连边空间、时间复杂度均会达到 \(O(n^2)\),难以通过。
考虑线段树怎样优化上述过程。我们以 \(x\to [l,r]\) 举例说明。按照普通的连边方式,我们应该连 \(x\to i(i\in [l,r])\)。考虑在线段树上从父亲向左右儿子连边,这样我们只需要将 \(x\) 连向线段树上表示 \([l,r]\) 的几个区间,然后再由这几个区间连向 \(i\) 即可。表示出来就是 \(x\to[l,r]\to i(i\in[l,r])\)。前面的连边复杂度是 \(O(\log n)\) 的,后者就是线段树的预处理。所以总复杂度为 \(O(n\log n)\)。
\([l,r]\to x\) 的连边是类似的,只不过要将线段树上的连边方向反过来。而对于两者都有的题,建两颗线段树即可。
2.2 例题
例 1 [CF786B] Legacy
这道题就是上面说过的两种情况都有的题,在两颗线段树上连边然后跑最短路即可。具体的,我们称从父亲连向儿子的线段树为出树,儿子连向父亲的线段树为入树,则对于题目给定的边,应该从入树上的节点向出树上的连。
当然了,对于入树上的叶子节点,它还有一个额外的入度来源,就是它在出树上对应的节点。所以我们还要从出树的叶子节点向入树上对应的叶子节点连边。跑的时候从出树上的叶子节点开始跑最短路即可。
代码如下:
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int Maxn = 2e5 + 5;
const int Inf = 1e18;
int n, m, s;
int head[Maxn << 3], edgenum;
struct node {
int nxt, to, w;
}edge[Maxn * 40];
void add(int u, int v, int w) {
edge[++edgenum] = {head[u], v, w};
head[u] = edgenum;
}
int tot;
int idx1[Maxn], idx2[Maxn];
namespace Sgt1 {//出树
#define lp (p << 1)
#define rp (p << 1 | 1)
void build(int p, int l, int r) {
tot = max(tot, p);
if(l == r) {
idx1[l] = p;//记录叶子节点编号
return ;
}
int mid = (l + r) >> 1;
build(lp, l, mid), build(rp, mid + 1, r);
add(p, lp, 0), add(p, rp, 0);
}
void mdf(int p, int l, int r, int pl, int pr, int x, int w) {
if(pl <= l && r <= pr) {
add(x, p, w);
return ;
}
int mid = (l + r) >> 1;
if(pl <= mid) mdf(lp, l, mid, pl, pr, x, w);
if(pr > mid) mdf(rp, mid + 1, r, pl, pr, x, w);
}
}
namespace Sgt2 {//入树
void build(int p, int l, int r) {
if(l == r) {
idx2[l] = p + tot;
return ;
}
int mid = (l + r) >> 1;
build(lp, l, mid), build(rp, mid + 1, r);
add(lp + tot, p + tot, 0), add(rp + tot, p + tot, 0);
}
void mdf(int p, int l, int r, int pl, int pr, int x, int w) {
if(pl <= l && r <= pr) {
add(p + tot, x, w);
return ;
}
int mid = (l + r) >> 1;
if(pl <= mid) mdf(lp, l, mid, pl, pr, x, w);
if(pr > mid) mdf(rp, mid + 1, r, pl, pr, x, w);
}
}
int dis[Maxn << 3], vis[Maxn << 3];
#define pii pair<int, int>
#define mk make_pair
priority_queue <pii> q;
void dijkstra(int s) {
for(int i = 1; i <= (tot << 1); i++) dis[i] = Inf, vis[i] = 0;
q.push(mk(0, s));
dis[s] = 0;
while(!q.empty()) {
int x = q.top().second;
q.pop();
for(int i = head[x]; i; i = edge[i].nxt) {
int to = edge[i].to;
if(dis[to] > dis[x] + edge[i].w) {
dis[to] = dis[x] + edge[i].w;
q.push(mk(-dis[to], to));
}
}
}
}
signed main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> n >> m >> s;
Sgt1::build(1, 1, n);
Sgt2::build(1, 1, n);
for(int i = 1; i <= n; i++) {
add(idx1[i], idx2[i], 0);//出树向入树连边
}
for(int i = 1; i <= m; i++) {
int opt, u, v, l, r, w;
cin >> opt;
switch(opt) {
case 1: {
cin >> u >> v >> w;
add(idx2[u], idx1[v], w);
break;
}
case 2: {
cin >> u >> l >> r >> w;
Sgt1::mdf(1, 1, n, l, r, idx2[u], w);
break;
}
case 3: {
cin >> u >> l >> r >> w;
Sgt2::mdf(1, 1, n, l, r, idx1[u], w);
break;
}
}
}
dijkstra(idx1[s]);
for(int i = 1; i <= n; i++) {
if(dis[idx2[i]] == Inf) cout << "-1 ";
else cout << dis[idx2[i]] << " ";
}
return 0;
}
例 2 [SNOI2017] 炸弹
典中典。我们发现每一个炸弹可以引爆的炸弹是一段连续区间,所以考虑从每一个炸弹向它的爆炸区间连边,显然这个可以用线段树优化建图来优化。
然后我们现在的目标就是在图上进行一个计数,但是发现此时的图上会有环,所以先跑一遍 \(\text{Tarjan}\) 缩点,将整张图转化为一张 \(\text{DAG}\) 后再进行拓扑排序计数。但是此时发现还有一个问题,如果我们记录的是每一个强连通分量内爆炸了多少个炸弹,由于 \(\text{DAG}\) 并不是树,所以可能会有一个点的答案被算两次,这样答案就是错误的。
不过考虑到最终每个点的答案一定对应着一段连续的区间,所以对于每个强联通分量只需要记录它对应的爆炸区间即可,然后拓扑排序的时候更新区间的左右端点,最后计算答案即可。
例 3 [POI2015] PUS
考虑将大小限制转化为连边,然后跑差分约束。发现此题的连边是很多个单点连向很多个区间,首先考虑用线段树优化建图优化单点连区间的过程,但是此时每一个单点仍然需要连较多区间,复杂度仍有 \(O(k^2)\)。考虑到同一操作内每一个单点连向的区间一致,所以可以建立一个虚拟的中转点 \(x\),让 \(k\) 个单点先连向 \(x\),然后再连向线段树上的区间。
接下来跑差分约束即可,当然对于此题来说,只要有环就对应无解,所以在有解的时候直接拓扑排序一遍即可求解。注意判断题目中已经给出了数字的那些位置即可。
3 线段树分治
3.1 概念
线段树分治是一种离线处理带撤销问题的方法,比如最常见的类型就是“某一操作的存在 / 有效时间是 \([l,r]\)”,最后询问某个时间的答案。这种题目的一大特点就是执行操作的做法较为简单,但是直接撤销操作的做法比较困难,这个时候就可以考虑线段树分治。
线段树分治的基本思想就是,对于时间轴建一颗线段树,然后在线段树上的每一个区间,维护在这个区间内有效的所有操作。然后对于询问而言,我们遍历整颗线段树,每经过一个区间就将区间中的操作加入贡献,直到遍历到叶子节点就表明走到了一个询问。然后回溯的时候删除刚刚进行的操作即可。
遍历线段树的复杂度是 \(O(n)\) 的,然后对于每一个操作的添加是 \(O(\log n)\),所以线段树分治的复杂度是 \(O(m\log n)\),非常优秀。
3.2 例题
例 1 【模板】线段树分治
这道题从所有的方面看都很符合上面说到的条件,所以我们直接上线段树分治来维护。现在的问题就是怎样判定二分图,对于这道题,需要一个能够快速插入并查询的方法,我们考虑使用扩展域并查集。
具体的,将每一个点拆成两个点,表示其在左部点 \(S\) 或右部点 \(T\)。连边时,只连 \(u_S-v_T\) 和 \(u_T-v_S\);而判定则只需要判定当前连边的 \(u,v\) 是否在同一个集合内有连边,即看是否存在 \(u_S-v_S\) 或 \(u_T-v_T\) 即可。
然后这个东西还需要支持删除操作,所以我们还得使用可撤销并查集。关于可撤销并查集,详见 杂项 - 可撤销并查集。由于可撤销并查集的时间复杂度还有一个 \(O(\log n)\),所以总的复杂度为 \(O(m\log n\log k)\)。
代码如下:
#include <bits/stdc++.h>
using namespace std;
const int Maxn = 2e5 + 5;
const int Inf = 2e9;
int n, m, k;
namespace Dsu {
int fa[Maxn], siz[Maxn];
void init() {
for(int i = 1; i <= (n << 1); i++) fa[i] = i, siz[i] = 1;
}
int find(int x) {
return fa[x] == x ? x : find(fa[x]);
}
int st[Maxn], top;
void merge(int x, int y) {
x = find(x), y = find(y);
if(x == y) return ;
if(siz[x] > siz[y]) swap(x, y);
fa[x] = y;
siz[y] += siz[x];
st[++top] = x;
}
void del(int tar) {
while(top > tar) {
int x = st[top--];
siz[fa[x]] -= siz[x];
fa[x] = x;
}
}
}
bool ans[Maxn];
namespace Sgt {
#define pii pair<int, int>
#define mk make_pair
vector <pii> t[Maxn << 2];
#define lp (p << 1)
#define rp (p << 1 | 1)
void mdf(int p, int l, int r, int pl, int pr, int u, int v) {
if(pl <= l && r <= pr) {
t[p].push_back(mk(u, v));
return ;
}
int mid = (l + r) >> 1;
if(pl <= mid) mdf(lp, l, mid, pl, pr, u, v);
if(pr > mid) mdf(rp, mid + 1, r, pl, pr, u, v);
}
bool flg = 1;
void dfs(int p, int l, int r) {
bool tmp1 = flg;
int tmp2 = Dsu::top;
for(auto x : t[p]) {
int u = x.first, v = x.second;
if(Dsu::find(u) == Dsu::find(v)) flg = 0;
Dsu::merge(u, v + n);
Dsu::merge(u + n, v);
}
if(l == r) {
ans[l] = flg;
flg = tmp1;
Dsu::del(tmp2);
return ;
}
int mid = (l + r) >> 1;
dfs(lp, l, mid), dfs(rp, mid + 1, r);
flg = tmp1;
Dsu::del(tmp2);
}
}
int main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> n >> m >> k;
for(int i = 1; i <= m; i++) {
int u, v, l, r;
cin >> u >> v >> l >> r;
Sgt::mdf(1, 1, k, l + 1, r, u, v);
}
Dsu::init();
Sgt::dfs(1, 1, k);
for(int i = 1; i <= k; i++) {
cout << (ans[i] ? "Yes\n" : "No\n");
}
return 0;
}
例 2 [CF576E] Painting Edges
发现此题和模板题很像,并且 \(50\) 种颜色也支持暴力维护。唯一区别在于此题必须要合法才能进行操作。
我们考虑这样一个过程,对于每一次修改,我们不直接将他插到线段树上,而是等遍历到对应的叶子之后再考虑当前贡献。假设当前遍历到的叶子是 \(i\),下一次修改 \(e_i\) 的位置是 \(j\)。此时我们已经知道了 \(i\) 操作前所有操作的贡献,然后来判断修改 \(i\) 操作是否合法,如果合法,说明 \((i,j)\) 这一段内 \(e_i\) 的颜色就是 \(c_i\);否则说明在 \((i,j)\) 这一段内 \(e_i\) 的颜色不变。此时我们再将上面说到的区间贡献插入线段树中即可。
不难发现,上面的过程实际上是在一边分治,一边往线段树内插入贡献,而这种技巧被称为 “半在线线段树分治”。
例 3 [CF603E] Pastoral Oddities
首先通过手玩会发现一个比较强的性质:满足条件的图中一定全部是偶数大小的连通块。于是对于全局有如下做法:将所有边按照边权从小到大排序,然后不断加边直到图中全是偶数大小连通块。容易发现的是,加边一定不会更劣,所以这个贪心是正确的。发现这个过程可以轻易用并查集维护出来。
考虑原题目怎么求解。我们会发现一个问题,由于整个的边集在不断增加,答案一定是单调不增的。也就是说,对于任意一条边,它存在于最优边集内的时间是连续的一段。这个时候我们就可以通过上面的性质来进行求解了。
我们考虑仿照上一例中 ”半在线线段树分治“ 的思想。具体的,我们从右往左遍历叶子节点,当我们走到一个叶子节点 \(i\) 后,如果该节点没有满足要求,则开始从小到大添加在它之前出现的,边权大于当前答案的边,直到满足要求为止。此时,刚刚添加进来的边的存活时间就是它出现的位置一直到 \(i\),将这一部分贡献添加到线段树上即可。
4 线段树二分
4.1 概念
顾名思义,由于线段树本身就是一种分治的数据结构,这使得我们可以在线段树上直接二分求解答案。具体来讲,朴素的二分 + 线段树的复杂度应该是 \(O(\log ^2n)\) 的,但是直接在线段树上二分可以做到 \(O(\log n)\)。
线段树二分大体上可以分为两种:全局二分和区间二分。
4.1.1 全局二分
实际上全局二分并不难,因为本质上权值线段树中找第 \(k\) 小就是一种线段树二分。我们当前在线段树上的区间就是当前的二分区间,取中点 \(mid\) 的值然后进行判断,看是递归到左子树还是右子树。值得注意的是,对于某些题目(例如二分的是前缀和),我们递归到右子树的时候需要给前缀信息加上整个左子树的贡献才能继续二分。
4.1.2 区间二分
考虑一个朴素的思路,我们先将查询区间 \([l,r]\) 拆成线段树上的几个区间 \([l_i,r_i]\),然后遍历 \(i\),看答案落在了哪一个小区间内,就在这个区间里做全局二分即可。不过显然这样太过麻烦了,我们考虑将拆分区间和二分结合起来。
我们拆分区间的时候一定是按顺序遍历了 \([l_i,r_i]\) 的,当我们到达一个区间之后,直接在这个区间二分。此时有可能答案并不在这个区间中,那就给前缀信息加上整个区间的贡献然后返回即可。
4.2 例题
例 1 [PA2015] Siano
容易发现所有的操作都可以在线段树上进行,唯一的难点在于每一次要将高度 \(\ge b_i\) 的割掉,而这样的点在区间中分布是散乱的。考虑将 \(a_i\) 排序,则每次生长之后高度一定单调不降。此时高度 \(\ge b_i\) 的就是一段连续后缀,区间覆盖即可。然后一直重复下去,不难发现,整个过程中的高度都是单调不降的。
考虑怎样维护该信息。通过上面分析不难看出,每一株草的高度可以表示为 \(k\times a_i+b\) 的形式,而我们的操作只有区间对 \(k\) 加上某一个值,或者区间覆盖 \(b\) 为某一个值(当然同时也要覆盖 \(k\) 为 \(0\),不过这个可以和 \(b\) 的覆盖一起做),直接维护懒标记即可。
对于找出连续后缀,直接线段树二分出最后一个高度 \(< b_i\) 的位置即可,所以还要维护区间高度最大值(也就是右端点草的高度)。最后再维护一个区间总和即可。
5 树套树
5.1 概念
这个概念在前面已经见过多次了,现在我们再来回顾一下。
关于线段树的树套树常用的一般有两种,即线段树套平衡树和树状数组套权值线段树。
还是以经典例题来说明:【模板】树套树
5.1.1 线段树套平衡树
我们在线段树上的每一个节点内维护一棵平衡树,存储该区间内所有的数字。对于操作 \(1,4,5\),它们是具有可合并性的,所以在线段树上拆分区间后用平衡树求出对应答案再累加即可。而对于操作 \(2\),它不可合并,所以我们只能二分答案,然后利用操作 \(1\) 判断转移方向来求解。显然这样做的复杂度是 \(O(\log ^3 n)\) 的。
具体的过程和代码详见 平衡树 - 树套树。
5.1.2 树状数组套权值线段树
我们知道静态主席树相当于维护了一个线段树的前缀和信息。既然和前缀和有关,我们就可以利用另一种维护前缀和的数据结构来维护这个线段树的前缀和。所以就有了树状数组套权值线段树。具体的,树状数组上每一个节点对应一棵权值线段树,存储对应区间内的数字信息。
- 对于操作 \(1,3\),遍历树状数组并对对应线段树进行操作即可。
- 对于操作 \(2\),显然需要做线段树二分,但是此时需要将所有有用的 \(O(\log n)\) 个节点提出来,然后作差求出对应值。
- 对于操作 \(4,5\),我们可以利用上面已经实现的操作 \(1,2\) 来完成。具体的,对于求 \(x\) 的前驱,我们求出 \(x\) 的排名后减一后再查值即为答案;对于求 \(x\) 的后继,求出 \(x+1\) 的排名后再求值即为答案。
显然上述所有操作的复杂度都是 \(O(\log^2 n)\),因此这种做法实际上在理论复杂度上是比线段树套平衡树优的,事实上也的确如此,而且码量也比线段树套平衡树小一些。
代码如下:
#include <bits/stdc++.h>
using namespace std;
const int Maxn = 5e4 + 5;
const int Inf = 2e9;
const int L = -1e8;
const int R = 1e8;
int n, m, a[Maxn];
int rt[Maxn];
namespace Sgt {
struct node {
int l, r, sum;
}t[Maxn * 460];
#define lp t[p].l
#define rp t[p].r
int tot = 0;
void pushup(int p) {
t[p].sum = t[lp].sum + t[rp].sum;
}
void mdf(int &p, int l, int r, int x, int val) {
if(!p) p = ++tot;
if(l == r) {
t[p].sum += val;
return ;
}
int mid = (l + r) >> 1;
if(x <= mid) mdf(lp, l, mid, x, val);
else mdf(rp, mid + 1, r, x, val);
pushup(p);
}
int rnk(int p, int l, int r, int pl, int pr) {
if(!p || pl > pr) return 0;
if(pl <= l && r <= pr) {
return t[p].sum;
}
int mid = (l + r) >> 1, res = 0;
if(pl <= mid) res += rnk(lp, l, mid, pl, pr);
if(pr > mid) res += rnk(rp, mid + 1, r, pl, pr);
return res;
}
int kth(int p[], int q[], int n1, int n2, int l, int r, int k) {
if(l == r) return l;
int mid = (l + r) >> 1, res = 0;
for(int i = 1; i <= n2; i++) res += t[t[q[i]].l].sum;
for(int i = 1; i <= n1; i++) res -= t[t[p[i]].l].sum;
if(res < k) {
for(int i = 1; i <= n1; i++) p[i] = t[p[i]].r;
for(int i = 1; i <= n2; i++) q[i] = t[q[i]].r;
return kth(p, q, n1, n2, mid + 1, r, k - res);
}
else {
for(int i = 1; i <= n1; i++) p[i] = t[p[i]].l;
for(int i = 1; i <= n2; i++) q[i] = t[q[i]].l;
return kth(p, q, n1, n2, l, mid, k);
}
}
}
namespace BIT {
int lowbit(int x) {
return x & (-x);
}
void mdf(int x, int v, int w) {
for(int i = x; i <= n; i += lowbit(i)) {
Sgt::mdf(rt[i], L, R, v, w);
}
}
int rnk(int l, int r, int k) {
int sum = 0;
for(int i = r; i; i -= lowbit(i)) {
sum += Sgt::rnk(rt[i], L, R, L, k - 1);
}
for(int i = l - 1; i; i -= lowbit(i)) {
sum -= Sgt::rnk(rt[i], L, R, L, k - 1);
}
return sum + 1;
}
int p[Maxn], q[Maxn];
int kth(int l, int r, int k) {
int n1 = 0, n2 = 0;
for(int i = r; i; i -= lowbit(i)) q[++n2] = rt[i];
for(int i = l - 1; i; i -= lowbit(i)) p[++n1] = rt[i];
return Sgt::kth(p, q, n1, n2, L, R, k);
}
int pre(int l, int r, int k) {
int res = rnk(l, r, k) - 1;
if(res == 0) return -2147483647;
else return kth(l, r, res);
}
int nxt(int l, int r, int k) {
int res = rnk(l, r, k + 1);
if(res == r - l + 2) return 2147483647;
else return kth(l, r, res);
}
}
int main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> n >> m;
for(int i = 1; i <= n; i++) cin >> a[i];
for(int i = 1; i <= n; i++) BIT::mdf(i, a[i], 1);
while(m--) {
int opt;
cin >> opt;
switch(opt) {
case 1: {
int l, r, k;
cin >> l >> r >> k;
cout << BIT::rnk(l, r, k) << '\n';
break;
}
case 2: {
int l, r, k;
cin >> l >> r >> k;
cout << BIT::kth(l, r, k) << '\n';
break;
}
case 3: {
int x, k;
cin >> x >> k;
BIT::mdf(x, a[x], -1);
a[x] = k;
BIT::mdf(x, a[x], 1);
break;
}
case 4: {
int l, r, k;
cin >> l >> r >> k;
cout << BIT::pre(l, r, k) << '\n';
break;
}
case 5: {
int l, r, k;
cin >> l >> r >> k;
cout << BIT::nxt(l, r, k) << '\n';
break;
}
}
}
return 0;
}
6 吉司机线段树
吉司机线段树(segment tree beats),是一种用于维护区间最值 / 区间历史最值的数据结构。
6.1 区间最值
区间最值操作指的是,对于一个区间 \([l,r]\),将所有的 \(a_i(i\in [l,r])\) 和 \(x\) 取 \(\max\) 或 \(\min\)。也就是 \(\forall i\in[l,r],a_i\leftarrow\max(a_i,x)\) 或 \(\forall i\in[l,r],a_i\leftarrow\min(a_i,x)\)。
区间最值操作具体还可以分为不带区间加减和带区间加减两种类型。
6.1.1 不带区间加减
我们发现题目中操作有三种:区间取 \(\min\),区间求 \(\max\),区间求和。
由于我们要对区间取 \(\min\),所以实际上我们只会对大于 \(x\) 的数进行修改,别的不用管。所以我们会有如下思路:在线段树的每个节点上维护该区间的最大值 \(mx\),严格次大值 \(se\),最大值出现次数 \(cnt\) 以及区间和 \(sum\)。接下来考虑怎样完成区间取 \(\min\) 操作:
- 如果 \(mx\le x\),则不必对该区间进行操作。
- 如果 \(se<x< mx\),则我们需要将区间中的最大值改为 \(x\),同时区间和加上 \(cnt\times(x-mx)\)。最后我们还要给整个区间打上一个修改的标记,也就是标记当前最大值修改为 \(x\)。
- 如果 \(x\le se\),此时我们不能确定哪些点要进行修改,索性直接不管,递归到子节点继续判断是否满足上面两个条件即可。
看上去这个算法很暴力,但是根据势能分析,该算法的时间复杂度是 \(O(m\log n)\) 的。
核心代码如下:
struct node {
int mx, se, cnt, tag, sum;
}t[Maxn << 2];
#define lp (p << 1)
#define rp (p << 1 | 1)
void pushup(int p) {
t[p].sum = t[lp].sum + t[rp].sum;
if(t[lp].mx > t[rp].mx) {
t[p].mx = t[lp].mx, t[p].cnt = t[lp].cnt;
t[p].se = max(t[lp].se, t[rp].mx);
}
else if(t[lp].mx < t[rp].mx) {
t[p].mx = t[rp].mx, t[p].cnt = t[rp].cnt;
t[p].se = max(t[rp].se, t[lp].mx);
}
else if(t[lp].mx == t[rp].mx) {
t[p].mx = t[lp].mx, t[p].cnt = t[lp].cnt + t[rp].cnt;
t[p].se = max(t[lp].se, t[rp].se);
}
}
void build(int p, int l, int r) {
t[p].tag = -1;
if(l == r) {
t[p].mx = t[p].sum = a[l];
t[p].se = -1;
t[p].cnt = 1;
return ;
}
int mid = (l + r) >> 1;
build(lp, l, mid), build(rp, mid + 1, r);
pushup(p);
}
void pushtag(int p, int v) {
if(t[p].mx <= v) return ;
t[p].sum += t[p].cnt * (v - t[p].mx);
t[p].mx = t[p].tag = v;
}
void pushdown(int p) {
if(t[p].tag != -1) {
pushtag(lp, t[p].tag), pushtag(rp, t[p].tag);
t[p].tag = -1;
}
}
void mdf(int p, int l, int r, int pl, int pr, int v) {
if(t[p].mx <= v) return ;
if(pl <= l && r <= pr && t[p].se < v) {
pushtag(p, v);
return ;
}
pushdown(p);
int mid = (l + r) >> 1;
if(pl <= mid) mdf(lp, l, mid, pl, pr, v);
if(pr > mid) mdf(rp, mid + 1, r, pl, pr, v);
pushup(p);
}
6.1.2 带区间加减
首先让我们考虑在上面那道题的基础上,加入区间加操作怎么做。实际上我们有两种办法来解决这个问题:
- 考虑套用上一题的做法,再维护一个区间加标记 \(add\)。这样构成了一个二元组标记 \((add, cov)\),表示先将值加上 \(add\) 再和 \(cov\) 取 \(\min\)。当我们从 \(p\) 下传到 \(sp\) 的时候,懒标记应该变成 \((add_{sp}+add_p,\min(cov_{sp}+add_p,cov_p))\)。
- 我们换一种思路,容易发现上面一道题的本质就是将线段树维护的元素分成了最大值和非最大值两个部分。于是我们维护区间加的标记也可以分成最大值和非最大值两个部分来维护。那么区间取 \(\min\) 的时候我们只修改最大值的加法标记,区间加的时候同时修改两个标记即可。
两者的代码量基本相当,但是后者的思想可以拓展到更为复杂的情况中,我们称之为数域划分。
然后我们来看一道真正的模板题:[BZOJ4695] 最假女选手。
我们使用数域划分的方式将区间最值转化为区间加减,所以我们需要维护的值有区间最大值、严格次大值、最大值个数、区间最小值、严格次小值、最小值个数、区间和,标记有最大值加法标记、最小值加法标记、其它值加法标记。
然后在下传标记的时候需要注意两点:
- 我们需要知道子区间内是否有当前区间内的最大值,如果没有最大值则下传的最大值加法标记应该是当前区间的其它值加法标记。最小值同理。
- 如果一个区间的值域比较小,此时可能会出现一个值又是最大值又是最小值的情况,此时需要特判哪个标记会作用到当前值上。
根据势能分析,该做法的时间复杂度是 \(O(n\log^2 n)\) 的。核心代码如下:
struct node {
int sum, mx, lmx, cmx, mn, lmn, cmn;
//区间和、最大值、严格次大值、最大值个数、区间最小值、严格次小值、最小值个数
int amx, amn, add;
//最大值加法标记、最小值加法标记、其它值加法标记
}t[Maxn << 2];
#define lp (p << 1)
#define rp (p << 1 | 1)
void pushup(int p) {
t[p].sum = t[lp].sum + t[rp].sum;
if(t[lp].mx > t[rp].mx) {
t[p].mx = t[lp].mx, t[p].cmx = t[lp].cmx;
t[p].lmx = max(t[lp].lmx, t[rp].mx);
}
else if(t[lp].mx < t[rp].mx) {
t[p].mx = t[rp].mx, t[p].cmx = t[rp].cmx;
t[p].lmx = max(t[lp].mx, t[rp].lmx);
}
else {
t[p].mx = t[lp].mx, t[p].cmx = t[lp].cmx + t[rp].cmx;
t[p].lmx = max(t[lp].lmx, t[rp].lmx);
}
if(t[lp].mn < t[rp].mn) {
t[p].mn = t[lp].mn, t[p].cmn = t[lp].cmn;
t[p].lmn = min(t[lp].lmn, t[rp].mn);
}
else if(t[lp].mn > t[rp].mn) {
t[p].mn = t[rp].mn, t[p].cmn = t[rp].cmn;
t[p].lmn = min(t[lp].mn, t[rp].lmn);
}
else {
t[p].mn = t[lp].mn, t[p].cmn = t[lp].cmn + t[rp].cmn;
t[p].lmn = min(t[lp].lmn, t[rp].lmn);
}
}
void build(int p, int l, int r) {
if(l == r) {
t[p].sum = t[p].mx = t[p].mn = a[l];
t[p].cmx = t[p].cmn = 1;
t[p].lmx = -Inf, t[p].lmn = Inf;
return ;
}
int mid = (l + r) >> 1;
build(lp, l, mid), build(rp, mid + 1, r);
pushup(p);
}
void pushtag(int p, int l, int r, int amx, int amn, int add) {
if(t[p].mx == t[p].mn) {//如果区间内只有一个数
if(amx == add) amx = amn;//最大值和最小值加法标记应当一致
else amn = amx;
t[p].sum += t[p].cmx * amx;
}
else {
t[p].sum += t[p].cmx * amx + t[p].cmn * amn + (r - l + 1 - t[p].cmx - t[p].cmn) * add;
}
if(t[p].lmx == t[p].mn) t[p].lmx += amn;//次大值是最小值应加上最小值标记
else if(t[p].lmx != -Inf) t[p].lmx += add;
if(t[p].lmn == t[p].mx) t[p].lmn += amx;//次小值是最大值应加上最大值标记
else if(t[p].lmn != Inf) t[p].lmn += add;
t[p].mx += amx, t[p].mn += amn;
t[p].amx += amx, t[p].amn += amn, t[p].add += add;
}
void pushdown(int p, int l, int r) {
int mid = (l + r) >> 1;
int mx = max(t[lp].mx, t[rp].mx);
int mn = min(t[lp].mn, t[rp].mn);
//需要判断左右区间是否有最大 / 最小值
pushtag(lp, l, mid, t[lp].mx == mx ? t[p].amx : t[p].add, t[lp].mn == mn ? t[p].amn : t[p].add, t[p].add);
pushtag(rp, mid + 1, r, t[rp].mx == mx ? t[p].amx : t[p].add, t[rp].mn == mn ? t[p].amn : t[p].add, t[p].add);
t[p].amx = t[p].amn = t[p].add = 0;
}
void mdfmin(int p, int l, int r, int pl, int pr, int v) {
if(t[p].mx <= v) return ;
if(pl <= l && r <= pr && t[p].lmx < v) {
pushtag(p, l, r, v - t[p].mx, 0, 0);
return ;
}
pushdown(p, l, r);
int mid = (l + r) >> 1;
if(pl <= mid) mdfmin(lp, l, mid, pl, pr, v);
if(pr > mid) mdfmin(rp, mid + 1, r, pl, pr, v);
pushup(p);
}
void mdfmax(int p, int l, int r, int pl, int pr, int v) {
if(t[p].mn >= v) return ;
if(pl <= l && r <= pr && t[p].lmn > v) {
pushtag(p, l, r, 0, v - t[p].mn, 0);
return ;
}
pushdown(p, l, r);
int mid = (l + r) >> 1;
if(pl <= mid) mdfmax(lp, l, mid, pl, pr, v);
if(pr > mid) mdfmax(rp, mid + 1, r, pl, pr, v);
pushup(p);
}
void mdfsum(int p, int l, int r, int pl, int pr, int v) {
if(pl <= l && r <= pr) {
pushtag(p, l, r, v, v, v);
return ;
}
pushdown(p, l, r);
int mid = (l + r) >> 1;
if(pl <= mid) mdfsum(lp, l, mid, pl, pr, v);
if(pr > mid) mdfsum(rp, mid + 1, r, pl, pr, v);
pushup(p);
}
6.2 区间历史最值
区间历史最值问题,指的是我们不仅要维护一个原序列 \(a_i\),还要同时维护一个原序列 \(b_i\),在 \(a_i\) 做了一次修改之后,\(b_i\) 会和对应的 \(a_i\) 取 \(\max\) / 取 \(\min\) / 求和,最后询问两个序列的有关信息的一类问题。
我们先来看一道较为基础的题目:CPU 监控。
题目中所给出的操作有区间加、区间覆盖、区间最大值、区间历史最大值。那么我们就要对每一个区间维护两个值 \(a,b\),表示当前值和历史最值。我们实际上可以将要维护的东西看作一个向量 \(\begin{bmatrix}a\\b\end{bmatrix}\),此时我们就可以引入一个东西来完成它的维护了——广义矩阵乘法。
我们知道,朴素的矩阵乘法的形式是 \(C_{i,j}=\sum A_{i,k}B_{k,j}\),实际上这是一种 \((\times,+)\) 两种运算的矩阵乘法。假如我们把运算改成 \((+,\max)\),则形式会变成 \(C_{i,j}=\max(A_{i,k}+B_{k,j})\)。显然这种矩阵乘法依然满足结合律。
而在维护区间最值和区间历史最值的时候,用广义矩阵乘法维护标记是再好不过的选择了。
那么对于区间加,我们的目标是 \(\begin{bmatrix}a\\b\end{bmatrix}\leftarrow \begin{bmatrix}a+k\\\max(b,a+k)\end{bmatrix}\)。那么可以轻易构造出如下广义矩阵乘法:
对于区间覆盖,这个向量似乎难以完成任务。不过我们可以给它加上一维辅助的变量,变成 \(\begin{bmatrix}a\\b\\0\end{bmatrix}\),于是有:
当然你也不能一直拿着一个矩阵在那瞎乘,毕竟 \(3^3\) 的常数还是有的。我们发现,上面所构造出来的矩阵均可以写作 \(\begin{bmatrix}a &-\infty& c\\b&0&d\\-\infty&-\infty&0\end{bmatrix}\) 的形式,我们试着把两个矩阵标记相乘:
发现要维护的始终只有这四个值,所以直接维护即可。需要注意的是这个矩阵的初始形式是 \(\begin{bmatrix}0 &-\infty& -\infty\\-\infty&0&-\infty\\-\infty&-\infty&0\end{bmatrix}\),所以初始化的时候要将标记初始化为这个样子。以及由于标记是左乘的,所以要注意运算时的顺序。
核心代码如下:
struct Tag {
int a, b, c, d;
Tag operator + (const Tag &p) const {
return (Tag){a + p.a, max(b + p.a, p.b), max(a + p.c, c), max({b + p.c, d, p.d})};
}
};
namespace Sgt {
struct node {
int mx, hmx;
Tag tag;
}t[Maxn << 2];
#define lp (p << 1)
#define rp (p << 1 | 1)
void pushup(int p) {
t[p].mx = max(t[lp].mx, t[rp].mx);
t[p].hmx = max(t[lp].hmx, t[rp].hmx);
}
void build(int p, int l, int r) {
t[p].tag = {0, -Inf, -Inf, -Inf};
if(l == r) {
t[p].mx = t[p].hmx = a[l];
return ;
}
int mid = (l + r) >> 1;
build(lp, l, mid), build(rp, mid + 1, r);
pushup(p);
}
void pushtag(int p, Tag tag) {
t[p].tag = tag + t[p].tag;
t[p].hmx = max({tag.b + t[p].mx, t[p].hmx, tag.d});//注意一定要先更新历史最大值
t[p].mx = max(tag.a + t[p].mx, tag.c);
}
void pushdown(int p) {
pushtag(lp, t[p].tag), pushtag(rp, t[p].tag);
t[p].tag = {0, -Inf, -Inf, -Inf};
}
void mdf(int p, int l, int r, int pl, int pr, Tag tag) {
if(pl <= l && r <= pr) {
pushtag(p, tag);
return ;
}
pushdown(p);
int mid = (l + r) >> 1;
if(pl <= mid) mdf(lp, l, mid, pl, pr, tag);
if(pr > mid) mdf(rp, mid + 1, r, pl, pr, tag);
pushup(p);
}
int qmax(int p, int l, int r, int pl, int pr) {
if(pl <= l && r <= pr) {
return t[p].mx;
}
pushdown(p);
int mid = (l + r) >> 1, res = -Inf;
if(pl <= mid) res = max(res, qmax(lp, l, mid, pl, pr));
if(pr > mid) res = max(res, qmax(rp, mid + 1, r, pl, pr));
return res;
}
int qhmax(int p, int l, int r, int pl, int pr) {
if(pl <= l && r <= pr) {
return t[p].hmx;
}
pushdown(p);
int mid = (l + r) >> 1, res = -Inf;
if(pl <= mid) res = max(res, qhmax(lp, l, mid, pl, pr));
if(pr > mid) res = max(res, qhmax(rp, mid + 1, r, pl, pr));
return res;
}
}
上面这道题并没有结合区间最值进行考察,那我们来看一道区间最值 + 区间历史最值的问题:【模板】线段树 3。
实际上有了前面的铺垫以后,这个题并没有这么困难。由于有最值操作,所以直接考虑数域划分。在划分后,由于我们还要维护历史最值,所以对于最大值和非最大值各开一套矩阵标记维护即可。也就是说,整体操作我们依然沿用区间最值的模板,但是标记的维护采用历史最值的方式来维护即可。
当然仍需要注意下传时是否有最大值的问题。核心代码如下:
namespace Sgt {
struct Tag {
int a, b;
Tag operator + (const Tag &p) const {
return (Tag){a + p.a, max(b + p.a, p.b)};
}
};
struct node {
int sum, cmx;//区间和 最大值数量
int mx, hmx;//最大值 最大值的历史最大值
int lmx, hlmx;//非最大值的最大值(次大值) 非最大值的历史最大值
Tag tgmx, tglmx;//最大值标记 非最大值标记
}t[Maxn << 2];
#define lp (p << 1)
#define rp (p << 1 | 1)
void pushup(int p) {
t[p].sum = t[lp].sum + t[rp].sum;
if(t[lp].mx > t[rp].mx) {
t[p].mx = t[lp].mx, t[p].cmx = t[lp].cmx, t[p].hmx = t[lp].hmx;
t[p].lmx = max(t[lp].lmx, t[rp].mx), t[p].hlmx = max({t[lp].hlmx, t[rp].hmx, t[rp].hlmx});
}
else if(t[lp].mx < t[rp].mx) {
t[p].mx = t[rp].mx, t[p].cmx = t[rp].cmx, t[p].hmx = t[rp].hmx;
t[p].lmx = max(t[lp].mx, t[rp].lmx), t[p].hlmx = max({t[lp].hmx, t[lp].hlmx, t[rp].hlmx});
}
else {
t[p].mx = t[lp].mx, t[p].cmx = t[lp].cmx + t[rp].cmx, t[p].hmx = max(t[lp].hmx, t[rp].hmx);
t[p].lmx = max(t[lp].lmx, t[rp].lmx), t[p].hlmx = max(t[lp].hlmx, t[rp].hlmx);
}
}
void build(int p, int l, int r) {
t[p].tgmx = {0, -Inf}, t[p].tglmx = {0, -Inf};
if(l == r) {
t[p].sum = t[p].mx = t[p].hmx = a[l];
t[p].cmx = 1;
t[p].lmx = t[p].hlmx = -Inf;
return ;
}
int mid = (l + r) >> 1;
build(lp, l, mid), build(rp, mid + 1, r);
pushup(p);
}
void pushtag(int p, int l, int r, Tag tgmx, Tag tglmx) {
t[p].sum += t[p].cmx * tgmx.a + (r - l + 1 - t[p].cmx) * tglmx.a;
t[p].hmx = max(t[p].mx + tgmx.b, t[p].hmx), t[p].mx = t[p].mx + tgmx.a;
if(t[p].lmx != -Inf) {
t[p].hlmx = max(t[p].lmx + tglmx.b, t[p].hlmx), t[p].lmx = t[p].lmx + tglmx.a;
}
t[p].tgmx = tgmx + t[p].tgmx, t[p].tglmx = tglmx + t[p].tglmx;
}
void pushdown(int p, int l, int r) {
int mx = max(t[lp].mx, t[rp].mx);
int mid = (l + r) >> 1;
pushtag(lp, l, mid, t[lp].mx == mx ? t[p].tgmx : t[p].tglmx, t[p].tglmx);
pushtag(rp, mid + 1, r, t[rp].mx == mx ? t[p].tgmx : t[p].tglmx, t[p].tglmx);
t[p].tgmx = {0, -Inf}, t[p].tglmx = {0, -Inf};
}
void mdfsum(int p, int l, int r, int pl, int pr, int x) {
if(pl <= l && r <= pr) {
pushtag(p, l, r, (Tag){x, x}, (Tag){x, x});
return ;
}
pushdown(p, l, r);
int mid = (l + r) >> 1;
if(pl <= mid) mdfsum(lp, l, mid, pl, pr, x);
if(pr > mid) mdfsum(rp, mid + 1, r, pl, pr, x);
pushup(p);
}
void mdfmin(int p, int l, int r, int pl, int pr, int x) {
if(t[p].mx <= x) return ;
if(pl <= l && r <= pr && t[p].lmx < x) {
pushtag(p, l, r, (Tag){x - t[p].mx, x - t[p].mx}, (Tag){0, -Inf});
return ;
}
pushdown(p, l, r);
int mid = (l + r) >> 1;
if(pl <= mid) mdfmin(lp, l, mid, pl, pr, x);
if(pr > mid) mdfmin(rp, mid + 1, r, pl, pr, x);
pushup(p);
}
int qmax(int p, int l, int r, int pl, int pr) {
if(pl <= l && r <= pr) {
return t[p].mx;
}
pushdown(p, l, r);
int mid = (l + r) >> 1, res = -Inf;
if(pl <= mid) res = max(res, qmax(lp, l, mid, pl, pr));
if(pr > mid) res = max(res, qmax(rp, mid + 1, r, pl, pr));
return res;
}
int qhmax(int p, int l, int r, int pl, int pr) {
if(pl <= l && r <= pr) {
return max(t[p].hmx, t[p].hlmx);
}
pushdown(p, l, r);
int mid = (l + r) >> 1, res = -Inf;
if(pl <= mid) res = max(res, qhmax(lp, l, mid, pl, pr));
if(pr > mid) res = max(res, qhmax(rp, mid + 1, r, pl, pr));
return res;
}
int qsum(int p, int l, int r, int pl, int pr) {
if(pl <= l && r <= pr) {
return t[p].sum;
}
pushdown(p, l, r);
int mid = (l + r) >> 1, res = 0;
if(pl <= mid) res += qsum(lp, l, mid, pl, pr);
if(pr > mid) res += qsum(rp, mid + 1, r, pl, pr);
return res;
}
}
7 李超线段树
7.1 概念
李超线段树是线段树的一种变种,主要用于维护二维平面上的直线信息以及查询操作。它的应用范围很广,只要式子的形式能转化为一次函数就可以考虑使用李超线段树进行求解 / 优化。
具体的,李超线段树支持下面两种操作:
- 动态在平面中插入一条线段 / 直线。
- 在平面上询问与一条直线 \(x=k\) 相交的线段 / 直线中,交点纵坐标最大点的坐标 / 编号。
7.2 过程
李超线段树的实质是在 \(x\) 轴上对单位长度维护线段树,而在线段树上的每一个节点,维护一个该区间的最优线段,其定义如下:
- 该线段定义域覆盖整个区间。
- 该线段在区间中点处取值最大。
那么考虑怎样插入的时候维护这个最优线段。设当前遍历到区间 \([l,r]\),原区间最优线段为 \(l_1\),当前要插入 \(l_2\)。
- 若当前区间无最优线段,或 \(l_2\) 完全在 \(l_1\) 上方,将 \([l,r]\) 的最优线段改为 \(l_2\)。
- 若 \(l_1\) 完全在 \(l_2\) 上方,则 \([l,r]\) 最优线段不变。
- 否则,比较二者在 \(mid\) 处的大小,然后向下递归:
- 此时考虑上面不是最优线段的线段 \(l\),它仍可能成为接下来子区间的最优线段。我们现在需要知道这条线段 \(l\) 和另一条线段在哪个区间相交,就向哪个区间递归。
- 具体的,我们比较两个线段在两个端点的大小情况,然后以此判断交点位置即可。如果 \(l\) 在左端点处取值更小,又因为 \(l\) 在中点处取值也更小,因此交点必在右区间;否则就应该在左区间。
对于查询,由于我们维护的是每一个区间在 \(mid\) 处取最大值的线段,那么实际上每一个节点维护的信息是类似于没有下传的懒标记的。所以查询时,我们要从查询点对应的叶子节点一路向上走并取最大值。这实际上是标记永久化的思想。
如果插入的是直线,那么修改和查询的复杂度均为 \(O(\log n)\)。如果插入的是线段,那么需要先将线段拆成 \(O(\log n)\) 个区间,对每个区间再进行插入,修改复杂度就是 \(O(\log^2 n)\),查询复杂度不变。
同时值得注意的是,由于李超线段树常常维护的是值域上的信息,因此会需要用动态开点,而李超线段树动态开点的空间复杂度是 \(O(m)\) 的,其中 \(m\) 为线段个数。
7.3 例题
例 1 [HEOI2013] Segment
模板题,代码如下:
#include <bits/stdc++.h>
using namespace std;
const int Maxn = 2e5 + 5;
const int Mod = 1e9;
const double eps = 1e-10;
int n, tot;
struct Seg {
int id;
double k, b;//线段表示为 y=kx+b
double calc(int x) {
return x * k + b;
}
};
namespace Sgt {
Seg t[Maxn << 2];
#define lp (p << 1)
#define rp (p << 1 | 1)
int fcmp(double x, double y) {//浮点数比较精度
if(fabs(x - y) <= eps) return 0;
if(x - y > eps) return 1;
else return -1;
}
void ins(int p, int l, int r, Seg sg) {//在整个区间插入线段
if(!t[p].k && !t[p].b) {//没有线段就直接插入
t[p] = sg;
return ;
}
int mid = (l + r) >> 1;
if(fcmp(t[p].calc(mid), sg.calc(mid)) == -1) swap(t[p], sg);//换最优线段
if((l == r) || (fcmp(t[p].calc(l), sg.calc(l)) == 1 && fcmp(t[p].calc(r), sg.calc(r)) == 1)) {//到叶子节点或没有交点就返回
return ;
}
if(fcmp(t[p].calc(l), sg.calc(l)) == 1) ins(rp, mid + 1, r, sg);//看哪边有交点
else ins(lp, l, mid, sg);
}
void mdf(int p, int l, int r, int pl, int pr, Seg sg) {//找到插入的区间
if(pl <= l && r <= pr) {
ins(p, l, r, sg);
return ;
}
int mid = (l + r) >> 1;
if(pl <= mid) mdf(lp, l, mid, pl, pr, sg);
if(pr > mid) mdf(rp, mid + 1, r, pl, pr, sg);
}
#define pdi pair<double, int>
#define mk make_pair
pdi _max(pdi x, pdi y) {
if(fcmp(x.first, y.first) == 0) return x.second < y.second ? x : y;
if(fcmp(x.first, y.first) == 1) return x;
else return y;
}
pdi query(int p, int l, int r, int x) {
if(l == r) {
return mk(t[p].calc(x), t[p].id);
}
int mid = (l + r) >> 1;
pdi res = mk(t[p].calc(x), t[p].id);
if(x <= mid) return _max(res, query(lp, l, mid, x));//标记永久化思想
else return _max(res, query(rp, mid + 1, r, x));
}
}
int main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> n;
int lst = 0;
while(n--) {
int opt;
cin >> opt;
switch(opt) {
case 0: {
int k;
cin >> k;
k = (k + lst - 1) % 39989 + 1;
pdi res = Sgt::query(1, 1, 39989, k);
lst = res.second;
cout << lst << '\n';
break;
}
case 1: {
++tot;
int x_0, y_0, x_1, y_1;
cin >> x_0 >> y_0 >> x_1 >> y_1;
x_0 = (x_0 + lst - 1) % 39989 + 1, x_1 = (x_1 + lst - 1) % 39989 + 1;
y_0 = (y_0 + lst - 1) % Mod + 1, y_1 = (y_1 + lst - 1) % Mod + 1;
double k, b;
if(x_0 == x_1) {//注意特判 x0=x1 的情况
k = 0, b = max(y_0, y_1);
}
else {
if(x_0 > x_1) swap(x_0, x_1), swap(y_0, y_1);
k = (y_1 - y_0) * 1.0 / (x_1 - x_0);
b = y_0 - x_0 * k;
}
Sgt::mdf(1, 1, 39989, x_0, x_1, (Seg){tot, k, b});
break;
}
}
}
return 0;
}
例 2 [SDOI2016] 游戏
我们发现操作所增加的数字是 \(ax+b\) 的形式,自然想到李超线段树。我们先树链剖分,这样对每一条重链维护其对应的线段即可。
但是此时这个 \(x\) 是和 \(s\) 之间的距离,不能直接用李超线段树维护,考虑将其转化为 \(dis_x\) 的形式。那么做如下分类讨论:
- 当 \(x\) 在 \(s\to lca\) 上时,增加的数字是 \(a(dis_s-dis_x)+b\),即 \(-a\cdot dis_x+(a\cdot dis_s+b)\)。
- 当 \(x\) 在 \(t\to lca\) 上时,增加的数字是 \(a(dis_x+dis_s-2dis_{lca})+b\),即 \(a\cdot dis_x+(a\cdot dis_s-2a\cdot dis_{lca}+b)\)。
如此便可转化为关于 \(dis_x\) 的一次函数,所以我们求值的时候不是用 \(x\) 带入了,而是带入 \(dis_x\)。同时由于是区间查询,所以还要像普通线段树一样维护一个 \(mn\),递归的时候上传标记即可。同时也不要忘记标记永久化。
时间复杂度其实是一个较为惊悚的 \(O(n\log^3 n)\),但是其实常数很小,所以可以通过。
例 3 [CEOI2017] Building Bridges
由于桥梁不能在空中相交,所以选出来的柱子一定是顺次相连。那么直接考虑 dp,设 \(dp(i)\) 表示考虑到第 \(i\) 根柱子时的代价最小值,则有转移方程:
显然后面的和式可用前缀和优化,然后将其写为一次函数斜截式的式子:
这是斜率优化 dp 的标准式,不过遗憾的是,式子中的 \(k_i=h_i\) 不单调,并且 \(x_j=2h_j\) 也不单调,所以无法直接维护出来。考虑利用李超线段树,后面的式子可以看作是一个关于 \(h_i\) 的一次函数,我们将所有直线插入李超线段树,然后查询 \(x=h_i\) 时的最小值即可得出答案。复杂度 \(O(n\log n)\)。
可以看出,李超线段树有时可以替代凸包维护的单调性,直接求出答案。
例 4 [CF932F] Escape Through Leaf
不难想到可以直接树形 dp,设 \(dp(x)\) 表示节点 \(x\) 的答案,则会有转移方程:
显然后面的式子是一个关于 \(a_x\) 的一次函数,可以用李超线段树优化。但是由于我们是在一个子树内找 \(y\),所以子树内的所有直线信息都要保留,我们想到了线段树合并。
那么仿照线段树合并,我们可以得出李超线段树合并的作法如下:
- 若 \(u,v\) 有一个空节点,直接返回不是空节点的那一个。
- 否则,将 \(v\) 中的直线直接插入 \(u\) 中,然后再向下递归合并。
看上去这个做法非常暴力,就是将 \(v\) 中的所有直线往 \(u\) 里插了一次。但是它的整体复杂度实际上是 \(O(n\log n)\) 的。证明如下:
- 考虑每条直线在合并时的操作,如果这条直线有变动,那么只有两种可能:被删除或者深度加一。那么每条直线最多加 \(\log n\) 次深度就会被删除,所以每一条直线的操作次数是 \(O(\log n)\) 的,即总时间复杂度为 \(O(n\log n)\)。
当然由于需要线段树合并,所以李超线段树也需要动态开点,代码如下:
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int Maxn = 2e5 + 5;
const int Inf = 1e18;
int n, a[Maxn], b[Maxn];
int head[Maxn], edgenum;
struct node {
int nxt, to;
}edge[Maxn];
void add(int u, int v) {
edge[++edgenum] = {head[u], v};
head[u] = edgenum;
}
const int M = 1e5;
struct Seg {
int k, b;
int operator() (const int &x) const {
return k * x + b;
}
};
int rt[Maxn];
namespace Sgt {
struct node {
int l, r;
Seg sg;
}t[Maxn * 20];
#define lp t[p].l
#define rp t[p].r
int tot = 0;
void ins(int &p, int l, int r, Seg sg) {
if(!p) {
t[p = ++tot].sg = sg;
return ;
}
int mid = (l + r) >> 1;
if(t[p].sg(mid) > sg(mid)) swap(t[p].sg, sg);
if((l == r) || (t[p].sg(l) < sg(l) && t[p].sg(r) < sg(r))) return ;
if(t[p].sg(l) < sg(l)) ins(rp, mid + 1, r, sg);
else ins(lp, l, mid, sg);
}
int merge(int x, int y, int l, int r) {
if(!x || !y) return x + y;
ins(x, l, r, t[y].sg);
if(l == r) {
return x;
}
int mid = (l + r) >> 1;
t[x].l = merge(t[x].l, t[y].l, l, mid);
t[x].r = merge(t[x].r, t[y].r, mid + 1, r);
return x;
}
int query(int p, int l, int r, int x) {
if(!p) return Inf;
if(l == r) {
return t[p].sg(x);
}
int mid = (l + r) >> 1;
if(x <= mid) return min(query(lp, l, mid, x), t[p].sg(x));
else return min(query(rp, mid + 1, r, x), t[p].sg(x));
}
}
int dp[Maxn];
void dfs(int x, int fth) {
bool flg = 0;
for(int i = head[x]; i; i = edge[i].nxt) {
int to = edge[i].to;
if(to == fth) continue;
dfs(to, x);
flg = 1;
rt[x] = Sgt::merge(rt[x], rt[to], -M, M);
}
if(flg) dp[x] = Sgt::query(rt[x], -M, M, a[x]);
else dp[x] = 0;
Sgt::ins(rt[x], -M, M, (Seg){b[x], dp[x]});
}
signed main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> n;
for(int i = 1; i <= n; i++) cin >> a[i];
for(int i = 1; i <= n; i++) cin >> b[i];
for(int i = 1; i < n; i++) {
int u, v;
cin >> u >> v;
add(u, v), add(v, u);
}
dfs(1, 0);
for(int i = 1; i <= n; i++) {
cout << dp[i] << " ";
}
return 0;
}
8 前缀最值线段树
8.1 概念
前缀最值线段树这个名称其实不那么具体,更具体的来讲应该叫 pushup \(\log\) 化线段树。这种线段树的经典例题就是 P4198 楼房重建。
显然,我们要保证楼房能被看见,就要保证斜率单调递增。那么我们在每一个位置上维护斜率,答案就是以 \(1\) 开头的最长上升子序列长度。我们考虑在线段树上的区间内维护 \(mx,len\),表示区间内斜率最大值和只考虑区间 \([l,r]\) 时的答案。发现此时的困难点在于不好合并。
合并时不能简单的将左右儿子的 \(len\) 相加,因为你不能保证右儿子的开头比左儿子结尾大。那么我们考虑取求解这个问题,设一个函数 \(\text{upd}(p,val)\) 表示节点 \(p\) 在保证开头大于 \(val\) 的情况下的答案。
- 考虑 \(p\) 的左儿子的 \(mx\) 值,如果其值大于 \(val\),说明这个区间的开头在左区间,递归到左区间。此时右区间可以全选,所以答案是 \(\text{upd}(lp,val)+len_p-len_{lp}\)。
- 如果左儿子的 \(mx\) 值不大于 \(val\),说明区间开头在右区间,答案自然是 \(\text{upd}(rp,val)\)。
那么 pushup 的时候就应该将 \(len_p\) 的值赋为 \(len_{lp}+\text{upd}(rp,mx_{lp})\)。不难发现,执行 \(\text{upd}\) 函数的复杂度是 \(O(\log n)\) 的,而我们每一次 pushup 都要做 \(\text{upd}\),所以总时间复杂度为 \(O(n\log^2 n)\)。
代码如下:
#include <bits/stdc++.h>
using namespace std;
const int Maxn = 2e5 + 5;
const int Inf = 2e9;
const double eps = 1e-10;
int n, m;
namespace Sgt {
struct node {
double mx;
int len;
}t[Maxn << 2];
#define lp (p << 1)
#define rp (p << 1 | 1)
int upd(int p, int l, int r, double val) {
if(l == r) {
return (t[p].mx - val > eps ? 1 : 0);
}
int mid = (l + r) >> 1;
double res = t[lp].mx;
if(res - val > eps) return upd(lp, l, mid, val) + t[p].len - t[lp].len;
else return upd(rp, mid + 1, r, val);
}
void pushup(int p, int l, int r) {
t[p].mx = max(t[lp].mx, t[rp].mx);
int mid = (l + r) >> 1;
t[p].len = upd(rp, mid + 1, r, t[lp].mx) + t[lp].len;
}
void mdf(int p, int l, int r, int x, double val) {
if(l == r) {
t[p].mx = val;
t[p].len = 1;
return ;
}
int mid = (l + r) >> 1;
if(x <= mid) mdf(lp, l, mid, x, val);
else mdf(rp, mid + 1, r, x, val);
pushup(p, l, r);
}
int query() {
return t[1].len;
}
}
int main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> n >> m;
while(m--) {
int x, y;
cin >> x >> y;
double k = 1.0 * y / x;
Sgt::mdf(1, 1, n, x, k);
cout << Sgt::query() << '\n';
}
return 0;
}
但是此时会发现,上面的做法在第一种情况中求右区间贡献时,用的是总贡献减左区间贡献。这就要求维护的东西有可减性,但显然有的信息并没有。此时我们将 \(len\) 的定义更改,表示仅考虑区间 \([l,r]\) 的情况下,右儿子的贡献和。那么 \(\text{upd}\) 函数就应该定义如下:
- 如果左儿子的 \(mx\) 值大于 \(val\),说明这个区间的开头在左区间,递归到左区间。此时右区间可以全选,所以答案是 \(\text{upd}(lp,val)+len_p\)。
- 否则说明这个区间的开头在右区间,答案即为 \(\text{upd}(rp,val)\)。
那么此时 pushup 的时候就应该将 \(len_p\) 赋为 \(\text{upd}(rp,mx_{lp})\)。这个做法的泛用性是更强的,上面例题也可以用这种方式来解决,在此不再赘述。
9 标记拼接
9.1 概念
标记拼接其实只是一种思想,它的意思是说对于某个信息,我们不好直接维护出来,就需要在线段树上维护一些其它的标记来维护出这个信息的更新。一般情况下这种题目的难点就在于 pushup 函数的细节较繁琐。
举个最经典的例子就是线段树维护区间最大子段和,显然这个值是难以直接维护的,所以我们考虑再维护出区间的前缀和最大值 和 后缀和最大值来辅助求解。
9.2 例题
例 1 [CF1192B] Dynamic Diameter
题目要求直径,也就是树上两点间的最长距离。不妨写出树上两点间距离公式,即 \(dis_a+dis_b-2\times dis_{lca}\)。由于我们需要动态维护这个东西,所以考虑将其转化到欧拉序上进行进一步求解。
根据欧拉序的相关定义可知,\(a,b\) 的 \(\text{LCA}\) 实际上就是欧拉序上两点间深度最小的一个点。然而实际上在此题中我们不必关心具体的 \(\text{LCA}\) 是谁,只要保证它在 \(a,b\) 之间即可,因为如果是另一个深度更大的点答案一定不会更优。
现在相当于我们要找出三个点 \(a,b,c\),它们在欧拉序上满足 \(pos_a<pos_b<pos_c\),并且使得 \(dis_a+dis_c-2\times dis_b\) 最大。考虑使用线段树求解,我们可以将上式拆成两个部分,即 \(dis_a-2\times dis_b\) 和 \(dis_c\)。于是我们就需要维护出前者和后者的最大值。而前者的最大值又需要知道 \(dis_b\) 的最小值,并且还需要按照 \(a,b\) 的位置前后分类讨论。于是我们要维护的东西就有:
- \(dis_x\) 的最大值。
- \(dis_x\) 的最小值。
- \(pos_x<pos_y\) 的 \(dis_x-2\times dis_y\) 的最大值。
- \(pos_x>pos_y\) 的 \(dis_x-2\times dis_y\) 的最大值。
- 满足要求的 \(dis_x+dis_y-2\times dis_z\) 的最大值。
标记拼接维护上述五个标记即可。

浙公网安备 33010602011771号