图结构
图也是由多个节点连接而成的,但一个节点可同时连接多个其他节点,多个节点也可以同时指向一个节点。多对多的关系。
基本概念
图一般由两个集合共同组成,一个是非空但有限的顶点集合V,另一个是描述顶点之间连接关系的边集合E。一个图正是由这些顶点(节点)和对应的边组成。图表示为:G=(V,E)
一个图可表示为,一种为无向图(仅仅是连线,不指明方向),有向图(表明了方向)
集合V={A,B,C,D},集合E={(A,B),(B,C),(C,D),(D,A),(C,A)},
每个节点的度就是与其连接的边数,每条边可以包含权值,也可以不包含。
集合V={A,B,C,D},集合E={<A,B>,<B,C>,<C,D>,<D,A>,<C,A>}
如果无向图的一条边(A,B),A、B互为邻接点;有向图的一条边<A,B>,起点A邻接到终点B,有向图的每个节点分为入度和出度,其中入度为与顶点相连且指向该顶点的边的个数,出度则是从该顶点指向邻接顶点的边的个数
只要图中不出现回路边或是重边,则称其为简单图
在无向图中,任意两个顶点都有一条边相连,该图为无向完全图
在有向图中,任意两个顶点之间都有由方向互为相反的两条边连接,该图为有向完全图
任意两点都是连通的,称这个图为连通图。对有向图,如果图中任意顶点A和B,即从A到B的路径,从B到A的路径,该有向图为强连通图。
对图G=(V,E)和G’=(V’,E’),若满足V’是V的子集,并且E’是E的子集,则称G是G‘的子图。
无向图的极大连通子图称为连通分量,有向图的极大连通子图称为强连通分量。连通子图是原图的子图,并且子图也是连通图,同时应该具有最大的顶点数,即再加入原图的其他顶点,导致子图的不连通,拥有极大顶点数的同时也要包含依附于这点顶点所有边才行。
图1和图3为连通分量,图2不是连通分量
存储结构
邻接矩阵
邻接矩阵为矩阵表示图中各顶点之间的邻接关系和权值。
对不带权值的图
G
i
j
=
{
1
,
无向图的
(
v
i
,
v
j
)
或有向图的
<
v
i
,
v
j
>
是图中的边
0
,
无向图的
(
v
i
,
v
j
)
或有向图的
<
v
i
,
v
j
>
不是图中的边
G_{ij}=\left\{ \begin{array}{c} 1,\text{无向图的}\left( v_i,v_j \right) \text{或有向图的}<v_i,v_j>\text{是图中的边}\\ 0,\text{无向图的}\left( v_i,v_j \right) \text{或有向图的}<v_i,v_j>\text{不是图中的边}\\ \end{array} \right.
Gij={1,无向图的(vi,vj)或有向图的<vi,vj>是图中的边0,无向图的(vi,vj)或有向图的<vi,vj>不是图中的边
对带权值得图
G
i
j
=
{
w
i
j
,
无向图的
(
v
i
,
v
j
)
或有向图的
<
v
i
,
v
j
>
是图中的边
0
或
∞
,
无向图的
(
v
i
,
v
j
)
或有向图的
<
v
i
,
v
j
>
不是图中的边
G_{ij}=\left\{ \begin{array}{c} w_ij,\text{无向图的}\left( v_i,v_j \right) \text{或有向图的}<v_i,v_j>\text{是图中的边}\\ 0或\infty ,\text{无向图的}\left( v_i,v_j \right) \text{或有向图的}<v_i,v_j>\text{不是图中的边}\\ \end{array} \right.
Gij={wij,无向图的(vi,vj)或有向图的<vi,vj>是图中的边0或∞,无向图的(vi,vj)或有向图的<vi,vj>不是图中的边
由于没有自回路顶点,主对角线上得元素全是0,顶点之间是相互连接。
总结
- 无向图的邻接矩阵必定是一个对称矩阵。
- 无向图,邻接矩阵的第i行非0(或非 ∞ \infty ∞)的个数就是第i个顶点的度
- 有向图,邻接矩阵的第i行非0(或非 ∞ \infty ∞)的个数就是第i个顶点的出度(横着),入度(竖的)
代码实现有向图
构建二维数组存放顶点
#define MaxVertex 5 // 最大顶点数
typedef char E; // 顶点存放的数据类型
typedef struct MatrixGraph
{
int vertexCount, edgeCount; // 顶点和边
int matrix[MaxVertex][MaxVertex]; // 邻接矩阵
E data[MaxVertex]; // 各个顶点对应的数据
} * Graph;
创建图(初始化)、加入顶点函数和加入边函数。若创建无向图,得到其邻接矩阵,只需在加入边时,将[a][b]和[b][a]都为1
// 创建图
Graph create()
{
Graph graph = malloc(sizeof(struct MatrixGraph));
graph->vertexCount = graph->edgeCount = 0; // 初始化顶点和边
for(int i = 0 ; i < MaxVertex; ++i) // 对二维矩阵(矩阵)初始化
{
for(int j = 0; j < MaxVertex; ++j)
{
graph->matrix[i][j] = 0;
}
}
return graph;
}
// 加入顶点
void addVertex(Graph graph, E element)
{
if(graph->vertexCount >= MaxVertex)
{ // 顶点数量大于最大
return;
}
else
{ // 依次放入顶点
graph->data[graph->vertexCount++] = element;
}
}
// 加入边
void addEdge(Graph graph, int a, int b)
{
if(graph->matrix[a][b] == 0) // A->B边不存在时
{
graph->matrix[a][b] = 1; // 创建边,有向图
// graph->matrix[b][a] = 1; // 无向图需要加入这
graph->edgeCount++; // 边的数量加1
}
}
将邻接图打印输出
// 将图的邻接矩阵打印
void printGraph(Graph graph)
{
for(int i = -1; i < graph->vertexCount; ++i)
{
for(int j = -1; j < graph->vertexCount; ++j)
{
if(j == -1)
{
printf("%c", 'A'+i);
}
else if(i == -1)
{
printf("%3c",'A'+j); // 占位为3
}
else
{
printf("%3d",graph->matrix[i][j]);
}
}
putchar('\n');
}
}
测试
//创建有向图(顶点和边)
Graph graph = create();
for(int c = 'A'; c <= 'D';++c)
{
addVertex(graph,(char) c);
}
addEdge(graph,0,1); //A->B
addEdge(graph,1,2); //B->C
addEdge(graph,2,3); //C->D
addEdge(graph,3,0); //D->A
addEdge(graph,2,0); //C->A
printGraph(graph);
邻接表
通过数组存放图的信息,在容量上局限。比如当遇到稀疏图时,边数比较少时,大量的位置实际上为0.造成了浪费。
因此考虑链式结构。对图中的每个顶点,建立一个数组,存放一个头结点,将与其邻接的顶点,通过一个链表记录。
代码实现有向图的邻接表
定义
#define MaxVertex 5 // 最大顶点数
typedef char E; // 顶点存放的数据类型
typedef struct Node // 普通节点记录邻接顶点信息
{
int nextVertex;
struct Node * next;
} * Node;
struct HeadNode // 头结点记录元素
{
E element;
struct Node * next;
};
// 定义邻接表
typedef struct AdjacencyGraph
{
int vertexCount, edgeCount; // 顶点和边
int matrix[MaxVertex][MaxVertex]; // 邻接矩阵
struct HeadNode vertex[MaxVertex]; // 头结点
} * Graph;
创建邻接表,加入顶点和边,并打印邻接表函数
// 创建邻接表
Graph create()
{
Graph graph = malloc(sizeof(struct AdjacencyGraph));
graph->vertexCount = graph->edgeCount = 0;
return graph;
}
// 加入顶点
void addVertex(Graph graph, E element)
{
graph->vertex[graph->vertexCount].element = element;
graph->vertex[graph->vertexCount].next = NULL;
graph->vertexCount++;
}
// 加入边
void addEdge(Graph graph, int a, int b)
{
// 拿到头结点的next
Node node = graph->vertex[a].next;
Node newNode = malloc(sizeof(struct Node));
newNode->next = NULL;
newNode->nextVertex = b; // 新创建的节点指向b
if(!node) // 如果头结点下一个没有,则直接连上去
{
graph->vertex[a].next = newNode;
}
else // 否则说明当前顶点已经连接至少一个其他顶点,
{
do
{
if(node->nextVertex == b)
{
return free(newNode); // 已经连接对应的顶点
}
if(node->next)
{
node = node->next; // 向后遍历
}
else
{
break; // 没有下个,最后一个节点,结束
}
} while (1);
node->next = newNode;
}
graph->edgeCount++;
}
// 打印邻接表
void printGraph(Graph graph)
{
for(int i = 0 ; i < graph->vertexCount; ++i)
{
printf("%d | %c",i,graph->vertex[i].element);
Node node = graph->vertex[i].next;
while (node)
{
printf(" -> %d", node->nextVertex);
node = node->next;
}
putchar('\n');
}
}
但存在的问题是:只能快速得到某个顶点指向了哪些顶点,只能计算到顶点的出度,但无法快速计算顶点的入度。因此建立一个逆邻接表,以表示所有指向当前顶点的顶点列表
图练习题
- 在n个顶点中有向图,若所有顶点的出度之和为s,则所有顶点的入度之和为s。
- 一个具有n个顶点的无向完全图中,所含的边数为
任意两个顶点都有一条边相连,那每个顶点都会有n-1条与其连接的边,总数为n*(n-1),但在无向图中,边数为 n × ( n − 1 ) 2 \frac{n\times \left( n-1 \right)}{2} 2n×(n−1) - 把n个顶点连接为一个连通图,则至少需要几条边?n-1
只需要找一个每个节点都与其相连的,连成一根直线(树) - 对一个具有n个顶点和e条边的无向图,在其对应的邻接表中,所含边结点有多少
对无向图。结点个数等于边数的两倍,对有向图,结点数等于边数
图的遍历
类似迷宫图。图的搜索从图的某一顶点出发,寻找图中对应顶点位置。
深度优先搜索(DFS)
类似前序遍历
一路向前,走到死胡同,倒回去走其他方向,不断重复寻找,直到目标
使用邻接表表示图,邻接表直接保存相邻顶点,在遍历相邻顶点会更快(O(V+E))阶,而使用邻接矩阵,需要遍历完整二维数组(O(V^2))阶
/*深度优先搜索算法
*@param graph 图
*@param startVertex 起点顶点下标
*@param targetVertex 目标顶点下标
*@param visited 已经达到过顶点数组
*/
void dfs(Graph graph, int startVertex, int targetVertex, int * visited)
{
printf("%c -> ", graph->vertex[startVertex].element); // 打印当前顶点值
visited[startVertex] = 1; // 标记顶点
Node node = graph->vertex[startVertex].next; // 先取第一个节点开始,遍历当前顶点所有的分支
while (node)
{
if(!visited[node->nextVertex]) // 排除已经访问过的顶点
{
dfs(graph, node->nextVertex, targetVertex,visited); //搜索其他顶点
}
node = node->next; //取下一个节点
}
}
测试
Graph graph = create();
for(int c = 'A'; c <= 'F';++c)
{
addVertex(graph,(char) c);
}
addEdge(graph,0,1); //A->B
addEdge(graph,1,2); //B->C
addEdge(graph,1,3); //B->D
addEdge(graph,1,4); //D->E
addEdge(graph,4,5); //E->F
int arr[graph->vertexCount];
for(int i = 0 ; i < graph->vertexCount; ++i)
{
arr[i] = 0; // 初始化
}
dfs(graph, 0 , 5, arr);
搜索结果返回
_Bool dfs(Graph graph, int startVertex, int targetVertex, int * visited)
{
printf("%c -> ", graph->vertex[startVertex].element); // 打印当前顶点值
visited[startVertex] = 1; // 标记顶点
Node node = graph->vertex[startVertex].next; // 先取第一个节点开始,遍历当前顶点所有的分支
if(startVertex == targetVertex)
{
return 1; // 当前顶点为寻找节点,直接返回
}
while (node)
{
if(!visited[node->nextVertex]) // 排除已经访问过的顶点
{
if(dfs(graph, node->nextVertex, targetVertex,visited))
{
return 1;
} //搜索其他顶点
}
node = node->next; //取下一个节点
}
return 0;
}
测试,找到顶点,返回1
Graph graph = create();
for(int c = 'A'; c <= 'F';++c)
{
addVertex(graph,(char) c);
}
addEdge(graph,0,1); //A->B
addEdge(graph,1,2); //B->C
addEdge(graph,1,3); //B->D
addEdge(graph,1,4); //D->E
addEdge(graph,4,5); //E->F
int arr[graph->vertexCount];
for(int i = 0 ; i < graph->vertexCount; ++i)
{
arr[i] = 0; // 初始化
}
printf("\n%d ",dfs(graph, 0 , 5, arr));
广度优先搜索(BFS)
类似层序遍历,优先将每一层进行遍历。在图的搜索中,先探索顶点的所有分支,依次看分支的所有分支
首先A到B,B有三条路,依次访问三个顶点
从第一个顶点H开始,同样方式,只有一个分支,找到C,继续记录,把C添加到队列:有G、K、C
回去看第二个顶点G,由于C已经看过,找到F和D,记录下K、C、F、D
K已经是死胡同,接着看C,将E记录进去:F、D、E,接着看D、F
最后剩E,看I和J顶点
广度优先遍历,尽可能扩展范围。使用队列
typedef int T; // 顶点下标作为元素
struct QueueNode
{
T element;
struct QueueNode * next;
};
typedef struct QueueNode * QNode;
struct Queue
{
QNode front, rear;
};
typedef struct Queue * LinkedQueue;
_Bool initQueue(LinkedQueue queue)
{
QNode node = malloc(sizeof(struct QueueNode));
if(node == NULL)
{
return 0;
}
else
{
queue->front = queue->rear = node;
}
return 1;
}
_Bool offerQueue(LinkedQueue queue, T element)
{
QNode node = malloc(sizeof(struct QueueNode));
if(node == NULL)
{
return 0;
}
else
{
node->element = element;
queue->rear->next = node;
queue->rear = node;
return 1;
}
}
_Bool isEmpty(LinkedQueue queue)
{
return queue->front == queue->rear;
}
T pollQueue(LinkedQueue queue)
{
T e = queue->front->next->element;
QNode node = queue->front->next;
queue->front->next = queue->front->next->next;
if(queue->rear == node)
{
queue->rear = queue->front;
}
free(node);
return e;
}
广度优先搜索
遍历
/*
* @param graph
* @param startVertex 起点顶点下标
* @param targetVertex 目标顶点下标
* @param visited 已经达到过顶点数组
* @param queue 辅助队列
*/
void bfs(Graph graph, int startVertex, int targetVertex, int * visited, LinkedQueue queue)
{
offerQueue(queue, startVertex); // 将起点放进队列
visited[startVertex] = 1; // 标记起始位置已走过
while (!isEmpty(queue))
{
int next = pollQueue(queue); // 队列中取出节点
printf("%c - >", graph->vertex[next].element); // 从队列中取出一个顶点进行打印
Node node = graph->vertex[next].next;
while (node)
{
if(!visited[node->nextVertex]) // 若没有走过,直接入队
{
offerQueue(queue, node->nextVertex);
visited[node->nextVertex] = 1; // 标记为1
}
node = node->next;
}
}
}
测试
Graph graph = create();
for(int c = 'A'; c <= 'F';++c)
{
addVertex(graph,(char) c);
}
addEdge(graph,0,1); //A->B
addEdge(graph,1,2); //B->C
addEdge(graph,1,3); //B->D
addEdge(graph,1,4); //D->E
addEdge(graph,4,5); //E->F
// addEdge(graph,3,6);
int arr[graph->vertexCount];
for(int i = 0 ; i < graph->vertexCount; ++i)
{
arr[i] = 0; // 初始化
}
struct Queue queue;
initQueue(&queue);
bfs(graph, 0 ,5,arr, &queue);
查找目标元素,找到即返回
_Bool bfs(Graph graph, int startVertex, int targetVertex, int * visited, LinkedQueue queue)
{
offerQueue(queue, startVertex); // 将起点放进队列
visited[startVertex] = 1; // 标记起始位置已走过
while (!isEmpty(queue))
{
int next = pollQueue(queue); // 队列中取出节点
printf("%c - >", graph->vertex[next].element); // 从队列中取出一个顶点进行打印
Node node = graph->vertex[next].next;
while (node)
{
if(node->nextVertex == targetVertex)
{
return 1;
}
if(!visited[node->nextVertex]) // 若没有走过,直接入队
{
offerQueue(queue, node->nextVertex);
visited[node->nextVertex] = 1; // 标记为1
}
node = node->next;
}
}
return 0;
}
测试
printf("\n%d ",bfs(graph, 0 ,3,arr, &queue));
图练习提
- 若一个图的边集为:{(A,B),(A,C),(B,D),(C,F),(D,E),(D,F)},对图进行深度优先搜索,得到的顶点序列可能是
圆括号是一个无向图,ACFDEB
- 若以一个图边集为{(A,B),(A,C),(B,D),(C,F),(D,E),(D,F)},对图进行广度优先搜索,顶点序列为
按照规则ACBFDE,先C则下一层先F - 对无向连通图,从顶点A开始对图进行广度优先遍历,顶点序列可能为
A,B,F,C,D,G,E
图应用
- 如果原图本身不连通,那么其连通分量(强连通分量)不止一个
- 如果原图本身连通,那么其连通分量(强连通分量)就是其本身
生成树
极小连通子图,边数的极小。要求原图的子图是连通的,具有最大的顶点数和最小的边数,再去掉任意一条边导致图的不连通。理解为极大连通子图尽可能去掉能去掉的边
针对极小连通子图,一般讨论无向图。(对于有向图,不存在极小强连通子图)
原图本身就是连通图
右侧两图与左边图相比,包含相同的顶点数量,但边数被去掉,若再去掉任意一条边,会导致不连通。极小连通图不唯一
无论去掉哪些边情况,到最后一定只留下N-1条边(其中N是顶点数),每个顶点有且仅有一条路径相连,包含原图全部N个顶点的极小连通子图,称其为生成树。边数和顶点数满足定义,不存在回路情况。
如果原图本身不连通,则出现多个连通分量,得到一片生成森林,森林中的树的数量就是其连通分量的数量
通过深度优先搜索和广度优先搜索可以得到生成树,且生成树是不唯一的。
最小生成树:如果给一个无向图的边加上权值,要求生成树边的权值总和最小,称这棵树为最小生成树(也不唯一)
普利姆算法(Prim)
从任意一个顶点开始,往尽可能小的方向延伸
省去权重大的边或者导致回路的边
克鲁斯卡尔算法(Kruskal)
主动去选择那些小的边,而不是像Prim算法那样被动扩展延伸。任意一条边都可以选择,并不是只有顶点旁边才可选择。也可能出现多棵树,但最后一定连成一棵树,最后形成一棵最小生成树。
比如直接找到最小边
最短路径问题
如地铁考虑换乘和成本问题
单源最短路径
从一个顶点出发,到其他顶点的最短路径
迪杰斯特拉算法
从A出发
dist记录A到其他顶点的最短路径,path记录最短路径所邻接的顶点,
如果需要求每一对顶点之间的最短距离,需要将所有顶点执行迪杰斯特拉算法,很麻烦。故采用弗洛伊德算法
弗洛伊德算法
对有向图,根据邻接矩阵
- 从1开始,一直到n(n是顶点数)的一个矩阵序列A1、A2、A3…从最初邻接矩阵开始往后推
- 每一轮更新非对角线、i行i列以外的元素(类似于求伴随矩阵),判断水平和垂直方向投影的两个元素之和是否比原值小,是则更新为新的值。
- 经历n轮后,得到最终最短的距离
得到所有顶点之间的最短距离,但没有记录哪个方向到达此顶点的。
编写程序弗洛伊德算法
定义无穷大小和邻接矩阵维数
#define INF 10000000
#define N 4
定义取最小函数
int min(int a, int b)
{
return a > b ? b : a;
}
弗洛伊德算法
void floyd(int matrix[N][N], int n)
{
for(int k = 0 ; k < n; ++k) // 执行K轮
{
for(int i = 0 ; i < n; ++i) // 行
{
for(int j = 0 ; j < n; ++j) // 列
{
matrix[i][j] = min(matrix[i][k] + matrix[k][j] , matrix[i][j]);
}
}
}
}
测试输出
int main()
{
int matrix[N][N] = {{0,1,INF,INF},
{4,0,INF,5},
{INF,2,0,INF},
{3,INF,7,0}};
floyd(matrix,N);
for(int i = 0 ; i < N; ++i)
{
for(int j = 0 ; j < N; ++j)
{
printf("%d ", matrix[i][j]);
}
putchar('\n');
}
}
拓扑排序
有向无环图,或流程图
拓扑排序将一个有向无环图进行排序得到有序的线性序列。只有完成前置任务在后续任务之前完成,该排序不唯一
利用队列完成,将入度为0的顶点,丢进队列中(丢进去之后更新一下图中其他顶点的入度)
所有入度数为0的顶点进入队列后,开始出队。出队时直接打印,开始排序。查看当顶点离开图后,会不会右其他顶点的入读变为0,如果有,将其他顶点入队。比如A从图中移除,B变成了入度为0的顶点,即将B丢进队列
检查一个有向图是否为有向无环图。如果顶点还没遍历完就队空,说明一定出现问题。
关键路径
在以上的有向无环图为每个任务添加一个权重,表示任务需要花费的时间,则后续任务就需要前置任务按时间完成后才能继续
计算事件最早完成事件(完成这个事件最快要多久)和事件最晚开始时间(事件不影响工期的情况下最晚可以多久开始),按拓扑排序的顺序进行。从起点A直接开始,最早和最晚时间都是0,按AOE图的顺序,计算任务B和C的最早和最晚时间。
最早完成时间为8天,活动最晚开始时间,从终点倒着往回看。
关键路径即为最早和最晚时间都一样的顶点,连成的路线。A->C->D->F。关键路径上的所有活动为关键活动,加快关键活动来缩短整个项目工期。