线段树的高级用法

CHANGE LOG

  • 2022.1.27 重构李超树部分。其它部分的施工计划已加入 to do list。
  • 2022.3.10 重构线段树合并。
  • 2022.4.1 重构线段树分裂。
  • 2022.4.2 重构树套树。

0. 常见技巧

0.1 动态开点线段树

当线段树数量过多或维护的下标值域过大时,我们就没有办法再使用左儿子两倍,右儿子两倍加一的编号方法了,因为空间不允许我们开 \(10 ^ 9\) 的数组。

好办,对于每个节点,维护其左儿子和右儿子的编号,向下传递时若对应儿子为空就新建一个节点,可以通过传递引用值方便地实现。查询时如果走到空节点就直接返回。

下面是一个单点修改,区间求和的动态开点线段树实现。可以发现相比于普通线段树,动态开点线段树仅仅是加了一个引用,且向下递归儿子时不再是 x << 1 / x << 1 + 1 而是 ls[x] / rs[x]

void modify(int l, int r, int p, int &x, int v) {
	if(!x) x = ++node; // 如果节点是空的,那就新建一个
	val[x] += v;
	if(l == r) return;
	int m = l + r >> 1;
	if(p <= m) modify(l, m, p, ls[x], v);
	else modify(m + 1, r, p, rs[x], v);
}
int query(int l, int r, int ql, int qr, int x) {
	if(ql <= l && r <= qr || !x) return val[x]; // 如果走到空节点会返回 val[0] = 0
	int m = l + r >> 1, ans = 0;
	if(ql <= m) ans += query(l, m, ql, qr, ls[x]);
	if(m < qr) ans += query(m + 1, r, ql, qr, rs[x]);
	return ans;
}

0.2 标记永久化

众所周知,线段树的区间修改需要打懒标记。但对于部分特殊的线段树而言,它不支持懒标记下传。例如可持久化线段树和动态开点线段树,如果下传懒标记,则需要新建节点,使空间常数过大。

此时可以考虑使用标记永久化的技巧。简单地说就是按着懒标记不下传,查询时考虑从根到目标区间的路径上每个节点的懒标记。

以区间取最大值,区间求最大值为例,对每个区间我们维护两个信息,一是 \(val\) 表示子树最大值,二是 \(laz\) 表示当前区间的懒标记的最大值。也就是说,对于一个区间,它的信息的真实值为它的 \(val\) 值与从根到当前区间的路径 \(P\) 上所有节点的 \(laz\) 值的最大值,因为能够下传懒标记到当前区间的节点恰好也全都在路径 \(P\) 上。

自然,我们希望知道满足哪些条件的信息可以标记永久化。

首先是信息在区间修改后必须快速更新。例如区间取最大值后,当前区间的最大值只需和修改值取 \(\max\)。这是可以区间修改的必要条件,如果不满足,连区间修改都无法做到,更不用提标记永久化了。

其次,修改必须 顺序无关。这是因为在查询过程中,我们按照从根到当前区间的顺序合并懒标记信息,与这些标记被打上的时刻顺序不同。举个例子,三次修改 \(a, b, c\)\(a\)\(c\) 被打到根上,\(b\) 打到根的左儿子上。查询根的左儿子的右儿子信息时,真正的修改顺序是 \(a, b, c\),但根据我们合并懒标记的顺序,从根到询问区间的路径真正体现出来的修改顺序是 \(a, c, b\)

一般地,只要正常下传懒标记能做且满足第二个条件,那么均可以标记永久化。

一个特例是区间赋值。区间赋值的修改顺序相关,但可以在懒标记中再维护时间戳转化为求时间戳最值。严格说是通过转化使得满足第二个条件。

1. 线段树合并与分裂

前置知识:动态开点线段树。

线段树合并算是一个基础算法,经常见于树上问题,因为 合并过程本身就形成了一棵树。由于它比较模板化,所以可以和多种算法相结合,有很大的应用空间,如维护 SAM 的 endpos 集合,优化树形 DP 等等。

相比而言,线段树分裂的用处并不多。它可以和 set 维护连续段(ODT)的技巧一并使用,支持区间排序操作。

1.1 线段树合并

考虑合并两棵线段树,分别记为 \(T_x\)\(T_y\)。为了不让复杂度过大,如果合并时两棵线段树上的当前区间 \(x, y\) 至少有一个为空,我们就可以返回了,这是因为一棵树和空节点合并,所得结果仍然为这棵树本身。否则,我们需要 新开节点(原因在本小节最后)\(z\) 表示 \(T_x, T_y\) 合并后的线段树 \(T_z\) 的当前区间所对应的节点,那么 \(z\) 的左儿子即 \(x\) 的左儿子和 \(y\) 的左儿子合并得到的结果,对于右儿子同理。

综上,我们得到以下算法:首先从 \(T_x\)\(T_y\) 的根 \(R_x, R_y\) 开始递归。设当前区间分别为 \(x, y\)

  • 如果 \(x, y\) 至少有一个为空,则返回另一个。
  • 否则 新开节点 \(z\),继续递归当前区间的左子区间和右子区间,令 \(z\) 的左儿子为 \(x\) 的左儿子和 \(y\) 的左儿子合并的结果,右儿子同理。然后合并 \(z\) 的左右儿子的信息,并返回 \(z\)
  • 注意,当递归到叶子节点时需要直接合并。大部分情况下叶子节点的合并是容易的,因为这只涉及两个长度为 \(1\) 的区间的信息。但 合并叶子处的信息与合并左右儿子的信息 是两个不同种类的合并操作,需要分来开考虑。例如可以是叶子处相加,左右儿子取 \(\max\)
int merge(int l, int r, int x, int y) {
	if(!x || !y) return x | y;
	int m = l + r >> 1, z = ++node; // node 是总节点个数
	if(l == r) return /* 合并叶子 x 和 y */, z;
	ls[z] = merge(l, m, ls[x], ls[y]);
	rs[z] = merge(m + 1, r, rs[x], rs[y]);
	return /* 合并左右儿子 */, z;
}

