图论初步
省流:本篇专供冲击NOIP一等的人使用,坐标HN
1.图的dfs/bfs
图论中的深度优先遍历\(dfs\)和广度优先遍历\(bfs\)的思想基础和深度优先搜索&&广度优先搜索基本一致。所以你为什么会不会?还不去复习搜索?
对于遍历,我们打一个\(vis\)数组,每遍历到一个点,就把它的\(vis\)设为\(true\)(\(false\)也可以,但你自己要记得)
-
对于\(dfs\),我们可以以它为起点进行递归
-
对于\(bfs\),我们可以将它扔进一个队列,下次循环时取出来作为起点。
不同的图有不同的\(dfs\)和\(bfs\)方法,事实上,对于不同的图论算法,建不同的图,写码难度不尽相同,你马上就会看到这点。
习题1.1.1
这个基本上是板子题了,虽然可能有更板的题,但是我忘了是哪道了,不好放上来。
看看例题代码,认真体会遍历与搜索的区别和联系。
还有,如果图不能保证连通,就不要把点数\(n\)作为运算和输出的依据!!!
AC Code:
#include<bits/stdc++.h>
using namespace std;
const int maxm=1e6+7;
const int maxn=1e5+7;
vector<int>e[maxm];
int vis[maxn];
int cnt;
void dfs(int n,int len)
{
vis[n]++;
cout<<n<<' ';
for(int i=0;i<e[n].size();i++)
{
int v=e[n][i];
if(!vis[v])
{
vis[v]++;
dfs(v,len);
}
}
}
void bfs(int n,int len)
{
queue<int> q;
q.push(n);
vis[n]++;
cout<<n<<' ';
while(!q.empty())
{
int u=q.front();
q.pop();
for(int i:e[u])
{
if(!vis[i])
{
vis[i]++;
q.push(i);
cout<<i<<' ';
}
}
}
}
int main()
{
int x=0,y=0;
cin>>x>>y;
for(int i=1;i<=y;i++)
{
int a=0,b=0;
cin>>a>>b;
e[a].push_back(b);
}
for(int i=0;i<=y;i++)
{
sort(e[i].begin(),e[i].end());
}
dfs(1,x);
cnt=0;
memset(vis,0,sizeof(vis));
cout<<endl;
bfs(1,x);
return 0;
}
2.拓扑排序
拓扑排序吧,你说它很难绝对是不对的,但它牵扯到最长路,基环树、二维莫队这些玩意儿,你又不能说它简单。
拓扑排序可以把图变成一个一维序列,这个一维序列满足实际问题中的先后顺序。
我一直都觉得这句话比较废话,你就姑且认为拓排可以把图变成一个有一定顺序的线性序列吧。
那怎么做呢?我们来思考。
我们考虑把实际问题中的事情抽象为点,事情的依赖(先后)关系抽象为有向边(无向边还算什么依赖?),为了讨论方便,我们将边的方向设定成先完成→后完成
实际问题中,最先要做的事情之前显然没有要做的事情(废话吧),抽象成图后,它所代表的点不会是任何一条边的终点(不会在哪个事件后完成),那么我们就找到拓排的起点了。
然后我们直接选择删掉这个点,以及以它为起点的所有边,那么所有的点连接的边的数目都会减一(剪掉重边的数目,如果有重边的话),如果再次有点满足起点的条件,那就把它扔进起点候选队列呗[doge]。
对于一个点所连接的边的数目,我们称为度数,对于有向图,以这个点为起点的边的数目称为出度,以这个点为终点的边的数目称为入度。显然一个点为起点的充要条件是入度为\(0\)。对于每个点的度数,我们直接开个\(degree\)数组维护就行。
值得一提的是,拓扑排序不仅要求图是有向的,并且要求图中没有环,什么意思呢?考虑以下邻接表数据,第一行为点数\(n\)和边数\(m\)。
4 4
1 2
2 3
3 4
4 1
很明显整个图就是四元环,每个点都有先于它的点,显然是没有拓扑序的。因为找不出最先完成谁,即以谁为起点。
那么因为这种优良的对数据过敏的性质,我们可以用拓扑排序来判定一个有向图是否有环(忽略边权正负问题)。
我们设置一个\(cnt\),每遍历到一个点就加一,如果\(cnt > n\),那肯定是有点没删掉(不然就\(n\)个点,你怎么走到\(n\)后面去),这时,我们按照题目要求,该输出No输出No,该输出NO输出NO,该输出不可以,总司令就输出不可以,总司令。
接下来就做个模板题吧~
习题2.1.1
没什么要注意的了,就把题A了就行了。
AC Code:
#include<bits/stdc++.h>
using namespace std;
const int maxn=205;
//#define DEBUG
int edges[maxn][maxn];
int degree[maxn];
queue<int>q;
int cnt=0;
void toposort(int s,int n)
{
for(int i=1;i<=n;i++)
{
if(degree[i]==0)q.push(i);
}
int buf=0;
while(!q.empty())
{
buf=q.front();
q.pop();
cout<<buf<<' ';
cnt++;
for(int i=1;i<=n;i++)
{
if(edges[buf][i]==1)
{
edges[buf][i]=0;
degree[i]--;
if(degree[i]==0)q.push(i);
}
}
}
}
int main()
{
int n=0,u=0,v=0;
cin>>n;
for(int i=1;i<=n;i++)
{
do
{
cin>>v;
edges[i][v]=1;
degree[v]++;
}
while(v>0);
}
#ifdef DEBUG
cout<<"edges:"<<endl;
for(int i=1;i<=n;i++)
{
for(int j=1;j<=n;j++)
{
cout<<edges[i][j];
if(j<n)cout<<' ';
}
if(i<n)cout<<endl;
}
cout<<endl;
cout<<"degree:"<<endl;
for(int i=1;i<=n;i++)
{
cout<<degree[i];
if(i<n)cout<<' ';
}
cout<<endl;
cout<<endl;
#endif
toposort(u,n);
return 0;
}
3.最小生成树
最小生成树MST,是指在一个图中,保留\(n-1\)条边,使其边权和最小。名字的来源是树有且只有\(n-1\)条边,可以证明,若有\(n\)条边,则必定会形成一个环,若边数小于\(n-1\),则图不可能连通,即有点会没有边相连。
注意到对于无向图,最小生成树所形成的图是保证点两两连通的边数最少的形式。这是它的实际意义。
对于有向图,情况比较复杂。我们可以采用朱刘算法,比如JSOI2008就有一道例题。但经验表明,在我们的目标等级,有向图的最小生成树并不多见,所以我们完全可以鸽掉,省一点时间。本文介绍求无向图的最小生成树两种经典算法。
3.1 kruskal
\(kruskal\)的思想是每次确定一条边
我们观察,发现既然最小生成树使点两两连通,那么所有的点必然都属于一个大的集合,我们可以称大集合为连通块。
那么我们可以考虑给每个点记录它所属的集合,然后将边按边权从小到大排序,从最小的边开始,一条一条边考虑。
我们选择一个边,当且仅当这条边的两个端点所属的集合不同。这样,我们就选择这条边,并将边权累加至答案以统计贡献。最后,将两个端点的集合合并。
当只剩下一个集合时,说明点已两两连通,满足MST的定义。此时可以输出答案。
如果边已完成遍历,而仍然有多个集合时,我们认为这张图的MST无解,这可能是有点不是任何一条边的端点造成的。
描述很啰嗦,但是写起来还是很容易的,而且\(kruskal\)基于并查集的思想,在优化算法下,并查集的复杂度为\(O(n\alpha(n))\),其中\(\alpha(n)\)为阿克曼函数的反函数,在算法竞赛的规模中一般是不大于\(4\)的常数,我们可以认为是个大常数的\(O(n)\),再不济,\(kruskal\)的复杂度也是\(O(n\log n)\)。好写、跑得快,场上可以优先选择。
习题3.1.1
OK,动手!
如果图不连通,你应当输出
orz
AC Code:
#include<bits/stdc++.h>
using namespace std;
const int maxn=5e3+5;
const int maxm=2e5+7;
int fa[maxn];
int sz[maxn];
void set_make(int len)
{
for(int i=1;i<=len;i++)
{
fa[i]=i;
sz[i]=1;
}
}
struct edge
{
int a;
int b;
int c;
};
edge edges[maxm];
bool operator <(edge x,edge y)
{
if(x.c<y.c)return true;
else return false;
}
int find(int n)
{
if(fa[n]==n)return fa[n];
else return fa[n]=find(fa[n]);
}
bool merge(int x,int y)
{
x=find(x);
y=find(y);
if(x==y)return false;
else
{
//if(sz[x]>sz[y])swap(x,y);
fa[x]=y;
sz[y]+=sz[x];
return true;
}
}
/*bool check(int len)
{
int cnt=0;
for(int i=1;i<=len;i++)
{
cout<<fa[i]<<' ';
if(fa[i]==i)
{
cnt++;
if(cnt>1)return false;
}
}
cout<<endl;
return true;
}*/
int main()
{
int n=0,m=0,res=0;
cin>>n>>m;
set_make(n);
for(int i=1;i<=m;i++)
{
cin>>edges[i].a>>edges[i].b>>edges[i].c;
}
sort(edges,edges+m+1);
for(int i=1;i<=m;i++)
{
//if(m==1)cout<<edges[1].c; //不需要考虑
/*if(check(n)==true)
{
cout<<res;
return 0;
}*/
if(merge(edges[i].a,edges[i].b)==false)
{
//cout<<"DON'T"<<endl;
continue;
}
else
{
//cout<<"Add edge between "<<edges[i].a<<' '<<" and "<<edges[i].b<<endl;
res+=edges[i].c;
n--;
//cout<<"Now there are "<<n<<" sets"<<endl;
if(n==1)
{
cout<<res;
return 0;
}
}
}
cout<<"orz";
return 0;
}
3.2 prim
\(prim\)的思想是每次确定一个点
考虑前面对于MST的定义,注意到“选取最小的边”这条要求也可以等价为每个点所连接的边的边权最小。这样,我们可以先确定每个点的答案,再累加后输出。
怎么确定每点的答案呢?根据一些教材的说法,如果没有最短路的基础,理解\(prim\)的思想会有些困难,事实上,没有\(prim\)的基础,理解最短路的思想也会有些困难。[doge]总之它们写法相近就是了。
我们先设立\(mins\)数组,每个元素初始化为一个极大的数(要求的是最小生成树)
然后我们选取一个点\(x\)作为起点,将\(mins[i]\)置\(0\)。
我们从当前的mins中选取值最小的一个位置,并遍历它的每条边,对于每条边的另一个端点,如果它没有被访问过,且当前边的边权小于它的\(mins\),那么显然可以修改它的\(mins\)。
这个思想显然是贪心,可以证明这个贪心的正确性。
如果在算法完成后,仍有点的\(mins\)等于初始化的最大值,那么显然它没有任何边连接,即整张图不连通,我们认为这张图的MST无解。
习题3.2.1
\(see\) \(you\) \(again\)。
AC Code:
#include <bits/stdc++.h>
using namespace std;
const int maxn=5e3+5;
const int maxm=2e5+7;
struct edge
{
int to;
int val;
};
vector<edge>graph[maxn];
int mins[maxn];
int vis[maxn];
inline void adde(int u,int v,int w)
{
graph[u].push_back({v,w});
}
int findmin(int len)
{
int ans=0;
for(int i=1;i<=len;i++)
{
if((mins[i]<mins[ans])&&(!vis[i]))
{
ans=i;
}
}
return ans;
}
void prim(int n,int s=1)
{
memset(mins,0x3f,sizeof(mins));
memset(vis,0,sizeof(vis));
mins[s]=0;
int res=0;
for(int i=1;i<=n;i++)
{
int u=findmin(n);
vis[u]=1;
for(int j=0;j<graph[u].size();j++)
{
if((!vis[graph[u][j].to])&&(graph[u][j].val<mins[graph[u][j].to]))
{
mins[graph[u][j].to]=graph[u][j].val;
}
}
}
for(int i=1;i<=n;i++)
{
if(mins[i]==0x3f3f3f3f)
{
cout<<"orz";
return;
}
res+=mins[i];
}
cout<<res;
}
int main()
{
int n=0,m=0,u=0,v=0,w=0;
cin>>n>>m;
for(int i=1;i<=m;i++)
{
cin>>u>>v>>w;
adde(u,v,w);
adde(v,u,w);
}
for(int i=1;i<=n;i++)
{
if(mins[i]==0x3f3f3f3f)
{
cout<<"orz";
return 0;
}
}
prim(n,1);
return 0;
}
4.最短路
对于图的每个节点,我们可以认为它们有固定的“位置”,而边构成了一条条“路”,加上边权以后,自然就有了“最短路”。
对于无边权的最短路,\(bfs\)就可以搞定,因为边权都一样的话,显然走过的边越少,路径就越短。
本文简单介绍三种求最短路的常用算法,注意到这些算法对边的方向并无要求。
4.1 floyd
\(floyd\)算法解决的是所谓“全源最短路”问题。就是写个函数跑一遍,每两点之间的最短路都出来了。
现在我们考虑怎么做。有个显然的想法是贪心,在两点之间选取最短的边构成一条路。但是这个算法写出来显然很复杂,有人(\(floyd\))就想了个更简洁的算法。
什么算法最简洁呢?不考虑数学问题的情况下,简单DP无非是最简单的。用几层循环填答案数组就可以了。那我们就往这个思想方向靠。
那怎么设\(dp\)数组表示状态呢?设\(dp[i][j]\)表示由\(i\)到\(j\)的最短路?那不变成之前的贪心了。
\(floyd\)老爷子是这么想的,设\(dp[i][j][k]\)表示经过\(k\)点,由\(i\)到\(j\)的最短路,显然我们就可以分段讨论,得到一个状态转移方程:
显然有初始条件
\(dp[i][i][k]=0\)
\(dp[i][j][k]=graph[i][j],if\) \(graph[i][j]!=0\)
\(if\) \(graph[i][j]==0,dp[i][j][k]=0x3f3f3f3f\)
\(k \in [1,n]\)
我们发现这个\(dp\)数组太大了,大到只能承受约\(1e2\)个点,不满足我们的需要。
注意到数组\(k\)一维在状态转移方程中并无实质性作用,而且最终答案还要处理成\(\max_{k=1}^n(dp[i][j][k])\),增大隐形常数。所以,我们可以考虑将\(k\)一维舍去,只保留\(dp[i][j]\),整理其他式子。
但在循环中,我们还需要保留\(k\)的处理,因为仍然有\(k\)。所以仍有三层循环。
\(floyd\)的时间复杂度为\(O(n^3)\),空间复杂度为\(O(n^2)\)。€€£的官方教材曾鼓励我们思考\(floyd\)的时间复杂度\(O(n^2+nm)\)的做法,但我教练说用不上一点,所以算了吧。
尝试做模板题吧,最终的状态转移方程形式优美、简洁,充满魅力!
习题4.1.1
注意到该题有重边,输入时就应选择最小的边存储。
AC Code:
#include<bits/stdc++.h>
using namespace std;
const int maxn=1e2+5;
const int maxm=4.5e3+7;
int graph[maxn][maxn];
int res[maxn][maxn];
void build(int num,int n)
{
int a=0,b=0,c=0;
memset(graph,0x3f,sizeof(graph));//初始边权为正无穷
for(int i=1;i<=num;i++)
{
cin>>a>>b>>c;
if(c<graph[a][b])graph[a][b]=graph[b][a]=c;//模板题有重边,需要特判
}
for(int i=1;i<=n;i++)
{
graph[i][i]=0;//自环设为零
}
}
void floyd(int sz)
{
for(int i=1;i<=sz;i++)
{
res[i][i]=0;//自环等于零
for(int j=1;j<=sz;j++)
{
res[i][j]=(graph[i][j]==0x3f3f3f3f?0x3f3f3f3f:graph[i][j]);//有边为边权,没边就无穷大
}
}
for(int k=1;k<=sz;k++)
{
for(int i=1;i<=sz;i++)
{
for(int j=1;j<=sz;j++)
{
res[i][j]=min(res[i][j],res[i][k]+res[k][j]);
}
}
}
for(int i=1;i<=sz;i++)
{
for(int j=1;j<=sz;j++)
{
cout<<res[i][j];
if(j<sz)cout<<' ';
}
if(i<sz)cout<<endl;
}
}
int main()
{
int n=0,m=0;
cin>>n>>m;
build(m,n);
floyd(n);
return 0;
}
4.2 spfa
关于\(spfa\),它死了——NOI 2018 T1 归程
\(spfa\)是\(bellman-ford\)算法的队列改进版,时间复杂度据说常数更优,但还是很容易被卡。即使如此,这里也只讲\(spfa\)。
\(spfa\)解决的是“单源最短路”问题,即跑一个函数,终点可以\(n\)个,但起点只能有一个。
我们考虑能不能对\(bfs\)做点优化,使它带边权。
对于最短路,同样的,我们设个\(dist[maxn]\)来解决这个问题。
首先,我们让起点入队,设其\(dist\)为\(0\),并打上标记。
然后照常\(bfs\)循环,但是我们此时取出队头后,我们需要重新将该点的\(vis\)设为\(false\),因为求最短路时,一个点很有可能要多次访问,所以先取消掉。
然后,我们就循环所有相连的点,然后……
是传说中的松弛!!!
for(int i=head[u];i!=0;i=edges[i].last)
{
int v=edges[i].to;
if(dist[v]>dist[u]+edges[i].val)
{
dist[v]=dist[u]+edges[i].val;
if(!vis[v])
{
q.push(v);
vis[v]=true;
}
}
}
这就是我们对\(bfs\)进行的改造,简单有效,而且确实长得跟\(prim\)挺像的。
所以你也知道了,\(spfa\)也是贪心,确定了每个点的答案。
\(spfa\)的设计策略是每次确定一个点
我总结的奇怪规律
值得一提的是,\(spfa\)受的住负边权,但受不住负环。同样的,你就可以用它来判负环,详见\(5.2节\)
习题4.2.1
OK,动手!
AC Code:
#include<bits/stdc++.h>
using namespace std;
const int maxn=1e4+5;
const int maxm=4.5e+7;
struct edge
{
int to;
int last;
int val;
};
edge e[maxm];
int head[maxn];
int vis[maxn];
int dist[maxn];
queue<int>q;
int tot=0;
inline void adde(int a,int b,int c)
{
e[++tot].last=head[a];
e[tot].to=b;
e[tot].val=c;
head[a]=tot;
}
void spfa(int n,int x=1)
{
memset(dist,0x3f,sizeof(dist));
q.push(x);
dist[x]=0;
vis[x]=1;
while(!q.empty())
{
int u=q.front();
q.pop();
vis[u]--;
for(int i=head[u];i!=0;i=e[i].last)
{
int v=e[i].to;
if(dist[v]>dist[u]+e[i].val)
{
dist[v]=dist[u]+e[i].val;
//cnt[i]=cnt[u]+1;
//if(cnt[i]>=n)return false;
if(!vis[v])
{
vis[v]++;
q.push(v);
}
}
}
}
//return true;
}
int main()
{
int n=0,m=0,s=1;
cin>>n>>m>>s;
//build(n,m);
int a=0,b=0,c=0;
for(int i=1;i<=m;i++)
{
cin>>a>>b>>c;
adde(a,b,c);
}
spfa(n,s);
for(int i=1;i<=n;i++)
{
if(dist[i]==0x3f3f3f3f)
{
cout<<2147483647;
if(i<n)cout<<' ';
continue;
}
cout<<dist[i];
if(i<n)cout<<' ';
}
return 0;
}
其实\(spfa\)为什么会被卡的这么死,我也不是很清楚,但是只要没负边权,你就用下面这个算法吧。
4.3 dijkstra
\(dijkstra\)是另一个比较巧妙的算法,常用写法是\(O(n\log n)\),较快。
然后我们在\(spfa\)的基础上做些小改动,简单来说就是定义一个\(node\)结构体,把每个点进行一次松弛后的距离和端点存起来,我们以这个结构体为基础,开一个\(priority\) \(queue\),然后其余的和\(spfa\)一致。
习题4.3.1
AC Code:
#include<bits/stdc++.h>
using namespace std;
const int maxn=1e5+5;
const int maxm=2e5+7;
struct edge
{
int to;
int last;
int val;
};
struct node
{
int dis;
int s;
friend bool operator <(node a,node b)
{
return a.dis>b.dis;
}
};
edge e[maxm*2];
int head[maxn];
int vis[maxn];
int dist[maxn];
priority_queue<node>q;
int tot=0;
inline void adde(int a,int b,int c)
{
e[++tot].last=head[a];
e[tot].to=b;
e[tot].val=c;
head[a]=tot;
}
void dijkstra(int n,int x=1)
{
memset(dist,0x3f,sizeof(dist));
q.push({0,x});
dist[x]=0;
vis[x]=1;
while(!q.empty())
{
int u=q.top().s;
q.pop();
vis[u]--;
for(int i=head[u];i!=0;i=e[i].last)
{
int v=e[i].to;
if(dist[v]>dist[u]+e[i].val)
{
dist[v]=dist[u]+e[i].val;
if(!vis[v])
{
vis[v]++;
q.push({dist[v],v});
}
}
}
}
}
int main()
{
int n=0,m=0,s=1;
cin>>n>>m>>s;
int a=0,b=0,c=0;
for(int i=1;i<=m;i++)
{
cin>>a>>b>>c;
adde(a,b,c);
}
dijkstra(n,s);
for(int i=1;i<=n;i++)
{
if(dist[i]==0x3f3f3f3f)
{
cout<<2147483647;
if(i<n)cout<<' ';
continue;
}
cout<<dist[i];
if(i<n)cout<<' ';
}
return 0;
}
关于\(spfa\)和\(dijkstra\)到底有什么区别,看一看我的远古口嗨\(may\) \(help\).
虽然也写的不是很清楚,但够用了。
5.图论著名问题选讲
以下讲解一些OI图论中的著名问题,他们基本都是以前的算法的改装版。
学习时,应重点搞懂\(tarjan\)老爷子的各种思想,尤其是求强连通分量的算法,它是很多算法的基础。
5.1 强连通分量&&缩点
曾经,当我的教练跟我提起强连通分量是什么的时候,我思考了一下,回答:“一个图的最大子集”。
她当时估计想呼死我:“一个图的最大子集是它本身!!!”
事实上,强连通分量确实是图的一个子集,但是,这个子图中,每个点两两直接有边连通,非常的牛逼。
那我们怎么找出一个图的强连通分量?\(tarjan\)老爷子提出了一个奇妙的Solution。他的想法啊,是基于\(dfs\)的。
具体来说,我们设两个数组,\(dfn\)和\(low\),前者是访问的时刻(第几个被访问的),后者是该点能追溯到的最早访问的点是第几个被访问的。这个“个”我们可以设个\(idx\)控制。因为一开始还没访问,所以这两个数组都应初始化为0,然后\(dfn\)就可以代替朴素\(dfs()\)的\(vis\)了嘛。不要再定义一个\(vis\)了!我就被这个搞混了!(
而且为了好判断节点所属强连通分量,我们开一个栈,也是\(dfs()\)的老习惯了。
首先肯定是开始访问,入栈,\(dfn\)和\(low\)都设为\(++idx\),比较好理解吧。
然后照例遍历所有相邻节点,如果没有被访问过,也就是\(dfn\)为空,还愣着干嘛快\(dfs\)啊。既然能够一路访问下去,那就说明这一路上能到的最早节点都是一样的,所以回溯时要把这一路的\(low\)都改,改成最小的,用\(min\),而且是用每条边两个端点的\(low\)取最小值。
如果这个点已经被访问过了,那\(dfn\)肯定就不等于0了。但是如果它没有被人要走,那我们也可以拉它入伙,毕竟也是一条路上的嘛。如果开一个\(scc\)数组记录每个原先节点所属的强连通分量,没被人要走就意味着这个值仍然为零。碰到这种情况,我们也用两个\(low\)更新一下\(low\)。
当我们跳出这个边的循环以后,\(dfn\)和\(low\)肯定都更新好了,那我们怎么判断强连通分量呢?
注意到由于一个强连通分量里任两点都可以直接连通,所以一个强连通分量内的点必然\(low\)都是相同的,而且我们还可以猜想,在这个分量里,必然有一个点,满足\(dfn==low\),因为就初值来看,能做到\(low\)最小,必然有\(dfn\)最小,因为它们都被初始化成了\(++idx\)。所以,在跳出遍历边的循环后,我们直接判断有没有点\(dfn==low\)。如果有的话,就开始弹栈,为了标记数目,我们再设个\(scccnt\)变量,一旦进入这个分支就++,然后出栈一个元素并用\(scccnt\)标记它的\(scc\),同时如果有需要,也维护下强连通分量的大小。
这个出栈循环的终止条件是栈顶元素不为这个\(dfn==low\)的节点。道理很简单,因为它已经出过一次了(进这个循环代码),如果这时不退,就会无限循环了。
由于一个讨厌的图不连通的问题,如果题面没有保证图联通,那就要将每个未访问的点都\(tarjan\)一遍,不然有些孤点就找不到了……TAT
可以证明对全部点跑\(tarjan\)的复杂度是\(O(n+m)\),因为\(dfn\)可以作为\(vis\),所以不存在点被重复访问的情况。
习题5.1.1
P2341 [USACO03FALL / HAOI2006]受欢迎的牛 G
其实这一题就用到了缩点……因为每个强连通分量内部两两直接连通,所以可以直接视为一个“大”点。这样,我们只要保留每个“大点”之间的边就可以了。具体的操作是开个\(from\)和\(to\)数组。每读一条边进原邻接表,也存一份在这两个数组里,然后跑完所有\(tarjan\)就可以把原邻接表清空,装新的邻接表了!
本题新建边时还要维护一下“大点”的出度。有一个小结论:缩完点后,有解当且仅当只有一个“大点”的出度为零,且解就是这个“大点”的大小。
证明是(居然给证明了):
-
如果有两个以上出度为零的“大点”,那么这些“大点”之间彼此无法爱慕,不符合题意,因此无解。
-
如果出度为零的“大点”一个都没有:
首先,我们有个结论:缩完点后的图如果是有向的,就不存在双向边
这个结论的正确性是显然的,在有向图中,如果两个点之间有双向边,那么它们之间就可以看作相互连通。显然可以继续缩成一个点( \(\therefore\)证毕
那如果没有出度不为0的点,即每个点都要有一条通向其他点的边,那即使其他点都指向一个点\(x\),它自己也得指向一个点,这个点就无法指向它,也就不满足爱慕的条件了。
-
因为每个大点内部都是两两连通的,所以原图中如果有一个属于强连通分量的点符合条件,那么同分量的点肯定都满足条件。
\(\therefore\)证毕。
AC Code:
#include<bits/stdc++.h>
using namespace std;
const int maxn=1e5+5;
const int maxm=5e5+7;
struct edge
{
int to;
int last;
};
edge edges[maxm*2];
int head[maxn];
int tot=0;
int dfn[maxn];
int low[maxn];
stack<int> s;
int idx=0;
int scccnt=0;
int scc[maxn];
int sccsize[maxn];
int val[maxn];
int from[maxn];
int to[maxn];
int degree[maxn];
int res=0;
int isres=0;
inline void adde(int u,int v)
{
edges[++tot].last=head[u];
edges[tot].to=v;
head[u]=tot;
}
void strongtarjan(int x)
{
s.push(x);
dfn[x]=low[x]=++idx;
for(int i=head[x];i!=0;i=edges[i].last)
{
int v=edges[i].to;
if(!dfn[v])
{
strongtarjan(v);
low[x]=min(low[x],low[v]);
}
else if(scc[v]==0)
{
low[x]=min(low[x],low[v]);
}
}
if(dfn[x]==low[x])
{
scccnt++;
int u=0;
do
{
u=s.top();
s.pop();
scc[u]=scccnt;
sccsize[scccnt]++;
}
while(x!=u);
}
}
int main()
{
int n=0,m=0,u=0,v=0;
cin>>n>>m;
for(int i=1;i<=m;i++)
{
cin>>u>>v;
adde(u,v);
from[i]=u;
to[i]=v;
}
for(int i=1;i<=n;i++)
{
if(!dfn[i])strongtarjan(i);
}
memset(head,0,sizeof(head));
memset(edges,0,sizeof(edges));
tot=0;
for(int i=1;i<=m;i++)
{
if(scc[from[i]]!=scc[to[i]])
{
adde(scc[from[i]],scc[to[i]]);
degree[scc[from[i]]]++;
}
}
for(int i=1;i<=scccnt;i++)
{
if(degree[i]==0)
{
res=i;
isres++;
}
}
cout<<(isres==1?sccsize[res]:0);
return 0;
}
习题5.1.2
好像题目顺序摆错了……没什么好讲的。
这题要求最长路,可以用\(lpfa\)解决,松弛的不等号反向就可以了。
AC Code:
#include<bits/stdc++.h>
using namespace std;
const int maxn=1e5+5;
const int maxm=5e5+7;
struct edge
{
int to;
int last;
};
edge edges[maxm*2];
int head[maxn];
int tot=0;
int dfn[maxn];
int low[maxn];
int vis[maxn];
stack<int> s;
int idx=0;
int scccnt=0;
int scc[maxn];
int sccsize[maxn];
int val[maxn];
int from[maxn];
int to[maxn];
int res=0;
queue<int>q;
int dist[maxn];
inline void adde(int u,int v)
{
edges[++tot].last=head[u];
edges[tot].to=v;
head[u]=tot;
}
void spfa(int x)
{
vis[x]=1;
q.push(x);
dist[x]=sccsize[x];
while(!q.empty())
{
int u=q.front();
q.pop();
vis[u]=0;
for(int i=head[u];i!=0;i=edges[i].last)
{
int v=edges[i].to;
if(dist[u]+sccsize[v]>dist[v])
{
dist[v]=dist[u]+sccsize[v];
if(!vis[v])
{
q.push(v);
vis[v]=1;
}
}
}
}
for(int i=1;i<=scccnt;i++)
{
res=max(res,dist[i]);
}
}
void strongtarjan(int x)
{
s.push(x);
dfn[x]=low[x]=++idx;
vis[x]=1;
for(int i=head[x];i!=0;i=edges[i].last)
{
int v=edges[i].to;
if(!dfn[v])
{
strongtarjan(v);
low[x]=min(low[x],low[v]);
}
else if(vis[v])
{
low[x]=min(low[x],dfn[v]);
}
}
if(dfn[x]==low[x])
{
scccnt++;
int u=0;
do
{
u=s.top();
vis[u]=0;
s.pop();
scc[u]=scccnt;
sccsize[scccnt]+=val[u];
}
while(x!=u);
}
}
int main()
{
int n=0,m=0,u=0,v=0;
cin>>n>>m;
for(int i=1;i<=n;i++)
{
cin>>val[i];
}
for(int i=1;i<=m;i++)
{
cin>>u>>v;
adde(u,v);
from[i]=u;
to[i]=v;
}
for(int i=1;i<=n;i++)
{
if(!dfn[i])strongtarjan(i);
}
memset(head,0,sizeof(head));
memset(edges,0,sizeof(edges));
tot=0;
for(int i=1;i<=m;i++)
{
if(scc[from[i]]!=scc[to[i]])
{
adde(scc[from[i]],scc[to[i]]);
}
}
for(int i=1;i<=scccnt;i++)
{
spfa(i);
}
cout<<res;
return 0;
}
5.2 传递闭包
你在网上搜搜,搜出来的关键词就不是这个内容(
所谓传递闭包,讲的高大上,实质上就是判断一个图中任意两点能不能彼此到达。因为是任意两个点,我们显然可以跑全源最短路,预先给数组初始化一个极大值。如果跑完以后,我们发现那两个点的最短路值依然是那个最大值,那就说明这两点,彼此根本无法到达。这也是我们想要的结果。
但是有人觉得这么搞不够快他们就是快男,所以我们考虑如何优化。
由于是任意两点,我们直接考虑对全源最短路模型修改。那肯定是floyd莫属了。
我们建边时,把边权去掉,每条边只采用\(0-1\)连通形式,然后,对于任意两个点,我们枚它们的中间点,那么,两点可以到只有以下两个条件:
-
这两点本身有边连通
-
有至少一个“中转点”,使得这两点连通。
然后我们回想\(floyd\)的状态转移方程(经过优化):
显然这个状态转移方程是和第二种情况等价的,那么我们只要把第一种情况加进去就可以了。
两种情况应该取并的关系,因为如果如果同时满足既直接连通,又有中转点,那肯定也是连通的,所以,我们对状态转移方程做出如下改动:
刚好还是floyd的三层循环!\(good\)
这个有什么用吧,没见过。只是有些题解说要提前判两点连通,可能会用这个?难绷。
习题5.2.1
它甚至是入门题库……
AC Code:
#include<bits/stdc++.h>
using namespace std;
const int maxn=105;
int graph[maxn][maxn];
bool dis[maxn][maxn];
int main()
{
int n=0;
cin>>n;
for(int i=1;i<=n;i++)
{
for(int j=1;j<=n;j++)
{
cin>>graph[i][j];
dis[i][j]=(graph[i][j]==1?true:false);
}
}
for(int k=1;k<=n;k++)
{
for(int i=1;i<=n;i++)
{
for(int j=1;j<=n;j++)
{
dis[i][j]=dis[i][j]||(dis[i][k]&&dis[k][j]);
}
}
}
for(int i=1;i<=n;i++)
{
for(int j=1;j<=n;j++)
{
cout<<dis[i][j];
if(j<n)cout<<' ';
}
if(i<n)cout<<endl;
}
return 0;
}
5.3 判负环&&差分约束
因为是环,肯定是一个点跑了一圈发现回到了自己,所以我们采用单源最短路的做法。
诶,你要找负环嘞!关于\(dijkstra\),它死了。关于\(spfa\),它复活了
与拓扑排序的思想类似,如果我们用\(spfa\)跑了超过\(n\)个点,那肯定也是出现了个环,我们退出来就行了。但是由于\(spfa\)并没有固定的\(vis\)标记(取队头后\(vis\)置零),那我们要头疼的就有很多了。
我们的解决方案是拿空间换编码难度,直接开个\(cnt\)数组记录每个点的路过,如果有一个点经过了\(n\)次以上,那总不对劲了吧!同理,松弛成功后,我们也应该在\(cnt\)基础上转移,而不是简单的++。
再提一句,正权会收敛,最终队列会为空,所以\(spfa\)只能判负环。
习题5.3.1
你别觉得这个很简单,拿\(spfa\)模板改一晚你都不一定能过……
AC Code:
#include<bits/stdc++.h>
using namespace std;
const int maxn=2e3+5;
const int maxm=5e6;
struct edge
{
int to;
int last;
long long val;
};
edge edges[maxm*2];
int head[maxn];
int vis[maxn];
int cnt[maxn];
long long dist[maxn];
queue<int>q;
int tot=0;
inline void adde(int a,int b,int c)
{
edges[++tot].to=b;
edges[tot].last=head[a];
edges[tot].val=c;
head[a]=tot;
}
bool spfa(int n,int s)
{
memset(dist,0x3f,sizeof(dist));
memset(vis,0,sizeof(vis));
q.push(s);
dist[s]=0;
vis[s]=1;
cnt[s]++;
while(!q.empty())
{
int u=q.front();
q.pop();
vis[u]=0;
for(int i=head[u];i!=0;i=edges[i].last)
{
int v=edges[i].to;
if(dist[v]>dist[u]+edges[i].val)
{
dist[v]=dist[u]+edges[i].val;
if(!vis[v])
{
cnt[v]++;
if(cnt[v]>=n)return false;
q.push(v);
vis[v]=1;
}
}
}
}
return true;
}
int main()
{
int t=0,n=0,m=0,u=0,v=0,w=0;
bool flag=true;
cin>>t;
for(int i=1;i<=t;i++)
{
while(!q.empty())
{
q.pop();
}
memset(head,0,sizeof(head));
memset(edges,0,sizeof(edges));
memset(cnt,0,sizeof(cnt));
tot=0;
cin>>n>>m;
for(int i=1;i<=m;i++)
{
cin>>u>>v>>w;
if(w>=0)
{
adde(u,v,w);
adde(v,u,w);
}
else
{
adde(u,v,w);
}
}
flag=spfa(n,1);
cout<<(flag==false?"YES":"NO");
if(i<t)cout<<endl;
}
return 0;
}
好,接下来是判负环的最重要应用:差分约束
差分约束,也常叫差分约束系统,指的是对于如下不等式组:
问是否能找到一组整数解,使得所有的不等式都成立,而且可以证明,对于有解的情况,必然有无数组解,因为在一组解上加上任意一个正整数都能使原不等式成立,这是不等式的基本性质。
那么,我们的问题就是怎么判无解。
首先,我们考虑能不能将其转化为一个信息学中有算法的问题,不然这事儿应该左转MO。
注意到我们学单源最短路的时候学的神操作:松弛。它的式子一般是这样的:
诶,不错哦!看来每一条这样的式子都对应了一条边呢!那我们就考虑用跑单源最短路,把这些解搞出来。
我们把一个原不等式建模成由被减数连向减数的一条权值为\(c\)的边。那么我们跑出来的\(dist\)数组肯定就是一组解!
那我们再联想,\(spfa\)刚不说过了吗,无解的情况是有负环,那我们判无解可不可以借助这条性质呢?
答案是可以的。因为就算原不等式组你减我,我减他,他减你这么形成一个环,如果是正环,\(spfa\)可以自己消掉,无所畏惧。但是负环的话,几个未知数可以通过这个环不断自减,最后减到\(-\infty\)。事实上,在不等式中,减负数是能改变方向的,所以负环不行。
关于为什么会有负边权,为什么\(dijkstra\)不行,我只能说差分约束问题的难点在建边,如何把原不等式正确的转化成图的边。这里实在无力证明,从网站上扒张可信的表吧(
- \(x_a-x_b\ge c\)
转化成\(x_b-x_a\le -c\),加边为\(adde(a,b,-c)\)
- \(x_a-x_b\le c\)
转化成\(x_a-x_b\le c\),加边为\(adde(b,a,c)\)
- \(x_a=x_b\)
转化成\(x_a-x_b\le 0\&\&x_b-x_a\le 0\),加边为\(adde(b,a,0),adde(a,b,0)\)
习题5.3.2
说实话,这个也没怎么用过……
AC Code:
#include<bits/stdc++.h>
using namespace std;
const int maxn=1e5+5;
const int maxm=5e6;
struct edge
{
int to;
int last;
long long val;
};
edge edges[maxm*2];
int head[maxn];
int vis[maxn];
int cnt[maxn];
long long dist[maxn];
queue<int>q;
int tot=0;
inline void adde(int a,int b,int c)
{
edges[++tot].to=b;
edges[tot].last=head[a];
edges[tot].val=c;
head[a]=tot;
}
bool spfa(int n,int s)
{
memset(dist,0x3f,sizeof(dist));
memset(vis,0,sizeof(vis));
q.push(s);
dist[s]=0;
vis[s]=1;
cnt[s]++;
while(!q.empty())
{
int u=q.front();
q.pop();
vis[u]=0;
for(int i=head[u];i!=0;i=edges[i].last)
{
int v=edges[i].to;
if(dist[v]>dist[u]+edges[i].val)
{
dist[v]=dist[u]+edges[i].val;
if(!vis[v])
{
cnt[v]++;
if(cnt[v]>=n+1)return false;
q.push(v);
vis[v]=1;
}
}
}
}
return true;
}
int main()
{
int n=0,m=0,u=0,v=0,w=0;
bool flag=true;
cin>>n>>m;
for(int i=1;i<=m;i++)
{
cin>>u>>v>>w;
adde(v,u,w);
}
for(int i=1;i<=n;i++)
{
adde(0,i,0);
}
flag=spfa(n,0);
if(flag==false)
{
cout<<"NO";
return 0;
}
for(int i=1;i<=n;i++)
{
cout<<dist[i];
if(i<n)cout<<' ';
}
return 0;
}
5.4 割点/割顶/割边/桥
这两个知识点名字是真的多……
割点和割边就是指删掉这个点或边以后,能使原图中的强连通分量增加,就是这么个意思。因为割边感觉像是一座桥,如果砍断,两边就失去了联系(即强连通分量增加),所以又把割边叫桥。割点下面再说。
如何判定呢?我们可以考虑删掉每条点或边,然后暴力\(tarjan\)判断。复杂度直接上升到\(O(n(n+m))\)或者\(O(m(n+m))\),题目容易超时,那我们要不要再考虑一下直接对\(tarjan\)做个优化,维持原复杂度呢?
我们就来研究下割点和割边的性质,先研究割点的:
有一个感性的想法,割点一定是某个SCC的点对外连通的点,这样把它割掉以后这个SCC就没有对外连通的点了,free了,肯定可以使连通块个数增加。而且可以证明如果一个对外连通的点是割点,那么它所在的SCC一定只有一个对外连通的点,反之,这个点割掉了,SCC里的其他点还有路能够连通图的其他部分,那肯定不能增加SCC的数目,就不满足定义了(
上面这句加粗的话还是比较抽象,我们可以更加具化一点嘛?其实就是,对于后面搜到的SCC,它能连通的图中其他的点必然都比它先遍历,怎么衡量这个“先遍历”
呢?诶,用\(dfn\)和\(low\)吧!
一个点\(x\)是割点,当且仅当\(\exist\)一个点\(y\)是SCC内部的点,\(y\)最早能到达的点都比\(x\)后遍历,或者刚好能够到\(x\),也就是\(low[y]\ge dfn[x]\)。这样,\(y\)不经过\(x\),就无法到达SCC外的点,那把\(x\)割掉,\(y\)就“与世隔绝”了,SCC的数目就增加了。
注意这里不要求\(x\)所在的SCC里的所有的点都满足,而是只要一个就够了。就算只能能隔离一个,只要SCC数目增加了,也是割点嘛!
而且我们可以不用把每个点的SCC判出来再判割点,那样会提高复杂度,我们发现在\(tarjan\)回溯时就可以确定每个点的\(low\)了,那我们就直接在回溯时紧接着\(low[x]\)的更新判定\(low[y]\ge dfn[x]\)。这样不会增长复杂度,还是\(tarjan\)的\(O(n+m)\)
但是对根节点(你可以认为是在main里调用\(tarjan\)时传入的节点)需要进行特判,它至少要有两个点满足这种情况才能看成割点,为什么呢?我们看一幅无向图:
4 5
1 2
1 4
2 3
2 4
3 4
假如我们从1搜进去,那么根节点就是1,此时,你把1割掉,2,3,4依然能构成一个SCC,SCC的数目由1到1,不满足割点的定义。
那为什么要把割点叫做割顶呢?因为在一个图上,一个SCC基本表现为一个多边形,那点不就是多边形的顶点!所以也可以叫割顶嘛!
好了,接下来把模板题快速过掉!
习题5.4.1
\(tarjan\)里可以不判每个点所属的\(SCC\)了,用不上,费时间(
AC Code:
#include<bits/stdc++.h>
using namespace std;
const int maxn=1e5+5;
const int maxm=5e5+7;
struct edge
{
int to;
int last;
};
edge edges[maxm*2];
int head[maxn];
int tot=0;
int dfn[maxn];
int low[maxn];
int vis[maxn];
stack<int> s;
int idx=0;
int iscut[maxn];
int cutnum=0;
inline void adde(int u,int v)
{
edges[++tot].last=head[u];
edges[tot].to=v;
head[u]=tot;
}
void strongtarjan(int x,int root)
{
s.push(x);
dfn[x]=low[x]=++idx;
vis[x]=1;
int child=0;
for(int i=head[x];i!=0;i=edges[i].last)
{
int v=edges[i].to;
if(!dfn[v])
{
strongtarjan(v,root);
low[x]=min(low[x],low[v]);
if(low[v]>=dfn[x])
{
child++;
if(x!=root||child>1)
{
cutnum+=!iscut[x];
iscut[x]=1;
}
}
}
else if(vis[v])
{
low[x]=min(low[x],dfn[v]);
}
}
}
int main()
{
int n=0,m=0,u=0,v=0;
cin>>n>>m;
for(int i=1;i<=m;i++)
{
cin>>u>>v;
adde(u,v);
adde(v,u);
}
for(int i=1;i<=n;i++)
{
if(!dfn[i])strongtarjan(i,i);
}
cout<<cutnum<<endl;
for(int i=1;i<=n;i++)
{
if(iscut[i]==1)
{
cout<<i;
if(i<n)cout<<' ';
}
}
return 0;
}
接下来再看割边的性质:
与割点类似的,一个SCC有且只有一条通往外界的边时,这条边才能被称为割边,如果有多条边可以通往外界,那么这个SCC就肯定没有割边。正确性是显然的。
那怎么做呢?我们想到把割边转化成连接割边的点,对于一条边的端点\(x\),如果另一个端点\(y\)最早能到达的点都比\(x\)晚遍历到,那么\(y\)肯定是不能通过\(\{x,y\}\)这条边走到别的SCC去的,那么\(\{x,y\}\)这条边就可以称之为割边。
那么条件的表达式会不会也是\(low[y]\ge dfn[x]\)呢?很遗憾,并不是,这里并不能取等。为什么呢?因为我们找割边时并不允许沿着一条边来回走,也是,走一次回溯时就可以判掉它行不行了,还走两次有什么用呢?
那我们怎么控制不来回走呢?道理也很简单,我们将边按0-1,2-3,4-5……这样配对,然后就可以发现21=3,41=5……并且反过来也成立。那么我们只要这么在循环时判断,如果是这样,我们不更新\(low[x]\)就可以了。
同样的,因为\(dfn\)和我们不走反边的功劳,割边的求法也是\(O(n+m)\)的。
接下来是一道典。
习题5.4.2
这就是信息学算法的实际应用吧!(bushi)
AC Code:
#include<bits/stdc++.h>
using namespace std;
const int maxn=1e5+5;
const int maxm=5e5+7;
struct edge
{
int to;
int last;
};
edge edges[maxm*2];
int head[maxn];
int tot=1;//存边0-1,2-3,...配对
int dfn[maxn];
int low[maxn];
int vis[maxn];
stack<int> s;
int idx=0;
struct bridge
{
int x;
int y;
friend bool operator <(bridge a,bridge b)
{
if(a.x==b.x)return a.y<b.y;
else return a.x<b.x;
}
};
bridge bri[maxm];
int cnt=0;
inline void adde(int u,int v)
{
edges[++tot].last=head[u];
edges[tot].to=v;
head[u]=tot;
}
void strongtarjan(int x,int e)
{
s.push(x);
dfn[x]=low[x]=++idx;
vis[x]=1;
for(int i=head[x];i!=0;i=edges[i].last)
{
int v=edges[i].to;
if(!dfn[v])
{
strongtarjan(v,i);
low[x]=min(low[x],low[v]);
if(low[v]>dfn[x])
{
bri[++cnt]={x,v};
}
}
else if(i!=(e^1))
{
low[x]=min(low[x],dfn[v]);
}
}
}
int main()
{
int n=0,m=0,u=0,v=0;
cin>>n>>m;
for(int i=1;i<=m;i++)
{
cin>>u>>v;
adde(u,v);
adde(v,u);
}
for(int i=1;i<=n;i++)
{
if(!dfn[i])strongtarjan(i,0);
}
sort(bri+1,bri+cnt+1);
for(int i=1;i<=cnt;i++)
{
if(bri[i].x>bri[i].y)swap(bri[i].x,bri[i].y);
cout<<bri[i].x<<' '<<bri[i].y;
if(i<cnt)cout<<endl;
}
return 0;
}
5.5 2-sat
\(2-sat\)问题是一类方案安排问题,问能不能能找到一组合适的bool值,完全满足如下的约束条件组:
这里解释一下\(\lor\)的意思,一般地:
就是\(\lor\)表示“至少”的意思,左右两边的式子至少满足一个。
因为这里的每个式子只有两个条件,如果一个式子有\(k\)个条件,我们就称其为\(K-SAT\)。比较可惜的是,当\(k\ge 3\)时,这个问题是个NP问题,没有多项式时间的解法(要解决是一般是爆搜)。但\(k=2\),也就是\(2-SAT\),我们还是有些简便的办法的。
哦,那怎么处理呢?我们想到前面的差分约束,都是满足一堆式子,\(2-SAT\)能建模成图论问题吗?按照差分约束的经验,难点也多半是在建边。
但是也没那么难,我们有了种类并查集的启发,马上就可以想到用两个点分别表示某个变量选和不选的情况,为什么不直接说i和i+n呢?因为有些毒瘤题目给数据时不是这么定义的QAQ
但是我们这里还是可以简化问题的,我们设i为选\(x_i\)的情况,i+n为不选\(x_i\)的情况,那么,原展开式中的\(\xRightarrow\space\)就可以归纳为这些节点互相连边的情况。
我们当然可以丢一个复杂的连边表,但我们说过了\(2-SAT\)问题会比差分约束简单,所以你可以考虑直接这么加边:
adde(u+(!uval)*n,v+vval*n);//u,v为读入节点
adde(v+(!vval)*n,u+uval*n);//uval,vval为应取什么值
位运算就是神奇吧
好,接下来我们怎么做呢?我们想到差分约束是靠负环判无解,那我们对于\(2-SAT\)该怎么做呢?考虑到我们连边的肯定就没有矛盾,如果我们找出一个强连通分量,两两连通,那肯定都不会排斥!当然,如果有一个变量的两个值同时在一个强连通分量里,那肯定就无解了,一个变量怎么能同时取两个值呢?
对于每个变量具体取值的问题,我们决定,选择i和i+n中所在的SCC编号小的。如果i小,我们输出1,反之就输出0。而且不建议边处理边输出,建议扔到一个数组里。
习题5.5.1
OK,动手吧!
AC Code:
#include<bits/stdc++.h>
using namespace std;
const int maxn=2e6+5;
const int maxm=3e6+7;
struct edge
{
int to;
int last;
};
edge edges[maxm*2];
int head[maxn];
int tot=0;
int dfn[maxn];
int low[maxn];
int vis[maxn];
stack<int> s;
int idx=0;
int scccnt=0;
int scc[maxn];
int ans[maxn];
inline void adde(int u,int v)
{
edges[++tot].last=head[u];
edges[tot].to=v;
head[u]=tot;
}
void strongtarjan(int x)
{
s.push(x);
dfn[x]=low[x]=++idx;
vis[x]=1;
for(int i=head[x];i!=0;i=edges[i].last)
{
int v=edges[i].to;
if(!dfn[v])
{
strongtarjan(v);
low[x]=min(low[x],low[v]);
}
else if(vis[v])
{
low[x]=min(low[x],dfn[v]);
}
}
if(dfn[x]==low[x])
{
scccnt++;
int u=0;
do
{
u=s.top();
vis[u]=0;
s.pop();
scc[u]=scccnt;
}
while(x!=u);
}
}
int main()
{
int n=0,m=0,u=0,v=0,uval=0,vval=0;
cin>>n>>m;
for(int i=1;i<=m;i++)
{
cin>>u>>uval>>v>>vval;
adde(u+(!uval)*n,v+vval*n);
adde(v+(!vval)*n,u+uval*n);
}
for(int i=1;i<=2*n;i++)
{
if(!dfn[i])strongtarjan(i);
}
for(int i=1;i<=n;i++)
{
if(scc[i]==scc[i+n])
{
cout<<"IMPOSSIBLE";
return 0;
}
ans[i]=(scc[i]>scc[i+n]?1:0);
}
cout<<"POSSIBLE"<<endl;
for(int i=1;i<=n;i++)
{
cout<<ans[i];
if(i<n)cout<<' ';
}
return 0;
}
6.判定问题
6.1 欧拉路&&欧拉回路
欧拉路和欧拉回路是\(dfs\)的一个有力练习。
我们先不加证明地给出欧拉(回)路的定义和判定方法:
- 对于无向图:
-
存在欧拉路的充要条件是只有两个度数为奇数的点,称为奇点
-
存在欧拉回路的充要条件是所有的点度数都为偶数。
- 对于有向图:
-
计每条出边对度数贡献为1,每条入边为-1
-
存在欧拉路的充要条件是有且仅有两个点,一个度数为1,一个度数为-1,遵循“1起-1终”
-
存在欧拉回路的充要条件是所有点度数都为\(0\)
这下标准明确了,先判掉要不要不可以总司令
如果可以的话,我们来想怎么实现\(dfs\)
我们再考虑定义:欧拉路是一笔画,每条边只能走一次,但……点好像没有特殊性质。
但朴素的\(dfs\)可是靠点的\(vis\)来判断终止的啊!这下真就“停不下来”了!
诶,还有一个办法,既然点没限制,边有限制,那我们就给边打\(vis\)吧!
我们定义一个结构体\(edge\),包含边的终点和\(vis\),再把熟悉的vector<int> graph[maxn]换成vector<edge> graph[maxn],这样不就给边打上花火打上\(vis\)了吗!\(good\)
然后,我们开始\(dfs\),遍历传入点的每条边,如果这条边还没访问过,那我们就打好\(vis\),以它的另一个端点为起始点进行\(dfs\),在全部边都访问完后,我们就可以将起始点输出了。
你以为结束了?\(no\) \(way\)!你会发现,打印的路径和答案刚好相反?!怎么回事!
你是\(dfs\)啊,你在所有边都搜完后才把点保存输出啊!你试试,按“1起-1终”的规则,结果你的起始点是不是最后打印?要不得啦!
那咋办咧?你开个栈,把输出换成入栈不就行了吗!
在\(dfs()\)全部执行完后,再打一个弹一个,OKAY。
理论上有向图和无向图的\(欧拉dfs()\)只有建图的区别,你写写下面两道题,确实难点基本上只在处理度数。
习题6.1.1
本题为\(div.\)有向图。
这里解释一下,本题的\(euler()\)和\(习题6.1.2\)的有一些不相同。
这是因为加了一些优化。
讨论区把这种优化称为“当前弧优化”。
具体来说,我们在遍历一个点的每条边时,如果还要一条一条查边的\(vis\),有点费时间,我们可以空间换时间,开一个\(now\)数组,然后每给一条边打\(vis\),在递归之前,我们就给起始点的\(now\)置为当前边的编号+1,循环时\(i\)直接置为\(now[起始点]\),然后也别机械\(i++\)了,直接让\(i+1\)和\(now[起始点]\)打擂台,给\(i\)赋最大值。
这样就跳掉了已经打\(vis\)的边,连\(if\)都不跑了,你就说快不快吧\(doge\)。如果不这么写,You will get only \(\color{#E74C3C}{90pts}\) \(\color{#052242}{with}\) \(\color{#052242}{testcase10TLE}\),写了后,那个点直接掉到\(<100ms\),让我感觉出了个专门卡优化的点,但是我没有证据。
AC Code:
#include<bits/stdc++.h>
using namespace std;
const int maxn=1e5+7;
const int maxm=2e5+7;
struct edge
{
int to;
int vis;
friend bool operator <(edge a,edge b)
{
return a.to<b.to;
}
};
vector<edge>graph[maxn];
int degree[maxn];
int s=0;
int t=0;
stack<int>ans;
int now[maxn];
inline void adde(int u,int v)
{
graph[u].push_back({v,0});
degree[u]++;
degree[v]--;
}
void euler(int x)
{
for(int i=now[x];i<graph[x].size();i=max(i+1,now[x]))
{
if(!(graph[x][i].vis))
{
graph[x][i].vis=1;
now[x]=i+1;
euler(graph[x][i].to);
}
}
ans.push(x);
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
int n=0,m=0,u=0,v=0,cnt=0;
cin>>n>>m;
for(int i=1;i<=m;i++)
{
cin>>u>>v;
adde(u,v);
}
for(int i=1;i<=n;i++)
{
if(degree[i]==1)
{
if(s!=0)
{
cout<<"No";
return 0;
}
s=i;
}
else if(degree[i]==-1)
{
if(t!=0)
{
cout<<"No";
return 0;
}
t=i;
}
else if(degree[i]==0)
{
cnt++;
}
else
{
cout<<"No";
return 0;
}
}
for(int i=1;i<=n;i++)
{
sort(graph[i].begin(),graph[i].end());
}
if(cnt==n)euler(1);
else euler(s);
while(!ans.empty())
{
int buf=ans.top();
ans.pop();
cout<<buf;
if(!ans.empty())cout<<' ';
}
return 0;
}
习题6.1.2
P2731 [USACO3.3]骑马过栅栏 Riding the Fences
本题为\(div.\)无向图。
你说优化为什么没有?因为它数据规模小[doge]
AC Code:
#include<bits/stdc++.h>
using namespace std;
const int maxn=505;
const int maxm=1e4+7;
int graph[maxn][maxn];
int degree[maxn];
int anss=0;
int anst=0;
vector<int>ans;
inline void adde(int u,int v)
{
graph[u][v]++;
}
void euler(int len,int s)
{
for(int i=1;i<=len;i++)
{
if(graph[s][i]==0)continue;
graph[s][i]--;
graph[i][s]--;
euler(len,i);
}
ans.push_back(s);
}
int main()
{
int m=0,u=0,v=0,cnt=0,num=0;
cin>>m;
for(int i=1;i<=m;i++)
{
cin>>u>>v;
adde(u,v);
adde(v,u);
num=max(num,max(u,v));
degree[u]++;
degree[v]++;
}
int s=1;
for(int i=1;i<=num;i++)
{
if(degree[i]%2==1)
{
cnt++;
s=i;
break;
}
}
if(cnt!=0)euler(num,s);
else
{
for(int i=1;i<=num;i++)
{
if(degree[i]!=0)
{
euler(num,i);
break;
}
}
}
for(int i=ans.size()-1;i>=0;i--)
{
cout<<ans[i]<<endl;
}
return 0;
}
再强调一声,欧拉图要求图要连通!!!
如果图不连通,你应当输出
orz——P3366【模板】最小生成树
开个玩笑啊,你别每题都只记得输出orz了[doge]。
6.2 二分图简介
二分图也是个变化多端的知识点,虽然母题模型不多,但是别的奇葩量有一大堆。限于目标要求,本文主要介绍二分图的判定和二分图最大匹配。
首先来了解二分图的定义,如果能把一个图的点划分为两个集合,集合内部的点两两没有边相连,那这就是个二分图。
首先是一个显然的性质,一个图是二分图的充要条件是其不含奇环。因为集合内部没有边,肯定要走偶数次才能回到自己的集合,你可以手画一个。
有了这个性质,我们就可以开始判定了。
还是万能的\(dfs\),怎么把“集合内部没有边”加进去呢
一个普遍的方法是染色,我们把遍历到的节点交替染色,如果搜到的节点颜色重合了,那就染不成了,因为边都是连接两个不同集合,如果有一条边两端颜色相同,那显然不行。
而且,我们还要把“不行”传递上来,所以,如果判断到回溯时产生“不行”的信号,那本层也是返回“不行”,不行就要不行到底,中途可以怎么行?
好,切模板题时间。
习题6.2.1
你们也喜欢用NOIP2010T1当二分图判定例题,但我已经把它划给并查集了,为了提交数,我们尽量不重题哈。\(doge\)
因为二分图并没有要求图是连通的(不连通也可以划到一个集合里嘛),所以我们要以每个点为起点,都搜一遍,把答案累加,才能输出。
这个坑点竟然没有\(hack\),我要造一组。
AC Code:
#include<bits/stdc++.h>
using namespace std;
const int maxn=1e4+5;
const int maxm=1e5+7;
struct edge
{
int to;
int last;
};
edge edges[maxm*2];
int head[maxn];
int tot=0;
int vis[maxn];
int ans[3];
int res=0;
inline void adde(int a,int b)
{
edges[++tot].to=b;
edges[tot].last=head[a];
head[a]=tot;
}
bool dfs(int s,int op)
{
ans[op]++;
vis[s]=op;
for(int i=head[s];i!=0;i=edges[i].last)
{
int v=edges[i].to;
if(!vis[v])
{
if(dfs(v,3-op)==false)return false;
}
else if(vis[v]==op)return false;
}
return true;
}
int main()
{
int n=0,m=0,u=0,v=0;
cin>>n>>m;
for(int i=1;i<=m;i++)
{
cin>>u>>v;
adde(u,v);
adde(v,u);
}
for(int i=1;i<=n;i++)
{
if(!vis[i])
{
memset(ans,0,sizeof(ans));
if(dfs(i,1)==false)
{
cout<<"Impossible";
return 0;
}
res+=min(ans[1],ans[2]);
}
}
cout<<res;
return 0;
}
接下来学习二分图的一个著名问题:最大匹配。当然它还有完美匹配和最大权匹配几个变种,并且匹配的概念也可以推广到一般图上,但我们不管那么多了(
所谓二分图最大匹配,就是问你在二分图的两个部分中间能有多少对点配对。所谓配对,也就是直接的有边相连,只是说由于题目给的逗霸连边,很有可能有多种配对方案,每种配对方案能凑出几个配对,我们称为配对数是几。最大匹配要求的就是配对数最大的一种方案。
关于这个问题有两种算法,但是根据我们的懒惰目标要求,我们只学一种“匈牙利算法”。我绝对不会告诉你另一种算法对网络流的衔接更有帮助
你们可能都听过所谓“Re:最大匹配之网络流の降维打击”但我觉得\(O(n\sqrt m)\)的复杂度吸引力不是很大,所以我们讲网络流时你可以自己想想。
接下来我们学习匈牙利算法,它基本上是对一边的点每个点跑一遍\(dfs()\)。但这个\(dfs()\)有一定的改编,而且递归中的改参数过程异常毒瘤让我想起了treap
我们想想,按照最初的定义,我们该怎么求最大匹配。一个很显然的想法是对于同一部分的每个点,逐个扫所有边,如果选择这条边那一头的端点没事,那就连呗;如果让出这个点去跟别的点配对能够让其他同部分点配对,那就相当于配对数可以增加,对答案有增益,我们当然要这么干。
我们考虑对匹配的那个部分(扫边的)建一个\(match[maxn]\)数组,表示另一部分中的每个点应该在这一部分中找哪个点配对,这里我们将两部分的点分开编号(即都是1~n)。
我们人为规定从左部分扫描边,匹配右部分。
然后,因为有\(dfs()\)成分,所以我们先写一个\(dfs()\),然后也写个\(vis\)。对左部分的每个点,我们跑次\(dfs()\)。但是\(vis\)我们打的是表示右部分的点。
在\(dfs()\)内,对于每个右端点,我们能将它匹配有两个条件:
-
它和传入左端点有边相连
-
它还没被访问过,\(vis\)为空
这两个条件必须都满足。这时,我们给这个右端点打上\(vis\),但是还不能最终确定!因为可能牺牲这对配对会有更多的配对!难绷。
那我们再思考一下,什么情况能够确定匹配呢?
-
这个右端点还没有被匹配,我们暂时给它涂上
-
这个右端点已经被匹配过了,但它的原配能找到更好的点
肯定嘛,对方有别的对象了,肯定是匹配方重新找嘛(bushi)
显然这两个条件有一个能满足,我们就可以将传入左端点和这个右端点配对了喜大普奔,发个invitationtrue回去。
如果右部分所有点都遍历完了,传入左端点依然没有配对的点,那就只能扔个false回去了(
这样我们的\(dfs()\)就大功告成了!匈牙利算法的主函数只要对左部分的每个点都跑遍\(dfs()\),有一个点成功就rp++就可以了!还是很简单的!(bushi)
顺带一提,我们在每个点\(dfs()\)走过的路径叫做增广路,二分图求匹配相关问题的过程,也就是不断找增广路的过程。
匈牙利算法的复杂度为\(O(nm)\),\(n\)为两个部分的点数和,\(m\)为总边数。
最后还有一个概念:假设选一个点就代表“覆盖”了这个点和所有以这个点为端点的边,我们也很关心最少选几个点,能够覆盖所有的边,这个问题就是最小点覆盖问题。
对于这个问题,我们不加证明地给出\(\mathrm{k\ddot{o}nig}\)定理:
最小点覆盖数=最大匹配数
刷题时会用到。
习题6.2.2
AC Code:
#include<bits/stdc++.h>
using namespace std;
const int maxm=5e2+5;
const int maxn=5e2+5;
const int maxe=5e4+7;
int graph[maxn][maxm];
int vis[maxn];
int match[maxn];
int cnt;
bool dfs(int n,int len)
{
for(int i=1;i<=len;i++)
{
if(graph[n][i]&&!vis[i])
{
vis[i]++;
if(match[i]==0||dfs(match[i],len))
{
match[i]=n;
return true;
}
}
}
return false;
}
int hagarian(int left,int right)
{
int res=0;
for(int i=1;i<=left;i++)
{
memset(vis,0,sizeof(vis));
if(dfs(i,right))++res;
}
return res;
}
int main()
{
int n=0,m=0,e=0;
cin>>n>>m>>e;
for(int i=1;i<=e;i++)
{
int a=0,b=0;
cin>>a>>b;
graph[a][b]=1;
}
if(n==1&&m==1&&e!=0)
{
cout<<1;
return 0;
}
int res=hagarian(n,m);
cout<<res;
return 0;
}
2024/8/28 20:25:00 初稿!完结撒花!(发布于洛谷)

浙公网安备 33010602011771号