【不定期更新】图论100问
最大半连通子图
首先考虑一个 scc ,显然,scc 中任意两个节点满足半联通。缩点后,原图就变成一个 DAG。
下证半联通子图的必要条件是存在原点 u,使得 u 到子图中任意一个其他节点都存在有向路径。
假设对于 u,v,u 不能到 v 而 v 不能到 u,那么将 v 作为新的节点,继续重复上述过程,一定有一个节点 w ,满足到子图中任意一个其他节点都存在有向路径。
得证。
而且容易发现任意半联通子图都有且仅有一个源点 w。
这启示我们用拓扑排序来解决这个问题。
设 f_x 表示以 x 为根,满足半联通的子树的大小。
显然链是满足条件的。对于子树=2的情况,手玩了几组发现可以连接成单链的情况,自然>=3的情况也可以接成单链。
综上所述,我们只需要在缩完点的图中跑 DAG,然后找到最长链的情况即可。
注意到这里的 G’ 由点集 V’ 决定。由于是求方案数,所以必须考虑判重的问题。
注意到原图是没有重边的。然而缩点后可能出现重边,但是两种方案的点集是一样的,只不过选择的连接边不同而已。
所以用 map 判一下重边即可。(这里我也没有注意到,是被人hack后发现的)
对于图的连通性问题,我们通常要考虑以下几点:
- 图的连通性
- 重边,自环(对于 dcc 算法重边是有影响的,染色法不能出现自环,缩点算法后是可能出现重边的,要注意判重)
- 简单环,简单路径的定义,注意无向图的简单环不能经过重复的边,大小至少为2,如 1 2 2 1 是合法的,而 1 2 是不合法的,因为同一条边经过了两次
- 有的题目重边可以忽略,但有的题目重边会影响答案,如图论计数等
- 自环一般影响不大,但是要注意自环算简单环。简单路径是没有经过相同点的迹。简单环则是起点和终点相同的简单路径。简单环一定是简单路径。回路是起点和重点相同的迹,也就是说可以经过相同点。
- 注意到上述概念都涉及到相同边不能经过两次。这一点在有向图中应该问题不大,因为一旦经过相同点两次,则说明该路径已经构成简单路径和简单环,当然也构成回路。在无向图中这样要求是很有必要的。
CF962F Simple Cycles Edges
You are given an undirected graph, consisting of n n vertices and m m edges. The graph does not necessarily connected. Guaranteed, that the graph does not contain multiple edges (more than one edges between a pair of vertices) or loops (edges from a vertex to itself).
A cycle in a graph is called a simple, if it contains each own vertex exactly once. So simple cycle doesn’t allow to visit a vertex more than once in a cycle.
Determine the edges, which belong to exactly on one simple cycle.
题意大概是说,给定一个无自环和无重边的无向图,求哪些边有且仅被包含在一个简单环中。
第一感是用 DFS 来解决。
分类讨论:
-
若 (u,v) 是树边,则只需树上差分统计经过这条边的回环即可。注意到一个复杂的简单环是可以由两个简单环拼成的,所以不能只考虑被一个简单环覆盖的条件。
-
首先,它一定在一个环中。其次,环之间不能有交叉。对于非树边的判断其实是类似的,如果这个环和其他环无交叉,那么这条连接边也是满足条件的。
-
注意到一个简单环里的节点是有联系的。考虑用 c_x 来建立这个联系。
-
首先,判断一条边是否只经过一个简单环,然后,考虑该环中的其他边是否都不被其他简单环经过。
-
经过观察,我们发现它的含义其实就是 sum_v-sum_u=dep_v-dep_u ,此时该环包含的所有点都满足条件。
至于具体实现,这里用的是二次树上差分。
我用的是暴力跳链,严谨的话应该用树链剖分。
#include<bits/stdc++.h>
using namespace std;
const int mx=2e5+5;
inline int read()
{
int X=0; bool flag=1; char ch=getchar();
while(ch<'0'||ch>'9') {if(ch=='-') flag=0; ch=getchar();}
while(ch>='0'&&ch<='9') {X=(X<<1)+(X<<3)+ch-'0'; ch=getchar();}
if(flag) return X;
return ~(X-1);
}
struct block{
int u,v,idx;
};
int n,m;
int head[mx],nxt[mx],ver[mx],id[mx],tot;
int ans[mx],f[mx],g[mx];
int pos[mx],dep[mx];
int vis[mx],fa[mx],son[mx];
int topo[mx],cnt;
vector<block> query;
void add(int x,int y,int z) {
ver[++tot]=y,id[tot]=z,nxt[tot]=head[x],head[x]=tot;
}
void tarjan(int x,int fath) {
vis[x]=1;
for(int i=head[x];i;i=nxt[i]) {
int y=ver[i]; if(y==fath) continue;
if(!vis[y]) {
fa[y]=x,son[x]=y,dep[y]=dep[x]+1,pos[y]=id[i],tarjan(y,x);
}
else if(dep[y]<dep[x]) {
query.push_back((block){x,y,id[i]});
f[x]++,f[y]--;
}
}
topo[++cnt]=x;
}
int main() {
n=read(),m=read();
for(int i=1;i<=m;i++) {
int x=read(),y=read();
add(x,y,i),add(y,x,i);
}
for(int i=1;i<=n;i++)
if(!vis[i]) {
cnt=0,query.clear();
tarjan(i,0);
for(int j=1;j<=cnt;j++) {
int x=topo[j];
f[fa[x]]+=f[x];
}
for(int j=0;j<query.size();j++) {
int u=query[j].u,v=query[j].v,idx=query[j].idx,tmp=u;
while(tmp!=v&&f[tmp]==1) tmp=fa[tmp];
if(tmp==v) g[u]++,g[v]--,ans[idx]=1;
}
for(int j=1;j<=cnt;j++) {
int x=topo[j];
g[fa[x]]+=g[x];
}
for(int j=1;j<=cnt;j++) {
int x=topo[j];
if(x==i) continue;
if(g[x]==1) ans[pos[x]]=1;
}
}
int res=0;
for(int i=1;i<=m;i++)
if(ans[i]) res++;
printf("%d\n",res);
for(int i=1;i<=m;i++)
if(ans[i]) printf("%d ",i);
}
事实上本题可以转化为求点数等于边数的 vcc。然而我没有想到
支配树
用 DFS 树来理解它。分为三种边:前向边,横叉边和返祖边。
主要提一下半支配点 semi(x) 的求法:
-
用带权并查集维护由前向边和横叉边组成的 dfn(w \in edge) \req dfn(u) 且 dfn(v->u) 最小的点的序号
-
anc_min(v) 维护的是 v 到根节点的集合,可能通过横叉边到达非直系节点,有前提 dfn(v) \leq dfn(w),非直系节点的 semi(w) 也算,但是显然只会贡献一次,所以令 anc(x)=fa(x) 是在寻找祖先集合的贡献,这部分贡献应该给其子树的所有节点,如果令 anc(x)=son(x) 的话,其前叉边的贡献已经不会变了,而返祖边可能有贡献,所以把当前还未处理到的祖先节点看做并查集所表示的集合。至于前叉边的贡献在求 semi(u) 的过程中就可以算出来了。
-
按时间戳从大到小处理。遍历反向边 (u,v),如果 v 是 u 的祖先,则 res=min(res,dfn[semi[y]]);
-
如果 v 不是 u 的祖先,若 (u,v) 是前向边,那么询问从 v 出发的横叉边 (之前已经处理过了)以及并查集所代表的 dfn(w) \req dfn(u) 的祖先集合即可。
-
若 (u,v) 是横叉边,同样 find 压缩路径,这一部分就把横叉边+返祖边都压缩了。
然后将所有 (semi(x),x) 连边。这里用了等效替代的思想,即通过树边到 x 与所有通过 semi(x) 的半支配路径两种情况。注意到转化后图变成了 DAG,可以 拓扑排序 + LCA 解决。
同时注意到,每个节点最多只有 2 条入边。(这个 hf 没有提及,但是我认为是一个正确的性质)
时间复杂度 O((n+m)logn+(n+m)a(n)) 。
我们这里有 O((n+m)a(n)) 的更优算法:
-
如果z的semi和x的一样,则idom[x]=semi[x]。
-
否则 idom[x]=idom[z]。
枚举 x=semi[y],此时 刚好将时间戳在 [1,dfn[x]-1] 的点处理完, find(y) 压缩路径,此时求到的 semi[anc_min[y]] 就是所谓的 z。这步是时机,因为如果并查集连到 x 以上的节点,就不满足 z 在 [semi[y],y] 的路径上了。
void sm_tarjan() {
for(int i=1;i<=n;i++)
semi[i]=anc[i]=anc_min[i]=i;
for(int j=num;j>1;j--) {
int x=id[j];
int res=j;
for(int i=un_mp.head[x];i;i=un_mp.nxt[i]) {
int y=un_mp.ver[i];
if(!dfn[y]) continue;
if(dfn[y]<dfn[x]) {
res=min(res,dfn[semi[y]]);
}
else {
find(y);
res=min(res,dfn[semi[anc_min[y]]]);
}
}
semi[x]=id[res];
anc[x]=fa[x];
dfs_tr.add(semi[x],x);
x=id[j-1];
for(int i=dfs_tr.head[x];i;i=dfs_tr.nxt[i]) {
int y=dfs_tr.ver[i];
find(y);
if(semi[anc_min[y]]==x) {
idom[y]=semi[y];
}
else {
idom[y]=anc_min[y];
}
}
}
for(int i=2;i<=num;i++) {
int x=id[i];
if(!dfn[x]) continue;
if(semi[x]!=idom[x]) idom[x]=idom[idom[x]];
nw_tr.add(idom[x],x);
}
}
矿场搭建
图不是联通的,所以我们依次考虑若干联通块。
首先考虑割点。
-
如果一个联通块不存在割点,那么至少需要建立两个救援点,方案数为 n ( n − 1 ) / 2 n(n-1)/2 n(n−1)/2
-
如果一个联通块存在多个割点,不妨把它转化成 vcc 缩点后的树。把每个割点都作为新图中的节点,并在每个个点与包含它的所有 vcc 连边
-
此时我们发现如果一个 vcc 所包含的全局的割点为 1(即新图中的叶子节点),那么我们一定在除了割点的任意一个点建立救援点,同时发现建一个救援点是最优的。
-
然后我们发现任意删除图中的一个割点,其余节点都存在至少一条到叶节点的路径。而且树的叶子节点至少有2个,即使去掉一个也不会使所有救援点都消失,所以是不矛盾的。
然后注意求 vcc 是将 y 节点以前的弹出,而不是将 x 节点以前的弹出,否则就会弹出一些兄弟节点(这些节点原本是不存在割点的),此时 x 就成为割点了。
软件安装
还是那个问题:注意缩点后入度为0和重边的情况。重边意味着会重复计算同一个子树。不过本题保证没有重边。
注意到这是一个基环树。缩点后跑树上背包即可。注意必须从 0 连向缩点后入度为0的节点。

浙公网安备 33010602011771号