图的基本概念
图(G)由顶点集(V)和边集(E)组成,记为(G=(V,E)),其中(V(G))表示图(G)中顶点的有限非空集;(E(G))表示图(G)中顶点之间的关系(边)集合。若(V={v_1,v_2,v_3,ldots,v_n}),用(|V|)表示图(G)中顶点的个数,也称为图(G)的阶,(E={(u,v)|uin V,vin V}),用(E)表示图(G)中边的条数。
注意:线性表可以是空表,树可以是空树,但图不可以是空图。也就是说,图中不能一个顶点也没有,图中顶点集(V)一定非空,但是边集(E)可以为空,此时图中只有顶点而没有边。
有向图
若E是有向边(也称为弧)的有限集合时,则图G为有向图。弧是顶点的有序对,记为(<v,w>),其中v,w是顶点。w称为弧头,v称为弧尾,称为从顶点v到顶点w的弧,也称为v邻接到w,或w邻接自v。
简单图
一个图G如果满足:
- 不存在重复边。
- 不存在顶点到自身的边。
则可以称为简单图。上图((a),(b)),都是简单图,并且数据结构中只讨论简单图。
多重图
若图(G)中,某两点之间的边数多于一条,又允许顶点通过一条边和自己关联,则G为多重图。多重图的定义和简单图是相对的。
完全图
在无向图中,如果任意两个顶点之间都存在边,则称该图为无向完全图。含有n个顶点的无向完全图有(frac{n(n-1)}{2})条边。在有向图中,如果任意两个顶点之间都存在方向相反的两条弧,则称该图为有向完全图。含有n个顶点的有向完全图有(n(n-1))条有向边。
上图中((b))为无向完全图,((c))为有向完全图。
子图
设有两个图(G=(V,E))和(G^`=(V^`,E^`)),若(E^`)是(E)的子集,(V^`)是(V)的子集,则称(G`)是(G)的子图。
上图中((c))是((a))的子图。
注意:并非(V)和(E)的任何子集都能构成(G)的子图,因为这样的子集可能不是图,也就是说,(E)的子集中的某些边关联的顶点可能不再这个(V)的子集中。
连通,连通图和连通分量
在无向图中,若从顶点v到顶点w有路径存在,则称v和w是连通的。若图G中任意两个顶点都是连通的,则称G为连通图,否则称为非连通图。无向图中极大连通子图称为连通分量。如果一个图中有n个顶点,并且有小于n-1条边,则此图必是非连通图,如下图所示有三个连通分量(极大连通子图)。
图的邻接矩阵存储表示法具有以下特点:
- 无向图的邻接矩阵一定是一个对称矩阵(并且唯一)。因此在实际存储邻接矩阵时只需要存储上(或下)三交矩阵的元素即可。
- 对于无向图,邻接矩阵的第i行(或第i列)非零元素的个数正好是第i个顶点的度(TD(V_i))。
- 对于有向图,邻接矩阵的第i行(或第i列)非零元素的个数正好是第i个顶点的出度(OD(V_i))(或入度(ID(V_i)))。
- 用邻接矩阵法存储图,很容易确定图中任意两个顶点之间是否有边相连。但是,要确定图中有多少条边,则必须按行,按列对每个元素进行监测,所花费的时间代价很大。这是用邻接矩阵存储图的局限性。
- 稠密图适合用邻接矩阵存储表示。
- 设图G的邻接矩阵为A,(A^n)的元素(A^n[i][j])等于由顶点i到顶点j的长度为n的路径的数目,该结论了解即可,证明方法在离散数学中。
邻接表法
当一个图为稀疏图时,是用邻接矩阵表示法显然浪费了大量的存储空间。而图的邻接表法结合了顺序存储和链式存储的方法,大大的减少了这种不必要的浪费。
所谓邻接表就是对图G中的每个顶点(V_i)建立一个单链表,第i个单链表中的结点表示依附于顶点(V_i)的边(对于有向图则是以顶点(V_i)为尾的弧),这个单链表就成为顶点(V_i)的边表(对有向图来说是出边表)。边表的头指针和顶点的数据信息采用顺序存储(称为顶点表),所以在邻接表中存在两种结点;顶点表结点和边表结点。
图的邻接表存储结构定义如下:
#define MaxVertexNum 100
typedef char VertexType;
typedef struct ArcNode // 边表结点
{
int adjvex; // 该弧所指向的顶点的位置。
struct ArcNode *next;// 指向下一条依附于该顶点的弧的指针。
}ArcNode;
typedef struct VNode // 顶点表结点
{
VertexType data; // 顶点信息
ArcNode *first; // 指向依附于该顶点的弧的指针
}VNode,AdjList[MaxVertexNum];
typedef struct
{
AdjList vertices; // 邻接表
int vexnum,arcnum; // 图的顶点数和弧数
}ALGraph;
图的邻接表存储方法具有一下特点:
- 如果G为无向图,则所需的存储空间为(O(|V|+2|E|));如果G为有向图,则所需的存储空间为(O(|V|+|E|))。前者的倍数2是由于无向图中,每条边在邻接表中出现了两次。
- 对于稀疏图,采用邻接表表示将极大的节省存储空间。
- 在邻接表中,给定一顶点,能很容易的找到它的所有临边,因为只需要读取它的邻接表就可以了。在邻接矩阵中,相同的操作则需要扫描一行,花费的时间是(O(n))。但是如果要确定给定的两个顶点间是否存在边,则在邻接矩阵里可以立即查到,在邻接表中则需要在相应结点对应的边表中查找另一节点,效率较低。
- 在有向图的邻接表表示中,求一个给定顶点的出度只需计算其邻接表中结点个数即可;但求其顶点的入度,则需要遍历全部的邻接表。因此也有人采用逆邻接表的存储方式来加速求解给定顶点的入度。当然这实际上与邻接表的存储方式是类似的。
- 图邻接表表示并不唯一,这是因为在每个顶点对应的单链表中,各边结点的链接次序可以任意,取决于建立邻接表的算法以及边的输入次序。
十字链表
十字链表是有向图的一种链式存储结果。在十字链表中,对应于有向图中的每条弧有一个结点,对应于每个顶点也有一个结点。这些节点的结构如下:
弧结点中有5个域:其中尾域(tailvex)和头域(headvex)分别只是弧尾和弧头这两个顶点在图中的位置,链域hlink指向弧头相同的下一条弧,链域tlink指向弧尾相同的下一条弧,info域指向该弧的相关信息。这样弧头相同的弧在同一个链表上,弧尾相同的弧也在同一个链表上。
顶点域中有三个域:data域存放顶点相关的数据信息,如顶点名称,firstin和firstout两个域分别指向以该顶点为弧头和弧尾的第一个弧结点。
其中,mark为标志域,可用以标记该条边是否被搜索过;ivex和jvex为该边衣服的两个顶点在图中的位置;ilink指向下一条依附于顶点ivex的边;jlink指向下一条依附于顶点jvex的边,info为指向和边相关的各种信息的指针域。
十字链表
每个顶点也有一个结点表示,它由如下所示的两个域组成。
data | firstedge |
---|
其中,data域存储该顶点的相关信息,firstedge域指示第一跳依附于该顶点的边。
在邻接多重表中,所有依附于同一顶点的边串联在同一链表中,由于每条边依附于两个顶点,则每个边结点同时连接在两个链表中。
BFS算法求解单源最短路径问题
如果图(G=(V,E))为非带权图,定义从顶点u到顶点v的最短路径(d(u,v))为从u到v的任何路径中最少的边数;如果没有通路,则为(d(u,v)=infty)。
使用BFS,我们可以求解一个满足上述定义的非带权路径的单源最短路径问题,这是由广度优先搜索总是按照距离由近到远来遍历图中每个顶点的性质决定的。
BFS算法求解单源最短路径问题的算法如下:
void BFS_MIN_Distance(Graph G,int u)
{
for(int i=0;i<G.vexnum;i++)
d[i]=INT_MAX;
visited[u]=true;
d[u]=0;
EnQueue(Q,u);
while(!IsEmpty(Q))
{
DeQueue(Q,u);
for(w=FirstNeighbor(G,u);w>=0;w=NextNeighbor(G,u,w))
{
if(!visited[w])
{
visited[w]=true;
d[w]=d[u]+1;
EnQueue(Q,w);
}
}
}
}
深度优先搜索(Depth-First-Search)
与广度优先搜索不同,深度优先搜索((DFS))类似于树的先序遍历。正如其名称中所暗含的意思一样,这种搜索算法所遵循的策略是尽可能“深”的搜索一个图。它的基本思想如下:首先访问图中某一起始顶点v,然后从v出发,访问与v邻接且未被访问的任一定点(w_1),再访问与(w_1)邻接且未被访问的任意顶点(w_2),……重复上述过程。当不能再继续向下访问时,一次退回到最近被访问的顶点,若他还有邻接顶点未被访问过,则从该点开始继续上述搜索过程,知道搜索顶点均被访问过为止。
一般情况下,其递归形式的算法非常简洁。下面描述其算法过程。
#define MAX_VERTEX_NUM 100
bool visited[MAX_VERTEX_NUM];
void DFSTraverse(Graph G)
{
for(v=0;v<G.vexnum;i++)
visited[v]=false;
for(v=0;v<G.vexnum;i++)
if(!visited[v])
DFS(G,v);
}
void DFS(Graph G,int v)
{
visit(v);
visited[v]=true;
for(w=FirstNeighbor(G,v);w>=0;w=NextNeighor(G,v,w))
if(!visited[w])
DFS(G,w);
}
DFS算法性能分析
-
DFS算法是一个递归算法,需要借助一个递归工作栈,故她的空间复杂度为(O(|V|))。
-
遍历图的过程实质上是对每个顶点查找其临接点的过程,其耗费的时间取决于所采用的存储结构。当以邻接表进行表示时,查找每个顶点的临接点所需时间为(O(|V|)),故总的时间复杂度为(O(|V|^2))。当以邻接表表示时,查找所有顶点的临接点所需时间为(O(|E|)),访问顶点所需时间为(O(V)),此时,总的时间复杂度为(O(|V|+|E|))。
上面的代码BFSTraverse和DFSTraverse中添加了第二个for循环,再选取初始点,继续进行遍历,以防止一次无法遍历图中的所有顶点。
图的应用
本节是历年考察的重点。图的应用主要包括:最小生成树,最短路径,拓扑排序和关键路径。一般而言,这部分内容直接以算法设计题形式考查的可能性很小,而更多的是结合图的实例来考查算法的具体执行过程。此外,还需要掌握对于给定的模型建立相应的图去解决问题。
最小生成树(Minimum-Spaning-Tree)
一个连通图的生成树是图的极小连通子图,它包含图中所有顶点,并且只包含极可能少的边。这意味着对于生成树来说,若砍去它的一条边,就会是生成树变成非连通图;若给它增加一条边,就会形成图中的一条回路。
对于一个带权连通无向图(G=(V,E)),生成树不同,每棵树的权(即树中所有边上的权值之和)也可能同。
设R为G的所有生成树的集合,若T为R中边的权值之和最小的那棵生成树,则称T为G的最小生成树。
不难看出,最小生成树具有如下性质:
1. 最小生成树不是唯一的,即最小生成树的树形不唯一,R中可能有多个最小生成树。当图G中各边权值互不相等时,G的最小生成树是唯一的;若无向连通图G的边比顶点数少1,即G本身就是一棵树,G的最小生成树就是其本身。
2. 最小生成树的边的权值之和总是唯一的,虽然最小生成树不唯一,但其对应的边的权值之和是唯一的,而且是最小的。
3. 最小生成树的边数为顶点数减 1。
构造最小生成树有多种算法,但大多数算法都利用了最小生成树的下列性质:
假设(G=(V,E))是一个带权连通无向图,U是顶点集V的一个非空子集。若((U,V))是一条具有最小权值的边,其中(uin U,vin {V-U}),则必存在一棵包含边(u,v)的最下生成树。
Prime算法
随意选一个点,开始建立该点集,和其他点的路径长度关系,有路径就有没有的话 设置一个(infty),然后开始在路径的集合里面寻找最短的到达一个点的路径,将这个点加到该点集之中。然后因为加入了新的点,这个时候点集就改变了,我们需要更新一下新的点集到其余点的路径,然后再次选出一个该点集到没有加入该点集的点的最短的路径的一个点。然后就这样一直搞。
KruSkal算法
按照路径的长度进行从小到大的排序,排序完毕之后,选出最小的一条边,作为当前长度。然后选出第二小的边,检查是否会成环,不会的话把长度加起来,然后选第三小的边,检查是否会成环,不会的话把长度加起来,然后选第四小的边。。。。知道跳出来 顶点-1条边。
以前做的《布线问题》作为例题,来解释Prime和Kruskal。
最短路径
Dijkstra算法
和Prime是一样的,不过Prime是最小生成树,计算的是将这些点连起来花费的最小代价。而Dijkstra计算的是,从某点开始到其他点花费的最小代价。
假设从1开始出发,选取一个目前1到那个点最近的点,将该点标记为已访问,然后从1头过这个点我到达其他点的距离会不会更近,如果更近的话,更新一下距离数组。然后从距离数组中选取出另一个未被加入的点,并且是距离1距离最小的点,假设让1通过该点到达其他点会不会更近,如果更近的话更新一下距离数组。
Floyd算法
多源最短路径:核心代码如下
for(k=1;k<=n;k++) //Floyd核心算法...
{
for(i=1;i<=n;i++) // 所有的 路 都让 k 加进去试试
{
for(j=1;j<=n;j++) //如果 从 i到j的路上 有k 走的会更轻松的话 , 那就让 k 去吧
{
if(e[i][j]>e[i][k]+e[k][j]) // 判断 是否会 更加轻松
e[i][j]=e[i][k]+e[k][j];
}
}
}
三层for循环,如果从i到j路过k的话更快就更新一下距离数组。
拓扑排序
有向无环图:一个有向图中不存在环,则称为有向无环图,简称DAG图。
AOV网:如果用DAG图表示一个工程,其顶点表示活动,用有向边(<V_i,V_j>)表示活动(V_i)必须先于活动(V_j)进行的这样一种关系,则这种有向图称为顶点表示活动的网络记为AOV网。在AOV网中,活动(V_i)是(V_j)的直接前驱,活动(V_j)是(V_i)的直接后继,这种前驱和后继关系具有传递性,且任何活动(V_i)不能以它自己作为自己的前驱或后继。
拓扑排序:在图论中,由一个有向无环图的顶点组成的序列,当且仅当满足下列条件时,称为该图的一个拓扑排序。
- 从DAG图中选择一个没有前驱的顶点并输出。
- 从图中删除该顶点和所有以它为为起点的有向边。
- 重复1和2直到当前的DAG图为空或当前图中不存在无前驱的顶点为止。而后一种情况则说明有向图中必然存在环。
拓扑序:如果图中有从v到w有一条有向路径,则v一定排在w之前。满足此条件的顶点序列称为一个拓扑序。
获得一个拓扑序的过程就是拓扑排序。
(AOV)如果有合理的拓扑序,则必定是有向无环图((Directed Acyclic Graph,DAG))。
这个东西就意味着V在开始之前就必须结束。
void TopSort()
{
for(cnt=0;cnt<|V|;cnt++)
{
V=未输出的入度为0的顶点;
if(这样的V不存在)
{
Error("图中有回路");
break;
}
输出V,或者记录V的输出序号;
for(V的每个临接点W)
{
Indegree[W]--;// 每个临接点的入度-1。
}
}
}
void TopSort()
{
for(图中每个顶点V)
if(InDegree[V]==0)
EnQueue(V,Q);
while(!IsEmpty(Q))
{
V=DeQueue(Q);
输出V,或者记录V的输出序号;
cnt++;
for(V的每个临接点W)
if(--Indegree[W]==0)
EnQueue(W,Q);
}
if(cnt!=|V|)
Error("图中有回路");
}
关键路径
在带权有向图中,以顶点表示事件,有向边表示活动,边上的权值表示完成活动需要的开销,则这种图称为(AOE)网。
(AOE)网具有以下两个性质:
- 只有在某顶点所代表的时间发生后,从该顶点出发的各有向边所代表的活动才可以进行;
- 只有在进入某一顶点的各有向边,所代表的活动都已经结束时,该顶点所代表的事件才可以发生。
在AOE网中仅有一个入度为0的顶点,称为开始顶点(源点),它表示整个工程的开始;网中也仅存在一个出度为0的顶点,称为结束顶点(汇点),它表示整个工程的结束。
在AOE网中有些活动是可以并行进行的,从源点到汇点的有向路径可能有多条,并且这些路径长度可能不同。完成不同路径上的活动所需时间虽然不同,但是只有所有路径上的活动都完成了,整个工程才能算结束了。因此,从源点到汇点的所有路径中,具有最大路径长度的路径称为关键路径,我们将关键路径上的活动称为关键活动。
完成整个工程的最短时间就是关键路径的长度,也就是关键路径上各活动花费开销的总和。这是因为关键活动影响了整个工程的时间,即如果关键活动不能按时完成的话,整个工程的完成时间就会增长。因此只要找到了关键活动,就找到了关键路径,也就可以得出最短完成时间。
事件(V_k)的最早发生时间VE(K)
他是指从最早开始顶点V到(V_k)的最长路径长度。事件的最早发生时间决定了所有人从(V_k)开始的活动最早能够开工的最早时间。
时间(V_k)的最早发生时间(VE(k))
它是指从开始顶点V到(V(k))的最长路径长度。时间的最早发生时间决定了所有从(V_k)开始的活动能够开工的最早时间。可以用下面的递推公式进行计算。
(ve(源点)=0)
(ve(k)=Max{ve(j)+Weight(v_j,v_k)}),(Weight(v_j,v_k))表示(<v_j,v_k>)上的权值。
时间(v_k)的最迟发生时间(vl(k))
它是指在不推迟整个工程完成的前提下,即保证他所指向的事件(v_i)在(ve(i))时刻能够发生时,该事件最迟必须发生的时间。
活动(a_i)最早开始时间(e(i))
它是指该活动的七点所表示的事件最早发生时间。如果边(<v_k,v_j>)表示活动(a_i),则有(e(i)=ve(k))。
活动(a_i)的最迟开始时间。
它是指该活动的终点所表示的事件最迟发生时间与该活动所需时间之差。
一个活动(a_i)的最迟开始时间(l(i))和其最早开始时间(e(i))的差额(d(i)=l(i)-e(i))。
它是指该活动完成的时间余量,是在不增加整个工程所需的总时间的情况下,活动(a_i)可以拖延的时间。如果一个活动的时间余量为0时,说明该活动必须要如期完成,否则就会拖延完成整个工程的进度,所以成(l(i)-e(i)=0),即(l(i)=e(i))的活动(a_i)是关键活动。
求关键路径的算法步骤如下:(事件是结点,活动是边。)
- 求AOE网中所有事件的最早发生时间(ve())。
- 求AOE网中所有事件的最迟发生时间(vl())。
- 求AOE网中所有活动的最早开始时间(e())
- 求AOE网中所有活动的最迟开始时间(l())
- 求AOE网中所有活动的差额(d()),找出所有(d()=0)的活动构成关键路径。