线段树分治
核心:对于多查询问题,每个查询都有自身关于查询时间轴生效的时间段。将每个查询的时间段加到线段树对应的 \(O(\log)\) 个结点上,那么对于从根节点经过 \(\forall\) 叶节点,处理往叶节点走的过程中的每个结点上的任务,最终到达叶节点时,可以得到对应时段的正确状态。
线段树分治结合可撤销并查集
动态图连通性(模板)
线段树分治与可撤销并查集结合的模板题。
Q:\(n \leq 5000\),维护多次查询图中加边,删边,查询两点是否在同一连通块中这三种操作。
注意这并不能直接用可撤销并查集来做,因为可撤销并查集只能按照栈的方式删除刚刚加入的边,而本题需要指定删除哪些边,并不一定是刚刚加入的。
但是将线段树分治与可撤销并查集结合起来,就可以解决这个问题。
假设 \(n=3\),初始时无边,有以下操作序列:
我们维护好对于每一种边,在整个时间段上的哪些区间生效(如图中所示,边 \((1, 2)\) 只在时间段 \([1, 5]\) 上生效;边 \((1, 3)\) 在时间段 \([3,4],[7,8]\) 上生效)。将每种操作按照其生效区间加入到线段树中(每种操作对应 \(O(\log)\) 数量的区间,线段树结点上的每个区间维护一个操作列表,表示在该时间段上哪些操作生效),然后对这棵线段树进行 \(dfs\) 先序遍历,在刚到达某个结点时,执行这个时间段列表中的所有加边操作;从某个结点回溯离开时,执行相应的删边操作。我们会发现,此时的加边与删边操作在时间线上类似于入栈和出栈,可以用可撤销并查集维护;而对于所有查询操作,当到达某个叶节点时,若其对应查询操作,则此时并查集的状态在该查询到来时是正确的,直接按照当前状态回答即可。
#include <bits/stdc++.h>
using namespace std;
#define inf 0x3f3f3f3f
#define INF 0x3f3f3f3f3f3f3f3f
#define fr first
#define se second
#define endl '\n'
#define pb push_back
//#define int long long
typedef long long ll;
typedef __int128 LL;
typedef unsigned long long ull;
typedef pair<int,int> pii;
typedef pair<ll,int> pli;
const int maxn = 5005;
const int maxm = 500005;
struct Query{
int op, u, v;
}qry[maxm];
int last[maxn][maxn];
// 可撤销并查集
int fa[maxn], siz[maxn];
pii opt_stk[maxm]; // 栈,存放所有合并操作,元素类型为 (大集合祖先,小集合祖先)
int top=0;
int find(int x){
while(x != fa[x]){
x = fa[x];
}
return x;
}
bool union_(int x, int y){ // 合并 x, y 所在集合
int fx = find(x), fy = find(y);
if(fx != fy){
if(siz[fx] < siz[fy]) swap(fx, fy); // 启发式合并,fy 合并到 fx
opt_stk[++top] = {fx, fy};
fa[fy] = fx;
siz[fx] += siz[fy];
return true;
}
return false;
}
void rollback(){ // 撤销上一步的合并操作
auto [fx, fy] = opt_stk[top--];
fa[fy] = fy;
siz[fx] -= siz[fy];
}
int n, m;
bool ans[maxm];
#define lc p<<1
#define rc p<<1|1
vector<pii> task[maxm*10]; // task[p] 维护线段树结点p上对应的任务列表
void add(int p, int ql, int qr, int nl, int nr, int u, int v){ // 往时间段 [ql, qr] 上加边 (u, v)
if(ql <= nl && nr <= qr){
task[p].pb({u, v});
return;
}
int mid = nl + nr >> 1;
if(ql <= mid) add(lc, ql, qr, nl, mid, u, v);
if(qr > mid) add(rc, ql, qr, mid + 1, nr, u, v);
}
void dfs(int p, int nl, int nr){
// 处理当前线段树结点p的所有操作
int union_cnt = 0;
for(auto [u, v] : task[p]){
union_cnt += union_(u, v);
}
if(nl == nr){
auto [op, u, v] = qry[nl];
if(op == 2){
ans[nl] = (find(u) == find(v));
}
}
else{
int mid = nl + nr >> 1;
dfs(lc, nl, mid);
dfs(rc, mid + 1, nr);
}
for(int t = 0; t < union_cnt; t ++){
rollback();
}
}
void solve()
{
cin >> n >> m;
for(int i = 1; i <= m; i ++){
int op, u, v;
cin >> op >> u >> v;
if(u > v) swap(u, v);
qry[i] = {op, u, v};
}
for(int i = 1; i <= n; i ++){
fa[i] = i;
siz[i] = 1;
}
for(int i = 1; i <= m; i ++){ // 以查询编号为时间轴
auto [t, u, v] = qry[i];
if(t == 0){ // 加边(u, v)
last[u][v] = i;
}
else if(t == 1){ // 删边(u, v)
add(1, last[u][v], i - 1, 1, m, u, v);
last[u][v] = 0;
}
}
for(int u = 1; u <= n; u ++){
for(int v = u + 1; v <= n; v ++){
if(last[u][v] != 0){ // 最后一步是添加边,还需要持续到最后
add(1, last[u][v], m, 1, m, u, v);
}
}
}
dfs(1, 1, m);
for(int i = 1; i <= m; i ++){
if(qry[i].op == 2){
cout << (ans[i] ? "Y\n" : "N\n");
}
}
}
signed main()
{
// freopen("in.txt", "rt", stdin);
// freopen("out.txt", "wt", stdout);
ios::sync_with_stdio(false); cin.tie(nullptr); cout.tie(nullptr);
// int T=1; cin>>T; while(T--)
solve();
return 0;
}
P5787
线段树分治 结合 扩展域并查集判断二分图。
P5631
一道非常巧妙的题,结合线段树分治。
Q:给定 \(n\) 个点,\(m\) 条边的无向连通图,每条边都有边权。求出一棵该图的生成树,使得该生成树的边权集合 \(mex\) 尽可能小。只需要求这个最小 \(mex\) 值。
先考虑暴力怎么做:若 \(mex = 0\) 可行,则该生成树中一定不存在权值为 \(0\) 的边,其他边无所谓。那么我们只需要去除整个图中权值为 \(0\) 的边,看图的所有点是否全连通即可。对于其他 \(mex\) 值也一样,只需要去除掉所有权值为当前考察 \(mex\) 的边,再看整张图所有点的连通性即可。
考虑线段树分治:按照答案轴建立线段树。我们想看到的效果是:对于 \(\forall x\),只有权值为 \(x\) 的边不出现在考虑 \(mex = x\) 的图中。也就意味着,对于权值为 \(x\) 的边,它出现在考虑 \(mex \in [0,x-1] \cup [x+1,maxv]\) 的情况中。因此我们只需要在线段树上相应的这两个区间加入这条边即可(相当于在考虑答案在 \([0,x-1] \cup [x+1,maxv]\) 时,加入权值为 \(x\) 的边)。当考虑某个叶节点时,整张图中只有对应叶节点权值的边没有加入到图中,此时判断整张图的连通性即可。
CF1681F
又是一道特别巧妙的题。
Q:给定一棵大小为 \(n\) 的树,每条边都有一个颜色编号。定义 \(f(u, v)\):\(path(u, v)\) 上只出现一次的颜色的数量。求 \(\sum_{u=1}^{n}\sum_{v=u+1}^{n}f(u, v)\)。
考虑转化贡献计算的方式:相当于求对于每一种颜色 \(c\),\(c\) 只出现一次的路径数量 \(cnt_{c}\)。那么答案为 \(\sum_{c=1}^{n}cnt_{c}\)。
考虑对于颜色 \(c\),如何求 \(cnt_{c}\):我们可以考虑将树中所有颜色为 \(c\) 的边去除,再依次添加每一条颜色为 \(c\) 的边。这样每条颜色为 \(c\) 的边在计算贡献时就不会受其他颜色为 \(c\) 的边的影响。
那么在添加某一条颜色为 \(c\) 的边时,这条边的贡献实际上就是当前它连接的两个连通块大小的乘积(每个连通块中各选一点,形成一条包含这条颜色 \(c\) 的边的路径)。于是我们可以不实际添加边,对于每条颜色为 \(c\) 的边都按照这种方式计算它的贡献即可。
那么现在的问题是:如何快速构建去除某一种固定颜色为 \(c\) 的所有边的并查集?显然考虑线段树分治:按照颜色轴建立线段树,对于每条颜色为 \(c\) 的边,将其加入到考虑颜色 \([1,c-1] \cup [c+1,n]\) 的边时的图中。这样,到达权值为 \(c\) 的叶节点时,此时的图就是去除了所有颜色为 \(c\) 的边时的图。
具体实现见 code。
P4219
Q:一共 \(n\) 个点,初始时无边。多查询维护两种操作:
- \(A\space x\space y\):点 \(x\) 与点 \(y\) 之间连一条边,保证之前 \(x,y\) 不连通。
- \(Q\space x\space y\):查询 \((x, y)\) 这条边连接的两个端点所在连通块大小的乘积(去除这条边后的情形)。保证此时 \(x,y\) 之间存在边。
显然,在查询 \((x, y)\) 之间的边的答案时,需要在并查集中去除掉这条边本身之后再查两个端点的连通块大小。因此,每条边起作用的时间范围:从添加这条边开始之后的所有时刻中,去除正在查询这条边的时刻。在剩下的所有时刻,这条边都在图中。于是我们建立所有查询的时间轴,并将每条边添加到其对应应当存在的若干个时间段即可。具体实现见 code。
P5227
Q:给定 \(n\) 个点 \(m\) 条边的无向连通图,一共 \(q\) 条操作,每次操作删除给定的 \(c\) 条边。求删去这些边后,整张图是否连通。每条操作之间是相互独立的,\(1 \leq c \leq 4\)。
仍按照所有操作建立时间轴。对于每条边,统计它在哪些操作上被删除了,在剩下的所有操作构成的若干区间上添加这条边即可。
CF576E
Q:给定 \(n\) 个点 \(m\) 条边的无向图,一共有 \(k\) 种颜色(\(1 \leq k \leq 50\))。每条边初始时无颜色,规定这张图处于合法状态:仅保留任意一种颜色的边,均可使整张图是二分图。现在有 \(q\) 条操作,每条操作格式均如下:
- \(e\space c\):将第 \(e\) 条边染成颜色 \(c\)。若执行该操作后整张图仍合法,则执行操作并输出 YES;否则不执行操作并输出 NO。
由于 \(k\) 很小,考虑对每种颜色维护一个可撤销并查集。
ps: 本题关于修改边的颜色的特殊之处在于:对于每条操作,如果发现其要求性质不满足,则不执行这个操作。如果没有这种约束,那么这道题就与其他线段树分治模板题差不多了(都是维护某个操作在若干时间段上生效)。现在涉及到在线段树分治 dfs 的过程中,若检查出了某个操作不成功执行,如何恢复其不执行时的后续状态。
对于每个操作,需要维护好当前操作执行前的每种颜色的并查集,考虑线段树分治。按照所有操作建立时间轴,并假设所有操作都能在相应时刻成功执行(其实并不一定每条操作都会成功执行,后面会讲不成功执行时怎么恢复),在线段树的若干区间上添加操作,然后在线段树上 \(dfs\),到达叶节点时利用 扩展域并查集判断二分图 的方式判断相应操作执行后关于该颜色是否仍是二分图,如果仍是二分图,那么可以确定这个操作是成功执行的,此时继续进行 \(dfs\) 过程即可。(!重点)若执行叶节点代表的操作后,原图关于这种颜色不再是二分图,那么叶节点的这步操作实际上是不执行的,但是之前我们假设所有操作都成功执行,相应的操作已经加入到线段树结点上了。我们如何才能恢复这步操作,使之不执行呢?这一步操作不执行,等价于这一步操作结束后,相应边的颜色仍然是 上一个关于该边的操作 成功执行的颜色。 在线段树结点的任务列表上传递信息时,我们不再传递合并的两个点 \(u, v\),而是直接传递查询编号 \(id\)(\(qry[id] = \{u, v, c\}\))。若某个查询未成功执行,则直接修改相应查询的颜色 \(c\) 为上一个对该边成功执行对应的颜色。这样,在处理以后的查询时,每个线段树结点会按照查询编号取对应查询 \(qry[id] = \{u, v, c\}\),此时的颜色 \(c\) 不再是未成功执行的那个操作对应涂色的颜色。这样,相当于延续了该边的正确颜色,此后的过程也可以正确进行下去。具体实现见 code。
CF2222F
线段树分治 + 可撤销并查集,维护删除任意固定大小边权的所有边后的图:只需要在进入某个线段树结点前,添加其兄弟结点任务列表里的所有边;从某个结点回溯时撤销之前对兄弟结点的添加操作。
线段树分治结合 01 背包
CF601E (模板)
Q:给定 \(n\) 种商品,每个商品均有价值 \(v\) 和重量 \(w\)。初始时 $1 \backsim n $ 号商品加入 \(S\)。
\(q\) 种操作:
- \(1\space x\space y\):集合 \(S\) 中增加价值 \(x\),重量 \(y\) 的商品,商品编号自增得到。
- \(2\space x\):集合 \(S\) 中删除编号为 \(x\),保证当前编号为 \(x\) 的物品存在。
- \(3\):查询 \(f(S)\),其中:(\(k, bas, mod\) 均为给定整数,\(a(S, m)\) 表示从集合 \(S\) 中,挑选商品总重量 \(\leq m\),可获得商品的最大价值,即 \(01\) 背包的最优解)
按所有操作的时间点 \(1 \backsim q\) 建立线段树,按照商品的添加与删除情况,处理出每件商品生效的时间段,并添加到线段树结点中。在 dfs 线段树的过程中,将每个线段树结点上的物品加入到 \(01\) 背包中(跑一遍倒序体积循环)。这样,dfs 到叶节点处时,会得到相应的正确 \(01\) 背包。但是,从线段树结点回溯时,需要删除对应结点上的物品,我们需要考虑如何作撤销工作。
实际上,我们不需要考虑删除物品。由于线段树只有 \(O(\log)\) 层,我们可以将从根节点到当前结点的所有 \(01\) 背包的 \(dp\) 表备份下来(\(backup[高度][体积]\)),回溯的时候直接将之前记录的备份抄下来,就相当于回到之前的情况了。
loj6515
Q 省略。
只需要模拟双端队列的出入过程,处理出每个物品在队列内的时间段(即生效时间),然后线段树分治即可。
CF981E
一道很巧妙的题,线段树分治结合 bitset + dp。
Q:一个长度为 \(n\) 的序列,初始值均为 \(0\)。给定 \(q\) 条操作:
- \(l\space r\space v\):给序列 \([l, r]\) 上的数字加 \(v\)。
每条操作可以选择执行或不执行,最多执行一次。问整个序列在 \(1 \backsim n\) 范围内可达到的最大值有哪些。
我们考虑对于每个位置,该位置可以达到的数字集合(用一个长度为 \(n\) 的 \(01\) 串表示,每一位表示对应位数字是否可达到。为了操作快速,实现简单,bitset 是不二选择)
于是,考虑以序列位置为轴建立线段树。那么对于所有的操作,生效的时间段就是其操作的区间 \([l, r]\)。将所有操作加入到线段树之后,考虑 \(dp\) 转移:
状态定义:即上述的 \(n\) 位 \(01\) 串,每一位表示相应数字是否可达。
初始时,整个序列中每个位置都是 \(0\),因此初始状态中只有数字 \(0\) 的相应位置 \(1\),其他位均置 \(0\)。(注意:这里的状态只服务于序列中的某一位置,表示这个位置上可以取到哪些数字)
在经过每个线段树结点时,结点包含的所有任务(区间加的数字 \(v\))就是转移(加 \(v\) or 不加 \(v\))。假设转移前的 \(dp\) 状态为 \(b\),状态转移:
或运算的左右两侧分别表示不加 \(v\) 和加 \(v\) 达到的数字集合,结合在一起就是这个位置经该操作后可达的新数字集合。从根节点到叶节点的所有结点任务均做这样的状态转移,最终到达叶节点时的 bitset 集合就是这个位置可以达到的所有可能的数字集合。
一个比较巧妙的思考:对于 \(\forall\) 位置 \(i\),我们只考虑包含这个位置的所有操作(只能选满足 \(i \in [l, r]\) 的操作)。只考虑这些操作,无论每一项是否选择执行,我们会发现,对于所有可能的执行方案,位置 \(i\) 上的数字一定是最大值(因为保证了所有区间加的数字 \(v > 0\),剩下的就很显然了)。 并且只考虑这些操作,位置 \(i\) 经上述线段树分治过程得到的数字集合也一定可得到。因此,我们有了结论:对于每个数字,其能达到的所有可能的数字组成的集合均有可能成为整个序列的最大值。
因此,整个序列的最大值可能集合的 bitset 就是每个位置可达到数字集合的 bitset 或运算后的结果。
总复杂度 \(O(q\log n * \frac{n}{w})\)。
线段树分治结合线性基
CF938G (模板)
Q:给定 \(n\) 个点,\(m\) 条边的无向连通图,无重边自环,每条边有边权。有 \(3\) 种类型的操作:
- \(1\space x\space y\space d\):原图中加入点 \(x\) 到点 \(y\) 的边,权值为 \(d\)。
- \(2\space x\space y\):原图中删除边 \((x, y)\)。
- \(3\space x\space y\):求点 \(x\) 到点 \(y\),所有路径随便走,沿途边权的异或和最小值。
相当于 P4151 的带修+多查询版本。
本题关于线性基的结论见该篇博客关于上题的题解:blog
将所有加边与删边操作作排序处理,得到关于每一对结点的 (边权,持续时间段) 信息,并插入到时间轴线段树中。这样在 dfs 线段树时,只会涉及到加边操作,而删边操作在回溯时产生,因此可以用可撤销并查集来维护整张图。
但仅仅使用普通的可撤销并查集是不够的,因为需要查询的信息涉及到路径异或和。因此,对于在并查集中的每个结点,我们需要额外维护一个 “路径异或值” 信息 \(eor[u]\)。我们需要在 加边合并集合 以及 删边分裂集合 时,维护好所有结点的该信息。具体地:
- 处理 \((x, y)\) 边权为 \(w\) 的连边信息时,需要合并 \(x, y\) 所在集合。设两个结点的集合祖先分别为 \(fx, fy\)。为了保证并查集启发式合并的 \(O(\log)\) 复杂度,在合并集合时需要将小集合的祖先挂在大集合的祖先下面。 那么在做完这个操作之后,为了维护每个结点在合并集合后正确维护路径异或和信息,我们有结论:只需要将原来小集合的该信息改成 \(pathxor[x] \oplus pathxor[y] \oplus w\) 即可(这里的 \(pathxor[x]\) 表示 \(x\) 到所在集合根节点的路径异或和,在不断跳 \(fa\) 查找祖先时经过的所有 \(eor[u]\) 异或在一起得到。而其计算利用了带权并查集的原理,具体见:带权并查集)。
- 删除 \((x, y)\) 之间的连边:对于每个集合,祖先结点的路径异或和信息始终为 \(0\),因此在删除连边时,只需要将小集合祖先结点的路径异或和信息置为 \(0\) 就可以了。
由上面关于本题线性基的结论,我们发现对于每条查询 \((x, y)\),我们需要维护 这两点之间某一条简单路径的异或和 以及 当前图中所有环的异或和,并插入到线性基中。因此,在线段树分治过程中,我们不仅要维护可撤销并查集,还需要维护可撤销线性基。但是别担心,并没有可撤销线性基这种算法,我们只需要用 copy 版本 / 栈维护线性基修改了哪些位置 这两种方式其中之一就行了,这里我们使用后者,因为代码简洁且省空间。
两点之间某一条简单路径的异或和 我们可以在正在维护的并查集中查询到;而图中所有环的异或和,我们也能够实现动态地在线性基中加入与删除:在加边前,若查询到两个结点在同一集合中,说明产生了环,此时该环的异或和即为 \(pathxor[x] \oplus pathxor[y] \oplus w\),插入到线性基中即可;而线性基的删除同样是在 dfs 线段树的回溯过程中产生的,我们可以参照可撤销并查集的 rollback 函数,将在当前结点插入线性基的每个数删除掉就行:需要删除的数需要从栈中弹出,并将线性基相应位置的数置 \(0\) 即可。
至此所有细节分析完毕,具体实现见 code。
线段树分治结合字典树
P4585
线段树分治结合可持久化 \(01Trie\)。
Q:有 \(n\) 个商店,每个商店在开始时都有一件初始商品与相应的价格 \(p_{i}\)。现有两种类型的操作:
- \(0\space s\space v\):编号为 \(s\) 的商店进货了一件价格为 \(v\) 的商品。
- \(1 \space l\space r\space x\space d\):查询对于编号在 \([l, r]\) 范围内的商店中的所有 初始商品 或 \(d\) 天内(假设当前是第 \(t\) 天,则相当于第 \([\max(1,t-d+1),t]\) 天范围内)进货商品 的价格,\(\oplus\space x\) 的最大值。
显然初始商品与进货商品两部分的答案可以分别独立计算。对于初始商品,只需要以商店编号为序建立可持久化 \(01Trie\),然后对于每个询问更新对应商店编号区间的答案即可。
难点在于进货商品,因为对于进货商品,不仅有商品编号的约束,还存在着进货天数的约束。这种二维约束问题可以用树套树解决,但是数据范围太大了,导致不可以这样做。
考虑用线段树分治解决进货天数的约束,可持久化 \(01Trie\) 解决商品编号的约束。具体地,以天数为时间轴建立线段树。对于所有查询操作,将进货天数构成的区间分成 \(O(\log)\) 个时间段加入到线段树中,这样,对于每个子时间段我们求出在该时间段内的最优解,将所有子时间段的最优解结合即为整个时间段上该查询的最优解(本题最难想的地方,也是值得学习的一个地方)。对于每件进货商品,考虑将其加入到所有包含其进货时间点 \(d_{i}\) 的每个线段树结点中(从根节点到叶节点上的一条路径),表示相应时间段内存在这件进货商品。对于线段树上每个结点,对其中加入的所有进货商品按照商品编号升序排序,并建立可持久化 \(01Trie\)(每个线段树结点都要重建一次 \(01Trie\)),然后处理相应结点上的所有任务,此时的每个任务处理方式与可持久化 \(01Trie\) 上的一次查询是等价的。注意每个查询对应多个关于天数的子时间段,要取这些子时间段上所有答案的最优解。
ABC308 G
Q:初始时有一个可重集为空,维护以下 \(3\) 种类型操作:
- \(1\space x\):加入数字 \(x\)
- \(2\space x\):删除其中一个数字 \(x\)
- \(3\):查询其中任意两个数的最小异或和
本题看起来可以用线段树分治来做,但却算是大炮轰蚊子了,复杂度 \(O(q\log q\log A)\)。
比较简单的做法是只使用普通 \(01Trie\) 配合一些贪心策略。
记录一个字典树上 \(dp\) 的做法:
状态定义
\(minxor_{u}\):只考虑字典树结点 \(u\) 为根的子树内所有数,任取两个数的异或最小值。若整个子树中只有一个数或者没有数,则该状态无效,置为 \(INF\)。
\(only_{u}\):在字典树结点 \(u\) 为根的子树中,若整棵子树内只有一个数,那么这个数记为 \(only_{u}\),否则 \(only_{u}\) 置为 \(0\)。
答案即为 \(minxor[1]\)。(\(1\) 是字典树的根节点)
状态转移
在每一步添加/删除数字时,对该数在字典树路径上经过的 \(O(\log A)\) 个结点的信息作修改,以保证每次改变字典树状态后,\(minxor\) 和 \(only\) 这两个信息都能够修正正确。具体状态转移细节见 code 注释。




浙公网安备 33010602011771号