1. 基本术语
图有有向和无向之分,在此基础上对应有不同的术语及结构。
(有向)完全图:每个点到其它点都有直接路径。
连通图:图中任意两点连通。
连通分量:无向图中的极大连通子图。
强连通图:有向图中任意两点都存在路径。
强连通分量:有向图中的极大强连通子图。
生成树:一个连通图的极小连通子图,它含有连通图的全部顶点(n),但只有构成树的n-1条边。
这里涉及到极小连通子图和极大连通子图,分别对应生成树和连通分量,极小的意思是包含全部顶点但是边数最少,极大也是包含全部顶点但是边数最多。
最小生成树:针对带权图中的代价最小的生成树。
2. 图的存储
图的存储结构应用比较多的有两种:邻接矩阵和邻接表。这两个结构都是以顶点为内容来进行存储的,当对图中边应用较多时,可以采用稍微复杂的十字链表(有向图)或邻接多重表(无向图)结构来存储,是的,操作更为方便。
邻接矩阵就是一个二维表,比较简单,需要注意的是,带权和不带权边的信息不一样,倘若i,j之间不存在边,不带权图中G[i][j]=0; 带权图中G[i][j]=MAX。
邻接表采用链表方式存储,但头结点采用数组存储的,对应到两种结点:表结点与头结点。定义式如下:
//弧结点:
typedef struct ArcNode {
int adjvex;//弧指向的顶点
struct ArcNode *nextarc;//指向下一条弧
}ArcNode;
//头结点
typedef struct VNode {
VertexType data;//顶点数据,一般0,1,2...
ArcNode *firstarc;//指向的第一条弧
}VNode, AdjList[MAX_NUM];
//一个图的完整定义
typedef struct {
AdjList vertices;//表头结点数组
int vexnum, arcnum;
int kind;//图的种类
}ALGraph;
上面定义很清晰了。再谈谈比较少用但若应用需要会十分高效的十字链表和邻接多重表。
这两类表也是由弧节点和头结点构成,但是弧结点包含的信息更多,因为针对的是需要操作边的场合。邻接多重表的弧结点包含了两个顶点信息(一条弧的两个端点)以及两个指针域(分别指向与两端点连接的其他弧);十字链表的弧结点也有两个顶点信息(弧头和弧尾)和两个指针域(hlink, tlink),但是两个指针域的指向与前者不同,hlink指向与本弧中弧头相同的下一条弧, tlink指向与本弧中弧尾相同的下一条弧。
表头结点也不一样,由于有向图每个节点作为弧头和弧尾是不一样的,所以十字链表的头结点除了包含节点数据信息以外,还包括两个指针域,分别指向以该节点为弧头和弧尾的弧;邻接多重表的表头结点则只有一个链域,指向与头结点连接的弧。
3. 图的遍历
图的遍历无非两种:深度优先搜索(DFS)和广度优先搜索(BFS)。
给出这两个算法伪码实现(假设需要深度遍历图采用邻接矩阵存储,需要广度遍历的图采用邻接表存储,其实可以写个接口直接根据图类型调用不同函数即可,但这里为了使用刚刚介绍的存储结构):
//深度遍历,可以递归实现,当然可以用自己的栈实现
void DFS(int G[][], int v) {
visited[v] = TRUE;//标记被访问过的顶点
for(i=0; i<n; ++i) {
w = G[v][i];
if (i != v && w >= 0 && !visited[w])
DFS(G, w);
}
}
//广度遍历,存储结构是邻接矩阵,需要队列喽
void BFS(Graph G, int v) {
visited[v] = TRUE;
InitQueue(Q);
EnQueue(Q, v);
while(!QueueEmpty(Q)) {
DeQueue(Q, u);
for(w=G.vertices[u].firstarc; w>0; w=w->next) {
if (!visited[w]) {
visited[w] = TRUE;
EnQueue(Q, w);
}
}
}
}
4. 图的应用
应用上,图基本都是带权的,图的应用也分为有向图和无向图不同应用类型,无向图的应用有个最小生成树,有向图有关键路径以及最短路径问题。
4.1无向图应用
最小生成树的定义已经说过,在所有生成树中,代价最小的那个就是的。这个问题有两个解决方案:Prim和Kruskal。两者处理结果一致,但是形式上有所差别:前者以顶点着手,后者以边突破。这一差别导致应用上侧重点不同:前者对顶点少边数多的图(稠密图)比较适用,后者处理顶点多边数少(稀疏图)的图比较适用。这里有个记忆捷径:prim的单词数少啊,kruskal单词较长,类比点和边倒是极好记住的。
prim算法:现在只剩下顶点(边被删除),以其中任意一个v开始,找它的边,权值最小的边就连接上,基于这条边上的另外一个顶点w,再找与v、w相连接的边,边另外一个顶点不是v和w,找到权值最小的边连接上。。。以此类推直到所有的顶点都被连接就ok。伪码如下:
struct {
VertexType adjvex;//用来记录边顶点,可以方便输出边
VRType lowcost;
}closedge[n];
//G为邻接矩阵存储,有权图中不存在的边是MAX
void MST_Prim(int G[][], int v) {
for(i=0; i<n; ++i)
if(i != v)
closedge[i] = {v, G[v][i]};//初始化
closedge[v].lowcost = 0;//初始v
for(i=1; i<n; ++i) {
k = min(closedge);//找代价最小的边,对应顶点编号
print(closedge[k].adjvex, k);//打印边
closedge[k].lowcost = 0;
for(j=0; j<n; ++j) {
if (G[k][j] < closedge[j].lowcost)
closedge[j] = {k, G[k][j]};
}
}
}
Kruskal算法:现在只剩下顶点(边被删除),总是选择最小的边添加进去,但是要保证添加边后不会形成环,终止条件是所有顶点都是连通的。判断是否连通要用到并查集。伪代码如下:
typedef struct { //用来存储边信息
int u, v; //边顶点
int w; //边的权值
}Edge, edgelist[m];//m条边
void MST_Kruskal(int G[][], edgelist[]) {
sort(edgelist);//从小到大权值排序
int vexset[n]={0...n-1};//并查集归类,用于判断两顶点是否连通,初始值为节点编号,也就是每个顶点都是独立的。
k=0;//边从0到n-2,共n-1条,n个节点的书当然是n-1条边
j=0;//j从0到m遍历,edgelist[j]
while(k<n-1) {
//取两顶点所属集合sn1, sn2
sn1 = vexset[edgelist[j].u];
sn2 = vexset[edgelist[j].v];
if (sn1 != sn2) { //不属于同一集合,那么把边加进去
print(edgelist[j].u, edgelist[j].v);
++k;//加了一条边,k要自增
for(i=0; i<n; ++i) {
//把所有属于sn2的节点归并到sn1中,这就是并查集喽
if(vexset[i] == sn2)
vexset[i] = sn1;
}
}
++j;//处理下一条边
}
}
4.2有向图应用
有向图的应用相对广泛,而且大多是基于拓扑序列完成各种算法,拓扑序列就是从有向图中逐步输出入度为零的顶点,每输出一个顶点,对应弧要删除,同时该弧头对应节点的入度减一,知道输出所有顶点,该输出序列即为该图的拓扑序列。从上面叙述可知,带环的图是不存在拓扑序列的。
基于拓扑序列,可以拓展求关键路径:在拓扑排序过程中,结点依次入栈,同时记录下每个节点最早开始时间:
//这里假设G是邻接表的存储方式
Status TopSort(Graph G, Stack &T) {
InDegree(G, indegree);//求得个顶点的入度indegree[0..n-1],并把入度为零的顶点推到栈S
InitStack(T); count = 0; ve[0..n-1] = {0};//ve为各结点最早开始时间
while(!StackEmpty(S)) {
Pop(S, j); Push(T, j); ++count;
for(w=G.vertices[j].firstarc; w; w=w->next) {
k = w->adjvex;
if (--indegree[k] == 0)
Push(S, k);
//设info为弧结点对应的权值
if (ve[j] + w->info > ve[k])
ve[k] = ve[j] + w->info;
}
}
if (count < n)
return FALSE;
return TURE;
}
Status CriticalPath(Graph G) {
if (!TopSort(G, T))
return FALSE;
vl[0..n-1] = ve[0..n-1];//初始化最晚开始时间
while(!StackEmpty(T)) {
Pop(T, j);
for (w=G.vertices[j].firstarc; w; w=w->next) {
k = w->adjvex;
if (vl[j] + w->info < vl[k])
vl[j] = vl[k] - w->info;
}
}
for(i=0; i<n; ++i) {//输出关键路径上结点
if(ve[i] == vl[i])
print(i);
}
}
经常也会遇到这样的应用:图中某点到其它点的最短路径。有了这个解答后,可拓展到任意两点的最短路径求解。
先看看单源最短路径问题:从v0开始,找到直达顶点的路径D并保存,从这些路径中找最短(s1)的对应顶点v1(v0到v1最短路径就是s1),然后从v1找直达路径x[..],如果s1加上这些路径x[..]比P中短,替换之,排除s1后从D中再找最短…直到所有顶点找完。伪代码如下:
void ShortPath_Dij(int G[][], int v0, PathMatrix &P, PathTable &D) {
//v0到v的最短路径P[v]及其带权长度D[v]
//若P[v][w]为TRUE,则w是从v0到v最短路径上的顶点
//final[v]为TRUE,表示已经求得v0到v的最短路径
for (v=0; v<n; ++v) {
final[v] = FALSE; D[v] = G[v0][v];
for (w=0; w<n; ++w)
P[v][w] = FALSE;//设空路径
if (D[v]<MAX) {
P[v][v0] = TRUE;
P[v][v] = TRUE;
}
}
D[v0] = 0; final[v0] = TURE;
for (i=1; i<n; ++i) {
min = MAX;
for (w=0; w<n; ++w)//找最短的边
if(!final[w])
if (D[w]<min) {
v = w;
min = D[w];
}
final[v] = TRUE;
for (w=0; w<n; ++w) //更新最短路径以及距离
if(!final[w] && min+G[v][w] < D[w]) {
D[w] = min + G[v][w];
P[w] = P[v];
P[w][w] = TRUE;
}
}
}