数据结构与算法——图
图(graph)是一种比线性表、树更为复杂的数据结构。在线性表中,数据元素之间呈线性关系,即每个元素只有一个直接前驱和一个直接后继。在树型结构中,数据元素之间有明显的的层次关系,即每个结点只有一个直接前驱,但可有多个直接后继,而在图结构中,每个结点即可有多个直接前驱,也可有多个直接后继,因此,树结构是图结构的一种特殊情形。当一个树结构中允许同一结点出现在不同分支上时,该树结构实际上就是一个图结构。图的最早应用可以追溯到十八世纪数学家欧拉(EULer)利用图解决了著名的哥尼斯堡桥的问题,为图在现代科学技术领域的应用奠定了基础。
一、图的基本概念
1、图的定义
图是一种数据结构,图和树一样可以用二元组表示。它可定义为Graph=(V,R)其中,V={x|x∈datatype},R={VR},VR={<x,y>|P(x,y)∧(x,y∈V)}。在图中,数据元素常称为顶点(Vertex),V是顶点的非空有穷集合;R是边(弧)的有穷集合。VR是两个顶点之间的关系集合。顶点之间关系可用序偶对来表示。若<x,y>∈VR,则〈x,y>表示从x到y有一条弧(arc,或称又向边),且称x为弧尾(tail)或初始点(initial node),称y为弧头(Read)或终端点(terminal node),此时的图称为有向图(digraph)。若<x,y>∈VR,必有<y,x>∈VR即VR是对称的,以无序对(x,y)代替这两个有序对,表示x和y之间的一条边(edge),此时的图称为无向图(undigraph)。谓词P(x,y)表示从x到y有单向通路或其他信息。从逻辑上看,图是由顶点和边组成,边反映出顶点之间的联系。
2、图的基本术语
不考虑结点的自返圈,即结点到其自身的边,若〈V1,V2〉或〈V1,V2〉是图的一条边,则V1≠V2,并且也不允许一条边在图中重复出现。
(1)完全图
在一个有n个顶点的图中,若每个顶点到其他(n-1)个顶点都连有一条边,则图中有n个顶点且有n*(n-1)/2条边的图称为完全图。任一个具有n个顶点的有向图,其最大边数为n*(n-1).
(2)邻接点、相关边
对于无向图G=(V,E),若(V1,V2)∈E,则称V1和V2互为邻接点(adjacent),即V1和V2相邻接,而边(V1,V2)则是与结点V1和V2“相关联的边”。在有向图G=(V,A)中,若<V1,V2>∈A,则称结点V1邻接到结点V2,结点V2邻接于V1,而边〈V1,V2〉是与结点V1,V2相关联的。
(3)顶点的度、入度、出度
顶点的度(degree)是和V相关联的边的数目,计为TD(V).在有向图G=(V,A)中,如果弧<V1,V2>∈A,则以V1为头的弧的数目称为V1的入度(indegree),记为ID(V1);以V1为尾的弧的数目称为V1的出度(outdegree),,记为OD(V1);顶点的度为TD(V1)=ID(V1)+OD(V1).
(4)路径、回路
无向图G=(V,E)中,从顶点V到顶点V’的路径(Path)是一个顶点序列(V=Vi0,Vi1,Vi2,……,Vim=V’),其中(Vij-1,Vij)∈E,1<=j<=m.如果是有向图,则路径也是有向的,顶点序列满足(Vij-1,Vij)∈E,1<=j<=n,路径长度是路径上的边或弧的数目。第一个顶点和最后一个顶点相同的路径称为回路或环(cycle),序列中顶点不重复的称为简单路径。除了第一个顶点和最后一个顶点外,其余顶点不重复的回路,称为简单回路或简单环。
(5)子图
假设有两个图G={V,{E}}和G’={V’,{E’}},如果V’包含于V,E’包含于E,则称G’是G的子图。
(6)连通和强连通
在无向图G中,如果从顶点V到顶点V’有路径,则称V和V’是连通的。如果对于图G中任意两个顶点Vi,Vj ∈V都是连通的,则称为G是连通图(connected graph).连通分量指的是无向图中极大连通子图。在有向图G中,如果对于每一对Vi,Vj∈V,Vi<>Vj,从Vi到Vj和从Vj到Vi都存在路径,则称G是强连通图。有向图中极大强连通子图称作为有向图G的强连通分量。
(7)生成树
一个连通图的生成树,它含有图中全部顶点,但只有足以构成一棵树的n-1条边。
(8)权、网
在图的边或弧上,有时标有与它们相关的数,这种与图的边或弧相关的数称作权(Weight)。这些权可以表示从一个顶点到另一个顶点的距离或代价。这种带权的图常称作网(network)。
二、图的存储结构
1、邻接矩阵表示法
邻接矩阵是表示顶点之间相邻关系的矩阵。设G=(V,E)是具有n个顶点的图,由G的邻接矩阵是具有如下性质的n阶方阵其定义为:
A[i,j]= 1 若<i,j>或<j,i>∈E
反之 A[i,j]= 0
这一n*n的方阵,可借助二维数组作为存储结构。将邻接矩阵中的0,1还成权值,就是图的邻接矩阵。无向图的邻接矩阵是对称矩阵,顶点vi的度是邻接矩阵中第i行(或第i列)的元素1之和。有向图的邻接矩阵不一定是对称矩阵;顶点vi的出度是邻接矩阵中第i行元素之和,入度是邻接矩阵中第i列的元素之和。用邻接矩阵表示有向图所需的存储空间为n*n位。对无向图,由于其对称性,仅需存入下三角(或上三角)的元素,故有n个顶点的无向图仅需n(n+1)/2存储空间。用邻接矩阵表示有n个顶点的图,测试其边的数目时,必须按行、列逐次测试,故需O(n*n)次。通过邻接矩阵可容易判定顶点间有无边(弧),容易计算顶点的度(出度、入度);缺点是所占用空间只和顶点个数有关,和边数无关,在边数较少时,空间浪费较大。一般在顶点数较少且边数稠密时应用邻接矩阵。
2、邻接表
邻接表是为了克服邻接矩阵在图为稀疏图时的空间浪费大的这个缺点而提出的。邻接表是顶点的向量结构和边(弧)的单链表结构的集合,每个顶点结点包括两个域,将n个顶点放在一个向量中(成为顺序存储的结点表);一个顶点的所有邻接点链结成单链表,该顶点在向量中有一个指针域指向其第一个邻接点。邻接表结构: 顶点结点:vexdata | frist adjvex | info | next
其中,vexdata是顶点数据,firstarc是指向该顶点第一个邻接点的指针,adjvex是邻接点在向量表中的下标,info是邻接点的信息,next是指向下一邻接点的指针。
对无向图,容易求各顶点的度;边表中结点个数是边数的两倍。对有向图,容易求顶点的出度;若求顶点的入度则不容易,要遍历整个表。为了求顶点的入度,有时可设逆邻接表(指向某顶点的邻接点链接成单链表)。所谓逆邻接表就是对图中的每个顶点i建立一个单链表,把被i邻接的顶点放在一个链表中,即边表中存放的是入度边而不是出度边。一般在处理稀疏矩阵时用邻接表。
构建一个图的邻接表
struct node /*单链表中结点结构 */
{
int vertex; /*顶点编号*/
struct node * next /*指针域*/
};
struct headnode /*数组中元素的结构*/
{
int vert; /*顶点编号*/
struct node * link /*指针域*/
};
为了方便,先给出一个将顶点b加入到顶点a的邻接链表的函数:
struct headnode * linkup (a, b, head);
int a, b;
struct headnode * head;
{
struct node * p;
while(head-> vertex! = a) /*找到编号为a的顶点*/
{
head = head+1;
p = head-> link;
}
while( (p! =NULL) && ( p-> vertex ! =b ))
p =p->next /*查编号为b的顶点是否为a的链接顶点*/
if (p = =NULL) /*如果未查到*/
{
p = (struct node * )malloc(sizeof(struct node)); // 开辟新节点
p-> vertex = b; /*将b放入*/
p-> next = head->link; /*指针重新定向*/
head-> link =p; /*a指向b*/
return(head);
}
}
构造邻接表的函数为:
struct headnode *adjlist(d, n)
int n;
int d[ ];
{
struct headnode head[100] ;
struct node * q, * p ;
int i,vl;
for(i=0; i<n; i++ )
{
head[i].vert=d[i]; /*为每个顶点建立一个连接*/
head[i].link=NULL; /*下一个指针先置空*/
printf (“inputlinked list of n”); /*输入和此顶点相连的顶点*/
scanf (“%d”, &vl);
while (vl> =0) /*若此数值为有效/*
{
p= (struct node * ) malloc(sizeof(struct node)); /*开辟新节点*/
p->vertex=vl; /*放入顶点编号*/
p->next = ead[i].link;/*重新将指针定向*/
head[i].link=p;
scanf(“%d”, &vl); /*再输入下一个相连的顶点编号*/
}
}
return (head);
}
以上函数返回值为邻接表的首地址。
二、图的遍历
给定一个无向连通图,从图的任意指定顶点出发,依照某种规则去访问图中所有顶点,且每个顶点仅被访问一次,这一访问过程叫作图的遍历(graph traversal)数的遍历是利用树求解各类问题的基础,是树的一个最基本的运算。同样,图的遍历算法是求解图的连通性、拓扑排序和求关键路径等算法的基础。图的遍历比树的遍历复杂,由于图的任一点都有可能和其余个顶点相邻接,故在访问了某个顶点之后,沿着某条边再次访问同一顶点,出现了重复访问某一顶点的问题。为了避免同一顶点被多次访问,在图的遍历过程中,必须记住每个已被访问过的顶点,为此可设置一个表示顶点是否已被访问过的辅助数组visited[1…n],它的初值为0或假,一旦访问了Vi,便置visited[i]为1或真。图的遍历按照深度优先和广度优先规则去实施,通常有深度优先搜索法(depth_firstsearch)和广度优先搜索法( breadth_frist search)两种.
1、深度优先搜索
深度优先搜索是在访问某一顶点Vo之后,由Vo出发,选取一个与Vo邻接且没有被访问的任一点W1进行访问。再由W1出发,选取一个与W1邻接且没有被访问的任一点W2进行访问,再由W2出发,……,依次类推,重复上述过程,直至某顶点Wi已无未被访问过的邻接点时,则退回一步找到一个顶点Wi-1的其他尚未被访问的邻接点。如果存在尚未被访问过的邻接点,则访问此邻接点,然后再从该顶点出发,按深度优先进行访问;如果退回一步后还没有尚未被访问的邻接点,则再回退一步再进行搜索,重复上述过程,一直到所有顶点都被访问过为止。所谓“访问”究竟是进行什么运算,视不同应用而定且访问的顺序不是唯一的。
#include <stdio.h>
#define MAX 5 /*定义最大顶点个数5*/
typedef struct EdgeNode{
intadjnum; /*邻接点域,存储位置序号,*/
intinfo;
structEdgeNode *nextarc; /*链域,指向下一条边(弧)的指针*/
}EdgeNode;
typedef struct VNode{ /*表头结点的类型VNode*/
intvexdata; /*表头结点的数据域*/
structEdageNode *firstarc; /*表头结点的指针域*/
}VNode;
typedef VNode AdGraph[MAX+1]; /*定义图的类型AdGraph,为一个一维数组*/
typedef struct{
AdGraphadgraph; /*邻接表*/
inte,h; /*边和顶点的数目*/
}Adlist;
采用邻接表结构图的深度优先遍历的递归算法:
intvisited[MAX+1]; /*结点是否访问过标记*/
voidDFSTraverse(Adlist G)
{
for(v=0;v<G.n;v++)
{
visited[v]=0; /*初始化所有均未被访问过*/
}
for(v=0;v<G.n;v++)
{
if(!visited[v])
{
DFS(G,V); /*以某一顶点为起始点确保所有结点均被访问到*/
}
}
}
voidDFS(Adlist G,int v) /*从顶点v开始递归,深度优先便利图G*/
{
Vistted[v]=1;
printf(“%d”,v); /*打印顶点信息*/
for(w=First_Adjv(G,v);w;w=Next_Adjv(G,v,w))
{
if(!visited[w])
{
DFS(G,w); /*对v的未访问的邻接顶点w递归调用DFS*/
}
}
}
图的深度优先遍历的非递归遍历算法:
intvisited[MAX+1]; /*标记结点是否访问过*/
voidNonRecDFSTraverse(Adlist G)
{
int stack1[2*MAX+1],v,top1=0; /*申请栈1*/
int stack2[2*MAX+1],top2=0; /*申请栈2*/
for(v=0;v<G.n;v++)
{
Visited[v]=0; /*初始化,所有结点均未被访问过*/
}
for(v=0;v<G.n;v++) /*确保所有结点均被访问到*/
{
if(!visited[v])
{
stack1[++top1]=v;
while(top1>0)
{
v=stack1[top1--];
if(!visited[v])
{
printf(“%d”,v); /*打印顶点的信息*/
}
for(w=First_Adjv(G,v);w;w=Next_Adjv(G,v,w))
{
if(!visited[w])
{
stack2[++top2]=w;
}
}
while(top2>0)
{
stack1[++top1]=stack2[top2--];
}/*while*/
}/*while*/
}/*if*/
}/*for*/
}/*NonRecDFSTraverse*/
2、广度优先搜索
无向图的广度优先搜索过程:从图G中某一顶点Vo出发,在访问了Vo之后,首先依次访问与Vo邻接的全部顶点W1,W2,….,Wd。然后再依次访问W1,W2,….,Wd邻接的尚未被访问过的全部顶点,再从这些被访问过的顶点出发,逐一访问与它们邻接的尚未被访问过的全部顶点。依次类推,直到所有的顶点全被访问完为止,这一搜索过程称为广度优先搜索。在上述搜索过程中,若W1在W2之前访问过,则W1的邻接点也将在W2的邻接点之前访问。因此,对于广度优先算法,无需记录所有走过的路径,但却需记录与一个顶点相邻接的全部顶点,由于访问过这些顶点之后,还将按照先被访问的顶点就先访问它的邻接点的方式进行广度优先搜索。为此我们用一个先进先出的队列来记录这些顶点。广度优先搜索的序列也不是唯一的。
广度优先算法:
3、图的生成树和连通分量
在对无向图进行遍历时,对于连通图,仅需依次调用搜索过程(dfs或bfs)。即从图的任意一点出发,便可遍历到图中每个顶点,按照图的生成树的定义,图中的全部顶点和搜索过程所经过的边集,即构成了该两通图的生成树。求图的连通分量实际是图的遍历的一种应用。当无向图是连通图时,从图中某点Vo出发遍历图,不能访问到图的所有顶点,而只能访问到包含Vo所在的最大连通子图(连通分量)中的所有顶点。若从非连通图中每个连通分量中的一个顶点出发遍历图,则可求出无向图的所有连通分量。
为了求得非连通图的所有的连通分量,只需调用dfs或bfs,并对图中每个顶点进行检测。若某顶点已被访问,则该顶点落在图中已求得的连通分量上;若某顶点未被访问,则从该顶点出发遍历图,便可求得图的另一个两通分量其算法描述如下:
Void component(graph g) /*g为用邻接表或邻接矩阵表示的有n个顶点的无向图,求该图的所有连通分量*/
{
for(i=1,i<n,i++)
Visited[i]=0;
for(i=1;i<n;i++)
{
if (Vistted[i]=0)
{
dfs(g,Vi);
printf(“输出dfs过程中访问到的顶点及所有依附于顶点的边”);
}
}
}
有向图的连通性及其将连通分量,均可用dfs和bfs遍历方式求得。
三、最小生成树
一棵生成树的代价即树上各边的代价之和,如果该生成树的代价最小,则称该书为最小生成树(也称最小代价生成树)。
求图的最小生成树有着广泛的应用价值,例如,如何在n个城市之间建立通信网络图,并且连通n个城市只需要n-1条线路,而且通信代价最小。
构造最小生成树的算法主要有普里姆(prim)算法和克鲁斯卡尔(kruskal)算法两种。
(1)prim算法
普里姆于1975年提出了构造最小生成树的一种方法,该算法的要点是:按照将顶点逐一连通的步骤,把已连通的顶点加入到集合V中,这个集合V开始时为空集。首先任选一顶点加入V,然后,从依附与该顶点的边中选取权值最小的边作为生成树的一条边,并将依附于该边且在集合V外的另一个顶点加入到V。表示这两个顶点已通过权值最小的边两通了。以后,每次从一个顶点在集合V中而另一个顶点在V外的各条边中选取权值最小的一条,作为生成树的一条边,并把依附与该边且在集合V外的顶点并入V,依次类推,直到全部顶点都已连通(全部顶点加入到V),即构成所要求的最小生成树。
若N=(V, E)是连通网,V={V1,V2,…,Vn}是网的顶点集合,E是N上最小生成树中边的集合。引入顶点集合U和边的集合TE,U的初始状态为{V1},它存放的是当前所得到(还未完成的)最小代价生成树上的所有顶点,TE的初始状态为φ。在prim算法的每一步,都从所有的边{(u,v)|v∈V-U,u∈U}中找出所有代价最小的边(u,v),同时将v并入U,(u,v)并入集合TE,直到U=V为止。此时TE中必有n-1条边,则T=(V,TE)为N的最小生成树。
Prim算法的时间复杂度为O(n*n),它与网上的边的数目无关,因此它适合稠密图。
算法描述:
void minispantree-PRIM(adjmatrix gn, vexptr u0)
{
For(int v=1;v<vexnumber;v++)
{
IF(V!=u0)
{
While(closedge[v])
{
Vex=u0;
Lowcost=gn[u0,v];/*辅助数组初始化*/
}
}
Closedge[u0].lowcost=0;
For (i=1;i<vexnumber-1;i++)
{
K=minimum(closedge); /*求代价最小的顶点k∈V-U}
printf(“%,%”,closedge[k].vex,k);/*输出生成树的边*/
closedge[k].lowcost=o;/*顶点k并入u集*/
for(i=1;i<vexnum;i++)
{
If(gn[k,v]<closedge[v].lowcost)
{
Closedge[v].lowcost=gn[k,v];
Closedge[v].vex=k;
}
}