时间复杂度分析:每次合并的复杂度为两棵线段树 重合 的节点个数,也就是 删去 的节点个数。因此,线段树合并的总复杂度为所有线段树的节点个数之和。若一开始有 \(n\) 棵线段树,每棵线段树仅存在一个位置有值,则将这些线段树合并成一棵线段树的总时空复杂度为 \(\mathcal{O}(n\log n)\)

  • 若线段树合并不新建节点,则整个过程会破环原有线段树的结构。如果我们将 \(y\) 的信息合并到 \(x\) 上,则对于 所有 包含节点 \(x\) 的线段树,其存储的信息均会改变。但我们只希望更新 当前 线段树在节点 \(x\) 对应的下标区间处的信息。这和 可持久化 数据结构需要新开节点的原因相同,因此也可以称作 可持久化线段树合并。打个比方,借了同学的笔记,就不应该在上面乱涂乱画(将别的同学的笔记抄在上面)。
  • 如果被合并的线段树 \(T_y\) 的信息在合并后不会用到(询问离线后及时查询),那么我们可以不新建节点而直接将每个节点 \(y\) 的信息合并到节点 \(x\) 上,即在上述步骤中用 \(x\) 代替 \(z\)。可以有效减少空间开销(空间常数砍半)。具体写法见例 I.
  • 一般来说 merge 的时候最好带上 \(l, r\),因为我们得知道什么时候递归到了叶子节点,从而合并叶子节点处的信息,否则叶子结点就会从两个空节点上传信息。若合并的过程中,所有叶子节点最多在一棵线段树中出现,此时就可以不下传 \(l, r\),因为根据算法,当我们递归到叶子节点时,\(x, y\) 至少有一个为空,直接返回了。当区间信息可以快速合并时也不需要下传,因为我们不从子节点中合并东西上来,自然不用担心叶节点合并两个空节点。一个满足前者的例子是线段树合并维护 SAM 的 endpos 集合,此时一个叶子节点最多在一棵线段树上有值。

1.2 应用

检查线段树合并是否适用,我们只需检查能否快速合并两个叶子节点,以及快速 pushup,而不需要支持 快速合并两个区间的信息(这是笔者在初学线段树合并时常犯的错误,即因为无法快速合并两个有交区间的信息而认为无法线段树合并)。注意这不同于 pushup,因为 pushup 合并的两个区间 无交。由于几乎所有线段树题目均满足这些条件,所以我们断言,只要能用线段树维护的信息,线段树合并就能做。

  • 线段树合并(在大部分情况下)可以代替复杂度更高的启发式合并,例如并查集在合并时需要维护连通块内每个节点的信息。由于前者的大常数,两者效率其实并没有太大差别。
  • 线段树合并可以优化 DP,见 DP 优化方法 II 整体 DP 部分(暂未施工)。
  • 线段树合并可以求出 SAM 的每个节点的 \(\rm endpos\) 集合。
  • 线段树常用于求解深度有关的树上问题,如多次查询某个节点的 \(k\) 级儿子的信息,就可以线段树合并预处理出每个节点的所有儿子以深度作为下标的信息。长链剖分 + 数据结构也可以维护。

1.3 线段树分裂

和 FHQ Treap 分裂一样,线段树分裂有按值分裂和按排名分裂两种方法。按排名分裂的流程如下。

设当前区间为 \([l, r]\),当前节点为 \(x\),要分裂出的线段树的当前节点为 \(y\)\(y\) 为新建出来的节点),且需要保留 \(T_x\) 维护的较小的 \(k\) 个值。

\(v\)\(x\) 的左子树所维护的值的个数,分三种情况讨论:

  • \(k < v\),说明 \(x\) 的右子树要全部给 \(y\),并向左子树分裂。
  • \(k = v\),则将 \(x\) 的右子树全部给 \(y\) 后返回(这一步和上一步合并起来也可以,不过单独拎出来判一下可以减小时间和空间常数)。
  • \(k > v\),则向右子树分裂,并令 \(k\) 减去 \(v\)

对于按值分裂,类似上述过程即可。设需要保留 \(T_x\) 小于等于 \(k\) 的值,且当前区间中点为 \(m\)

  • \(k < m\),说明 \(x\) 的右子树要全部给 \(y\),并向左子树分裂。
  • \(k = m\),则将 \(x\) 的右子树全部给 \(y\) 后返回。
  • \(k > m\),则向右子树分裂。

显然,一次分裂会新建 \(\log n\) 个节点,因此时空复杂度均为线性对数。代码见 IX.

1.4 例题

线段树合并:I. ~ VIII.

线段树分裂:IX. ~ XI.

I. P4556 [Vani有约会]雨天的尾巴 /【模板】线段树合并

将链修改转化为树上差分,最后线段树合并。注意每合并完一个节点就直接查询该节点的答案,不然需要可持久化,空间开销过大。

#include <bits/stdc++.h>
using namespace std;

const int K = 17, N = 1e5 + 5;
vector <int> e[N];
int n, m, dn, dfn[N], ans[N], fa[N], dep[N], lg[N], mi[K][N];
void dfs1(int id, int f) {
	dep[id] = dep[fa[id] = f] + 1, mi[0][dfn[id] = ++dn] = id;
	for(int it : e[id]) if(it != f) dfs1(it, id);
}
int get(int x, int y) {return dep[x] < dep[y] ? x : y;}
int lca(int u, int v) {
	if(u == v) return u;
	if((u = dfn[u]) > (v = dfn[v])) swap(u, v);
	int d = lg[v - (++u) + 1];
	return fa[get(mi[d][u], mi[d][v - (1 << d) + 1])];
}

int node, R[N], val[N << 6], mx[N << 6], ls[N << 6], rs[N << 6];
void push(int x) {
	val[x] = max(val[ls[x]], val[rs[x]]);
	mx[x] = val[x] == val[ls[x]] ? mx[ls[x]] : mx[rs[x]];
}
void modify(int l, int r, int p, int &x, int v) {
	if(!x) x = ++node;
	if(l == r) return val[x] += v, mx[x] = p, void();
	int m = l + r >> 1;
	if(p <= m) modify(l, m, p, ls[x], v);
	else modify(m + 1, r, p, rs[x], v);
	push(x); 
}
int merge(int l, int r, int x, int y) {
	if(!x || !y) return x | y;
	if(l == r) return val[x] += val[y], mx[x] = l, x;
	int m = l + r >> 1;
	ls[x] = merge(l, m, ls[x], ls[y]), rs[x] = merge(m + 1, r, rs[x], rs[y]);
	return push(x), x;
}
void dfs2(int id, int f) {
	for(int it : e[id]) if(it != f) dfs2(it, id), R[id] = merge(1, N, R[id], R[it]);
	ans[id] = val[R[id]] ? mx[R[id]] : 0;
}
int main() {
	cin >> n >> m;
	for(int i = 2; i <= n; i++) lg[i] = lg[i >> 1] + 1;
	for(int i = 1, u, v; i < n; i++) cin >> u >> v, e[u].push_back(v), e[v].push_back(u);
	dfs1(1, 0);
	for(int i = 1; i <= lg[n]; i++) // forget to initialize mi
		for(int j = 1; j + (1 << i) - 1 <= n; j++)
			mi[i][j] = get(mi[i - 1][j], mi[i - 1][j + (1 << i - 1)]);
	for(int i = 1; i <= m; i++) {
		int u, v, w, d; cin >> u >> v >> w, d = lca(u, v);
		modify(1, N, w, R[u], 1);
		modify(1, N, w, R[v], 1);
		modify(1, N, w, R[d], -1);
		if(fa[d]) modify(1, N, w, R[fa[d]], -1);
	}
	dfs2(1, 0);
	for(int i = 1; i <= n; i++) cout << ans[i] << "\n";
	return 0;
}

*II. P3521 [POI2011]ROT-Tree Rotations

对于每一个节点,是否交换左右子树不影响它的祖先,于是我们可以直接贪心。

