目录
本篇不涉及图的存储、拓扑排序、欧拉路等。
1. 无向图的连通性
1.1 割点与割边
联通分量:无向图中所有能互通的点构成联通分量。一个图中可能有多个联通分量。
割点:在无向图中,如果一个点删除后,联通分量变多,则该点为割点。注意:如果有只有一个点的联通分量,那么该点删除后,联通分量变少。
割边:在无向图中,如果一条边删除后,联通分量变多,则该点为割点。
1. 求割点
求图的割点与割边,需要用到图的深度优先生成树,如下图。
其中生成树上的回退边是指,指向已经遍历过的点的边,如下(b)图中的虚线边。
(1)对于根节点r,根节点是割点当且仅当在生成树上,根节点至少有两个儿子。
(2)对于非根节点v,v是割点当且仅当在生成树上,v至少有一个儿子节点u,u及其后代节点没有回退边连向v的祖先。
如(b)图,点上的数字表示递归遍历到的点的顺序,带有下划线的数字表示递归回溯的顺序。
对于点e,儿子g及其后代没有回退边连向e的祖先,所以e是割点;儿子f及其后代有回退边连向祖先,所以e删除后,f不会被划分为一个联通分量,但是e仍然是割点,因为g。
所以用num[v]表示v的递归序,low[v]表示v及其后代能够回退到的num最小的祖先,初始low[v]=num[v],在了解代码后,你会发现这种初始化是没有问题的。
核心代码如下:
int num[N],low[N],dfn,r,is[N];
void dfs(int v,int f){
num[v]=low[v]=++dfn;
int c=0;//儿子节点数量,用于根节点是否是割点判断
for(int i=head[v];~i;i=es[i].nex){
int u=es[i].to;
if(u==f)continue;//如果是连向父亲的边,不管
if(!num[u]){
c++;
dfs(u,v);
low[v]=min(low[v],low[u]);
if(v!=r&&low[u]>=num[v]||v==r&&c>1)is[v]=1;
}else low[v]=min(low[v],num[u]);
}
}
其中有两个关键点:
(1)low[v]的更新:如果点u没有考察过,那么low[v]=min(low[v],low[u]);;如果考察过,那么表示是回退边,用num[u]更新low[v]。
(2)非根节点割点的判断:low[u]>=num[v],只要儿子节点u不能回退到v的祖先,那么v割点,其中v的祖先t,必有num[t]<num[v]。
洛谷3388的完整代码如下:
#include<bits/stdc++.h>
using namespace std;
const int N=2e4+50;
const int M=1e5+50;
struct edge{
int to,nex;
}es[M<<1];
int head[N],cnt;
void init(){
memset(head,-1,sizeof(head));
cnt=0;
for(auto e:es)e.nex=-1;
}
void add(int f,int t){
es[cnt].to=t;
es[cnt].nex=head[f];
head[f]=cnt++;
}
int num[N],low[N],dfn,r,is[N];
void dfs(int v,int f){
num[v]=low[v]=++dfn;
int c=0;//儿子节点数量,用于根节点是否是割点判断
for(int i=head[v];~i;i=es[i].nex){
int u=es[i].to;
if(u==f)continue;//如果是连向父亲的边,不管
if(!num[u]){
c++;
dfs(u,v);
low[v]=min(low[v],low[u]);
if(v!=r&&low[u]>=num[v]||v==r&&c>1)is[v]=1;
}else low[v]=min(low[v],num[u]);
}
}
int main(){
int n,m;
cin>>n>>m;
init();
for(int i=0;i<m;i++){
int u,v;
cin>>u>>v;
add(u,v);
add(v,u);
}
for(int i=1;i<=n;i++){
if(!num[i]){
r=i;
dfs(i,0);
}
}
int ans=0;
for(int i=1;i<=n;i++)ans+=is[i];
printf("%d\n",ans);
for(int i=1;i<=n;i++)if(is[i])printf("%d ",i);
}
如果有多个联通分量,那么只需对每个联通分量dfs就行了,代码如下:
for(int i=1;i<=n;i++){
if(!num[i]){
r=i;
dfs(i,0);
}
}
考虑图的特殊情况:
①有重边:重边理论上对割点是没有影响的,而在如上的代码中,如果e、g(上图(b)中的e、g)有重边,那么在g考虑第二条指向e的边时,会因e是g的父亲而跳过。
②自环:当点e考虑自环边时,会因为遍历过而执行low[v]=min(low[v],num[u]),其中u==v,没有影响。
③不联通:对每个联通分量dfs即可。
2. 求割边
对于求割边,非根节点与根节点的判断方式一样,对于点v连向u的边x,如果low[u]>num[v],那么x是割边,也即u不能回退到v及其v的祖先(注意割点是主要u不能回退到v的祖先即可,能回退到v没有关系)。
伪代码如下:
int num[N],low[N],dfn;
void dfs(int v,int f){
num[v]=low[v]=++dfn;
for(int i=head[v];~i;i=es[i].nex){
int u=es[i].to;
if(u==f)continue;//如果是连向父亲的边,不管
if(!num[u]){
dfs(u,v);
low[v]=min(low[v],low[u]);
if(low[u]>num[v])i是割边;
}else low[v]=min(low[v],num[u]);
}
}
考虑图的特殊情况:
①不联通:和割点的处理方式一样。
②自环:自环是不会算作割边的,在代码中会因遍历过而执行low[v]=min(low[v],num[u]),其中u==v,没有影响。
③重边:如果v、u有重边,那么理论上这些边都不会是割边,所以只需预处理哪些点之间有重边即可。
3. 应用
在无向图中:
①判断一条边是否在环中,等价于判断其是否是割边。
1.2 双联通分量
针对无向图而言。
1.2.1 点双联通分量
点双联通图:图中任意两个点之间至少有两条点不重复(除端点外)的路径。
点双联通分量:一个图中的点双联通极大子图,一个图中可能有多个,也可能只有一个,即本身。
一个图中的点双联通分量与该图的割点有什么关系呢?
①点双联通分量中一定没有割点,这点可以用反证法证明。
②两个不同点双联通分量最多只有一个公共点,这也可以用反证法证明:如果有两个公共点,那么这两个点双联通分量可以合并为一个点双联通分量,与点双联通是极大子图矛盾。
③不同的点双联通分量的公共点一定是割点,可以用反证法。
③一个割点至少是两个点双联通分量的公共点,由③可得。
仍然是刚才的图,(b)中,e、a是割点,图中有三个点双联通分量{g,e},{a,c,e,f,d},{a,b},e是前两个的公共点,a是后两个的公共点。所以一个图中的所有割点划分出了所有的点双联通分量。
(1)先考虑第一个问题:如何知道点双联通分量有多少个,这里假设图中只有一个联通分量,也即整个图是联通的。
很简单,对于每个点v,其儿子u,如果v删除后儿子u会划分为一个联通分量,那么v、u、与u连接的一些点就组成了一个点双联通分量。具体地,在代码中只要满足v!=r&&low[u]>=num[v]||v==r&&c>1那么v、u就能划分出一个点双联通分量。代码如下:
int num[N],low[N],dfn,r,is[N],ans;
void dfs(int v,int f){
num[v]=low[v]=++dfn;
int c=0;//儿子节点数量,用于根节点是否是割点判断
for(int i=head[v];~i;i=es[i].nex){
int u=es[i].to;
if(u==f)continue;//如果是连向父亲的边,不管
if(!num[u]){
c++;
dfs(u,v);
low[v]=min(low[v],low[u]);
if(v!=r&&low[u]>=num[v]||v==r&&c>1)is[v]=1,ans++;
}else low[v]=min(low[v],num[u]);
}
}
当然最终答案为ans+1。因为如果根节点是割点,那么v==r且c==1时的那个点双联通分量没有考虑进去;如果根节点不是割点,那么仍然与根节点相连的那个点双联通分量没有考虑进去,可以节点图(b)思考。
(2)如何求出所有的点双联通分量的大小与点集。
思路:在dfs遍历的时候,把生成树上的边保存起来存到栈中,当遇到ans++的情况的时候,也即u不能回退v的祖先的时候,从栈中弹边,直到边的from为v。比如仍然还是图(b)。
按照遍历顺序,从g回溯到e时,栈中的边为{a->c,c->e,e->f,f->d,e->g},回到e时,发现e对于g是割边,从栈中弹边,直到from为e,那么会弹出{e->g},所以这两个点就是一个联通分量了。
注:如果一个图只有一个点,那么认为其是一个点双联通分量;两个点也可以是点双联通分量。
洛谷8435 模板代码如下:
#include<bits/stdc++.h>
using namespace std;
const int N=5e5+50;
const int M=2e6+50;
struct edge{
int f,to,nex;
}es[M<<1];
int head[N],cnt;
void init(){
memset(head,-1,sizeof(head));
cnt=0;
for(auto e:es)e.nex=-1;
}
void add(int f,int t){
es[cnt].to=t;
es[cnt].f=f;
es[cnt].nex=head[f];
head[f]=cnt++;
}
int num[N],low[N],dfn,s,is[N];
vector<vector<int> >rec;
stack<int>stk;
void process(int v){
if(stk.empty()){
//用于判断只有一个点的子图
rec.push_back(vector<int>(1,v));
return ;
}
vector<int>tmp;
int i;
do{
i=stk.top();
stk.pop();
tmp.push_back(es[i].to);
}while(es[i].f!=v);
tmp.push_back(v);
rec.push_back(tmp);
}
void dfs(int v,int f){
num[v]=low[v]=++dfn;
int c=0;
for(int i=head[v];~i;i=es[i].nex){
int u=es[i].to;
if(u==f)continue;
if(!num[u]){
c++;
stk.push(i);
dfs(u,v);
low[v]=min(low[v],low[u]);
if(low[u]>=num[v]&&v!=s||v==s&&c>1){
is[v]=1;
process(v);
}
}else low[v]=min(low[v],num[u]);
}
}
int main(){
int n,m;
cin>>n>>m;
init();
for(int i=0;i<m;i++){
int u,v;
cin>>u>>v;
add(u,v);
add(v,u);
}
for(int i=1;i<=n;i++){
if(!num[i]){
s=i;
dfs(i,0);
process(i);
}
}
printf("%d\n",rec.size());
for(auto vec:rec){
printf("%d ",vec.size());
for(auto v:vec)
printf("%d ",v);
printf("\n");
}
}
其中在dfs后,还需要再处理栈一次,一是为了处理只有一个点的联通分量,二是因为ans+1,前面说过ans为点双联通分量的数量,按照前面的求解方法,最终答案ans需要+1,是因为有一个与根节点相关的点双联通分量没有处理到。这里同理, 有一个点双联通分量没有处理到。
其中特别注意:在process函数中,不能将:
do{
i=stk.top();
stk.pop();
tmp.push_back(es[i].to);
}while(es[i].f!=v);
写为:
do{
i=stk.top();
stk.pop();
tmp.push_back(es[i].to);
}while(!is[es[i].f]);
也即,不能通过判断是否处理到了from为割点的边来确定这是一个联通分量,你可能会认为这两种写法是一样的,因为在process前,将is[v]=1,也即将v标记为了割点,但问题就是在这个点双联通分量中可能不止v一个割点,任然考虑图(b)。
现在生成树的遍历顺序改变一下,b->a->c->e->f->d->g。那么从g回溯到e时,栈中{a->b,a->c,c->e,e->f,f->d,e->g}。弹出边e->g,栈中: {a->b,a->c,c->e,e->f,f->d},然后回溯到a,因为c>1,所以弹边,如果弹边是按照while(es[i].f!=v),那么会弹出{a->c,c->e,e->f,f->d};但如果按照!is[es[i].f],那么只会弹出{e->f,f->d},因为e是割点。
1.2.2 边双联通分量
边双联通图:图中任意两个点之间至少有两条边不重复(点可以重复)的路径。
边双联通分量:一个图中的边双联通极大子图,一个图中可能有多个,也可能只有一个,即本身。
与点双联通类似,边双联通分量与割边有密切关系,具体地,一条割边连接两个边双联通分量,如图。
明显有:边双联通个数=割边个数+1。
如果把一个边双联通看成一个点(缩点),那么最终形成了一颗树,如图。
考虑这么一个问题:至少添加多少条边能使一个图为边双联通图。问题等价于:求出缩点树后,至少添加多少条边,使其为边双联通图。答案为:(树上度为1的点+1)/2。你可能想问说度为1的点不就是叶节点吗,为什么不说是叶节点。实则不然,因为这是无根树,所以任意一个点都能作为根,然而根可以度为1,但根不是叶节点,所以为了不产生混淆,所度为1是更好的选择。
而具体求每个边双联通分量的方法与求点双联通类似(注意:判断割边不用特殊考虑根节点),具体代码如下(洛谷8436):
#include<bits/stdc++.h>
using namespace std;
const int N=5e5+50,M=2e6+50;
struct edge{
int to,nex,id;
}es[M<<1];
int head[N],cnt;
void init(){
memset(head,-1,sizeof(head));
cnt=0;
for(auto e:es)e.nex=-1;
}
void add(int f,int t,int id){
es[cnt].to=t;
es[cnt].id=id;
es[cnt].nex=head[f];
head[f]=cnt++;
}
int num[N],low[N],dfn,stk[N],top=-1,ans;
vector<int>res[N];
void process(int v){
ans++;
while(stk[top]!=v){
res[ans].push_back(stk[top]);
top--;
}
res[ans].push_back(v);
top--;
}
void dfs(int v,int fid){
num[v]=low[v]=++dfn;
stk[++top]=v;
for(int i=head[v];~i;i=es[i].nex){
int u=es[i].to;
if(es[i].id==fid)continue;//这里是根据id判断是否是走过的边,与割点中根据f(父亲)不//同,原因在于考虑重边,如果没有重边,可以写为if(u==f)continue
if(!num[u]){
dfs(u,es[i].id);
low[v]=min(low[v],low[u]);
if(low[u]>num[v])process(u);
}else low[v]=min(low[v],num[u]);
}
}
int main(){
int n,m;
cin>>n>>m;
init();
for(int i=0;i<m;i++){
int u,v;
cin>>u>>v;
add(u,v,i);
add(v,u,i);
}
for(int i=1;i<=n;i++){
if(!num[i]){
dfs(i,-1);
process(i);
}
}
printf("%d\n",ans);
for(int i=1;i<=ans;i++){
printf("%d",res[i].size());
for(auto v:res[i])
printf(" %d",v);
printf("\n");
}
}
考虑图的特殊情况:
①自环:对于自环边,在代码中会被认为是回退边,更新low[v]=min(num[u],low[v]),其中u==v,不产生影响。
②不联通:对于每个联通分量,dfs一次即可。
③重边:对于重边,需要特别注意,如果两条边是重边,那么这两条边一定不是割边,因为割去任意一条边,另一条边都还在,所以需要把其中一条当成回退边,具体在代码中,需为每条边记录一个id,根据id来判断是否是从父亲走来的边,而不是边的to为父亲来判断。
可能你会在某些书上看到说一个边双联通分量中所有点的low值相同,所以可以根据一个图中有多少种不同的low值确定有多少个边双联通分量,真的是这样吗,看如下的图。
每个点左边的数字是递归顺序,右边的数字是low值,这是一个边双联通分量,但是每一个点的low值并不一样。
更简单的一个图:
2.有向图的连通性
强联通图:在有向图中,如果任意两个点互相可达(也即对于任意u、v,从u能找到到v的路径,从v也能找到到u的路径),则该图为强联通图。
强联通分量(Strongly Connected Component,SCC):一个图中的极大强联通子图。
自然有关的问题就是一个图中有多少个SCC,以及每个SCC有哪些点。
在讨论这个问题前,需要先明确一些问题。
将一个SCC看成一个点,那么最终的图一定是有向无环图,因为如果出现环了,那么这个环应该为一个更大的SCC,就矛盾了。
求解所有的SCC主要有两个算法。
2.1 kosaraju
kosaraju算法主要用到了拓扑排序与反图的方法。
考虑刚才的图,因为缩点后的图为有向无环图,所以其必有拓扑排序,但问题在于我们不知道缩点后的图的样貌,但如果直接对原图采用递归的方法求解拓扑排序,那么一定有这样的结论:缩点图上的拓扑排序中越排在前面的SCC认为是越高优先级的,那么对原图拓扑排序一定有高优先级的SCC中至少会有一个点排在所有低优先级的SCC中的所有点的前面。比如在缩点图中,SCC的优先级从高到低为F、E、A,那么对原图拓扑排序一定有F中的至少一个点在拓扑的最前面,E中至少有一个点在A中所有点的前面。为什么说至少有一个点,什么时候刚好只有一个点呢,考虑如下这种图:
每个点上的顺序为递归遍历到的顺序,图中有两个SCC,因为优先级高的SCC最终至少会有一个点会跑到优先级低的SCC,在优先级低的SCC中的所有点拓扑排序确定完后才会回到高优先级的SCC。当然还有一种特殊情况,就是先从5进入dfs,那么拓扑排序就为1、2、3、4、5、6、7、8。
确定原图中每个点的拓扑后,再考虑反图。
高优先级的F在反图中变为了低优先级,又因为原图的拓扑排序中高优先级的SCC中至少一个点在低优先级的SCC所有点的前面,所以从原图的拓扑排序的前面往后考虑,会先考虑到高优先级的F中的一个点,然后再反图中dfs一次即可找到F中的所有点了,因为反图中高优先级的F出度为0,x、y边把F给堵住了。求出F中的所有点后,删除这些点,就变为一样的规模更小的问题了。
具体代码如下(洛谷3609) :
#include<bits/stdc++.h>
using namespace std;
const int N=1e4+50;
vector<int>g[N],rg[N];
int topo[N],k,vis[N];
void dfs(int v){
vis[v]=1;
for(int u:g[v]){
if(!vis[u])dfs(u);
}
topo[k--]=v;
}
vector<int>ans;
void dfs2(int v){
ans.push_back(v);
vis[v]=1;
for(int u:rg[v])
if(!vis[u])dfs2(u);
}
int main(){
int n,m;
cin>>n>>m;
for(int i=0;i<m;i++){
int u,v;
cin>>u>>v;
g[u].push_back(v);
rg[v].push_back(u);
}
k=n;
memset(vis,0,sizeof(vis));
for(int i=1;i<=n;i++)
if(!vis[i])dfs(i);
memset(vis,0,sizeof(vis));
vector<vector<int> >res;
for(int i=1;i<=n;i++){
if(!vis[topo[i]]){
ans.clear();
dfs2(topo[i]);
sort(ans.begin(),ans.end());
res.push_back(ans);
}
}
sort(res.begin(),res.end());
printf("%d\n",res.size());
for(auto vec:res){
for(auto v:vec)
printf("%d ",v);
printf("\n");
}
}
其中dfs确定每个店的拓扑排序,dfs2求出所有的SCC。
注:这里所说的拓扑排序与传统的不一样,因为正常来说有环的图是没有拓扑排序的,但是这里的拓扑排序不是为了确定每个点的顺序,只是为了用拓扑排序的方法让高优先级的SCC中的至少一个点在低优先级的所有点的前面,然后从前往后考虑,总是先考虑到高优先级的SCC中的一个点,再利用反图求出高优先级中所有点。
2.2 tarjan
tarjan算法用到了无向图中用到的low(每个点能回退到的祖先)和num(每个点的递归序)。如图:
在dfs的过程中,每个SCC会有一个入口点,也即dfs到的SCC中的第一个点。
有这样的结论:如果不考虑缩点图中的边为回退边,那么对于任意一个SCC,有该SCC中的入口点s的num[s]=low[s],其他点本身的low[v]<本身的num[v]。
其他点的low[v]<num[v]很好理解,因为SCC中任意两个点互相可达,SCC中的任意点都能到入口点,那么对于任意点v(非入口点),一定有v或者v的后代能回退到v的祖先,因为如果从入口点s到v后,v及其后代都不能回到v的祖先,那么也一定回不到s,就与是SCC矛盾了。
那么入口点s的num[s]为什么一定等于low[s]呢,也有一个前提条件:不考虑缩点图中的边为回退边。这个条件逻辑上相当于让每个SCC内部的点的low的更新分割开来,只由自己内部的点的num更新,也即一个SCC中的点的low不由另一个SCC中的num更新。那么对于任意入口点s,就一定有num[s]=low[s],因为他没有回退边。
具体地考虑上面的图b,从f->e的边就是缩点图中的边,这个边不能考虑为回退边,因为考虑为了回退边,那么就会用e的num更新f的low,因为这里f比e到递归到,所以不会有影响,但如果e先递归到,那么就会有问题了。
这里你可以手画一下更大一点的图,比如让上面的e所在的SCC中有更多的点。
如何求出有多少个SCC,以及每个SCC中有哪些点呢。明显SCC的个数为num[v]=low[v]的点的个数。
因为递归与栈有密切关系(先递归到的后回溯,栈先进后出),所以可以用栈记录dfs过程中的点,回溯到low[v]=num[v]的点时,就弹栈。
代码如下 (洛谷3609):
#include<bits/stdc++.h>
using namespace std;
const int N=1e4+50;
vector<int>g[N];
int num[N],low[N],dfn,stk[N],top=-1,vis[N];
vector<vector<int> >res;
void dfs(int v){
num[v]=low[v]=++dfn;
stk[++top]=v;
for(int i=0;i<g[v].size();i++){
int u=g[v][i];
if(!num[u]){
dfs(u);
low[v]=min(low[v],low[u]);
}else if(!vis[u])low[v]=min(low[v],num[u]);
}
if(low[v]==num[v]){
vector<int>rec;
while(stk[top]!=v)vis[stk[top]]=1,rec.push_back(stk[top--]);
vis[stk[top]]=1,rec.push_back(stk[top--]);
sort(rec.begin(),rec.end());
res.push_back(rec);
}
}
int main(){
int n,m;
cin>>n>>m;
for(int i=0;i<m;i++){
int u,v;
cin>>u>>v;
g[u].push_back(v);
}
for(int i=1;i<=n;i++)
if(!num[i])dfs(i);
sort(res.begin(),res.end());
printf("%lu\n",res.size());
for(auto vec:res){
for(auto v:vec)
printf("%d ",v);
printf("\n");
}
}
这里有个关键点为如何不考虑缩点图中的边为回退边,因为最初并不知道哪些边同时是缩点图中的边,但是是有方法可以判断的,方法就是:连向已经从栈中弹出的点的边。注意:这里的不考虑是指不考虑其为回退边,也即用其更新low,因为考虑了会导致其他SCC的num更新这个SCC的low,如果入口点有这样一条边,那么会导致入口点的num!=low了。但有时缩点图中的边是作为dfs走向另一个SCC的边。如下图(缩点图):
如果先dfs A中的点,然后走到了F,那么F中的点递归完后,会回到F中的入口点,就能将F中的所有点确定为一个SCC,然后再走向E,如果y这条边是E中的入口点连向F的,F又先递归到,那么就出问题了。
具体在代码中,用vis记录是否已确定为SCC中的点。
2.3 DAG(有向无环图)
在有向图中,将SCC变为一个点后,整个图就变成了一个DAG,所以很多与SCC有关的问题也与DAG挂钩,所以我给出一些有关DAG的结论(这些结论也是我做题总结的):
①在有向图中,最少从多少个点开始遍历,可以遍历完整个图:有向图缩点后对应的DAG上入度为0的点的数量。这个应该是很好证明的。
②在有向图中,最少添加多少条边,使整个图是一个SCC:答案=SCC==1?0:max(DAG上入度为0的点的数量,DAG上出度为0 的数量)。一个点的入度为0且出度为0也要考虑入内。首先肯定是考虑缩点图,因为不可能在一个SCC内部添加边,而在任意一个有向图中,有这样的结论:如果每个点都有出度和入度,并且整个图是连通的(注意:有向图中两个点连通指其中至少一个点可达另一个点),那么整个图一定为SCC(证明略)。所以考察入度为0(x个)点与出度为0(y个)的点的数量,这里假设y>x,而总有一种方法可以使x个出度为0的点连到x个入度为0的点上,y-x个出度为0的点连到其他的SCC上,并且整个图连通(证明略)。
3. 基环树
这里只考虑无向图,在无向图中,基环树是只有一个环的连通图,n个点、n条边。其实就是在一颗无向树(n个点,n-1条边)上添加一条边形成的。如图:
将环缩为一个点后,仍然是一颗树。可以看出基环树处处与树关联,所以尽管基环树严格意义上来说并不是一颗树,但仍叫其为树。
关于基环树的问题,可以从以下几个方面考虑:
①一棵树+一条边:从树的角度考虑,特殊处理环中的一条边;
②环+多颗树:在基环树中,环上的每一个点在考虑除环中的点后都是一颗树。
③环+一棵树:即找出环后,缩点->一棵树。
上面涉及到的步骤中难点主要在于找环、将基环树分解为一棵树+一条边。
对于找环,可以采用"剪树"的方法,先剪去度为1的点,删边后,再剪去度为1的点(类似于拓扑排序的使用队列的方法),那么最终只剩一个环,因为环中点的度至少为2。代码如下:
int vis[N],du[N],inq[N];
vector<int>vs;
void dfs(int v){
vis[v]=1;
vs.push_back(v);
for(int i=head[v];~i;i=es[i].nex){
int u=es[i].to;
du[u]++;
if(!vis[u])dfs(u);
}
}
void slove(int v){
vs.clear();
dfs(v);
queue<int>que;
for(int v:vs)if(du[v]==1)que.push(v),inq[v]=1;
while(!que.empty()){
int v=que.front();
que.pop();
for(int i=head[v];~i;i=es[i].nex){
int u=es[i].to;
if(!inq[u]){
du[u]--;
if(du[u]==1)que.push(u),inq[u]=1;
}
}
}
}
其中,du数组为每个点的度(注意无向图中的每条边拆为了两条有向边),vis[i]表示i是否遍历过,inq[i]表示i是否已经入队列。最终不在队列中(即inq[i]==0)的点即为环中的点。
将基环树分解为一棵树+一条边,可以使用并查集。
如果n个点与n条边的图不连通,那么也即形成了基环树森林,只需对每个连通块处理一下就行。
列题见洛谷2607,代码如下:
环+多颗树的处理方法:
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=1e6+50;
const ll INF=0x3f3f3f3f3f3f3f3f;
struct edge{
int to,nex;
}es[N<<1];
int head[N],cnt;
void init(){
memset(head,-1,sizeof(head));
cnt=0;
for(auto e:es)e.nex=-1;
}
void add(int f,int t){
es[cnt].to=t;
es[cnt].nex=head[f];
head[f]=cnt++;
}
ll val[N];
int vis[N],du[N],inq[N];
vector<int>vs;
void dfs(int v){
vis[v]=1;
vs.push_back(v);
for(int i=head[v];~i;i=es[i].nex){
int u=es[i].to;
du[u]++;
if(!vis[u])dfs(u);
}
}
ll dp1[N],dp2[N];
void dfs2(int v,int f){
dp1[v]=val[v],dp2[v]=0;
for(int i=head[v];~i;i=es[i].nex){
int u=es[i].to;
if(u==f||!inq[u])continue;
dfs2(u,v);
dp1[v]+=dp2[u];
dp2[v]+=max(dp1[u],dp2[u]);
}
}
ll slove(int v){
vs.clear();
dfs(v);
queue<int>que;
for(int v:vs)if(du[v]==1)que.push(v),inq[v]=1;
while(!que.empty()){
int v=que.front();
que.pop();
for(int i=head[v];~i;i=es[i].nex){
int u=es[i].to;
if(!inq[u]){
du[u]--;
if(du[u]==1)que.push(u),inq[u]=1;
}
}
}
vector<int>inf;
for(int v:vs)
if(!inq[v]){
inf.push_back(v);
dfs2(v,0);
}
int n=inf.size();
vector<vector<ll> >dp(n);
for(int i=0;i<n;i++)dp[i]=vector<ll>(2,0);
dp[0][0]=dp1[inf[0]];
dp[0][1]=-INF;
for(int i=1;i<n;i++){
dp[i][0]=dp[i-1][1]+dp1[inf[i]];
dp[i][1]=max(dp[i-1][0],dp[i-1][1])+dp2[inf[i]];
}
ll ans=dp[n-1][1];
for(int i=0;i<n;i++)dp[i]=vector<ll>(2,0);
dp[0][0]=-INF;
dp[0][1]=dp2[inf[0]];
for(int i=1;i<n;i++){
dp[i][0]=dp[i-1][1]+dp1[inf[i]];
dp[i][1]=max(dp[i-1][0],dp[i-1][1])+dp2[inf[i]];
}
ans=max(ans,max(dp[n-1][0],dp[n-1][1]));
return ans;
}
int main(){
int n;
cin>>n;
init();
for(int i=1;i<=n;i++){
int x;
cin>>val[i]>>x;
add(x,i);
add(i,x);
}
ll ans=0;
for(int i=1;i<=n;i++)
if(!vis[i])ans+=slove(i);
cout<<ans;
}
处理思路为:对于每个基环树,找出环后,对于每个点,连接一棵树,求出这个点选和不选的最优值,这里需要先知道如果图只是一棵树怎么求得最优值,其实就是简单的树形dp(如果这棵树只有他这一个点,那么选的最优值就是这个点的值,不选的最优值为0),然后就变为了这样一个问题:一个环,每个点选有一个值,不选有一个值,相邻的点不能同时选,怎么选最优。非常明显的一个dp问题,只是这里是环,如果不是环,就是简单的线性dp,是环只需枚举一下第一个点选和不选的两种情况,两种情况都dp一下,选的话,最后一个点就时不选的最优值,不选的话,最后一个就是选和不选两者中的最优值。
一棵树+一条边的处理方法:
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=1e6+50;
struct edge{
int to,nex;
}es[N<<1];
int head[N],cnt;
void init(){
memset(head,-1,sizeof(head));
cnt=0;
for(auto e:es)e.nex=-1;
}
void add(int f,int t){
es[cnt].to=t;
es[cnt].nex=head[f];
head[f]=cnt++;
}
int fa[N];
void init_set(int n){
for(int i=1;i<=n;i++)fa[i]=i;
}
int find(int x){
return fa[x]=(fa[x]==x?x:find(fa[x]));
}
void merge(int x,int y){
x=find(x);
y=find(y);
fa[x]=y;
}
bool same(int x,int y){
return find(x)==find(y);
}
vector<int>roots,rv;
ll val[N];
ll dp1[N],dp2[N],flag;
void dfs(int v,int f,int r,int p){
dp1[v]=0;
dp2[v]=val[v];
for(int i=head[v];~i;i=es[i].nex){
int u=es[i].to;
if(u==f)continue;
dfs(u,v,r,p);
dp1[v]+=max(dp1[u],dp2[u]);
dp2[v]+=dp1[u];
}
if(v==p&&flag)dp2[v]=0;
}
ll solve(int r,int p){
ll ma=0;
flag=0;
dfs(r,0,r,p);
ma=dp1[r];
flag=1;
dfs(r,0,r,p);
ma=max(ma,dp2[r]);
return ma;
}
int main(){
int n;
cin>>n;
init();
init_set(n);
for(int v=1;v<=n;v++){
cin>>val[v];
int u;
cin>>u;
if(same(v,u)){
roots.push_back(v);
rv.push_back(u);
}else{
add(v,u);
add(u,v);
merge(v,u);
}
}
ll sum=0;
for(int i=0;i<roots.size();i++)
sum+=solve(roots[i],rv[i]);
cout<<sum;
}
这种思路只需特殊考虑那条边,考虑那条边连接根r(把其中一个点认为树的根,代码中也是这么做的)与一个点v,那么思路也是根不选时dp一下,根选时dp一下。
4. 差分约束系统
差分约束系统的前置知识为最短路以及在图中判断负环。
差分约束系统是指m个n元一次不等式组,且每个不等式满足如下形式:。需要你求出一组解。
这里有一个性质:如果有解,那么一定有无穷组解。因为同时在每个变量x上同时加上一个常数C,不等式仍然成立。所以你总可以让x1=0。
那为什么这道题与最短路与负环有关呢?在一个图中,点v的最短路为其所有相邻的点u的最短路加上从u到v的边的权值中的最小值,也即满足对于任意u,如果有从u到v的边,那么有d[v]<=d[u]+e<u,v>,这与形式相同。如果把看成i的最短路,那么就可以从j向i连一条权值为的边,然后求最短路。明显地,如果每个点都有最短路,自然满足所有不等式。什么时候无解呢,也即有点没有最短路,也即出现了负环。
为什么出现负环无解呢?首先解释这么个点,如果u到v有边权值为k1,v到t有边权值为k2,也即有d[v]<=d[u]+k1,d[t]<=d[v]+k2,即d[t]<=d[u]+k1+k2。如果有负环,不妨设从q到p之间有一条负边权值为-k1(k1>0),p到q有一条正路径权值为k2(k2>=0,k1>k2),所以有d[q]<=d[p]+k2,d[p]<=dp[q]-k1,所以有d[p]+k1<=dp[q]<=d[p]+k2,矛盾。同理如果无解也必有负环。
这里判断负环时,因为图不一定是强联通的,所以你不能从某一个点所以建立一个虚拟点,到每个点的连一条权值为0的边,明显地,这不影响负环的判断,此时每个点的最短路也确实是一组解。
代码如下(洛谷P5960):
#include<bits/stdc++.h>
using namespace std;
const int N=5005,M=1e4+50;
const double INF=1e15,eps=1e-3;
struct edge{
int to,nex,w;
}es[M];
int head[N],cnt;
void init(){
memset(head,-1,sizeof(head));
cnt=0;
for(auto e:es)e.nex=-1;
}
void add(int f,int t,int w){
es[cnt].to=t;
es[cnt].w=w;
es[cnt].nex=head[f];
head[f]=cnt++;
}
int n,m,inq[N],num[N];
int dist[N];
bool spfa(int s){
for(int i=1;i<=n;i++)dist[i]=INF;
memset(inq,0,sizeof(inq));
memset(num,0,sizeof(num));
dist[s]=0;
queue<int>que;
que.push(s);
inq[s]=1;
while(!que.empty()){
int v=que.front();
que.pop();
inq[v]=0;
for(int i=head[v];~i;i=es[i].nex){
int u=es[i].to;
double w=es[i].w;
if(w+dist[v]<dist[u]){
dist[u]=w+dist[v];
if(!inq[u])inq[u]=1,que.push(u);
if(++num[u]>n)return true;
}
}
}
return false;
}
int main(){
cin>>n>>m;
init();
for(int i=1;i<=n;i++)add(0,i,0);
for(int i=0;i<m;i++){
int u,v,w;
cin>>u>>v>>w;
add(v,u,w);
}
if(spfa(0)){
cout<<"NO";
}else{
for(int i=1;i<=n;i++)printf("%d ",dist[i]);
}
}