P5787
WeLikeStudying · · 题解
upd on 2025/8/5 6:52
声明:本人保证是在阅读并理解了洛谷题解补充计划和洛谷模板题题解规范的完整内容后提交该文章的。
upd on 2025/8/7 11:44
初稿提交时审核通过,但作者自查发现笔误,为造成的审核重复性劳动表示歉意。
算法介绍
问题背景
本模板题旨在解决一类经典的离线动态图问题。考虑一个场景:我们需要维护一个图,支持动态地加入和删除边,并实时查询图的某些性质(例如连通性、是否为二分图等)。
对于只有加边的操作,一个标准的、带有路径压缩优化的并查集能以近乎常数的时间复杂度高效完成。然而,删除操作却是一个难题。因为路径压缩等均摊优化会彻底改变并查集的内部结构,使得撤销某一次特定的合并操作变得异常困难。
可撤销数据结构
让我们先思考一个简化版的问题:如果操作并非任意删除,而是严格地撤销上一步操作呢?
这种回退的需求,实现起来就容易得多。以并查集为例,我们可以:
- 放弃路径压缩,仅使用按秩合并(按大小或深度),保证查询复杂度为稳定的
O(\log N) 。 - 使用一个栈来记录每次合并操作所做的修改(例如,哪个节点的父节点被改变了,哪个节点的秩增加了)。
- 当需要撤销时,只需从栈顶取出记录,执行逆操作即可将数据结构恢复到前一个状态。
用线段树对时间进行分治
然而,原问题要求删除任意指定时间的边,这似乎超出了可撤销模型的处理能力。线段树分治的巧妙之处就在于,它通过对时间维度进行分治,将一个看似需要可追溯化的复杂问题,转化为了仅需可撤销数据结构即可解决的模型。
其核心流程如下:
- 离线处理:首先,我们需要知道所有操作的起始和终止时间。
- 时间轴映射:将每条边的生命周期(从加入到删除)看作时间轴上的一个区间
[l, r] 。 - 线段树剖分:建立一棵代表总时间
[1, T] 的线段树。将每条边的存在区间[l, r] 分解,并“挂载”到线段树上O(\log T) 个对应的节点上。 - DFS 遍历与状态维护:对线段树进行深度优先遍历,具体如下。
- 进入一个节点时,将该节点上挂载的所有边加入我们的可撤销数据结构中。
- 当遍历到叶子节点(代表某个具体时刻)时,进行查询。
- 离开一个节点并回溯时,利用数据结构的可撤销性,撤销进入该节点时所做的所有修改。
通过这种方式,任意时刻的删除操作被自然地转化为了 DFS 回溯时的撤销操作,完美地适配了可撤销数据结构的特性。
对于P5787的特化:加权并查集
在掌握了线段树分治的通用框架后,我们需要解决本题的核心问题:如何利用一个可撤销的数据结构,来动态维护一个图是否为二分图。
一个图是二分图的充要条件是图中不存在奇数环。这里,我们采用一种精妙的带权并查集来维护节点间的关系,从而判断奇数环的产生。
其核心思想如下:
关系量化:
- 我们给并查集的每个节点
x 增加一个“权值”w(x) 。这个权值定义为x 到其父节点f(x) 的关系。我们用0 代表同类(在二分图的同一部),用1 代表异类(在二分图的不同部)。
路径查询
- 查询函数
fat(x) (find) 现在不仅要找到根节点,还要计算出x 到根节点的路径上所有权值的异或和。这个异或和就代表了x 与根节点的总体关系。如果异或和为0 ,说明x 与根节点是同类;如果为1 ,则是异类。
合并与冲突判断
- 当加入一条边
(u, v) 时,分别查询u 和v 的信息,得到(root_u, dist_u) 和(root_v, dist_v) 。
判断冲突:
- 如果根节点相同
root_u = root_v :说明u 和v 已经连通。此时,它们之间的关系已经由路径权值确定,为dist_u \operatorname{xor} dist_v 。 - 如果
dist_u \operatorname{xor} dist_v=0 ,说明u 和v 在图中已经是“同类”。但新加入的边(u,v) 要求它们必须是“异类”。这就产生了矛盾,说明形成了一个奇数环。 - 如果
dist_u \operatorname{xor} dist_v=1 ,说明它们已经是“异类”,新边没有带来矛盾,可以忽略。
执行合并:
- 如果根节点不同:我们将一个根节点(如
root_u )合并到另一个(如root_v )上。此时需要设定新的权值w 。为了维持关系的正确性,w(root_u) 必须设定为dist_u \operatorname{xor} dist_v \operatorname{xor} 1 。这样才能保证在新的树形结构下,u 和v 的关系依然是“异类”。
通过将这种可撤销的带权并查集与线段树分治框架结合,我们就能准确地在每个时间点判断图是否为二分图。
复杂度证明
设操作(边)的总数为
时间复杂度:
预处理(操作挂载):一条边的生命周期为
DFS 遍历:DFS 会遍历线段树的每个节点。在每个节点,算法会应用(进入时)和撤销(离开时)所有挂载在该节点上的操作。总的应用与撤销次数与总挂载次数同阶,为
数据结构操作:我们使用的是可撤销的扩展域并查集。由于放弃了路径压缩,只采用按秩合并,其 find 和 unite 操作的单次时间复杂度为稳定的
总体时间复杂度:将上述几点综合考虑,得到总时间复杂度为
空间复杂度
线段树节点:线段树本身需要
操作存储:
可撤销并查集的栈:栈的深度取决于DFS的递归深度
因此,总空间复杂度为
代码实现
本节将逐一解析代码的关键部分,将前述的算法思想与最终的实现细节对应起来。代码主要包含两个核心模块:带权并查集的操作函数,以及线段树分治的递归框架。
在阅读前,请注意以下全局变量的含义:
f[i]: 节点i的父节点。w[i]: 节点i到其父节点的权值(0或1)。sz[i]: 以i为根的子树的大小(用于按秩合并)。o[u]: 线段树节点u上挂载的边的列表。e: 用于撤销操作的栈,存储合并信息。
带权并查集
这部分是整个数据结构的核心,负责维护节点间的关系并判断冲突。
// 查询节点x的根,并返回{根节点, x到根的路径权值异或和}
pair<int,bool> fat(int x)
{
bool y=0;
while(f[x]!=x)
{
y^=w[x];
x=f[x];
}
return make_pair(x,y);
}
// 宏定义,用于判断x和y是否为“同类”
#define sam(x,y) fat(x)==fat(y)
// 合并x和y,并返回用于撤销的信息
pair<int,int> tog(int x,int y)
{
pair<int,bool>a=fat(x),b=fat(y);
// 如果根已相同,则不合并,返回一个无效标记{0,0}
if(a.F==b.F)return make_pair(0,0);
// 按秩合并:将小树合并到大树
if(sz[a.F]>sz[b.F])swap(a,b);
// 设置小树根的权值,维持x、y为异类的关系
w[a.F]=1^a.S^b.S;
// 更新父节点和大小
f[a.F]=b.F,sz[b.F]+=sz[a.F];
// 返回{被合并的根, 新的根},用于撤销
return make_pair(a.F,b.F);
}
// 撤销操作,恢复a.F为根,并更新a.S的大小
void split(pair<int,int>a)
{
// 如果是无效标记,则不操作
if(a.F==0)return;
// 恢复被合并根的父节点、权值和大小
w[a.F]=0,f[a.F]=a.F,sz[a.S]-=sz[a.F];
}
fat(x):实现了带路径权值计算的find操作。它在向上寻找根节点的同时,用变量y累加(异或)路径上所有w的值。sam(x,y):一个宏。当fat(x) == fat(y)成立时,意味着x和y的根节点相同,且它们到根的路径权值异或和也相同。这直接推导出x和y的关系是“同类”,与新加边的“异类”要求冲突,形成了奇数环。tog(x,y):实现了带权的unite操作。最核心的一行是w[a.F]=1^a.S^b.S,它精确地计算出新的权值,保证了合并后x和y的关系被正确地设定为“异类”。它返回的pair是撤销此次合并所需要的全部信息。split(a):tog的逆操作。它利用tog返回的pair,精确地将父节点、权值和大小恢复到合并前的状态。
线段树分治
这部分是算法的框架,负责将边的生命周期映射到线段树上,并通过DFS进行求解。
// 将边x挂载到其生命周期[lb, rb]对应的线段树节点上
void Add(int l,int r,int u,int lb,int rb,pair<int,int>x)
{
if(rb<l||r<lb)return;
if(lb<=l&&r<=rb)
{
++tl[u];
o[u].push_back(x);
return;
}
int mid=(l+r)>>1;
Add(l,mid,u<<1,lb,rb,x);
Add(mid+1,r,u<<1|1,lb,rb,x);
}
// DFS求解函数
void Query(int l,int r,int u)
{
// 记录进入此节点前的栈大小,作为回溯的“检查点”
unsigned bef=e.size();
// 遍历此节点上的所有边
for(int i=0;i<tl[u];++i)
{
// 如果发现奇数环
if(sam(o[u][i].F,o[u][i].S))
{
// 此节点代表的时间区间内,图都不是二分图
for(int j=l;j<=r;++j)printf("No\n");
// 回溯前,撤销本层已进行的所有合并
while(e.size()!=bef)
{
split(e.top());
e.pop();
}
return; // 剪枝,直接返回
}
else
{
// 否则,合并并记录操作
e.push(tog(o[u][i].F,o[u][i].S));
}
}
// 如果到达叶子节点,说明此时间点没有冲突
if(l==r)
printf("Yes\n");
else
{
// 否则,递归进入左右子区间
int mid=(l+r)>>1;
Query(l,mid,u<<1);
Query(mid+1,r,u<<1|1);
}
// 回溯:撤销本层所有操作,恢复到进入前的状态
while(e.size()>bef)
{
split(e.top());
e.pop();
}
}
Add:标准的线段树区间修改,将一条边的信息pair<int,int>x添加到所有完整覆盖其生命周期的线段树节点的vector<pair<int,int>> o[u]中。Query:这是线段树分治的DFS核心。- 通过
bef=e.size()记录检查点,是实现可撤销的关键。 - 在循环中,它先用
sam判断冲突,如果出现冲突,则此节点覆盖的整个时间段都无效,可以直接剪枝并返回,这是一个重要的优化。 - 如果没有冲突,则执行合并
tog,并将撤销信息e.push()。 - 递归结束后,通过
while(e.size()>bef)循环,精确地撤销本层递归中进行的所有tog操作,确保回溯到父节点时,状态与进入兄弟节点前完全一致。
- 通过
完整代码
#include<fstream>
#include<vector>
#include<stack>
#define F first
#define S second
const int N=110000;
using namespace std;
int n,m,k,tl[N<<2],f[N],sz[N];
bool w[N];
vector<pair<int,int> >o[N<<2];
stack<pair<int,int> >e;
pair<int,bool> fat(int x)
{
bool y=0;
while(f[x]!=x)
{
y^=w[x];
x=f[x];
}
return make_pair(x,y);
}
#define sam(x,y) fat(x)==fat(y)
pair<int,int> tog(int x,int y)
{
pair<int,bool>a=fat(x),b=fat(y);
if(a.F==b.F)return make_pair(0,0);
if(sz[a.F]>sz[b.F])swap(a,b);
w[a.F]=1^a.S^b.S;
f[a.F]=b.F,sz[b.F]+=sz[a.F];
return make_pair(a.F,b.F);
}
void split(pair<int,int>a)
{
if(a.F==0)return;
w[a.F]=0,f[a.F]=a.F,sz[a.S]-=sz[a.F];
}
void Add(int l,int r,int u,int lb,int rb,pair<int,int>x)
{
if(rb<l||r<lb)return;
if(lb<=l&&r<=rb)
{
++tl[u];
o[u].push_back(x);
return;
}
int mid=(l+r)>>1;
Add(l,mid,u<<1,lb,rb,x);
Add(mid+1,r,u<<1|1,lb,rb,x);
}
void Query(int l,int r,int u)
{
unsigned bef=e.size();
for(int i=0;i<tl[u];++i)
if(sam(o[u][i].F,o[u][i].S))
{
for(int j=l;j<=r;++j)printf("No\n");
while(e.size()!=bef)
{
split(e.top());
e.pop();
}
return;
}
else
e.push(tog(o[u][i].F,o[u][i].S));
if(l==r)
printf("Yes\n");
else
{
int mid=(l+r)>>1;
Query(l,mid,u<<1);
Query(mid+1,r,u<<1|1);
}
while(e.size()>bef)
{
split(e.top());
e.pop();
}
}
int main()
{
scanf("%d%d%d",&n,&m,&k);
for(int i=1;i<=n;++i)f[i]=i,sz[i]=1,w[i]=0;
for(int i=0;i<m;++i)
{
int x,y,l,r;
scanf("%d%d%d%d",&x,&y,&l,&r);
if(l<r)Add(1,k,1,l+1,r,make_pair(x,y));
}
Query(1,k,1);
return 0;
}