用权值线段树维护落在各个区间的数的个数。合并时考虑从大到小遍历每个区间,优先递归右子树(实际上先递归左子树也没问题,因为要求左儿子对右儿子的顺序对和逆序对,两者在计算贡献的形式上是对称的)。左儿子线段树上的区间记为 \(x\),右儿子记为 \(y\),我们考虑在 \(x\) 为空的 \(y\) 统计答案,并将右儿子合并至左儿子处(同样的,本题不需要可持久化,因为线段树一合并完即可计算答案,所以每个作为右儿子处的线段树被合并时,其信息已经统计完毕,即我们不会再查询这些线段树上的信息,故均可丢弃)。

\(y\) 处统计逆序对时,设 \(y\) 对应的区间为 \([l, r]\),我们需要知道大于 \(r\) 的左儿子线段树的叶子节点个数,因此记录 \(cnt\) 表示之。因为还要统计顺序对,所以我们也需要知道小于 \(l\) 的左儿子线段树的叶子节点个数,后者即左儿子线段树总大小 \(sz\) 减去 \(cnt\),这是由于此时 \(x\) 为空,所以左儿子线段树在 \([l, r]\) 处没有节点。

线段树合并时,若 \(y\) 为空则令 \(cnt \gets cnt + val_x\)\(val_x\) 即节点 \(x\) 表示的区间所包含的叶子节点个数。若 \(x\) 为空则说明此时可以在 \(y\) 处统计答案,令逆序对个数加上 \(val_y \times cnt\),因为每个 \(y\) 的叶子节点和比它大的 \(cnt\) 个左儿子线段树的叶子节点均可形成逆序对。同理,令顺序对个数加上 \(val_y \times (sz - cnt)\)

最终答案只需将左儿子和右儿子的答案相加,并加上顺序对和逆序对数量的较小值即可。时空复杂度线性对数。代码

III. P3224 [HNOI2012]永无乡

比较裸的线段树合并,用线段树维护并查集每个连通块内部所有元素是常见套路。

IV. P3899 [湖南集训]谈笑风生

这题啊,exciting!

因为 \(a, b\) 均为 \(c\) 的祖先,所以 \(a, b\) 也互为祖先后代关系。

\(b\)\(a\) 的祖先,则任何一个距离 \(a\) 不超过 \(k\)\(a\) 祖先均可以成为 \(b\),且任何 \(a\) 子树不等于 \(a\) 的点均可以成为 \(c\),因此贡献为 \(\min(dep_a - 1, k) \times (size_a - 1)\)

\(a\)\(b\) 的祖先,则任何一个在 \(a\) 子树内不等于 \(k\) 且距离 \(a\) 不超过 \(k\) 的节点均可以成为 \(b\),对应的 \(c\) 的方案数即 \(size_b - 1\)。因此贡献为

\[\sum\limits_{b\in \mathrm{subtree}(a) \land \mathrm{dist}(a, b) \leq k \land a \neq b} size_b - 1 \]

直接树上线段树合并维护每个节点 \(u\) 的子树内每个深度(作为线段树的下标)的所有子节点的子树大小减去 \(1\) 之和,查询直接查 \(a\) 对应的线段树下标 \([dep_a + 1, dep_a + k]\) 处所有值之和即可。

注意合并时要新建节点,因为每个节点的线段树形态均需保留(除非把询问离线下来回答)。否则会破坏线段树结构。

时空复杂度均为线性对数。

#include <bits/stdc++.h>
using namespace std;
const int N = 3e5 + 5, K = N * 40;
int n, q, node, dep[N], sz[N], R[N], ls[K], rs[K];
long long val[K];
vector <int> e[N];
void modify(int l, int r, int p, int &x, int v) {
	if(!x) x = ++node;
	val[x] += v;
	if(l == r) return;
	int m = l + r >> 1;
	if(p <= m) modify(l, m, p, ls[x], v);
	else modify(m + 1, r, p, rs[x], v);
}
long long query(int l, int r, int ql, int qr, int x) {
	if(ql > qr) return 0;
	if(ql <= l && r <= qr) return val[x];
	int m = l + r >> 1;
	long long ans = 0;
	if(ql <= m) ans = query(l, m, ql, qr, ls[x]);
	if(m < qr) ans += query(m + 1, r, ql, qr, rs[x]);
	return ans;
}
int merge(int x, int y) {
	if(!x || !y) return x | y;
	int z = ++node;
	ls[z] = merge(ls[x], ls[y]), rs[z] = merge(rs[x], rs[y]);
	return val[z] = val[x] + val[y], z;
}
void dfs(int id, int fa) {
	sz[id] = 1, dep[id] = dep[fa] + 1;
	for(int it : e[id]) if(it != fa) dfs(it, id), sz[id] += sz[it], R[id] = merge(R[id], R[it]);
	modify(1, n, dep[id], R[id], sz[id] - 1);
}
int main() {
	cin >> n >> q;
	for(int i = 1, u, v; i < n; i++) scanf("%d %d", &u, &v), e[u].push_back(v), e[v].push_back(u);
	dfs(1, 0);
	while(q--) {
		int p, k;
		scanf("%d %d", &p, &k);
		printf("%lld\n", 1ll * min(dep[p] - 1, k) * (sz[p] - 1) + query(1, n, dep[p] + 1, min(n, dep[p] + k), R[p]));
	}
	return 0;
}

V. CF600E Lomsat gelral

一道线段树合并简单题。代码

VI. CF208E Blood Cousins

一道线段树合并简单题。代码

VII. CF570D Tree Requests

一道线段树合并简单题。代码

VIII. CF1051G Distinctification

注意到一个数左移和右移可以抵消,故可以将一段连续的 \(a_i\) 全部移动到最左边,再将代价小的的尽量往右移。

  • 左移贡献:对每一段 \(a_i\) 维护 \(\sum a_ib_i\)\(\sum b_i\)\(\min a_i\),则贡献为 \((\min a_i)\left(\sum b_i\right) - \sum a_ib_i\)
  • 右移贡献:用线段树维护 \(b_i\times k_i\),其中 \(k_i\) 表示 \(b_i\) 在这一段从大到小的排名 \(-1\)

具体地,并查集维护每一段 \(a_i\),新加入 \((a_i,b_i)\) 时,先减去 \(a_i\) 这一段的贡献,更新后再加上,然后需要判断能否连通两段的连续段:

  • 若当前 \(a_i\) 大小 \(sz\)\(1\),则若左 / 右存在连续段,连通之。
  • 否则,将 \(a_i\)\(a_i+sz-1\) 强制连通(因为右移过程最大位置可以达到 \(a_i+sz-1\)),同时若 \(a_i+sz\) 存在连续段,连通之。

合并时也需要先减去每个连续段的贡献,线段树合并后再加上。代码

IX. P5494 【模板】线段树分裂

对于操作 0,将 \([1, n]\) 分裂成 \([1, x - 1]\)\([x, y]\)\([y + 1, n]\),再将 \([1, x - 1]\)\([y + 1, n]\) 合并。

