线段树分治
假如你需要维护一些信息,这些信息会在某一个时间段内出现,要求在离线的前提下回答某一个时刻的信息并,则可以考虑使用线段树分治。
典型的比如连通性,线性基等难以支持删除的信息。
简单概述 OJ 题单中 14 道题的做法,难度按个人认为的升序排序。
除此之外再提几道比较 educational 的线段树分治的题目,会打上 *。
「TJOI2018」数学计算
https://www.becoder.com.cn/problem/4413
因为 \(mod\) 不保证质数,所以不一定存在逆运算。
考虑线段树分治,然后只有乘法。
「BZOJ3237 Ahoi2013」连通图
https://www.becoder.com.cn/problem/17237
维护连通性板子,正常维护连通性是使用并查集的,但是这里需要撤销。
栈序撤销并查集
路径压缩过后并查集很难支持撤销,于是考虑通过启发式合并降低 find 的复杂度,把 fa 与 siz 的修改用栈存下来然后撤回。实现如下。
#define pi pair<int, int>
#define fi first
#define se second
const int N = 200020;
struct DSU {int fa[N], siz[N];stack<pi> stk;DSU() {rep(i, 0, N - 1)fa[i] = i, siz[i] = 1;}inline int find(int x) {while(fa[x] != x) x = fa[x];return x;}inline void merge(int x, int y) {x = find(x), y = find(y);if(x == y) return;if(siz[x] > siz[y]) swap(x, y);stk.push({x, y});fa[x] = y, siz[y] += siz[x];}inline void undo(int lst) {while(stk.size() > lst) {pi x = stk.top();stk.pop();fa[x.fi] = x.fi;siz[x.se] -= siz[x.fi];}}
} D;
注意复杂度是 \(n\log^2 n\) 的。
接着几个是维护并查集的例题
「CF1140F」Extending Set of Points
https://www.becoder.com.cn/problem/32792
首先通过线段树分治变成只加点。
然后考虑只有加入怎么求点的个数。
考虑构造一个无向图,有 \(n\) 个点 \(X_i\) 以及 \(n\) 个点 \(Y_i\),把集合加入一个点 \((a,b)\) 转化成给无向图中加一条边 \((x_a,Y_b)\) ,然后不难发现答案应该是无向图中每个连通块中 \(X\) 点与 \(Y\) 点个数的成绩,并查集维护即可。
「SHOI 2008」堵塞的交通
https://www.becoder.com.cn/problem/4931
维护连通性板子,不赘述。
「SHOI2014」神奇化合物
https://www.becoder.com.cn/problem/4056
也是板子,不赘述。
*P5787 二分图 /【模板】线段树分治
https://www.luogu.com.cn/problem/P5787
二分图判定同样也是可以通过并查集实现的,通过种类并查集将 \(i,j\) 颜色不等转化成连边 \((i+n,j),(i,j+n)\) ,然后判断是否对任意 \(i,i+n\) 均不在同一连通块中即可。
Painting Edges
https://www.becoder.com.cn/problem/23537
线段树分治维护二分图判定的板子题。
但是值得注意的是这个题中出现了加入加边不合法则无视该操作,处理的方法是假如一条边是不合法的,那么我们就先打上 tag,然后之后线段树分治的时候加边前先判断是否被打了 tag。
线性基同样也是可以支持栈序撤销的。
考虑用个栈存成功插入的元素,然后因为是栈序撤销,撤销时直接把插入位置清空即可。
当然有的时候因为线性基大小是很小的,直接暴力复制一个也是可以过的。
「BZOJ4184」 shallot
https://www.becoder.com.cn/problem/18184
线段树分治维护线性基板子。
贴个没有栈序撤销的代码。
#include <bits/stdc++.h>
#define rep(i, x, y) for (int i = x; i <= y; ++i)
#define drep(i, x, y) for (int i = x; i >= y; --i)
#define ll long long
#define pb push_back
#define IOS ios::sync_with_stdio(false), cin.tie(0), cout.tie(0)
using namespace std;
#define ui unsigned int
const int N = 500050;
struct D {ui f[32];D() { rep(i, 0, 30) f[i] = 0; }inline void ins(ui x) {drep(i, 30, 0) {if (x >> i & 1) {if (f[i]) {x ^= f[i];continue;}f[i] = x;return;}}}inline ui Q() {ui res = 0;drep(i, 30, 0) if ((res ^ f[i]) > res) res ^= f[i];return res;}
};
int n, a[N];
unordered_map<int, int> lst;
ui ans[N];
vector<ui> T[N << 2];
void ins(int ql, int qr, int x, int p = 1, int l = 1, int r = n) {if (qr < l || r < ql)return;if (ql <= l && r <= qr)return void(T[p].pb(x));int mid = (l + r) >> 1;ins(ql, qr, x, p << 1, l, mid);ins(ql, qr, x, p << 1 | 1, mid + 1, r);
}
void solve(int p, int l, int r, D x) {for (ui y : T[p]) x.ins(y);if (l ^ r) {int mid = (l + r) >> 1;solve(p << 1, l, mid, x);solve(p << 1 | 1, mid + 1, r, x);} else {ans[l] = x.Q();}
}
int main() {IOS;cin >> n;rep(i, 1, n) {cin >> a[i];if (a[i] < 0)ins(lst[-a[i]], i - 1, -a[i]), lst[-a[i]] = 0;elselst[a[i]] = i;}for (auto x : lst) {if (x.second != 0)ins(x.second, n, x.first);}D ep;solve(1, 1, n, ep);rep(i, 1, n) cout << ans[i] << '\n';return 0;
}
「BZOJ4644」经典傻逼题
题目名字怎么骂藏?
https://www.becoder.com.cn/problem/18644
但是确实是 sb 题,难点在读题。
定义 \(val_x\) 为与 \(x\) 相连的所有边的边权的异或和,然后不难发现答案应该是 \(val\) 的最大异或和,因为有修改,线段树分治即可。
假如你做过 [WC2011] 最大XOR和路径 那么就会有并查集与线性基同时使用的题目。
「HAOI2017」八纵八横
https://www.becoder.com.cn/problem/4164
根据 [WC2011] 最大XOR和路径 的做法,应当每次把环的边权和加入线性基中。
于是考虑并查集,维护每个点到根的边权异或和,然后再用栈序撤销线性基就行。
这个题输入输出比较恶心,注意一下。
「CF1442D」选数
https://www.becoder.com.cn/problem/31218
运用了线段树分治的思想,因为 \(t_i\ge n\) ,于是可以考虑这样的策略,枚举 \(i\) ,然后求出除了 \(i\) 之外的栈的背包 dp,然后枚举当前栈选几个。
然后直接线段树分治加背包就行,这个题性质比较好,不需要写插入,直接分治即可。
「雅礼集训 2018 Day10」贪玩蓝月
https://www.becoder.com.cn/problem/5503
经典题,不难发现线段树分治是可做的,但是过不去。
不难发现我们实际上要维护这样的一个问题,有一个序列,要在开头加入删除或者末尾加入删除,然后维护序列的和但是只满足结合律,要做到线性。
做法是双栈,具体双栈的介绍以及复杂度说明可以看这个文章 :https://www.becoder.com.cn/article/4282
「FJOI2015」火星商店问题
https://www.becoder.com.cn/problem/6483
个人认为这东西应该用线段树套 Trie 树是比较直接的。然后假如要优化空间可以离线一下空间少一只 \(\log\) 。
假如有线段树分治做法敲我,可能是我太菜了。
「BZOJ4311」向量
https://www.becoder.com.cn/problem/18311
经典的一个套路,求与 \((a,b)\) 点乘的最大值其实 \(ax+by=a(x+\frac b ay)\) ,然后不难发现后边是个直线 \(kx+b\) 的形式,于是可以用李超线段树解决。
因为有删除可以考虑给李超线段树栈序撤销或者可持久化。
李超线段树是可以可持久化的哦,空间也是 \(1\log\) 。
「CTSC2016」时空旅行
https://www.becoder.com.cn/problem/4933
很大一坨东西是定值,然后发现问题变成了 「BZOJ4311」向量 ,直接套上去就行了。
但是这样做有点卡空间。
OJ 上边的题就这些了,接着提下一些其它比较有价值的东西。
首先线段树是可以栈序撤销的,用个栈存修改的点的下标然后回撤回去。
假如只有单点修改空间是可以做到线性的,直接把修改的叶子节点存下来,然后撤回时先回撤叶子节点,然后将叶子到根的路径重新 pushup 更新一遍。
*P11619 [PumpkinOI Round 1] 种南瓜
https://www.luogu.com.cn/problem/P11619
不难发现题意应该是询问是否存在两个区间只相交但互不包含。
假如只有加入是好做的,我们每次加入 \([l,r]\) ,要判断是否有 \([L,R]\) 满足 \(L < l \leq R < r\) 或者 \(l < L \leq r < R\) ,对 \(R\) 为下标维护区间 \(L\) 的最小值,对 \(L\) 为下标维护区间 \(R\) 最大值,两颗线段树即可。
有删除加上线段树分治即可,复杂度是 \(q\log q\log n\) 的。
至于回撤操作,用栈序撤销的线段树即可,写 zkw 的话常数极小,不怕被卡常。代码也很简单。
#include <bits/stdc++.h>
#define rep(i, x, y) for (int i = x; i <= y; ++i)
#define drep(i, x, y) for (int i = x; i >= y; --i)
#define ll long long
#define pb push_back
#define IOS ios::sync_with_stdio(false), cin.tie(0), cout.tie(0)
using namespace std;
const int N = 200020;
int n, q, lst[N], l[N], r[N], op[N], stk[N], tp; vector<int> T[N << 2];
struct zkw {int t[N << 2], m, tp; pair<int, int> stk[N << 3]; // Min SegTreeinline void build() { m = 1, memset(t, 0x3f, sizeof t); while(m <= n) m <<= 1; }inline void pushup(int p) { t[p] = min(t[p << 1], t[p << 1 | 1]); }inline void up(int p) { while(p >>= 1) pushup(p); }inline void upd(int x, int y) { x += m, stk[++tp] = {x, t[x]}, t[x] = min(t[x], y), up(x); }inline void undo(int lst) { while(tp > lst) t[stk[tp].first] = stk[tp].second, up(stk[tp].first), --tp; }inline int qry(int l, int r) { int res = 1e9; if(l > r) return res;for(l += m - 1, r += m + 1; l ^ r ^ 1; l >>= 1, r >>= 1) {if(~l & 1) res = min(res, t[l ^ 1]); if(r & 1) res = min(res, t[r ^ 1]);} return res;}
} t1, t2;
inline void insert(int ql, int qr, int x, int p = 1, int l = 1, int r = q) {if(qr < l || r < ql) return; if(ql <= l && r <= qr) return void(T[p].pb(x));int mid = (l + r) >> 1; insert(ql, qr, x, p << 1, l, mid);insert(ql, qr, x, p << 1 | 1, mid + 1, r); }
inline void Ins(int l, int r) {++tp, stk[tp] = stk[tp - 1];if(t1.qry(l, r - 1) < l) stk[tp] = 1;if(-t2.qry(l + 1, r) > r) stk[tp] = 1;t1.upd(r, l), t2.upd(l, -r);
}
inline void solve(int p = 1, int l = 1, int r = q) {int lst = tp; for(int x : T[p]) Ins(::l[x], ::r[x]);if(l == r) cout << (stk[tp] ? "No\n" : "Yes\n");else { int mid = (l + r) >> 1;solve(p << 1, l, mid), solve(p << 1 | 1, mid + 1, r);} t1.undo(lst), t2.undo(lst), tp = lst;
}
int main() {IOS; cin >> n >> q, t1.build(), t2.build();rep(i, 1, q) {cin >> op[i];if(op[i] == 1) cin >> l[i] >> r[i];else cin >> l[i], lst[l[i]] = i; }rep(i, 1, q) if(op[i] == 1) insert(i, (!lst[i] ? q : lst[i] - 1), i);solve(); return 0;
}
*P10611 故事结局
https://www.luogu.com.cn/problem/P10611
线段树分治是非正解,需要卡常,我还没调出来,但还是提一下
先颜色均摊,然后变成只有加入,删除。
接着以行为下标建立线段树,然后把查询拆成 \(q\log n\) 个。
然后考虑子问题,即区间插入一个数 \(x\),删除之前一次操作,以及区间最值。
两种做法,一种直接标记永久化 + set,一种是再离线一遍然后线段树分治。
\(n,q\) 同阶,总复杂度是 \(n\log^3n\) 的,难以通过。
*CF500F New Year Shopping
https://www.luogu.com.cn/problem/CF500F
线段树分治维护背包板子,复杂度 \(nm\log n+q\) ,\(m\) 是背包容量。
常数很小直接 300ms 飞过去。
*P3206 [HNOI2010] 城市建设
https://www.luogu.com.cn/problem/P3206
维护动态最小生成树。
两种做法,一种是只保留有用边然后递归下去,细节比较繁琐,一种比较无脑,考虑线段树分治过后只有加边,然后变成 xxxx 经典题,用 LCT 维护生成树即可。
总结
个人认为线段树分治是一个特别实用的技巧,最近的 CF div2 有场的 F 也是线段树分治。在维护连通性时在代码难度上会远低于 LCT ,很多时候代码难度低,用途广,特别适合拿部分分以及辅助解决一些不支持删除的问题。