【不定期更新】图论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(n1)/2

  • 如果一个联通块存在多个割点,不妨把它转化成 vcc 缩点后的树。把每个割点都作为新图中的节点,并在每个个点与包含它的所有 vcc 连边

  • 此时我们发现如果一个 vcc 所包含的全局的割点为 1(即新图中的叶子节点),那么我们一定在除了割点的任意一个点建立救援点,同时发现建一个救援点是最优的。

  • 然后我们发现任意删除图中的一个割点,其余节点都存在至少一条到叶节点的路径。而且树的叶子节点至少有2个,即使去掉一个也不会使所有救援点都消失,所以是不矛盾的。

然后注意求 vcc 是将 y 节点以前的弹出,而不是将 x 节点以前的弹出,否则就会弹出一些兄弟节点(这些节点原本是不存在割点的),此时 x 就成为割点了。

软件安装

还是那个问题:注意缩点后入度为0和重边的情况。重边意味着会重复计算同一个子树。不过本题保证没有重边。

注意到这是一个基环树。缩点后跑树上背包即可。注意必须从 0 连向缩点后入度为0的节点。


__EOF__

本文作者仰望星空的蚂蚁
本文链接https://www.cnblogs.com/cqbzly/p/17530362.html
关于博主:评论和私信会在第一时间回复。或者直接私信我。
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角推荐一下。您的鼓励是博主的最大动力!
posted @   仰望星空的蚂蚁  阅读(43)  评论(0编辑  收藏  举报  
相关博文:
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· DeepSeek 开源周回顾「GitHub 热点速览」
点击右上角即可分享
微信分享提示