对于操作 1,将 \(p, t\) 两棵线段树合并。

剩下来都是权值线段树的基本操作。时空复杂度线性对数。

#include <bits/stdc++.h>
using namespace std;
const int N = 2e5 + 5;
long long val[N << 5];
int n, m, cnt, node, a[N], R[N], ls[N << 5], rs[N << 5];
void build(int l, int r, int &x) {
	x = ++node;
	if(l == r) return val[x] = a[l], void();
	int m = l + r >> 1;
	build(l, m, ls[x]), build(m + 1, r, rs[x]);
	val[x] = val[ls[x]] + val[rs[x]];
}
int merge(int x, int y) {
	if(!x || !y) return x | y;
	ls[x] = merge(ls[x], ls[y]), rs[x] = merge(rs[x], rs[y]);
	return val[x] += val[y], x; 
}
void modify(int l, int r, int p, int v, int &x) {
	if(!x) x = ++node;
	val[x] += v;
	if(l == r) return;
	int m = l + r >> 1;
	if(p <= m) modify(l, m, p, v, ls[x]);
	else modify(m + 1, r, p, v, rs[x]);
}
int query(int l, int r, long long k, int x) {
	if(l == r) return l;
	int m = l + r >> 1;
	if(k <= val[ls[x]]) return query(l, m, k, ls[x]);
	return query(m + 1, r, k - val[ls[x]], rs[x]);
}
long long query(int l, int r, int ql, int qr, int x) {
	if(ql <= l && r <= qr) return val[x];
	int m = l + r >> 1;
	long long ans = 0;
	if(ql <= m) ans = query(l, m, ql, qr, ls[x]);
	if(m < qr) ans += query(m + 1, r, ql, qr, rs[x]);
	return ans;
}
void split(int l, int r, int x, int &y, int v) {
	if(!x) return;
	y = ++node;
	int m = l + r >> 1;
	if(v < m) swap(rs[x], rs[y]), split(l, m, ls[x], ls[y], v);
	else if(v == m) swap(rs[x], rs[y]);
	else split(m + 1, r, rs[x], rs[y], v);
	val[x] = val[ls[x]] + val[rs[x]];
	val[y] = val[ls[y]] + val[rs[y]];
}
int main() {
	cin >> n >> m;
	for(int i = 1; i <= n; i++) cin >> a[i];
	build(1, n, R[cnt = 1]);
	for(int i = 1; i <= m; i++) {
		int op, a, c, tmp;
		long long b;
		cin >> op >> a >> b;
		if(op == 0) {
			cin >> c;
			split(1, n, R[a], R[++cnt], b - 1);
			split(1, n, R[cnt], tmp, c);
			R[a] = merge(R[a], tmp);
		}
		if(op == 1) R[a] = merge(R[a], R[b]);
		if(op == 2) cin >> c, modify(1, n, c, b, R[a]);
		if(op == 3) cin >> c, cout << query(1, n, b, c, R[a]) << "\n";
		if(op == 4) cout << (val[R[a]] < b ? -1 : query(1, n, b, R[a])) << "\n";
	}
	return 0;
}

X. P2824 [HEOI2016/TJOI2016]排序

使用 set 维护极长已知有序段,排序时需要对端点所在的有序段进行分裂,然后再将所有覆盖到的有序段合并成一个大段。通过线段树分裂与合并实现,注意区间升序或降序会影响分裂时的方法,需要讨论。时间复杂度线性对数。代码

XI. CF558E A Simple Task

和上一题是双倍经验。

2. 树套树

前置知识:动态开点线段树。

树套树就是在树形数据结构的每个节点内部再套一层树形数据结构。为方便说明,下文分别记为外层 ds(data structure,数据结构) 和内层 ds。常见形式有线段树套线段树,树状数组套线段树,以及线段树套平衡树。

  • 若内层 ds 为线段树则需动态开点。若每个内层线段树均开满,则空间复杂度平方级别,无法接受。
  • 当外层值域不大时,可将线段树换成树状数组以减小常数。

2.1 树状数组套线段树

BIT 套线段树可以解决带修二维数点或动态第 \(k\) 小,前提条件是至少一维的值域不大(线性),否则需将外层 ds 从 BIT 换成动态开点线段树。显然,当值域大小高达 \(10 ^ 9\)​​ 时,我们不能使用 BIT 而只能用动态开点线段树。

下面给出一些 BIT 套线段树的应用。

2.1.1 带修二维数点

支持修改一个点的点权,查询矩形点权和。假设 \(x\) 的值域仅有 \([1, n]\),且 \(n \leq 10 ^ 5\)\(y\) 的值域为 \([1, m]\)\(m\leq 10 ^ 9\)

对于单点修改操作,对 \(x\) 这一维在 BIT 上遍历,另一维 \(y\) 在线段树上修改。就是说,我们将 BIT 每个位置存储的东西从若干个位置(一段区间)上值的和变成了若干个位置(一段区间)上线段树的和,因为二维平面可以看做若干个一维直线排成一排。而外层对应 \(x\) 这一维,这意味着我们将平面剖成了 \(n\) 条竖直的直线,对于每一条直线用动态开点线段树维护,因为需要支持单点修改,区间求和(不使用 BIT 的原因是空间开不下 \(n\) 个大小为 \(m\) 的 BIT)。由于我们要支持对一个区间(矩形在 \(x\) 轴上的区间)的线段树在固定的区间(也就是矩形在 \(y\) 轴上的区间)上查询,所以外层也需要一个支持单点修改,区间查询的数据结构。

对于矩形查询操作,可以看成在 BIT 上执行区间查询,查询的内容是一段区间 \([x_{left}, x_{right}]\) 内所有线段树在某个固定的区间 \([y_{down}, y_{up}]\) 上的取值之和,相当于在 BIT 上若干位置维护的线段树在 \([y_{down}, y_{up}]\) 上的取值之和。BIT 的区间查询依赖于信息具有可减性,和是有可减性的,所以没问题。

因为每次操作均需 \(\log n\) 次值域大小为 \(m\) 的线段树上操作,故时空复杂度均为 \(q \log n \log m\)

  • 更详细的说明:在查询时,对于直接求和的信息,例如满足条件的数的个数,可以在遍历 BIT 时直接查询。但如果内层 ds 查询的形式为 线段树二分(如 3.1.3 动态区间第 \(k\) 小),就要把遍历到的所有位置对应的内层 ds 的根节点编号先记录下来,递归时一并考虑,因为递归方向(向左子树还是右子树)由这些位置上的信息同时确定。注意向下递归的过程中需实时更新当前所有位置对应的内层 ds 的编号。

2.1.2 动态逆序对

动态逆序对 本质上是三维偏序 / 带修点权的二维数点问题。

首先转化问题,删除位置 \(p\) 上的元素时,需要知道位置小于 \(p\),数值大于 \(a_p\) 且删除时间比它迟的数的个数。三个限制分别对应三维上需要满足的偏序关系。可以 cdq 分治做。

