目录
一、基本概念
1、图的定义
2、有向图和无向图
如果边是有方向的则称为有向图,如果边没有方向则称为无向图
3、无权图和带权图
对图中的边赋予具有一定意义的数值(路程、费用等等)的图称为带权图
4、完全图
任意两个顶点之间都存在一条边
无向完全图的边数为1/2*n*(n-1)
有向完全图的边数为n*(n-1)
稀疏图:有很少的边
稠密图:有很多的边,最稠密的图就是完全图
5、邻接点
如果顶点vi、vj之间存在一条边,则称它们互为邻接点
6、顶点的度
对于无向图,顶点v的度定义为和v相关联的边数
对于有向图,顶点v的度分为入度和出度
7、路径
若顶点vp和vq可以由若干条边连通,则称vp到vq存在一条路径
无权图的路径长就是路径上经过边数
带权图的路径长要乘以每条边的权
简单路径:除了起点和终点可以为同一个顶点外,其余顶点均不相同
起点和终点为同一个顶点的简单路径称为回路或环
8、图的连通性
如果从顶点vi到顶点vj有路径,则称vi和vj是连通的。
如果图中任意两个顶点都是连通的,则称该图是连通图。
对于有向图,如果图中的任意两个顶点vi和vj是双向连通的,则该图是强连通图。
9、连通分量
无向图的连通子图称为连通分量,有向图的强连通子图称为强连通分量
连通分量的概念是图的难点:首先连通分量是一个子图,其次这个子图是(强)连通图
【推论】
a、无向连通图的连通分量只有一个,就是它自身,称为极大连通子图
b、无向非连通图的连通分量有多个
c、有向强连通图的强连通分量只有一个,就是它自身,称为极大强连通子图
d、有向非强连通图的强连通分量有多个
连通分量通过遍历图来求出,从图中某个顶点v出发遍历图,如果能够遍历到所有顶点,则该图是连通的;如果遍历不到所有顶点,则图是非连通的,要在未被遍历的顶点中选择新的出发点,直到所有顶点都被遍历到,那么每次遍历得到的就是图的一个连通分量。
10、生成树
在图论中,树被定义为没有回路的连通图,生成树的研究对象是连通图
连通图G中包含所有顶点的极小连通子图(边最少)称为G的一棵生成树
【推论】
a、一个有n个顶点生成树有且仅有n-1条边
b、一个连通图的生成树不唯一
11、最小生成树
采用不同的遍历方法可以得到不同的生成树,从不同的顶点出发进行遍历也可以得到不同的生成树,所以图的生成树是不唯一的。对于连通的带权图G,其生成树也是带权的。我们把生成树各边的权值总和称为该树的权,把权值最小的生成树称为图的最小生成树。
二、图的存储结构
1、邻接矩阵
邻接矩阵是表示图中顶点之间相邻关系的矩阵。设G=(V,E)是具有n个顶点的图,则G的邻接矩阵是具有如下定义的n阶方阵:
// 邻接矩阵表示法的数据结构定义
#define MaxVertexNum 50 // 最大顶点数
typedef struct _MGraph{
VertexType vexs[MaxVertexNum]; // 顶点数组
int arcs[MaxVertexNum][MaxVertexNum]; // 邻接矩阵
} MGraph;
无向图的邻接矩阵:对称矩阵
有向图的邻接矩阵:各行之和是出度,各列之和是入度
带权图的邻接矩阵
2、邻接表
邻接表是一种链式存储结构,类似于链表数组
// 邻接表表示法的数据结构定义
#define MaxVertexNum 50
// 邻接表结点
typedef node {
int vex_id; // 顶点序号
struct node *next; // 指向下一个邻接点
} EdgeNode;
// 顶点表
typedef vnode {
VertexType vexs; // 顶点信息
EdgeNode *link; // 邻接表头
} VNode;
// 图
VNode ALGraph[MaxVertexNum];
无向图的邻接表
有向图可以用两个邻接表表示,出边表叫邻接表,入边表叫逆邻接表
3、十字链表
邻接表对于有向图的表示分为邻接表和逆邻接表,我们无法从一个表中获取某顶点的出度和入度情况,所以有人提出了十字链表的存储方式。
顶点表:
firstin:入边表头指针,指向顶点入边表的第一个节点
firstout:出边表头指针,指向顶点出边表的第一个节点
边表:
tailvex是边起点在顶点表的下标,headvex边终点在顶点表的下标
headlink入边表指针,指向终点相同的下一条入边;taillink出边表指针,指向起点相同的下一条出边
三、图的遍历
图的遍历是从某个顶点出发,沿着某条路径对图中每个顶点做且仅做一次访问。遍历图的算法是求解图的连通性、图的拓扑排序等算法的基础。
为了避免顶点的重复访问,可设一个布尔数组visisted[0...n-1],用来标记某个顶点是否被访问过,初始值均为假,若该顶点已被访问过,则以顶点序号为下标的数组元素置为真。
1、深度优先搜索遍历(DFS)
从图G中任选一顶点v作为初始出发点,首先访问出发点v,并将其标记为已访问过;然后依次搜索v的每个邻接点w,若w未曾访问过,则以w作为新的出发点,继续进行深度优先遍历,直到图中所有和v有路径相通的顶点都被访问到;若此时仍有顶点未被访问到(非连通图),则另选一个未访问过的顶点作为起点,重复上述过程,直到图中所有顶点都被访问到为止。
(1)以邻接矩阵为存储结构的DFS递归算法
int visited[MaxVertexNum] = {0};
//@param G 要遍历的图
// i 顶点序号
// n 顶点总数
void DFS(MGraph G, int i, int n)
{
printf("v%d ", i); // 首先访问顶点vi
visited[i] = 1;
for (int j = 0; j < n; j++) { // 依次遍历vi的每个邻接点
// 若(vi,vj)属于E(G)且vj未访问过
if (G.arcs[i][j] == 1 && !visited[j])
DFS(G, j, n);
}
}
(2) 以邻接表为存储结构的DFS递归算法
int visited[MaxVertexNum] = {0};
//@param G 要遍历的图
// i 顶点序号
void DFS(VNode G[], int i)
{
printf("v%d ", i); // 首先访问vi
visited[i] = 1;
EdgeNode *p;
int j;
p = G[i].link;
while (p != NULL) { // 依次遍历vi的每个邻接点
j = p->vex_id;
if (!visited[j]) // 若邻接点vj未被访问过
DFS(G, j);
p = p->next;
}
}
(3)以邻接矩阵为存储结构的DFS非递归算法
int visited[MaxVertexNum] = {0}
//@param G 要遍历的图
// i 出发点vi
// n 顶点总数
void DFS(MGraph G, int start, int n)
{
printf("v%d ", start);
visited[start] = 1;
// 当前正在访问的顶点
int cur = start;
// 用顺序栈保存访问过的顶点,以便回溯访问过的顶点的未被访问的邻接点
SeqStack S;
S.top = -1;
// 当前顶点是否有未被访问过的邻接点
bool done = false;
for (int k = 0; k < n; k++) {
if (G.arcs[cur][k] == 1 && !visited[k]) {
done = true;
break;
}
}
while (!StackEmpty(&S) || done) {
int i = 0;
while (i < n) {
if (G.arcs[cur][i] == 1 && !visited[i]) {
// 把当前顶点的未被访问过的邻接点vi压入栈中
S.top += 1;
S.data[S.top] = i;
// 访问该邻接点
printf("v%d ", i);
visited[i] = 1;
// 以该邻接点为出发点进行遍历
cur = i;
i = 0; // 从第一个顶点开始重新搜索
}
else
i++;
}
// 如果栈非空
if (!StackEmpty(&S) {
cur = S.data[S.top]; // 以栈顶顶点为出发点继续遍历
S.top -= 1;
}
}
}
2、广度优先搜索遍历(BFS)
广度优先搜索是一种按层次遍历的方法,基本思想是:从图G中任选一顶点Vi作为初始出发点,首先访问出发点Vi,接着依次访问Vi的所有未被访问过的邻接点Vi1,Vi2,...,Vit并均标记为已访问过,然后再按照Vi1,Vi2,...,Vit的次序,访问每一个顶点的所有未被访问过的邻接点并均标记为已访问过,依次类推,直到图中所有和Vi有路径相通的顶点都被访问到;若此时仍有顶点未被访问到(非连通图),则另选一个未访问过的顶点作为起点,重复上述过程,直到图中所有顶点都被访问到为止。
(1)以邻接矩阵为存储结构的广度优先搜索遍历算法
int visited[MaxVertexNum] = {0};
//@param G 要遍历的图
// i 顶点序号
// n 顶点总数
void BFS(MGraph G, int i, int n)
{
CirQueue Q; // 定义一个队列保存已访问过的顶点
InitQueue(&Q); // 初始化队列
printf("v%d ", i); // 首先访问顶点vi
visited[i] = 1; // 标记vi已访问过
EnQueue(&Q, i); // 将已访问过的顶点入队
int j, k;
while (!QueueEmpty(&Q)) {
k = DeQueue(&Q); // 删除队首元素
// 搜索vk的每个邻接点
for (int j = 0; j < n; j++) {
if (G.arcs[k][j] == 1 && !visited[j]) {
printf("v%d ", j);
visited[j] = 1;
EnQueue(&Q, j);
}
}
}
}
(2)以邻接表为存储结构的广度优先搜索遍历算法
int visited[MaxVertexNum] = {0};
//@param G 要遍历的图
// i 顶点序号
void BFS(VNode G[], int i)
{
CirQueue Q; // 定义一个队列保存已访问过的顶点
InitQueue(&Q); // 初始化队列
printf("v%d ", i); // 首先访问顶点vi
visited[i] = 1; // 标记vi已访问过
EnQueue(&Q, i); // 将已访问过的顶点入队
int j, k;
EdgeNode *p;
while (!QueueEmpty(&Q)) {
k = DeQueue(&Q);
p = G[k].link;
while (p != NULL) {
j = p->vex_id;
if (!visited[j]) {
printf("v%d ", j);
visited[j] = 1;
EnQueue(&Q, j);
}
p = p->next;
}
}
}
四、图的算法
1、生成树
关于生成树的概念上文中已有叙述,那么对于给定的连通图,如何求得其生成树呢?
设图G=(V,E)是一个具有n个顶点的连通图,从G的任一顶点出发,做一次深度优先搜索或广度优先搜索,就可以将图中的所有n个顶点都访问到。显然,在这两种遍历搜索方法中,从一个已访问过的顶点vi搜索到另一个未访问过的顶点vj必定经过G中的一条边(vi,vj),而这两种搜索方法对图中的n个顶点都仅访问一次,因此除初始出发点外,对其余n-1个顶点的访问一共要经过G中的n-1条边,这n-1条边将G中n个顶点连接成包含G中所有顶点的极小连通子图,可见它是G的一棵生成树。
通常,我们把由深度优先搜索所得的生成树称之为深度优先生成树,简称DFS生成树;而由广度优先搜索所得的生成树称之为广度优先生成树,简称BFS生成树。
2、最小生成树
最小生成树以无向图为研究对象
Prim算法
假设G=(V,E)是一个具有n个顶点的连通网,T=(U,TE)是G的最小生成树,其中U是T的顶点集,TE是T的边集,U和TE的初值均为空。
算法开始时,首先从V中任取一个顶点(假设取v1)并入U中,此时U={v1}。然后只要U是V的真子集,就从那些一个端点已在T中,另一个端点仍在T外的所有边中,找一条权值最小的边,假定为(vi,vj),其中vi∈U,vj∈V-U,并把该边(vi,vj)和顶点vj并入T的边集TE和顶点集U中,如此进行下去,每次往生成树中并入一个顶点和一条边,直到把所有n个顶点都并入生成树T的顶点集中,此时U=V,TE中包含n-1条边,T就是最后得到的最小生成树。
在算法中设一个辅助数组minedge[MaxVertexNum],记录从U到V-U权值最小的边,数组元素包含两个域,ver存储依附在U中的顶点,lowconst存储该边上的权值,对每个顶点v∈V-U在辅助数组中都存在一个分量minedge[v]。
// 权值类型
typedef int VRType;
// 辅助数组
typedef struct {
VertexType vex;
VRType lowcost;
} minedge[MaxVertexNum];
//@param G 待求解的图
// u 最小生成树的初始顶点(树根)
// n 图的顶点总数
void Prim(MGraph G, VertexType u, int n)
{
int k = VtxNum(u); // 顶点u的下标
for (int i = 0; i < n; i++) {
minedge[i].vex = u;
minedge[i].lowcost = G.arcs[k][i];
}
minedge[k].lowcost = 0; // 代价为0表示并入U中
for (int i = 1; i < n; i++) {
k = min(minedge[i]); // 1<=i<=n-1,找满足条件的最小边(u,k),u∈U,k∈V-U
printf("(%d,%d) ", minedge[k].ver, G.vexs[k]);
minedge[k].lowcost = 0; // 第k个顶点并入U中
// 由于U中新并入了顶点,更新权值最小的边
for (int j = 0; j < n; j++) {
if (G.arcs[k][j] < minedge[i].lowcost) {
minedge[j].ver = G.vexs[k]
minedge[j].lowcost = G.arcs[k][j];
}
}
}
}
Kruskal算法
假设G=(V,E)是一个具有n个顶点的连通网,T=(U,TE)是G的最小生成树,U的初值等于V,即包含G中的全部顶点。T的初始状态是只含有n个顶点的而无边的森林T=(V,Φ)。
该算法的基本思想是:按权值从小到大依次选取图G中的边,若选取的边使生成树T不形成回路,则把它并入TE中,保留作为T的一条边;若选取的边使生成树T形成回路,则将其舍弃,如此进行下去直到TE中包含n-1条边位置,此时的T即为最小生成树。
3、最短路径
Dijkstra算法
最短路径问题研究有向帯权图,比如两地是否有路径相通,哪一条最近,哪一条会费最少等等。
在上面的有向图中,假定以v1为源点,到其他各顶点的最短路径如下表所示:
源点 | 最短路径 | 终点 | 路径长度 |
---|---|---|---|
v1 | v1,v3,v2 | v2 | 5 |
v1 | v1,v3 | v3 | 3 |
v1 | v1,v3,v2,v4 | v4 | 10 |
v1 | v1,v3,v5 | v5 | 18 |
设有向图G=(V,E),其中V={1,2,...,n},cost是表示G的邻接矩阵,cost[i][j]表示有向边<i,j>的权。若不存在有向边<i,j>,则cost[i][j]为无穷大。设S是一个集合,每个元素表示一个顶点,从源点到这些顶点的最短距离已经求出。设v1为源点,集合S的初态只包含v1。数组dist记录从源点到其他各顶点当前的最短距离,初始值为dist[i]=cost[v1][i],i=2,...,n。
从V-S中选出一个顶点w,使dist[w]最小。从源点到达w只通过S中的顶点,把w加入集合S中并调整dist中记录的从源点到V-S中每个顶点的距离,即从原来的dist[v]和dist[w]+cost[w][v]中选择较小的作为新的dist[v]。直到S中包含V中其余顶点的最短路径。
//@param G 待求解的图
// v1 源点
// n 图的顶点总数
void Dijkstra(MGraph G, int v1, int n)
{
int inf = 32767 // 表示无穷大
int mind;
// 源点到其余各顶点的最短路径是否已求得
bool S[MaxVertexNum];
// 源点到其余各顶点最短路径初始值
int dist[MaxVertexNum];
// 保存最短路径上顶点的前趋顶点
int path[MaxVertexNum];
for (int v = 1; v < n; v++) {
S[v] = false;
dist[v] = G.arcs[v1][v];
}
// S初始只有源点
dist[v1] = 0;
S[v1] = true;
for (int i = 2; i < n; i++) {
mind = inf;
// 源点到其余各顶点的最短路径
for (int w = 1; w < n; w++) {
if (!S[w] && dist[w] < mind) {
v = w;
mind = dist[w];
}
}
// 标记为已求得
S[v] = true;
// 更新最短路径
for (int w = 1; w < n; w++) {
if (!S[w] && (dist[v] + G.arcs[v][w] < dist[w])) {
dist[w] = dist[v] + G.arcs[v][w];
path[w] = v;
}
}
}
}
4、拓扑排序
通常,在实现一项较大的工程时,经常会将该工程划分为若干个子工程,我们把这些子工程称为活动。在整个工程中,有些子工程没有先决条件,可以安排在任何时间开始;而有些子工程必须在其他相关子工程完成之后才能开始。
为了形象地反映出整个工程中各个子工程之间的先后关系,可用一个有向图来表示,图中的顶点代表活动(子工程),图中的有向边代表活动的先后关系,我们把这种有向无环图称为顶点活动网,简称AOV网。注意,AOV网中不应该有有向环(即回路),因为这意味着某项活动以自己为先决条件,这样的设计是有问题的!
我们看这样一个实际应用,计算机专业的同学需要修完一系列课程才能毕业,但是这些课程是有先后顺序的,比如“高等数学”是基础课,不需要先修其他课程;而“数据结构”则要先修完“离散数学”和“算法语言”才能学习,等等。这些课程的先后关系我们用下面的表格来表示:
课程编号 | 课程名称 | 先修课程 |
C1 | 计算机基础 | 无 |
C2 | 高等数学 | 无 |
C3 | 数据结构 | C4,C7 |
C4 | 算法语言 | C1 |
C5 | 操作系统 | C3,C6 |
C6 | 计算机原理 | C9 |
C7 | 离散数学 | C1,C2 |
C8 | 编译技术 | C3,C4 |
C9 | 大学物理 | C2 |
我们构建一个AOV网如下图:
对于一个有向无环图,若将G中所有顶点排成一个线性序列,使得图中任意一对顶点u和v,若<u,v>∈E(G),则u在线性序列中出现在v之前,这样的线性序列称为拓扑序列。也就是说,在AOV网中,若不存在回路(即环),所有活动可排成一个线性序列,使得每个活动的所有前趋活动都排在该活动的前面,此序列就是拓扑序列。由AOV网构造拓扑序列的过程称为拓扑排序。
对给定的AOV网,应首先判定是否存在环?检测的方法是对有向图构造其顶点的拓扑序列,如果网中所有顶点都在它的拓扑序列中,则该AOV网必定不存在环。
AOV网的拓扑序列不是唯一的,那么拓扑排序的基本思想描述如下:
- 在有向图中选一个没有前趋(入度为0)的顶点,且输出之
- 从有向图中删除该顶点及其与该顶点有关的所有边
- 重复执行上述两个步骤,直到全部顶点都已输出或图中的顶点没有前趋(入度为0)为止
- 输出剩余的无前趋顶点
以C1作为第一个输出的拓扑序列为:C1,C4,C2,C7,C9,C3,C6,C5,C8
以C2作为第一个输出的拓扑序列为:C2,C1,C9,C7,C4,C3,C6,C8,C5
在拓扑排序算法中,需要设置一个数组indegree保存所有顶点的入度值,再设一个栈来保存所有入度为0的顶点,以后每次选入度为0的顶点时,只需要出栈即可。算法中删除顶点及其所有边只需要检查顶点vi的出边表,把每条出边<vi,vj>的终点vj所对应的入度indegree[j]减1,如果vj的入度为0,则将j入栈。
#define VNUMS 20
//@param G 要遍历的图
// n 顶点总数
void TopuSort(VNode G[], int n)
{
int i, j, m = 0;
EdgeNode *p;
// 顶点入度数组
int indegree[VNUMS] = {0};
// 遍历入边表,统计每个顶点的入度值
for (i = 0; i < n; i++) {
p = G[i].link;
while (p) {
indegree[p->vex_id]++;
p = p->next;
}
}
SeqStack S;
InitStack(&S);
// 把入度为0的顶点入栈
for (i = 0; i < n; i++)
if (indegree[i] == 0) Push(&S, i);
while (!StackEmpty(&S)) {
i = Pop(&S); // 遍历栈顶入度为0的顶点
printf("v%d ", i);
m++;
p = G[i].link;
while (p) {
j = p->vex_id;
indegree[j]--; // 终点的入度减1,相当于删除边
if (indegree[j] == 0)
Push(&S, j);
p = p->next;
}
}
// 输出的顶点数小于图中的顶点数,说明图有回路,排序失败
if (m < n)
printf("The Graph has a cycle!");
}