2.5.1 图是什么
图是由顶点(vertex,node)和边(edge)组成。顶点代表对象。在示意图中,我们使用点或圆来表示。边表示的是两个对象的连接关系。
无向图术语
两个顶点之间如果有边连接,那么就视为两个顶点相邻。相邻顶点的序列称为路径。起点和终点重合的路径叫做圈。任何两点之间都有路径链接的图叫做连通图。顶点链接的边数叫做这个顶点的度。
没有圈的连通图叫做树(tree),没有圈的非连通图叫做森林。一棵树的边数恰好是顶点数-1,反之,边数等于顶点-1的连通图就是一棵树。
有向图术语
以有向图顶点v为起点的边的集合叫做v的初度,为终点的边叫做v的入度。没有圈的有向图叫做DAG。
2.5.3 图的搜索
例题 二分图判定
挑战程序设计竞赛 page 97
把相邻顶点染成不同颜色的问题叫做图着色问题。对图进行染色所需要的最小颜色书称为最小着色数。最小着色数是2的图称作二分图。
如果只用2种颜色,那么确定一个顶点的颜色之后,和它相邻的顶点的颜色也就确定了。因此我们可以选择任意一个顶点除法,依次确定相邻顶点的颜色,就可以判断是否可以被2种颜色染色了,这个问题如果用深度优先搜索的话,就能够简单的实现。
vector<int> G[maxn];//用链表的方式保存图
int V;
int color[maxn];//顶点的颜色(1,或者-1)
bool dfs(int v,int c){//把顶点v的颜色染成c
color[v]=c;
for(int i=0;i<G[v].size();i++){
if(color[G[v][i]]==c)retrun false;
if(color[G[v][i]]==0&&!dfs(G[v][i],-c))return false;//判断是否没有被染过色并且染色失败 则返回false
}
return true;
}
void solve(){
for(int i=0;i<V;i++){//有非联通图的情况
if(color[i]==0){
if(!dfs(i,1)){
cout << "No" << endl;
return;
}
}
}
cout << "Yes" << endl;
return;
}
如果是连通图,那么一次dfs就能访问到所有的顶点。如果题目描述中没有说明,那么有可能图是不连通的,这样就需要依次检查每个顶点是否访问过。由于每个顶点和边只访问过一次,因此复杂度为O(|V|+|E|)
2.5.4 最短路问题
最短路是给定两个顶点,在以这两个点为起点和终点的路径中,边的权值和最小的路径。
1.单源最短路问题1(Bellman-Ford算法)
单源最短路问题是固定一个起点,求它到其他所有点的最短路问题。
记从起点s出发到顶点i的最短距离为d[i]。则下述等式成立。
d[i]=min{d[j]+(从j到i的边的权值)|e=(j,i)∈E}
如果给定的图是一个DAG,就可以按拓扑序顶点编号,并利用这条递推关系式计算出d。但是,如果图中有圈,就无法依赖这样的顺序进行计算。在这种情况下,记当前到顶点i的最短路径为d[i],并设初值d[s]=0,d[i]=INF(足够大的常数),再不断使用这条递推关系式更新d的值,就可以算出新的d。只要图中不存在负圈,这样的更新操作就是有限的。结束之后的d就是所求的最短距离了。
struct edge{int from,to,cos;};
edge es[maxe];//边
int d[maxv];//最短距离
int V,E;//V是顶点数,E是边数
void shortest_path(int s){
for(int i=0;i<V;i++)d[i]=INF;
d[s]=0;
while(true){
bool update=false;
for(int i=0;i<E;i++){
edge e=es[i];
if(d[e.from]!=INF&&d[e.to]>d[e.from]+e.cost){
d[e.to]=d[e.from]+e.cost;
update=true;
}
}
if(!update)break;
}
}
这个算法叫做Bellman-Ford。时间复杂度为O(|V|+|E|)。如果没有负圈那么外层循环最多执行V-1次,如果有负圈那么在第V次也会更新d的值,所有可以用于判断是否有负圈。如果一开始把d[i]初始化为0,那么就能检查出所有的负圈。
//true 代表有负环
bool find_negative_loop(){
memset(d,0,sizeof(d));
for(int i=0;i<V;i++){
for(int j=0;j<E;j++){
edge e=es[j];
if(d[e.to]>d[e.from]+e.cost){
d[e.to]=d[e.from]+e.cost;
if(i==V-1)return true;
}
}
}
return false;
}
单源最短路径问题2(Dijkstra算法)
对Bellman-Ford算法进行修改(必须是非负图的情况下才能修改)
1.找到最短距离已经确定的顶点,从它出发更新相邻顶点的最短距离。
2.此后不需要再关心1中的“最短距离已经确定的顶点”。
int cost[maxv][maxv];//用邻接矩阵法保存图
int d[maxv];//最小值
bool used[maxv];//已经使用过的图
int V;
void dijkstra(int s){
for(int i=0;i<V;i++)d[i]=INF;
d[s]=0;
for(int i=1;i<=V-1;i++){
int k,min_s=INF;
for(int u=0;u<V;u++){
if(!used[u]&&min_s>d[u]){
min_s=d[u];
k=u;
}
if(min_s==INF)return;//图不连通
used[k]=true;
for(int u=0;u<V;u++){
if(!used[u]&&d[u]>d[k]+cost[k][u]){
d[u]=d[k]+cost[k][u];
}
}
}
}
}
使用邻接矩阵实现的Dijkstra算法复杂度为O(V2)。使用邻接表的话,更新最短距离只需要访问每条边一次即可,因此这部分的复杂度是O(|E|)。但是每次要枚举所有的顶点来查找下一个使用的顶点,因此最终的时间复杂度还是O(|V2|)。在E比较小的时候,大部分时间花在了查找下一个使用的顶点上,因此需要使用合适的数据结构对其进行优化。
需要优化的是数值的插入(更新)和取出最小值两个操作,因此使用堆就可以了。把每个顶点当前最短距离用堆来维护,在更新最短距离时,把对应的元素往根的方向移动以满足堆的性质。而每次从堆中取出的最小值就是下一次要使用的顶点。这样堆中元素共有O(|V|)个,更新和取出数值的操作有O(|E|)次,因此整个算法的时间杂度时O(|E|log|V|)。
下面使用STL的priority_queue的实现。在每次更新时往堆里插入当前最短距离和顶点的键值对。插入的次数时O(|E|)次,因此y元素的个数是O(|E|)个。当取出的最小值不是最短距离的话就丢弃这个值。
struce edge{int to,cost;};
typedef pair<int,int> p;//first是最短距离,second是顶点的编号
int V;
vector<edge> G[maxv];
int d[maxv];
void dijkstra(int s){
//通过指定greater<P>参数,堆按照first从小到大的属性呢取出值
priority_queue<p,vector<p>,greater<p>>que;
fill(d,d+V,INF);
d[s]=0;
que.push(P(0,s));
while(!que.empty()){
P p=que.top();
que.pop();
int v=p.second;
if(d[v]<p.first)continue;
for(int i=0;i<G[v].size();i++){
edge e=G[v][i];
if(d[e.to]>d[v]+e.cost){
d[e.to]=d[v]+e.cost;
que.push(P(d[e.to],e.to));
}
}
}
}
2.5.5 最小生成树
给定一个无向图,如果它的某个子图任意两个顶点都连通并且是一棵树,那么这棵树就叫做生成树(Spanning Tree)。如果边上有全职,那么使得边权和最小的生成树叫做最小生成树。
1.最小生成树问题1(prim算法)
int cost[maxv][maxv];
int V;
int mincost[maxv];
bool used[maxv];
int prim(){
fill(mincost,mincost+V,INF);
mincost[0]=0;
int res=0;
for(int i=0;i<V;i++){
int min_s=INF;
int k;
for(int u=0;u<V;u++){
if(!used[u]&&min_s>mincost[u]){
k=u;
min_s=d[u];
}
}
if(min_s==INF)return -1;//buliantong
used[k]=1;
for(int u=0;u<V;u++){
mincost[u]=min(mincost[u],mincost[k]);
}
res+=mincost[k];
}
return res;
}
2.最小生成树问题2(Kruskal)
Kruskal算法按照边的权值的顺序从小到大查看一遍,如果不产生圈(重编等也算在内),就把这条边加入到生成树中。至于这个算法为什么是正确的,其实是和Prim算法证明的思路基本相同。
接下来我们介绍如何判断是否产生圈。假设现在要把链接顶点u和顶点v的边加入生成树中。如果加入之前u和v不再同一个连通分量里那么加入e也不会产生圈。反之,如果u和v在同一个连通分量里,那么一定会产生圈。可以使用并查集高效地判断是否属于同一个连通分量。
Kruskal算法在排序上最费时,算法的复杂度是O(|E|log|V|)。
struct node{int u,v,cost};
bool cmp(const edge&e1,const edge& e2){
return e1.cost<e2.cost;
}
edge es[maxe];
int V,E;
int Kruskal(){
int res=0;
sort(es,es+E,comp);
init_union_find(V);//并查集的初始化。
for(int i=0;i<E;i++){
edge e=es[i];
if(!same(e.u,e.v)){
unite(e.u,e.v);
res+=e.cost;
}
}
return res;
}