如果从二维数点的角度出发,那就是将 \((i, a_i)\) 看成平面的点。删除 \(p\) 时查询 \(x\) 坐标范围 \([1, i - 1]\)\(y\) 坐标范围 \([a_i + 1, n]\) 的矩形(记作 \([1, i - 1]\times [a_i + 1, n]\))内部有多少点,以及矩形 \([i + 1, n] \times [1, a_i - 1]\) 内部有多少点,前者统计的是在 \(a_i\) 之前比 \(a_i\) 大的数的个数,后者统计的是在 \(a_i\) 之后比 \(a_i\) 小的数的个数。然后将 \((i, a_i)\) 从平面上删去,相当于点权置为零。可以 BIT 套动态开点线段树做。

两种方法时间复杂度均为线性对数平方。前者需要离线(因为要知道每个点被删除的时间)但空间复杂度线性,后者可以强制在线但空间复杂度多了两个 \(\log\)

#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 5, K = N << 8;
int n, m, node, a[N], rev[N], R[N], ls[K], rs[K], val[K];
long long ans;
void modify(int l, int r, int p, int &x, int v) {
	if(!x) x = ++node;
	val[x] += v;
	if(l == r) return;
	int m = l + r >> 1;
	if(p <= m) modify(l, m, p, ls[x], v);
	else modify(m + 1, r, p, rs[x], v);
}
int query(int l, int r, int ql, int qr, int x) {
	if(ql <= l && r <= qr) return val[x];
	int m = l + r >> 1, ans = 0;
	if(ql <= m) ans = query(l, m, ql, qr, ls[x]);
	if(m < qr) ans += query(m + 1, r, ql, qr, rs[x]);
	return ans;
}
void add(int x, int y, int v) {while(x <= n) modify(1, n, y, R[x], v), x += x & -x;}
int query(int x, int yd, int yu) {int s = 0; while(x) s += query(1, n, yd, yu, R[x]), x -= x & -x; return s;}
int query(int xl, int xr, int yd, int yu) {return yd > yu ? 0 : query(xr, yd, yu) - query(xl - 1, yd, yu);}
int main() {
	cin >> n >> m;
	for(int i = 1; i <= n; i++) cin >> a[i], rev[a[i]] = i, add(i, a[i], 1);
	for(int i = 2; i <= n; i++) ans += query(1, i - 1, a[i] + 1, n);
	for(int i = 1, p; i <= m; i++) {
		cin >> p, p = rev[p], cout << ans << "\n";
		ans -= query(1, p - 1, a[p] + 1, n) + query(p + 1, n, 1, a[p] - 1), add(p, a[p], -1);
	}
	return 0;
}

2.1.3 动态第 \(k\)

主席树带修是什么概念?区间第 \(k\) 小从静态变为 动态,只需将可持久化线段树换成树套树。

回顾使用主席树求解静态区间第 \(k\) 小的过程,我们利用线段树关于下标的前缀和(主席树) & 差分,用两棵线段树 \(T_r\)\(T_{l - 1}\) 相减得到当前区间 \([l, r]\) 包含的元素所形成的权值线段树 \(T_{l, r}\),求第 \(k\) 小便是在 \(T_{l, r}\) 上二分。

带修的(线段树)前缀和问题自然考虑树状数组。因为 BIT 将信息的前缀和摊到了它的 \(\log\) 个节点上,所以原来前缀 \([1, r]\) 对应的线段树 \(T_r\) 变成了 BIT 上若干个节点所存储的线段树之和,\(T_{l - 1}\) 同理。

因此,设 \([1, r]\) 在 BIT 上分解得到的节点的对应线段树的根节点编号集合为 \(Add\)\([1, l - 1]\)\(Sub\)。线段树上二分时同时考虑所有 \(Add\)\(Sub\) 里面节点的影响。即令 \(v = \sum\limits_{x \in Add} v_{ls_x} - \sum\limits_{x \in Sub} v_{ls_x}\),则 \(v\) 表示 \([l, r]\) 当中权值落在当前权值区间 \([L, R]\) 左儿子所表示区间 \([L, M]\) 的数的个数。很显然,若 \(k \leq v\) 则说明区间第 \(k\) 小落在 \([L, M]\) 当中,向左区间 \([L, M]\) 递归,并令 \(Add\)\(Sub\) 里面所有节点变成它的左儿子编号。否则 \(k\) 减去 \(v\),向右区间 \([M + 1, R]\) 递归,并令 \(Add\)\(Sub\) 里面所有节点变成它的右儿子编号。

显然,时空复杂度均为 \(q\log n\log V\),其中 \(V\) 为值域。

#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 5, K = N << 9;
int n, m, node, a[N], R[N], ls[K], rs[K], val[K];
void modify(int l, int r, int p, int &x, int v) {
	if(!x) x = ++node;
	val[x] += v;
	if(l == r) return;
	int m = l + r >> 1;
	if(p <= m) modify(l, m, p, ls[x], v);
	else modify(m + 1, r, p, rs[x], v);
}
vector <int> Add, Sub;
int query(int l, int r, int k) {
	if(l == r) return l;
	int m = l + r >> 1, v = 0;
	for(int it : Add) v += val[ls[it]];
	for(int it : Sub) v -= val[ls[it]];
	for(int &it : Add) it = k <= v ? ls[it] : rs[it];
	for(int &it : Sub) it = k <= v ? ls[it] : rs[it];
	if(k <= v) return query(l, m, k);
	return query(m + 1, r, k - v);
}
void add(int x, int y, int v) {while(x <= n) modify(0, 1e9, y, R[x], v), x += x & -x;}
int main() {
	cin >> n >> m;
	for(int i = 1; i <= n; i++) cin >> a[i], add(i, a[i], 1);
	for(int i = 1, l, r, k; i <= m; i++) {
		char op;
		cin >> op >> l >> r;
		if(op == 'C') add(l, a[l], -1), add(l, a[l] = r, 1);
		else {
			cin >> k, Add.clear(), Sub.clear();
			int x = r;
			while(x) Add.push_back(R[x]), x -= x & -x;
			x = l - 1;
			while(x) Sub.push_back(R[x]), x -= x & -x;
			cout << query(0, 1e9, k) << "\n";
		}
	}
	return 0;
}

2.2 线段树套线段树

线段树套线段树俗称二维线段树,是当信息不具有可减性时 BIT 套线段树的替代品,例如查询矩形内部所有点的权值的最大值。

使用方法就是把外层 ds 从 BIT 换成线段树,本质没有太大区别,这里说一个注意点。

二维线段树的外层线段树不支持 push_up。因此,对于外层单点修改,需要在从根到目标叶子区间的路径上的所有区间均插入内层修改。对于外层区间修改,必须且只能标记永久化,这意味着我们在更新路径上需要在所有区间的 \(val\)(每个 \(val\)\(laz\) 都是一棵线段树)处加入内层修改,并在目标区间的拆分区间的 \(laz\) 处加入内层修改。查询时也是求递归路径上所有懒标记 \(laz\) 的内层区间最大值的 \(\max\),再与拆分区间维护的 \(val\) 的内层区间最大值取 \(\max\)

