DS 进阶
李超线段树:
Problem: 维护一个一次函数集合 \(S\),支持两个操作:
- 加入一条一次函数 \(f(x) = kx + b(l \le x \le r)\)
- 给出 \(x\),求 \(\max_{f \in S}{f(x)}\)
首先对于一个一次函数,将 \([l, r]\) 拆成 \(\log n\) 个区间,然后把一次函数挂到这个节点上,每次询问对这些一次函数取个 \(\max\)。这个朴素算法有一个问题,就是可能一个节点可能挂了很多个函数,时间复杂度无法保证。
考虑改进这个朴素算法,我们考虑在每个节点上只保留一个函数。于是当两个函数同时挂到一个节点时,我们比较两个函数并得到他们的优势区间,显然只会有一个函数跨过区间,于是将另一个函数下放到它的优势区间。由于这是单边递归,时间复杂度显然是 \(O(\log n)\)。那么由于拆出来有 \(O(\log n)\) 的区间,单次操作时间复杂度 \(O(\log^2 n)\),查询 \(O(\log n)\)。于是总时间复杂度 \(O(m \log^2 n)\)。
注意到某些需要斜率优化的 \(\rm DP\) 也等价于加入一次函数和单点求 \(\max\),而且每次都是全局加不需要拆分区间,时间复杂度 \(O(n \log n)\),吊打 \(\rm CDQ\) 和平衡树。
例题。
code
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N = 1e5 + 10;
const double eps = 1e-9;
struct Segment{
long double k, b;
void init(int x0, int x1, int y0, int y1){
if(x0 == y0) k = 0, b = max(y0, y1);
else k = 1.0 * (y1 - y0) / (x1 - x0), b = y0 - 1.0 * x0 * k;
}
double val(int x){return 1.0 * k * x + b;}
}S[N];
// ask if x > y(x = y: 2)
int cmp(double x, double y){
if(y - x > eps) return 0;
if(x - y > eps) return 1;
return 2;
}
// get maxid
int max(int id1, int id2, int x){
int op = cmp(S[id1].val(x), S[id2].val(x));
if(op == 2) return (id1 < id2 ? id1 : id2);
return (op ? id1 : id2);
}
struct Segtree{
#define ls (o << 1)
#define rs (o << 1 | 1)
#define mid (l + r >> 1)
int tag[N << 2];
void upd(int o, int l, int r, int id1){
if(!tag[o]){tag[o] = id1; return;}
if(max(id1, tag[o], mid) == id1) swap(id1, tag[o]);
if(l == r) return;
if(max(id1, tag[o], l) == id1) upd(ls, l, mid, id1);
if(max(id1, tag[o], r) == id1) upd(rs, mid + 1, r, id1);
}
void findSeg(int o, int l, int r, int s, int t, int id){
if(s <= l && r <= t){upd(o, l, r, id); return;}
if(s <= mid) findSeg(ls, l, mid, s, t, id);
if(mid < t) findSeg(rs, mid + 1, r, s, t, id);
}
int qrymax(int o, int l, int r, int x){
if(l == r) return tag[o];
if(x <= mid) return max(tag[o], qrymax(ls, l, mid, x), x);
else return max(tag[o], qrymax(rs, mid + 1, r, x), x);
}
}Seg;
int n, tot, siz = 39990;
void ADD(int& x, int y, int mod){x = (x + y - 1) % mod + 1;}
signed main(){
ios::sync_with_stdio(0);
cin.tie(0); cout.tie(0);
cin >> n; int lstans = 0;
while(n--){
int opt; cin >> opt;
if(opt == 0){
int pos; cin >> pos; ADD(pos, lstans, 39989);
cout << (lstans = Seg.qrymax(1, 1, siz, pos)) << "\n";
}
else{
int x0, y0, x1, y1; cin >> x0 >> y0 >> x1 >> y1;
ADD(x0, lstans, 39989); ADD(x1, lstans, 39989);
ADD(y0, lstans, 1e9); ADD(y1, lstans, 1e9);
S[++tot].init(x0, x1, y0, y1);
Seg.findSeg(1, 1, siz, min(x0, x1), max(x0, x1), tot);
}
}
// system("pause");
return 0;
}
线段树合并
Problem: 维护 \(n\) 个元素,初始时,每个元素自成一个集合。操作可以将两个集合合并,或对某个集合进行查询。
用动态开点线段树维护集合。合并两棵动态开点线段树时,只有双方重合的节点需要处理,耗时为“重合节点数目”。每次合并,总节点数都会减少“重合节点数目”,而总结点数是 \(O(n \log n)\) 的,故总复杂度均摊 \(O(n \log n)\)。
这样讲可能过于抽象。来看例题 P3224 [HNOI2012] 永无乡。
连边等价于合并两个联通块,用并查集维护。对于一个联通块,我们用权值线段树维护即可。每次合并两个联通块时,只合并重复的节点即可。这样时间复杂度和空间复杂度均为 \(O(n \log n)\)。
code
#include<bits/stdc++.h>
using namespace std;
const int N = 1e5 + 10;
int n, m, fa[N], rev[N];
int findfa(int x){return fa[x] = (fa[x] == x) ? x : findfa(fa[x]);}
struct node{
int ls, rs, sum;
}t[N << 5];
int tot, rub[N << 5], tb;
int newnode(int val){
int id = (tb ? rub[tb--] : (++tot));
t[id] = {0, 0, 1}; return id;
}
struct Segtree{
#define mid (l + r >> 1)
int root;
void pushup(int o){t[o].sum = t[t[o].ls].sum + t[t[o].rs].sum;}
void init(int val){root = build(1, n, val);}
int build(int l, int r, int val){
int o = newnode(val); if(l == r) return o;
if(val <= mid) t[o].ls = build(l, mid, val);
else t[o].rs = build(mid + 1, r, val);
pushup(o);
return o;
}
int getkth(int o, int l, int r, int k){
if(t[o].sum < k) return -1;
if(l == r) return rev[l]; int lsiz = t[t[o].ls].sum;
if(lsiz >= k) return getkth(t[o].ls, l, mid, k);
else return getkth(t[o].rs, mid + 1, r, k - lsiz);
}
int merge(int o, int rt, int l, int r){
if((!o) || (!rt)) return o + rt;
t[o].ls = merge(t[o].ls, t[rt].ls, l, mid);
t[o].rs = merge(t[o].rs, t[rt].rs, mid + 1, r);
pushup(o); rub[++tb] = rt; return o;
}
}Seg[N];
void merge(int x, int y){
int fx = findfa(x), fy = findfa(y);
if(fx == fy) return;
fa[fy] = fx; Seg[fx].merge(Seg[fx].root, Seg[fy].root, 1, n);
}
signed main(){
ios::sync_with_stdio(0);
cin.tie(0); cout.tie(0);
cin >> n >> m;
for(int i = 1; i <= n; i++){
int x; cin >> x; rev[x] = i;
Seg[i].init(x); fa[i] = i;
}
for(int i = 1; i <= m; i++){
int x, y; cin >> x >> y;
merge(x, y);
}
int T; cin >> T;
while(T--){
char opt; int x, y; cin >> opt >> x >> y;
if(opt == 'Q') cout << Seg[findfa(x)].getkth(Seg[findfa(x)].root, 1, n, y) << "\n";
else merge(x, y);
}
// system("pause");
return 0;
}
线段树合并还可以用来优化树形 \(\rm DP\),比如 P6847 [CEOI2019] Magic Tree。
显然有一个 \(O(nk)\) 的暴力 \(\rm DP\),设 \(f_{u, i}\) 设考虑以 \(u\) 为根的子树中,时间 \(\le i\) 的最多果汁数。有两种转移:
-
不割 \(u\) 和父亲的连边:\(f_{u, i} = \sum_{v \in subtree(u)} f_{v, i}\)。
-
割 \(u\) 和父亲的连边:\(\forall i \in [d_u, k], f_{u, i} = \max(f_{u, i}, \sum_{v \in subtree(u)} f_{v, d_u} + w_u)\)
于是用下标为时间的线段树维护 \(f_{u}\),于是我们首先将 \(u\) 的所有子树的线段树进行合并。第二个转移等价于将区间 \([d_u, k]\) 取 \(\max\)。然后注意到 \(f_{u, i}\) 是单调不减的,于是每次等价于找到一个最大的 \(c\),使得 \(i \in [d_u, c], f_{u, i} \le val(val = \sum_{v \in subtree(u)} f_{v, d_u} + w_u)\),将 \([d_u, c]\) 全部置为 \(val\)。但是线段树合并的时间复杂度是均摊正确的,如果下传标记就会导致新建一堆节点增加很多节点,于是我们只能使用标记永久化的技巧,而标记永久化的标记需要满足可加性,但是赋值显然是不满足的,于是我们将赋值改成区间推平和区间加。区间推平就把整个区间删除即可。
code
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N = 1e5 + 10;
struct edge{
int v, next;
}edges[N << 1];
int head[N], idx;
void add_edge(int u, int v){
edges[++idx] = {v, head[u]};
head[u] = idx;
}
int n, m, k, d[N], w[N];
struct node{
int ls, rs, mx, mn, tag;
}t[N << 5];
int buc[N << 5], tot, tb;
int crenode(){return (tb ? buc[tb--] : (++tot));}
void delnode(int &id){
if(id == 0) return;
t[id] = {0, 0, 0, 0, 0}; buc[++tb] = id; id = 0;
}
struct Segtree{
int root;
#define mid (l + r >> 1)
void pushup(int o){
t[o].mx = max(t[t[o].ls].mx, t[t[o].rs].mx) + t[o].tag;
t[o].mn = min(t[t[o].ls].mn, t[t[o].rs].mn) + t[o].tag;
}
// merge two tree
void merge(int &o, int &rt, int l, int r){
if((!o) || (!rt)){o = o + rt; return;}
if(l == r){
t[o].tag += t[rt].tag; delnode(rt);
t[o].mx = t[o].mn = t[o].tag; return;
}
t[o].tag += t[rt].tag;
merge(t[o].ls, t[rt].ls, l, mid); merge(t[o].rs, t[rt].rs, mid + 1, r);
pushup(o); delnode(rt);
}
// set [s, e] = v (e <= v)
void modify(int &o, int l, int r, int s, int v){
if(!o) o = crenode(); //cout << o << " " << l << " " << r << " " << s << " " << v << "\n";
if(s <= l){
if(t[o].mx <= v){
delnode(t[o].ls); delnode(t[o].rs);
t[o].tag = t[o].mx = t[o].mn = v;
return;
}
else{
v -= t[o].tag;
if(t[t[o].ls].mn < v) modify(t[o].ls, l, mid, s, v);
if(t[t[o].rs].mn < v) modify(t[o].rs, mid + 1, r, s, v);
pushup(o); return;
}
}
v -= t[o].tag;
if(s <= mid) modify(t[o].ls, l, mid, s, v);
modify(t[o].rs, mid + 1, r, s, v);
pushup(o);
}
int qrysingle(int o, int l, int r, int x){
if(!o) return 0;
if(l == r) return t[o].tag;
if(x <= mid) return t[o].tag + qrysingle(t[o].ls, l, mid, x);
else return t[o].tag + qrysingle(t[o].rs, mid + 1, r, x);
}
int qryall(){return t[root].mx;}
}Seg[N];
void dfs(int u, int fa){
for(int i = head[u]; i; i = edges[i].next){
int v = edges[i].v; if(v == fa) continue;
dfs(v, u); Seg[u].merge(Seg[u].root, Seg[v].root, 1, k);
}
if(d[u]){
int s = w[u] + Seg[u].qrysingle(Seg[u].root, 1, k, d[u]);
Seg[u].modify(Seg[u].root, 1, k, d[u], s);
//cout << s << " " << Seg[u].qryall() << "\n";
}
}
signed main(){
ios::sync_with_stdio(0);
cin.tie(0); cout.tie(0);
cin >> n >> m >> k;
for(int i = 2; i <= n; i++){
int x; cin >> x;
add_edge(x, i); add_edge(i, x);
}
for(int i = 1; i <= m; i++){
int v; cin >> v;
cin >> d[v] >> w[v];
}
dfs(1, 0); cout << Seg[1].qryall();
// system("pause");
return 0;
}
线段树分治
线段树分治可以将 修改-查询-删除 这类操作以一个 \(\log\) 的代价变成 修改-查询-撤回。这种对于类似并查集的数据结构可以直接使用,方便操作。
先咕咕咕了。
猫树分治
对于一类可合并信息 \(U\) ,且两个 \(U_1, U_2\) 合并的代价比较大而将一个新元素加入 \(U\) 的代价不大的时候,可以考虑使用猫树分治来从而实现减少合并带来的时间复杂度开销。
猫树分治的具体思想就是对于一个区间 \([l, r]\),处理被 \([l, r]\) 完全包含的询问。不妨先处理 \([l, mid]\) 和 \([mid + 1, r]\) 的子问题。然后考虑所有跨过中点 \(mid\) 的询问。不难发现每个跨过中点的询问都可以被一个以 \(mid - 1\) 为结尾的后缀和一个以 \(mid\) 为开头的前缀合并而成,先处理出以 \(mid - 1\) 为结尾的所有后缀的信息,还有前缀就可以了。
例题:
Problem: 给出长度为 \(n\) 的序列 \(a_i\) 和固定模数 \(m\),\(q\) 个询问 \([l_i, r_i]\),每次查询 \([l_i, r_i]\) 中有多少个子序列满足和是 \(m\) 的倍数。其中 \(n, q \le 2 \times 10^5, m \le 20\)。
不难直接用线段树维护背包,时间复杂度 \(O(nm^2 \log n)\),无法通过。
接下来考虑猫树分治,显然对于一个背包加入一个点的时间复杂度是 \(O(m)\) 的,然后最后合并前后缀信息时不需要求出背包的所有信息,只求出和模 \(m\) 等于 \(0\) 的即可,这里也是 \(O(m)\) 的。于是这道题就以 \(O(nm \log n)\) 的时间复杂度解决了。
code
#pragma GCC optimize(3, "Ofast", "inline")
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N = 2e5 + 10, mod = 1e9 + 7;
int n, m, Q, a[N], ans[N];
struct node{
int val[20];
node(){val[0] = 1; for(int i = 1; i < 20; i++) val[i] = 0;}
}bg[N];
void ADD(int& x, int y){x = (x + y) % mod;}
void ADDbag(node& bg, int x){
node ret;
for(int i = 0; i < 20; i++) ret.val[i] = bg.val[i];
for(int i = 0; i < 20; i++) ADD(ret.val[(i + x) % m], bg.val[i]);
bg = ret;
}
struct query{
int l, r, id;
};
namespace cattree{
#define ls (o << 1)
#define rs (o << 1 | 1)
#define mid (l + r >> 1)
vector<query> vec[N << 2], Ql[N], Qr[N];
void clr(vector<query>& vvvv){vector<query> qwq; swap(qwq, vvvv);}
void ADDqry(int o, int l, int r, int s, int t, int id){
if((s <= mid && mid < t) || l == r){vec[o].push_back((query){s, t, id}); return;}
if(s <= mid) ADDqry(ls, l, mid, s, t, id);
else ADDqry(rs, mid + 1, r, s, t, id);
}
void solve(int o, int l, int r){
if(l == r){
for(int i = 0; i < vec[o].size(); i++) ans[vec[o][i].id] = 1 + (a[l] == 0);
return;
}
solve(ls, l, mid); solve(rs, mid + 1, r);
for(int i = 0; i < vec[o].size(); i++) Ql[vec[o][i].l].push_back(vec[o][i]), Qr[vec[o][i].r].push_back(vec[o][i]);
node lft, rht;
for(int i = mid; i >= l; i--){
ADDbag(lft, a[i]);
for(int j = 0; j < Ql[i].size(); j++) bg[Ql[i][j].id] = lft;
}
for(int i = mid + 1; i <= r; i++){
ADDbag(rht, a[i]);
for(int j = 0; j < Qr[i].size(); j++){
int id = Qr[i][j].id;
for(int k = 0; k < m; k++)
ADD(ans[id], bg[id].val[k] * rht.val[(m - k) % m] % mod);
}
}
for(int i = 0; i < vec[o].size(); i++) clr(Ql[vec[o][i].l]), clr(Qr[vec[o][i].r]);
}
}
signed 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], a[i] = a[i] % m;
cin >> Q;
for(int i = 1; i <= Q; i++){
int l, r; cin >> l >> r;
cattree::ADDqry(1, 1, n, l, r, i);
}
cattree::solve(1, 1, n);
for(int i = 1; i <= Q; i++) cout << ans[i] << "\n";
return 0;
}
/**/
Seg-beats
\(\rm Segbeats\) 主要用来解决一类区间和定值取 \(\min\) / \(\max\) 的操作。即每次对于给定的 \([l, r]\) 和 \(v\),\(\forall l \le i \le r, a_i \gets \min(a_i, v)\)。每次查询区间和之类的东西。
普通的线段树似乎好像不能快速维护这种东西。而我们伟大的吉如一老师给出了一个很优美的剪枝。(下面以区间取 \(\max\) 为例,取 \(\min\) 同理)对于一个线段树上的节点 \(o\)(对应区间 \([l_o, r_o]\)),维护 \([l_o, r_o]\) 中的最小值 \(mn_o\) 和严格次小值 \(se_o\)。然后对区间 \([l, r]\) 执行操作时,有以下三种情况:
-
\(mn_o \ge v\):无事发生。
-
\(mn_o \le v < se_o\):令 \(mn_o \gets v\) 即可。
-
\(se_o \le v\):直接对这个节点的左右儿子进行暴力递归更新。
这样的时间复杂度是对的吗?事实上,对于 \(O(n)\) 组询问次数,这个算法的时间复杂度是 \(O(n \log n)\) 的,即单次操作均摊 \(O(\log n)\)。为什么呢?不难发现只有时间复杂度只跟出发最后一种情况的次数有关,而最后一种情况触发一定会导致这个区间中的数种类数减一。由于线段树每个节点所对应的区间的数的种类数的和是 \(O(n \log n)\) 级别的,从而最多触发 \(O(n \log n)\) 次 第三种情况。
Segbeats 结合区间加操作时间复杂度是 \(O(n \log ^2 n)\) 的,但是我不会证明/fad。