图是常用的重要的一类数据结构,树可以看成是图的特例,树中每个数据元素至多允许一个前驱,只能反映数据元素之间一对多的关系,而图没有该限制,允许数据元素有多个前驱,因此可以反映数据元素之间多对多的关系。
图的概念
图是由顶点的有穷非空集合和顶点之间边的集合组成,通常表示为:G(V,E)。
其中,G表示一个图,V是图G中顶点的有穷非空集合,E是图G中边的有穷集合。
E是空集时,图G只有顶点没有边。
图的有关术语:
-
无向边:若顶点Vi到Vj之间的边没有方向,则称这条边为无向边,用无序偶对(Vi,Vj) 来表示。如下左图,G= (V1,{E1}),其中顶点集合V1={A,B,C,D};边集合E1={ (A,B) ,(B,C),(C,D), (D,A) , (A,C) } 。
-
有向边:若从顶点Vi 到Vj的边有方向,则称这条边为有向边,也称为弧。用有序偶〈Vi,Vj>来表示, Vi称为弧尾, Vj称为弧头。 如果图中任意两个顶点之间的边都是有向边,则称该图为有向图。 连接顶点A到D的有向边就是弧,A是弧尾,D是弧头,<A, D>表示弧,注意不能写成<D, A>。如下右图,G= (V2,{E2}),其中顶点集合V2={A,B,C,D}; 弧集合E2={<A,D>,<B,A>,<C,A>,<B,C>}。
-
简单图:在图中,若不存在顶点到自身的边,且同一条边不重复出现,称该图为简单图。
-
无向完全图:在无向图中,若任意两个顶点之间都存在边,称该图为无向完全图。含有n个顶点的无向完全图有(n(n-1)/2)条边。如下图所示:
-
有向完全图:在有向图中,如果任意两个顶点之间都存在方向互为相反的两条弧,则称该图为有向完全图。含有n个顶点的有向完全图有n(n-1)条边,如下图所示:
-
网:有些图的边或弧具有与它相关的数字,这种与图的边或弧相关的树叫做权。这些权可以表示一个顶点到另一个顶点的距离或耗费。这种带权的图常称为网。如下图所示:
-
度:顶点的度是指关联该顶点的边的数目。
-
子图:子图是图的边(及边所关联的顶点)的子集所形成的图。
-
路径:图中的路径指的是一系列相邻顶点。简单路径是一条不包含重复顶点的路径。环路是起点和终点相同的路径。如果两盒顶点之间存在一条路径,则称这两个顶点是连通的。如果图中每对顶点之间都有路径相连,则称该图是连通图。如果一个图是非连通的,那么它是由一组连通分量组成。
-
有很少条边或弧的图称为稀疏图,反之称为稠密图,这里的概念是相对而言的。
图的存储结构
图最常见的表示形式为邻接链表和邻接矩阵。邻接链接在表示稀疏图时非常紧凑而成为了通常的选择,相比之下,如果在稀疏图表示时使用邻接矩阵,会浪费很多内存空间,遍历的时候也会增加开销。但是,这不是绝对的。如果图是稠密图,邻接链表的优势就不明显了,那么就可以选择更加方便的邻接矩阵。
还有,顶点之间有多种关系的时候,也不适合使用矩阵。因为表示的时候,矩阵中的每一个元素都会被当作一个表。
邻接矩阵(数组表示法)
图的邻接矩阵存储方式是用两个数组来表示图。一个一维数组存储图中顶点信息,一个二维数组(称为邻接矩阵)存储图中的边或弧的信息。设图G有n个顶点,则邻接矩阵是一个nxn的方阵A,定义为:
示例:
我们知道,每条边上带有权的图叫做网,如果要将这些权值保存下来,可以采用权值代替矩阵中的0、1,权值不存在的元素之间用∞表示,定义为:
如下图,左图是一个有向网图,右图就是它的邻接矩阵。
邻接矩阵的特点:
- 判定两个顶点Vi与Vj是否关联,只需判断A[i,j]是否为1;
- 求顶点的度容易。
邻接矩阵的定义的代码实现:
#define MaxVerterNum 100
typedef char VerterType;
typedef int EdgeType;
typedef struct {
VerterType vexs[MaxVerterNum]; // 存储顶点的一维数组
EdgeType edges[MaxVerterNum][MaxVerterNum]; // 存储邻接矩阵的二维数组
int n,e; // 图当前的顶点数和边数
}MGraph;
邻接链表
对于边数相对顶点较少的图,这种结构是存在对存储空间的极大浪费的,特别是稀疏有向图。所以可以考虑用链表来按需存储。数组与链表相结合的存储方法称为邻接表。
处理办法:
- 图中顶点用一个一维数组存储,当然,顶点也可以用单链表来存储,不过数组可以较容易地读取顶点信息,更加方便。另外,对于顶点数组中,每个数据元素还需要存储指向第一个邻接点的指针,以便于查找该顶点的边信息。
- 图中每个顶点vi的所有邻接点构成一个线性表,由于邻接点的个数不定,所以用单链表存储,无向图称为顶点vi 的边表,有向图则称为顶点vi作为弧尾的出边表。
如图是一个无向图的连接表结构,有向图则类似。
无向图邻接表的特点:
3. n个顶点,e条边的无向图,需n个表头结点和2e个链表结点;
4. 顶点Vi的度等于链表i中的链表结点数。
对于带权值的网图,可以在边表结点定义中再增加一个weight 的数据域,存储权值信息即可,如下图所示。
有向图邻接表的特点:
5. n个顶点,e条弧的无向图,需n个表头结点和e个链表结点;
6. 链表i中的链表结点数为顶点Vi的出度。
有向图逆邻接表
与无向图的邻接表结构一样。只是在链表i上的结点是以Vi为弧头的各弧尾顶点。
此时,链表i上的结点数为Vi的入度。
邻接表的结构定义和建立算法:
typedef char VertexType; /* 顶点类型应由用户定义 */
typedef int EdgeType; /* 边上的权值类型应由用户定义 */
typedef struct EdgeNode /* 边表结点 */
{
int adjvex; /* 邻接点域,存储该顶点对应的下标 */
EdgeType info; /* 用于存储权值,对于非网图可以不需要 */
struct EdgeNode *next; /* 链域,指向下一个邻接点 */
}EdgeNode;
typedef struct VertexNode /* 顶点表结点 */
{
VertexType data; /* 顶点域,存储顶点信息 */
EdgeNode *firstedge;/* 边表头指针 */
}VertexNode, AdjList[MAXVEX];
typedef struct
{
AdjList adjList;
int numNodes,numEdges; /* 图中当前顶点数和边数 */
}GraphAdjList;
图的邻接矩阵与邻接表表示的比较
图的遍历
从图中某一顶点出发访遍图中其余顶点,且使每一个顶点仅被访问一次,这一过程就叫做图的遍历。
两种常用遍历图的方法:深度优先搜索、广度优先搜索。
深度优先搜索(DFS)
从图中某个顶点v出发,访问此顶点,然后从v的未被访问的邻接点出发深度优先遍历图,直至图中所有和v 有路径相通的顶点都被访问到。
深度优先搜索遍历图的过程为:
示例:
深度遍历算法的流程图:
邻接矩阵的深搜代码如下:
boolean visited[MAXVEX]; /* 访问标志的数组 */
/* 邻接矩阵的深度优先递归算法 */
void DFS(MGraph G, int i)
{
int j;
visited[i] = true;
printf("%c ", G.vexs[i]);/* 打印顶点,也可以其它操作 */
for(j = 0; j < G.numVertexes; j++)
if(G.arc[i][j] == 1 && !visited[j])
DFS(G, j);/* 对为访问的邻接顶点递归调用 */
}
/* 邻接矩阵的深度遍历操作 */
void DFSTraverse(MGraph G)
{
int i;
for(i = 0; i < G.numVertexes; i++)
visited[i] = false; /* 初始所有顶点状态都是未访问过状态 */
for(i = 0; i < G.numVertexes; i++)
if(!visited[i]) /* 对未访问过的顶点调用DFS,若是连通图,只会执行一次 */
DFS(G, i);
}
如果使用邻接表结构,代码如下:
Boolean visited[MAXSIZE]; /* 访问标志的数组 */
/* 邻接表的深度优先递归算法 */
void DFS(GraphAdjList GL, int i)
{
EdgeNode *p;
visited[i] = TRUE;
printf("%c ",GL->adjList[i].data);/* 打印顶点,也可以其它操作 */
p = GL->adjList[i].firstedge;
while(p)
{
if(!visited[p->adjvex])
DFS(GL, p->adjvex);/* 对为访问的邻接顶点递归调用 */
p = p->next;
}
}
/* 邻接表的深度遍历操作 */
void DFSTraverse(GraphAdjList GL)
{
int i;
for(i = 0; i < GL->numVertexes; i++)
visited[i] = FALSE; /* 初始所有顶点状态都是未访问过状态 */
for(i = 0; i < GL->numVertexes; i++)
if(!visited[i]) /* 对未访问过的顶点调用DFS,若是连通图,只会执行一次 */
DFS(GL, i);
}
广度优先搜索(BFS)
如果说图的深度优先遍历类似于树的前序遍历,那么图的广度优先遍历就类似于树的层序遍历。我们将下面的第一幅图稍微变形,变形原则是顶点A放置在最上面一层,如下面的第二幅所示。此时,在视觉上感觉图的形状发生了变化,其实顶点和边的关系还是完全相同的。
广度优先搜索遍历图的过程为:
邻接矩阵结构的广度优先遍历算法:
/* 邻接矩阵的广度遍历算法 */
void BFSTraverse(MGraph G)
{
int i, j;
Queue Q;
for(i = 0; i < G.numVertexes; i++)
visited[i] = FALSE;
InitQueue(&Q); /* 初始化一辅助用的队列 */
for(i = 0; i < G.numVertexes; i++) /* 对每一个顶点做循环 */
{
if (!visited[i]) /* 若是未访问过就处理 */
{
visited[i]=TRUE; /* 设置当前顶点访问过 */
printf("%c ", G.vexs[i]);/* 打印顶点,也可以其它操作 */
EnQueue(&Q,i); /* 将此顶点入队列 */
while(!QueueEmpty(Q)) /* 若当前队列不为空 */
{
DeQueue(&Q,&i); /* 将队对元素出队列,赋值给i */
for(j=0;j<G.numVertexes;j++)
{
/* 判断其它顶点若与当前顶点存在边且未访问过 */
if(G.arc[i][j] == 1 && !visited[j])
{
visited[j]=TRUE; /* 将找到的此顶点标记为已访问 */
printf("%c ", G.vexs[j]); /* 打印顶点 */
EnQueue(&Q,j); /* 将找到的此顶点入队列 */
}
}
}
}
}
}
邻接表的广度优先遍历代码如下:
/* 邻接表的广度遍历算法 */
void BFSTraverse(GraphAdjList GL)
{
int i;
EdgeNode *p;
Queue Q;
for(i = 0; i < GL->numVertexes; i++)
visited[i] = FALSE;
InitQueue(&Q);
for(i = 0; i < GL->numVertexes; i++)
{
if (!visited[i])
{
visited[i]=TRUE;
printf("%c ",GL->adjList[i].data);/* 打印顶点,也可以其它操作 */
EnQueue(&Q,i);
while(!QueueEmpty(Q))
{
DeQueue(&Q,&i);
p = GL->adjList[i].firstedge; /* 找到当前顶点的边表链表头指针 */
while(p)
{
if(!visited[p->adjvex]) /* 若此顶点未被访问 */
{
visited[p->adjvex]=TRUE;
printf("%c ",GL->adjList[p->adjvex].data);
EnQueue(&Q,p->adjvex); /* 将此顶点入队列 */
}
p = p->next; /* 指针指向下一个邻接点 */
}
}
}
}
}
DFS与BFS的比较
对比 DFS 和 BFS 可知,DFS 的最大优势在于它的内存开销要远远小于 BFS,因为它不需要存储每一层结点的所有孩子结点指针。根据数据和查找内容的不同, DFS 和 BFS 各有优势。例如,在一个家族树中,需要查找某个人是否仍然健在且假设这个人处于树的末端,那么 DFS 是一个更好的选择,而 BFS 可能需要花费非常长的时间达到最后一层。
DFS 算法能更快的找到目标。现在,如果要寻找一个已经过世很长时间的人,那么这个人可能更接近树的顶端。在这种情况下,BFS 查找比 DFS快。因此,每种算法的优势取决于数据和要查找的内容。
不过如果图顶点和边非常多,不能在短时间内遍历完成,遍历的目的是为了寻找合适的顶点,那么选择哪种遍历就要仔细研究了。深度优先更适合目标比较明确,已找到目标为主要目的的情况,而广度优先更适合在不断扩大遍历范围时找到相对最优解的情况。