2.3 例题

*I. P3437 [POI2006]TET-Tetris 3D

使用标记永久化的二维线段树维护矩形取最大值矩形求最大值,时空复杂度 \(\mathcal{O}(n\log S\log D)\)

#include <bits/stdc++.h>
using namespace std;
const int N = 1e3 + 5;
int n, D, S;
namespace ST {
	int node, val[N << 11], laz[N << 11], ls[N << 11], rs[N << 11];
	void modify(int l, int r, int ql, int qr, int &x, int v) {
		if(!x) x = ++node;
		laz[x] = max(laz[x], v);
		if(ql <= l && r <= qr) return val[x] = max(val[x], v), void();
		int m = l + r >> 1;
		if(ql <= m) modify(l, m, ql, qr, ls[x], v);
		if(m < qr) modify(m + 1, r, ql, qr, rs[x], v);
	}
	int query(int l, int r, int ql, int qr, int x) {
		if(!x) return 0;
		if(ql <= l && r <= qr) return max(laz[x], val[x]);
		int m = l + r >> 1, ans = val[x];
		if(ql <= m) ans = max(ans, query(l, m, ql, qr, ls[x]));
		if(m < qr) ans = max(ans, query(m + 1, r, ql, qr, rs[x]));
		return ans;
	}
}
int laz[N << 2], val[N << 2];
void modify(int l, int r, int ql, int qr, int u, int d, int x, int v) {
	ST :: modify(1, S, u, d, laz[x], v);
	if(ql <= l && r <= qr) return ST :: modify(1, S, u, d, val[x], v), void();
	int m = l + r >> 1;
	if(ql <= m) modify(l, m, ql, qr, u, d, x << 1, v);
	if(m < qr) modify(m + 1, r, ql, qr, u, d, x << 1 | 1, v);
}
int query(int l, int r, int ql, int qr, int u, int d, int x) {
	int ans = ST :: query(1, S, u, d, val[x]);
	if(ql <= l && r <= qr) return max(ans, ST :: query(1, S, u, d, laz[x]));
	int m = l + r >> 1;
	if(ql <= m) ans = max(ans, query(l, m, ql, qr, u, d, x << 1));
	if(m < qr) ans = max(ans, query(m + 1, r, ql, qr, u, d, x << 1 | 1));
	return ans;
}
int main(){
	cin >> D >> S >> n;
	for(int i = 1; i <= n; i++) {
		int d, s, w, x, y; cin >> d >> s >> w >> x >> y;
		int ht = query(1, D, x + 1, x + d, y + 1, y + s, 1);
		modify(1, D, x + 1, x + d, y + 1, y + s, 1, ht + w);
	}
	cout << query(1, D, 1, D, 1, S, 1) << endl;
	return 0;
}

3. 线段树分治

线段树分治更像一种技巧而非算法。

3.1 算法介绍

线段树分治是一种很有用的 离线 的按时间分治的技巧。它的分治体现在线段树的分治结构上。它适用于仅支持插入,不支持删除的数据结构(如果支持删除还要啥线段树分治啊),例如线性基,李超线段树,并查集。更广义地说,任何难以快速删除的操作(从若干次操作带来的影响当中撤销某一次操作,得到新的影响)均可以通过线段树分治将删除转化为撤销。

只要加入操作时修改的复杂度有保障,撤销就是简单的:用栈记录数组层面的修改操作,撤销时回退。

线段树分治的核心在于用线段树维护 时间轴,对时间轴进行分值。对于一个操作,我们在离线后可以知道它的影响时间段 \([l, r]\)。在线段树上找到 \([l,r]\) 的所有定位区间并加入该操作。最后我们需要对整颗线段树进行中序遍历:向儿子走表示加入子节点的操作贡献,向父亲走表示撤销当前节点的所有操作。不难看出,时刻 \(t\) 时的数据结构,就是从根节点遍历到 \([t,t]\) 时的数据结构,据此我们可以回答时刻 \(t\) 的询问。

时间复杂度为 \(\mathcal{O}(Tk\log T)\),其中 \(k\) 是一次修改的复杂度,\(T\) 是时间范围大小。一般来说 \(k = \log n\)\(n\) 是操作影响的序列长度,即数据结构一次操作的复杂度)。如例 I.

  • 注意点:递归到叶子节点回答完询问后 不要忘记撤销修改。否则叶子节点处的修改会一直保留并且对数据结构存储的信息有很大影响。

除了修改对询问的影响有时间范围(体现为删除某次修改),当询问对修改有时间范围要求(体现为查询某个时刻范围的修改)时,也可以使用线段树分治。

类似地,我们将询问的时间范围要求区间在线段树上分解成 \(\mathcal{O}(\log T)\) 个定位区间。对于每个修改,设其时间为 \(t\),则对从线段树的根到叶子 \([t, t]\)​​ 路径上的所有区间,加入本次修改。容易发现查询只需要将询问的时间范围的定位区间上所有修改带来的信息合并起来即可。如例 II.

时间复杂度仍然是 \(\mathcal{O}(Tk\log T)\),但空间复杂度视是否离线而不同,如下所述。

  • 询问 而非修改有时间范围要求时,我们可以 在线地 回答询问。对线段树上每个时间区间均维护对应的所有修改信息(加入修改时直接在线加,而不是先把修改存起来,最后遍历),那么询问每个定位区间即可立刻得到答案。这样做的代价是 空间开销太大,相当于在原本空间复杂度上乘了一个线段树的 \(\log T\)(平衡树好啊!平衡树线性空间!乘一个 \(\log\) 也还能接受)。

    相比而言,先把所有修改保存起来,询问离线下来,递归到每个区间的时候再构建对应所有修改形成的数据结构形态,然后更新所有时间范围定位区间包含当前区间的询问的答案,可以去掉这个 \(\log\),代价是 必须离线。对每个区间加入修改前需要清空数据结构,注意该部分的时间复杂度。

3.2 例题

I. P5787 二分图 /【模板】线段树分治

判断二分图可以用扩展域并查集,由于要支持撤销所以不能路径压缩,而应按秩合并(和可持久化并查集差不多的注意点)。时间复杂度 \(\mathcal{O}(m\log k\log n)\)

