4.1 图
4.1.1 图的定义
图(Graph)是一种用于描述多对多关系的数据结构。它是由顶点的有穷非空集合和顶点之间边的集合组成,通常表示为G(V,E)。(顶点(Vertex)表示实体、对象,边(Edge)表示结点之间的连接或关联关系)
*注:根据图论的定义,一个图必须至少包含一个顶点(节点),否则它将不被视为图。因此,一个有效的图不能是空图,必须至少包含一个顶点。
4.1.2 图的相关术语
- 无向图(Undirected Graphs):图中顶点之间的边没有方向。用圆括号表示“()”表示无向边。如(v,w)等同于(w,v)。
- 有向图(Directed Graphs):图中的所有边都有方向。用尖括号“<>”表示有向边。如<v,w>。
- 简单图(Simple Graph):没有重复的边,也没有自回路边的图。(我们考虑的都是简单图)
- 邻接点(Adjacent Vertices):两点之间有边直接相连,那么这两点互为邻接点。
- 无向完全图(Undirected Complete Graph):任意两顶点之间都有一条边相连。在含有n个顶点的无向完全图中,共有n(n-1)/2条边。
- 有向完全图(Directed Complete Graph):任意两顶点之间都有方向互为相反的两条弧相连接。在含有n个顶点的有向完全图中,共有n(n-1)条弧。
- 权(Weight):边附带的数值信息。
- 网络(Network):带权重的图。
- 稠密图(Dense Graph):边数接近完全图(点多,边也多);
- 稀疏图(Sparse Graph):相对来说,边数很少的图(点多,边却少);
4.1.3 图的存储
从图中的定义可知,一个图的信息包括两部分,即图中顶点的信息和描述顶点之间的关系---边的信息。下面是两种常用的图的存储结构。
1.邻接矩阵
所谓邻接矩阵(Adjacency Matrix),就是用矩阵表示图中各顶点之间的邻接关系和权值。
*注:0都表示该边不存在。
它有以下特点:
- 对无向图来说,它的邻接矩阵一定是对称矩阵;
- 对有向图来说,它的邻接矩阵一般都不是对称的;
- 主对角线上的元素必定为0,因为不存在自回路顶点。
优点:
- 查询快速:可以在常数时间内查询两个节点之间是否存在边或边的权重 ;
直观易懂:直观地展示了图中各个节点之间的连接关系,便于理解和可视化;
内存效率(对于稠密图):在稠密图(节点较多、边相对较多)的情况下,邻接矩阵可以有效地利用连续的内存空间存储节点关系,相对节约存储空间。
缺点:存储空间开销(对于稀疏图):在稀疏图(节点较多、边相对较少)的情况下,邻接矩阵的存储空间会产生大量冗余,浪费存储资源。
邻接矩阵适用于小规模或稠密图,并且在节点关系查询频繁、边的权重计算常用的场景下具有优势。
邻接矩阵的创建:
#include<stdio.h>
#define MAX 100
int AjMatrix[MAX][MAX];
void initializeMatrix()
{
for(int i = 0;i<MAX;i++)
for(int j = 0;j<MAX;j++)
AjMatrix[i][j] = 0;
}
void addEdge(int start,int end)//两个点
{
if(start>=0&&start<MAX&&end>=0&&end<MAX)
AjMatrix[start][end] = 1;
}
int main()
{
initializeMatrix();// 初始化邻接矩阵
int num;
printf("顶点个数:");
scanf("%d",&num);
addEdge(0,1);
addEdge(1,0);
addEdge(1,2);
addEdge(1,3);
addEdge(2,1);
addEdge(3,1);
printf("邻接矩阵为:\n");
for(int i=0;i<num;i++)
{
for(int j=0;j<num;j++)
printf("%d ",AjMatrix[i][j]);
printf("\n");
}
}
2.邻接表
邻接表(Adjacency Lists)是图的一种顺序存储结构与链式存储结构结合的存储方式。它主要通过链表的形式来存储每个节点的相邻节点信息。
优点:
- 节约存储空间:对于稀疏图(边数相对较少)来说,只需存储实际存在的边,省去了大量不必要的空间。
- 快速插入和删除边:由于使用链表存储节点之间的关系,插入和删除边的操作非常高效。只需要修改链表的指针,时间复杂度为O(1),而邻接矩阵需要修改矩阵元素,时间复杂度为O(1)。
缺点:
- 查找任意两个节点间是否存在边的操作相对较慢:在邻接表中查找任意两个节点之间是否有边需要遍历相应的链表,时间复杂度取决于节点度数的平均值。
- 无法直接获取两个节点间边的权重信息:对于加权图,邻接表只能存储边的存在与否,如果需要获取边的权重信息,还需要额外的数据结构来存储权重。
- 访问节点的入度信息较慢:邻接表用于有向图时,要获取某个节点的入度信息,需要遍历整个邻接表才能统计入度。
它适用于更小的稠密图、动态图、节点关联信息较多、遍历相邻节点频繁的情况。
邻接表的创建:
#include<stdio.h>
#include<stdlib.h>
// 邻接表中的节点
struct Node
{
int v;
struct Node* next;
};
//图
struct Graph
{
int numNode;// 图中顶点的数量
struct Node** adList;//存储邻接表动态数组
};
//创建图
struct Graph* CreateGraph(int value)//value为给的顶点数量
{
struct Graph* graph = (struct Graph*)malloc(sizeof(Graph));
graph->numNode = value;
// 创建邻接表数组
graph->adList = (struct Node**)malloc(sizeof(struct Node*)*value);
for(int i=0;i<value;i++)
graph->adList[i]=NULL;//初始化数组为空
return graph;
}
void addEdge(struct Graph* graph,int scr,int dest)
{
// 创建新节点并将其添加到链表中
struct Node* newNode = (struct Node*)malloc(sizeof(Node));
newNode->v = dest;
//将新节点的next指针指向顶点的邻接链表中的第一个节点。
//通过这个指针,我们可以遍历该链表,访问与源顶点相连的所有节点。
newNode->next = graph->adList[scr];
//将新节点添加到源顶点的邻接链表的头部,将其作为新的第一个节点。
graph->adList[scr] = newNode;
newNode = (struct Node*)malloc(sizeof(Node));
newNode->v = scr;
newNode->next = graph->adList[dest];
graph->adList[dest] = newNode;
//可理解为无向图的双向性
}
void printAjList(struct Graph* graph)
{
for(int i=0;i<graph->numNode;i++)
{
printf("顶点%d的邻居节点:",i);
struct Node* x = graph->adList[i];
while(x)
{
printf("%d ",x->v);
x = x->next;
}
printf("\n");
}
}
int main()
{
struct Graph* graph = CreateGraph(5);
addEdge(graph, 0, 1);
addEdge(graph, 0, 4);
addEdge(graph, 1, 2);
addEdge(graph, 1, 3);
addEdge(graph, 1, 4);
addEdge(graph, 2, 3);
addEdge(graph, 3, 4);
printAjList(graph);
}
上述写法为链式前向星(Linked List Representation of Graph)。
链式前向星的实现步骤:
-
定义一个结构体作为边的节点,通常包含两个字段:
dest
:表示边的目标节点。next
:指向下一条与源顶点相连的边的节点。
-
定义一个数组
adjList
,数组的长度为顶点的数量,每个元素是一个指向边节点的指针。这个数组用于存储每个顶点的邻接链表的头指针。 -
对于每条边 (src, dest),进行如下操作:
- 创建一个新的边节点,并为其分配内存空间。
- 将目标顶点的值赋给新节点的
dest
字段。 - 将新节点的
next
指针指向与源顶点相连的边的链表的头节点。 - 更新源顶点的邻接链表的头指针,使其指向新节点。
这样,通过链式前向星,可以在 O(1) 的时间复杂度内获取到每个顶点的邻居节点,便于进行图的遍历和其他操作。
当然,还有另外一种方法:
#include<stdio.h>
#include<stdlib.h>
// 边的节点结构体
struct Node
{
int data;
struct Node* next;
};
// 邻接表的结构体
struct AdjList
{
struct Node* head;
};
struct AdjList* CreateGraph(int num)
{
struct AdjList* list = (struct AdjList*)malloc(sizeof(AdjList)*num);
for(int i=0;i<num;i++)
{
list[i].head = NULL;// 初始化邻接表为空
}
return list;
}
void addEdge(struct AdjList* list,int src,int dest)
{
struct Node* newNode = (struct Node*)malloc(sizeof(Node));
newNode->data = dest;
// 将新节点插入到源顶点的邻接链表头部
newNode->next = list[src].head;
list[src].head = newNode;
newNode = (struct Node*)malloc(sizeof(Node));
newNode->data = src;
newNode->next = list[dest].head;
list[dest].head = newNode;//无向图
}
void showGraph(struct AdjList* list,int num)
{
for(int i=0;i<num;++i)
{
struct Node* newNode = list[i].head;
printf("顶点%d的邻居结点有:",i);
while(newNode)
{
printf("%d ",newNode->data);
newNode = newNode->next;
}
printf("\n");
}
}
int main()
{
int x;
printf("请输入图中结点的数量:");
scanf("%d",&x);
struct AdjList* list = CreateGraph(x);
addEdge(list, 0, 1);
addEdge(list, 0, 4);
addEdge(list, 1, 2);
addEdge(list, 1, 3);
addEdge(list, 1, 4);
addEdge(list, 2, 3);
addEdge(list, 3, 4);
showGraph(list,x);
}
4.1.4 图的遍历
"图的遍历"是指从图中的任意一顶点出发,对图中的所有顶点访问一次且只访问一次的次序序列。
1.DFS
深度优先搜索(Depth First Search,DFS),它类似于树的先序遍历,是树的先序遍历的推广。它通过尽可能深地访问图中的顶点,直到无法继续深入为止,然后回溯并选择下一个未访问的邻接顶点进行探索。 很明显,这是一个递归过程。
它的伪代码如下:
void DFS(Vertex V)
{
visited[V] = true;
for(V的每个邻接点W)
if(!visited[W])
DFS(W);
}
2.BFS
广度优先搜索(Breadth First Search,BFS)类似于树的按层次遍历的过程。它从起始顶点开始,逐层地访问与当前顶点相邻的未访问顶点,直到遍历完所有可达顶点为止。 一般采用队列实现。
它的伪代码如下:
void BFS(Vertex V)
{
visited[V] = true;
Enqueue(V,Q);
while(!IsEmpty(Q))
{
V = Dequeue(Q);
for(V的每个邻接点W)
if(!visited[W])
{
visited[w] = true;
Enqueue(W,Q);
}
}
}
4.2 最短路径
最短路径(Shortest Path),简单的说就是求两个不同顶点的所有路径中,边的权值之和最短的那一条路径。而这条路径上的第一个顶点为源点(Source),最后一个为终点(Destination)。
根据顶点是否固定又分为两种,一种是单源的,一种是多源的。
1. 单源最短路径
单源最短路径是从某固定源点出发到其他各个顶点的最短路径问题。
(1)无权图的单源最短路径算法(BFS) 伪代码如下:
void Unweighted(Vertex S)
{
Enqueue(S,Q);
while(!IsEmpty(Q))
{
V = Dequeue(Q);
for(V的每个邻接点W)
if(dist[W]==-1)
{
dist[W] = dist[v]+1;
path[W] = V;
Enqueue(W,Q);
}
}
}
(2)有权图的单源最短路径算法(Dijkstra算法)注* 权值不能为负数每次从未收录的顶点中选一个最小的权值收入,这点很体现贪心算法的基本思想。
伪代码如下:
void Dijkstra(Vertex S)
{
while(1)
{
V = 未收录顶点中最小的权值;
if(这样的V不存在)
break;
collected[V] = true;
for(V的每个邻接点W)
if(collected[W]==false)
{
if(dist[V]+E<v,w> < dist[W])
{
dist[W] = dist[V] + E<v,w>;
path[W] = V;
}
}
}
}
2. 多源最短路径
多源最短路径是求任意两个顶点之间的最短路径。
一般有两种方法:
(1)直接调用单源最短路算法|V|遍。(适用稀疏图)
(2)Floyd算法。(适用稠密图)
注* 允许带有负权值的边,但不允许有包含负权值的边组成的回路。
void Floyd()
{
for(i = 0;i<N;i++)
for(j = 0;j<N;j++)
{
D[i][j] = G[i][j];//初始化为邻接矩阵
path[i][j] = -1;
}
for(k = 0;k<N;k++)
for(i = 0;i<N;i++)
for(j = 0;j<N;j++)
if(D[i][k]+D[k][j]<D[i][j])
{
D[i][j] = D[i][k]+D[k][j];
path[i][j] = k;
}
}
4.3 最小生成树
最小生成树(Minimum Spanning Tree,简称MST),拆开理解,树,无回路,|V|个顶点一定有|V|-1条边;生成树,包括所有顶点,向生成树任意加一条边都构成回路;最小,边的权重之和是最小的。因此,简单地说是指在一个连通无向图中生成一棵包含所有顶点且权值和最小的树。
常用的解决最小生成树问题的算法有Prim算法和Kruskal算法。
在介绍这两种算法之前,先了解一下贪心算法。
贪心算法(Greedy Algorithm)是一种在每个阶段选择当前最优解的策略来求解问题的算法。它通常通过局部最优选择来达到全局最优解。(每一步都是最好的)
1. Prim算法
从一个根结点出发(适用于稠密图)伪代码如下:
void Prim()
{
MST = {s};
while(1)
{
V = 未收录顶点中离树的距离最小的顶点;
if(这样的V不存在)
break;
dist[V] = 0;//将V收录MST中,距离更新为1
for(V的每个邻接点W)
if(dist[W]!=0)
{
if(E<v,w> < dist[W])
{
dist[W] = E<v,w>;
parent[W] = V;
}
}
}
if(MST中收录的顶点不足|V|个)
Error("生成树不存在");
}
2. Kruskal算法
将多棵树合为一颗(适用于稀疏图)
此算法把每一个顶点都看成一棵树,找最小权值的边收录,并要求不会与已收录的边构成回路。伪代码如下:
void Kruskal(Graph G)
{
MST = {};//收边
while(MST中不到|V|-1条边&&E中还有边未收录)
{
从E中取一条权重最小的边E<v,w>;//借助最小堆
将E<v,w>从E中删除;
if(E<v,w>不在MST中构成回路)//并查集
将E<v,w>加入MST;
else
无视E<v,w>;
}
if(MST中不到|V|-1条边)
Error("生成树不存在");
}
4.4 拓扑排序
拓扑序:如果图中从V到W有一条有向路径,则V一定排在W之前。
所谓拓扑排序,就是对一个有向图构造拓扑序列的过程。
若不是无环图,拓扑排序不存在,因为如果有环路,它们之间的先后顺序是不确定的。
1. AOV
在一个表示工程的有向图中,用顶点表示活动,用弧表示活动之间的优先关系,这样的图叫AOV网(Activity On Vertex Network)
有向无环图(Directed Acyclic Graph,DAG),又称“流程图”。
(AOV网络如果有合理的(不存在环的)拓扑序)
每次输出没有前驱顶点(入度为0)的顶点,如下图所示:
伪代码如下:
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("图中存在回路");
}
2. AOE(关键路径)
所谓关键路径,是由绝对不允许延误的活动组成的。
无环有向图中,边表示活动、项目的工序,顶点表示活动、工序结束,这样的图叫AOE(Activity On Edge),一般用于安排项目工序。
一般可以这样表示:
持续时间:完成这到工序所需要的时间。
机动时间:在一个任务的最早开始时间和最晚完成时间之间的可延迟时间。
最早完成时间:所有项目最早的完成时间。
最晚完成时间:所有项目最晚的完成时间。
机动时间 = j 结点的最晚完成时间 - i 结点的最早完成时间 - 持续时间。
例下图:整个工程的工期为18天,有三组有机动时间的,其余组若推迟了,整个工期就都要延长。