const int N = 1e5 + 5;
int n, m, k, chk[N << 2], fa[N << 1], sz[N << 1];
int find(int x) {return fa[x] == x ? x : find(fa[x]);}
vpii val[N << 2];
void modify(int l, int r, int ql, int qr, int x, pii v) {
	if(ql > qr) return;
	if(ql <= l && r <= qr) return val[x].pb(v), void();
	int m = l + r >> 1;
	if(ql <= m) modify(l, m, ql, qr, x << 1, v);
	if(m < qr) modify(m + 1, r, ql, qr, x << 1 | 1, v);
}
void undo(vpii &x) {for(auto it : x) sz[it.fi] -= sz[it.se], fa[it.se] = it.se;}
void query(int l, int r, int x) {
	vpii upd;
	if(chk[x]) {
		for(auto it : val[x]) {
			int u = it.fi, v = it.se, uu = u + n, vv = v + n;
			u = find(u), v = find(v), uu = find(uu), vv = find(vv);
			if(u == v) {chk[x] = 0; break;}
			if(sz[u] < sz[vv]) swap(u, vv);
			if(sz[v] < sz[uu]) swap(v, uu);
			upd.pb(u, vv), upd.pb(v, uu);
			sz[u] += sz[vv], fa[vv] = u, sz[v] += sz[uu], fa[uu] = v;
		}
	}
	if(l == r) return puts(chk[x] ? "Yes" : "No"), undo(upd), void(); // 这里不要忘记 undo
	chk[x << 1] = chk[x << 1 | 1] = chk[x];
	int m = l + r >> 1;
	query(l, m, x << 1), query(m + 1, r, x << 1 | 1), undo(upd);
}

int main(){
	cin >> n >> m >> k, chk[1] = 1;
	for(int i = 1; i <= n << 1; i++) fa[i] = i, sz[i] = 1;
	for(int i = 1; i <= m; i++) {
		int x, y, l, r; read(x), read(y), read(l), read(r);
		modify(1, k, l + 1, r, 1, {x, y});
	} query(1, k, 1);
    return flush(), 0;
}

*II. P4585 [FJOI2015]火星商店问题

一道还算有趣的题目。区间求异或一个数的最大值不难想到可持久化 Trie。把修改和询问都挂到线段树上面,然后对于每个区间的所有修改,建出其可持久化 trie 然后更新所有以该区间为定位区间的询问的答案即可。时间复杂度 \(\mathcal{O}(n\log^2 n)\),空间复杂度 \(\mathcal{O}(n\log n)\)

const int N = 1e5 + 5;
const int inf = 1e9 + 7;

int n, m, a[N], ans[N];
struct Trie {
	int node, R[N], son[N << 5][2], sz[N << 5];
	void clear() {mem(son, 0, node + 5), mem(sz, 0, node + 5), node = 0;}
	void modify(int p, int &x, int v, int bit = 16) {
		cpy(son[x = ++node], son[p], 2), sz[x] = sz[p] + 1;
		if(bit == -1) return;
		int b = v >> bit & 1;
		modify(son[p][b], son[x][b], v, bit - 1);
	}
	int query(int x, int y, int v, int bit = 16) {
		if(bit == -1 || !(sz[y] - sz[x])) return 0;
		int b = v >> bit & 1, c = b ^ 1;
		if(sz[son[y][c]] - sz[son[x][c]]) return (1 << bit) +
			query(son[x][c], son[y][c], v, bit - 1);
		return query(son[x][b], son[y][b], v, bit - 1);
	}
} seq, tr;

struct Query {
	int l, r, v, id;
};
vector <Query> qu[N << 2];
vpii upd[N << 2];
void modify(int l, int r, int p, int x, pii v) {
	upd[x].pb(v);
	if(l == r) return;
	int m = l + r >> 1;
	if(p <= m) modify(l, m, p, x << 1, v);
	else modify(m + 1, r, p, x << 1 | 1, v);
}
void query(int l, int r, int ql, int qr, int x, Query v) {
	if(ql <= l && r <= qr) return qu[x].pb(v), void();
	int m = l + r >> 1;
	if(ql <= m) query(l, m, ql, qr, x << 1, v);
	if(m < qr) query(m + 1, r, ql, qr, x << 1 | 1, v);
}

void calc(vpii u, vector <Query> q) {
	tr.clear(), sor(u);
	int pre = 0;
	for(pii it : u)
		tr.modify(tr.R[pre], tr.R[it.fi], it.se), pre = it.fi;
	for(auto it : q) {
		int l = lower_bound(all(u), (pii){it.l, 0}) - u.begin();
		int r = upper_bound(all(u), (pii){it.r, inf}) - u.begin() - 1;
		if(r < 0) continue;
		l = l ? u[l - 1].fi : 0, r = u[r].fi;
		cmax(ans[it.id], tr.query(tr.R[l], tr.R[r], it.v));
	}
}
void solve(int l, int r, int x) {
	calc(upd[x], qu[x]);
	if(l == r) return;
	int m = l + r >> 1;
	solve(l, m, x << 1), solve(m + 1, r, x << 1 | 1);
}

int main() {
	cin >> n >> m, mem(ans, -1, N);
	for(int i = 1; i <= n; i++) 
		seq.modify(seq.R[i - 1], seq.R[i], a[i] = read());
	for(int i = 1, day = 0; i <= m; i++) {
		int op = read(); day += !op;
		if(op == 0) {
			int s = read(), v = read();
			modify(0, m, day, 1, {s, v});
		} else {
			int l = read(), r = read(), x = read(), d = read();
			ans[i] = seq.query(seq.R[l - 1], seq.R[r], x);
			if(d) query(0, m, max(0, day - d + 1), day, 1, {l, r, x, i});
		}
	} solve(0, m, 1);
	for(int i = 1; i <= m; i++) if(~ans[i]) cout << ans[i] << "\n";
	return 0;
}

3.3 参考资料

4. 李超线段树

李超线段树,简称李超树。它支持插入 直线线段,并查询某个横坐标处的 最值

4.1 算法简介

李超线段树可以 在线 解决下述问题:两种操作,添加直线 \(y_i = k_ix + b_i\) 或 给定 \(x\),求 \(\max k_ix + b_i\)

它的核心思想是维护 区间最优直线,及时排除不可能成为答案的直线。具体地,维护 \(v_i\) 表示区间 \([l_i, r_i]\) 的最优直线的 编号,即在 \(m = \dfrac {l_i + r_i} 2\)取值最大 的直线。

上述过程中,我们利用了这样一条性质:对于区间 \([l, r]\) 及其中点 \(m\),若两条直线 \(y_1, y_2\)\(m\) 处的取值满足 \(y_1(m)\geq y_2(m)\),那么 不可能 存在两点 \(a, b\) 满足 \(l\leq a\leq m \leq b \leq r\),使得 \(y_1(a) < y_2(a)\)\(y_1(b) < y_2(b)\)。换句话说,若 \(y_1\) 在区间中点处高于 \(y_2\),则 \(y_2\) 高于 \(y_1\) 的部分必然被区间某半边完全包含。

因此,考虑插入一条直线 \(y_j = k_jx + b_j\),设当前区间为 \([l_i, r_i]\),中点为 \(m\)。若 \(y_j(m) > y_{v_i}(m)\),说明 \(j\)\(v_i\) 更优,交换 \(v_i\)\(j\)。现在 \(y_j(m)\leq y_{v_i}(m)\)。如果 \(y_j\) 在区间左半部分有高于 \(y_{v_i}\) 的部分,则必然 \(y_j(l_i) > y_{v_i}(l_i)\),故当 \(y_j(l_i) > y_{v_i}(l_i)\) 时,向 \([l_i, m]\) 递归插入直线 \(j\)。同理,当 \(y_j(r_i) > y_{v_i}(r_i)\) 时,向 \((m, r_i]\) 递归插入直线 \(j\)。根据上述性质,两个分支最多只会进入一个,因此时间复杂度严格线性对数。

查询 \(x = k\) 的答案时,找到线段树上包含 \([k, k]\) 的路径 \(P\),路径上所有状态的最优直线在 \(x = k\) 处取值的最大值即为所求。因为根据算法过程,若存在直线 \(y_p = k_px + b_p\) 使得在 \(k\) 处它的取值最大,则它必然在 \(P\) 上的某一处。考虑反证法,若 \(y_p\) 被弹出 \(y_q\) 弹出叶子结点,或在叶子节点处 \(y_p\) 没有替换 \(y_q\) 成为最优直线,则根据流程我们有 \(y_q(k) \geq y_p(k)\),与在 \(k\)\(y_p\) 取值最大矛盾。

综上,我们在 \(\mathcal{O}(n\log V)\) 的时间内解决了问题。\(V\) 是横坐标范围。代码见例 I.

注意 初始化,根据最值类型确定第 \(0\) 条直线,如求最大值时,令 \(b_0 = -\infty\)​ 较为保险。

  • 一般来说,实现李超树的方法为 动态开点。不同于普通动态开点线段树,动态开点李超树的时间复杂度和插入直线个数 线性 相关。这是因为一条直线最多占用一个区间,而每个区间都有自己的最优直线,因此新建节点个数等于直线条数。这是它的优点:省空间

4.2 Extension

  • 扩展 1:注意到普通李超线段树 全局 插入直线。对于插入 线段局部 问题,我们先找到线段横坐标区间在李超树上的 拆分区间,再进行对于每个拆分区间的全局插入。时间复杂度线性对数平方,空间复杂度线性对数。
  • 扩展 2:李超树 不支持 删除直线。因此我们只能 离线线段树分治。时间复杂度 \(\mathcal{O}(n\log n\log V)\)。空间复杂度线性对数,瓶颈在于对每个区间存储时间范围定位区间包含该区间的所有修改,而非李超树。
  • 扩展 3:当横坐标过大时,离线离散化或在线动态开点。因为李超树本质仍是线段树,所以支持动态开点。时间复杂度同普通李超树。空间复杂度 线性
  • 扩展 4:李超树支持可持久化。
  • 扩展 5:李超线段树常与 斜率优化 同时使用。简单地说,斜率优化是维护若干个点 \((x_i, y_i)\),多次求斜率为 \(k\)\(y\) 轴上的截距最值,即 \(\min / \max y_i - kx_i\)。当 \(x_i\)\(k\) 都不单调时,无法使用单调队列维护。不妨将每个点看做直线 \(y = -x_ix + y_i\),并求出 \(x = k\) 时的最值。这是李超线段树的拿手好戏。具体见例 II. & IV.DP 优化 II 斜率优化部分例 XI. & XII.

4.3 李超树合并

李超树合并与线段树合并有相似之处。

当节点 \(y\) 合并至节点 \(x\) 上时,往 \(x\) 的子树里插入 \(y\)​ 的最优直线即可。复杂度无法用线段树合并每次湮灭掉一个区间的信息来分析,因为合并时 \(y\) 的信息不一定消失,而是进入了 \(x\) 的子树内部。

注意到总共只有 \(n\) 条直线,每向下递归一次均会使某条直线的深度加一,而所有直线深度之和的最大值显然不超过 \(n \log V\),因此最多只会递归这么多次。这说明李超树合并的时间复杂度为 \(\mathcal{O}(n\log V)\)

使用空间回收(即时刻保证每个区间均有最优直线,每条直线最多在一个区间内。对于湮灭的区间,清空所有信息 如左右儿子和最优直线,并丢进垃圾桶)时,空间复杂度为线性。

若合并需要可持久化(不能破坏原有李超树的结构,类似线段树合并)或不使用空间回收,则空间复杂度最坏可达到线性对数。

4.4 例题

I. P4097 [HEOI2013]Segment

模板题。

#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 5, X = 39989, Y = 1e9;
int n, cnt, mx[X << 2];
double k[N], b[N];
double get(int x, int id) {return k[id] * x + b[id];}
int get(int a, int c, int x) {
	double b = get(x, a), d = get(x, c);
	return b == d ? min(a, c) : b > d ? a : c;
}
void modify(int l, int r, int x, int v) {
	int m = l + r >> 1;
	if(get(m, v) > get(m, mx[x])) swap(mx[x], v);
	if(get(l, v) > get(l, mx[x])) modify(l, m, x << 1, v);
	else if(get(r, v) > get(r, mx[x])) modify(m + 1, r, x << 1 | 1, v);
}
void modify(int l, int r, int ql, int qr, int x, int v) {
	if(ql <= l && r <= qr) return modify(l, r, x, v);
	int m = l + r >> 1;
	if(ql <= m) modify(l, m, ql, qr, x << 1, v);
	if(m < qr) modify(m + 1, r, ql, qr, x << 1 | 1, v);
}
int query(int l, int r, int x, int p) {
	if(l == r) return mx[x];
	int m = l + r >> 1;
	if(p <= m) return get(query(l, m, x << 1, p), mx[x], p);
	return get(query(m + 1, r, x << 1 | 1, p), mx[x], p);
}
int main() {
	cin >> n;
	for(int i = 1, las = 0; i <= n; i++) {
		int op, c, d, e, f;
		scanf("%d %d", &op, &c), c = (c + las - 1) % X + 1;
		if(op == 0) printf("%d\n", las = query(1, X, 1, c));
		else {
			scanf("%d %d %d", &d, &e, &f), e = (e + las - 1) % X + 1;
			d = (d + las - 1) % Y + 1, f = (f + las - 1) % Y + 1;
			if(c > e) swap(c, e), swap(d, f);
			if(c == e) b[++cnt] = max(d, f);
			else k[++cnt] = (double)(f - d) / (e - c), b[cnt] = d - c * k[cnt];
			modify(1, X, c, e, 1, cnt);
		}
	}
	return 0;
}

*II. CF1175G Yet Another Partiton Problem

题解

III. P4254 [JSOI2008]Blue Mary 开公司

板子题了属于是。

IV. BZOJ1767 [CEOI2009]Harbingers

本题是 NOI 2014 购票(可撤销 BIT 维护凸包斜率优化,见 DP 优化 II 斜率优化部分例题 VIII.)的弱化版原题。考虑转移方程

\[f_i = \min_{j \in \mathrm{anc}(i)} f_j + s_i + V_i(d_i - d_j) \]

其中 \(d_i\) 表示点 \(i\) 的深度。由于没有 \(l\) 的限制,直接可持久化 / 可撤销李超树维护树上斜率优化。时间复杂度 \(\mathcal{O}(n\log n)\)

posted @ 2021-06-13 17:08  qAlex_Weiq  阅读(6849)  评论(11编辑  收藏  举报