目录
一、基本概念
图由顶点集V和边集E组成,|V|表示顶点个数,|E|表示边的个数。
注意:图没有空图的说法,因为顶点集不可为空(边集可以空)
1.有向图
边E有向(此时边E也称为弧),此时边由顶点对<v,w>表示(有时也可以用(),但用<>一定是有向边,()则不一定),代表从顶点v指向顶点w。
2.无向图
边E无向,边(v,w)既可代表从v指向w,也可表示从w指向v(准确而言,在无向图中是没有“指向”这一说法的)
3.简单图、多重图
简单图:①无重复边;②不存在顶点到自身的边(无自旋)。
多重图:图中两个顶点之间边数大于1,且允许顶点通过一条边和自身关联。(数据结构不讨论介个,大概知道是啥就行)
4.完全图(简单完全图)
对无向图:|E|取值范围为0~n(n-1)/2,当n(n-1)/2条边都存在时就是完全图(通俗而言就是每对顶点之间都有一条边)
对有向图:|E|取值范围为0~n(n-1),当n(n-1)条边都存在时就是完全图(通俗而言就是每对顶点之间都有方向相反的两条边)
5.子图
若V`是V的子集,E`是E的子集,则G=(V`,E`)是G=(V,E)的子图(注意题目中是否有G=(V`,E`)这样一个关系式,如果仅有前半段没有这个就不是子图,因为V`和E`可能没关系)
6.连通、连通图和连通分量(针对无向图)
连通:存在从顶点v到顶点w的路径,则称v和w连通(注意只是存在路径,不要求必需存在v到w的边)
连通图:图中任意两个顶点连通
连通分量:无向图中的极大连通子图(包括尽可能多的顶点和边)
注意:如果一个图边数小于n-1,则这个图一定为非连通图
7.强连通图、强连通分量(针对有向图)
强连通:有向图中一对顶点v和w,有从v到w也有从w到v的路径,则这两个顶点强连通
强连通图:图中任意一对顶点强连通
强连通分量:有向图中的极大强连通子图
注意:强连通图至少有n个边(一个环路)
8.生成树、生成森林
生成树:包含图中全部顶点的一个极小连通子图,n个顶点的图的生成树共n-1条边
生成森林:连通分量的生成树构成了非连通图的生成森林
9.顶点的度、入度和出度
度——TD(v):有向图 = 入度+出度,在邻接矩阵中度为对应行和列中元素值之和;无向图 = 节点邻近边的个数,无向图中各顶点度之和为偶数
入度——ID(v):以顶点v为终点的有向边的数目(箭头数目)
出度——OD(v):以顶点v为起点的有向边的数目(箭尾数目)
10.边的权和网
权:人为赋予的物理意义缩化的数值
网:各条边都带权值的图
11.稠密图、稀疏图
相对概念,就是看边数多少,一般可以按 |E| = |V|log|V| 为界限
12.路径、路径长度和回路
路径:从一个节点到另一个节点的顶点序列(由顶点和相邻顶点序偶构成的边所形成的序列)
路径长度:路径中边的数目
回路(环):第一个顶点和最后一个顶点相同,有拓扑序列就没环
注意:对n个顶点的图有大于n-1条边则图必有环
13.简单路径、简单回路
简单路径:顶点不重复出现的路径
简单回路:除第一个节点和最后一个节点,其他节点均不重复
14.距离
从顶点u到顶点v的最短路径,若不存在则为∞
15.有向树
顶点入度为0,其他顶点入度均为1的有向图
二、存储结构
1.邻接矩阵
用一个二维矩阵存储图中边的信息,对不带权图当存在(vi , vj)的边时则A[i][j] = 0,存在则=1;对带权图,存在则 = 权值。
#define MaxVertexNum 100
typedef struct{
char Vex[MaxVertexNum];
int Edge[MaxVertexNum][MaxVertexNum]; //可使用bool或枚举类型,字节数更少
int vexnum, arcnum;
}MGraph;
//带权图中增加一个表无穷的宏定义:
#define INFINITY XXX //XXX代表该元素类型值的最大上限,如int就是0x7fffffff
无向图:第 i 个节点的度 = 第 i 行(或列)的非零元素个数(对称矩阵)
有向图:第 i 个节点的出度 = 第 i 行的非零元素个数
第 i 个节点的入度 = 第 i 列的非零元素个数
第 i 个节点的度 = 第 i 行非零元素个数 + 第 i 列非零元素个数
邻接矩阵求“度”相关问题时间复杂度为O(|V|)
邻接矩阵空间复杂度为O(|V|^2),适合存储稠密图
ps:无向图可采用压缩存储(只存储上三角/下三角),详见第三章
对邻接矩阵A(无权),则的元素
[i][j]等于由顶点 i 到顶点 j 的长度为 n 的路径的数目。
要点:①计算指定顶点的“度”,时间复杂度如何?
②如何找顶点相邻的边,时间复杂度如何?
③如何存储带权图?
④如何压缩存储无向图的邻接矩阵?
⑤矩阵A, 的元素
[i][j]等于由顶点 i 到顶点 j 的长度为 n 的路径的数目。
2.邻接表
利用顺序表+链表的方式进行存储,用顺序表存储顶点和链表头指针,在顺序表对应顶点后延伸链表,表示顶点 vi的边表(有向图则为以vi为尾的弧)。图的邻接表表示方法不唯一。
#define MaxVertxNum 100
//边(“弧”)表节点
typedef struct ArcNode{
int adjvex;
struct ArcNode *next;
//InfoType Info; //边权值
}ArcNode;
//“顶点”表节点
typedef struct VNode{
VertexType data;
ArcNode *first;
}VNode, AdjList[MaxVertexNum];
//存储的图
typedef struct{
AdjList vertices; //邻接表
int vexnum, arcnum; //顶点数和边数
}ALGraph;
对比:树的孩子表示法(见第五章)
无向图:边节点数量为2|E|,整体空间复杂度为O(|V|+2|E|) ,适合存储稀疏图
有向图:边节点数量为|E|,整体空间复杂度为O(|V|+|E|)
寻找邻边:扫描相应顶点的边表。
计算“度”:①无向图:扫描对应顶点的边表,边表元素个数 = 顶点度数
②有向图:出度——邻接表中节点个数
入度——遍历全部邻接表,看顶点在其他边表中出现次数(或综合逆邻接表)
度=入度+出度
对比邻接矩阵和邻接表:
邻接表 | 邻接矩阵 | |
空间复杂度 | 无向图O(|V|+2|E|);有向图O(|V|+|E|) | O(|V|^2) |
适合 | 稀疏图 | 稠密图 |
表示方法 | 不唯一 | 唯一 |
度的计算 | 有向图的度和入度不方便,其他都方便 | 必需遍历对应行/列 |
找邻边 | 找有向图入边不方便,其他都方便 | 必需遍历对应行/列 |
3.十字链表(有向图)
(在之前链表部分时有提及,理解就行,考试应该不考代码)弧节点和顶点节点结构如下
存储形式如下:
空间复杂度:O(|V|+|E|),寻找出边按照绿的顶点对应线路走,寻找入边按照橙色顶点对应线路走
4.邻接多重表(无向图)
类似十字链表法的一种存储方式,边和顶点节点如下:
具体存储结构如下:
空间复杂度:O(|V|+|E|),删除边或节点的操作也很方便
总结以上两种方法:理解和实现比较复杂,但相关的操作(找度找边等)都很方便。
5.图的基本操作
Adjacent(G, x, y):判断图是否存在边( x, y )
邻接矩阵 | 邻接表 | |
无向图 | O(1) | O(1)~O(|V|) |
有向图 | O(1) | O(1)~O(|V|) |
Neighbors(G, x):列出图G中顶点x的所有邻边
邻接矩阵 | 邻接表 | |
无向图 | O(|V|) | O(1)~O(|V|) |
有向图 | O(|V|) | 出边:O(1)~O(|V|) 入边:O(|E|) |
InsertVertex(G, x):在图G中插入顶点x
邻接矩阵 | 邻接表 | |
无向图 | O(1) | O(1) |
有向图 | O(1) | O(1) |
DeleteVertex(G, x):在图G中删除顶点x
邻接矩阵 | 邻接表 | |
无向图 | O(|V|) | O(1)~O(|E|) |
有向图 | O(|V|) | 删出边:O(1)~O(|V|) 删入边:O(|E|) |
AddEdge(G, x, y):若无向边(x, y)或有向边<x, y>不存在,则添加该边
邻接矩阵 | 邻接表 | |
无向图 | O(1) | O(1) |
有向图 | O(1) | O(1) |
RemoveEdge(G, x, y):若无向边(x, y)或有向边<x, y>存在,则删除该边
邻接矩阵 | 邻接表 | |
无向图 | O(1) | O(1)~O(|V|) |
有向图 | O(1) | O(1)~O(|V|) |
FirstNeighbor(G, x):求图G中顶点x的第一个邻接点,若有则返回顶点号,若不存在返回-1
邻接矩阵 | 邻接表 | |
无向图 | O(1)~O(|V|) | O(1) |
有向图 | O(1)~O(|V|) | 找出边邻接点:O(1) 找入边邻接点:O(1)~O(|E|) |
NextNeighbor(G, x, y):设图G中顶点y为顶点x的一个邻接点,返回除y之外顶点x的下一个邻接点的顶点号,若y为最后一个邻接点则返回-1
邻接矩阵 | 邻接表 | |
无向图 | O(1)~O(|V|) | O(1) |
有向图 | O(1)~O(|V|) | O(1) |
Get_edge_value(G, x, y):获取图G中边(x, y)的权值
Set_edge_valus(G, x, y, v):设置图G中边(x, y)对应权值v
三、图的遍历
从某一顶点出发,按照某种搜索方式沿图中边对图中所有顶点进行一次且仅有一次的访问。
1.广度优先遍历(BFS)
1.广度优先搜索
①确定一个起始顶点v,将节点入队
②将对头元素v出队,依此访问v的各个未被访问的邻接节点,并将这些节点依此入队
③依此以各个邻接节点为起始节点重复步骤①②,直到整个图都遍历(队空+利用辅助数组标记各节点访问情况,防止出现非连通图未遍历的情况)
bool visited[Max_Vertex_Num];
void BFSTraverse(Graph G)
{
for(int i=0; i<G.vexnum; i++)
{
visited[i] = false;
}
InitQueue(Q);
for(int i=0; i<G.vexnum; i++)
{
if(!visited[i])
BFS(G, i); //对无向图调用BFS函数次数 = 连通分量数
}
}
//BFS
void BFS(Graph G, int v)
{
visit(v);
visited[v] = true;
EnQueue(Q, v);
while(!isEmpty(Q))
{
DeQueue(Q);
for(w=FirstNeighbor(G,v); w>=0; w=NextNeighbor(G, v, w))
{
if(!visited[w])
{
visit(w);
visited[w] = true;
EnQueue(Q, w);
}
}
}
}
其中,同一个图的邻接矩阵广度优先遍历序列唯一,而邻接表不唯一。
邻接矩阵 | 邻接表 | |
时间复杂度 | O(|V|^2) | O(|V|+|E|) |
2.广度优先生成树和森林
广度优先遍历时生成的搜索序列树,森林为非连通图中个连通分量的生成树的集合。
2.深度优先遍历(DFS)
1.深度优先搜索
不同于广度优先,深度优先搜索是从起始顶点开始一直向下搜索,直到搜索到叶子节点后返回之前的节点,查看是否还有未访问的孩子节点,直到整个树全部遍历。
深度优先遍历本质是递归运算,需要借助栈辅助实现,空间复杂度为O(|V|)
bool visited[Max_Vertex_Num];
void DFSTraverse(Graph G)
{
for(int v=0; v<G.vexnum; v++)
visited[v] = false;
for(int v=0; v<G.vexnum; v++)
if(!visited[v])
DFS(G, v); //对无向图调用DFS函数次数 = 连通分量数
}
void DFS(Graph G, int v)
{
visit(v);
visited[v] = true;
for(w=FirstNeighbor(G, v); w>=0; w=NextNeighbor(G, v, w))
{
if(!visited[w])
DFS(G, w);
}
}
邻接矩阵 | 邻接表 | |
时间复杂度 | O(|V|^2) | O(|V|+|E|) |
2.深度优先生成树和森林
和广度优先生成树类似的概念,只不过生成方式为DFS。
3.图的遍历和连通性
对无向图,进行BFS/DFS的次数 = 连通分量数
对有向图,若顶点到各顶点连通(都有路径)则只调用一次,若为强连通图,则从任意节点出发都只需调用一次BFS/DFS
四、图的应用(难点!!!)
1.最小生成树
一个连通图生成树中包含全部顶点和尽可能少的边,砍去一个边就非连通,增加一个边就形成回路。对带权连通无向图G=(V,E),生成树中T的各边权值和最小,则称T为G的最小生成树。
性质:①不唯一。当G中各边权值互不相等时,G的最小生成树唯一;若G的边数比顶点数少1,则G最小生成树为本身。
②边的权值和唯一且最小。
③边数 = 顶点数 - 1
④连通图才有最小生成树,非连通图的是森林
算法核心:G = (V,E)是带权连通无向图,U为顶点集一个非空子集,(u,v)是一条具有最小权值的边,其中u属于U,v属于V-U,则必存在一棵包含边(u,v)的最小生成树。
GENERIC_MST(G)
{
T = NULL;
while T未形成一棵生成树;
do 找到一条最小代价边(u,v)并且加入T后不会产生回路;
T = T U (u,v);
}
1.Prim算法(针对顶点)
步骤:①任取一个顶点加入树T
②选取与当前T中顶点集合距离最近的顶点加入树T,每加入一次顶点数和边数都+1
重复步骤①②,直到全部顶点都进入T
核心思想:贪心算法
时间复杂度:O()
适用性:边稠密图
void Prim(G, T)
{
T为空;
U = {w};
while((V-U)! = NULL)
{
设(u,v)为让u属于U,v属于(V-U)对最短边
T = T U {(u,v)}; //边入树
U = U U {v}; //顶点入树
}
}
//辅助数组:
isJoin[vexNum]; //标记各节点是否已加入树
lowCost[vexNum]; //各节点加入树的最小代价 != 权值,每次并入新节点后都需要更新
2.Kruskal算法(针对边)
步骤:①按权值大小对边进行排序
②依此选取最小的边,判断其两个顶点是否已全部归入树T内,若归入则判断一下个,若未归入则将顶点并入树T
③重复步骤②直到顶点全部入树T
核心思想:贪心算法
时间复杂度:O(|E|log|E|)
适用性:边稀疏图
void Kruskal(v, T)
{
T = v;
numS = n; //连通分量数
while(numS>1)
{
从E中选取权值最小的边(u,v);
if(v和u属于不同连通分量)
{
T = T U {(v,u)}; //边入树
numS--;
}
}
}
2.最短路径
求顶点间的距离问题。
1.BFS算法——无权图单源点最短路径问题
利用广度优先搜索的特性对无权图进行依此遍历,在遍历时记录所遍历顶点与初始顶点的距离。
#define MAXINT 0xfffffff
bool visited[Max_Vertex_Num];
int d[Max_Vertex_Num];
int path[Max_Vertex_Num]; //记录最短路径的途经点(每次记录前一个节点信息,最后串起来)
//BFS求单源点最短路径
void BFS_Min_Distance(Graph G, int u)
{
//d[i]表示从顶点u到顶点i的最短路径
for(int i=0; i<G.vexnum; i++)
{
d[i] = MAXINT;
path[i] = -1; //记录最短路径从哪个顶点过来的
}
d[u] = 0;
visited[u] = true;
EnQueue(Q,u);
while(!isEmpty(Q))
{
DeQueue(Q,u);
for(w=FirstNeighbor(G,u); w>=0; w=NextNeighbor(G, u, w))
{
if(!visited[w])
{
d[w] = d[u] + 1;
path[w] = u;
visited[w] = true;
EnQueue(Q, w);
}
}
}
}
2.Dijkstra算法——带权/无权图单源点最短路径问题
设置集合S记录已求得的最短路径顶点,dist[]记录从源点v0到其他各点当前的最短路径,path[]记录从源点到顶点 i 之间最短路径的前驱节点,final[]记录各顶点是否已找到最短路径。
步骤(类似Prim的思路):①初始化S为{0},即集合S只包含点v0,dist[i] 为v0与vi顶点间边的权值,若边不存在,则为∞
②从集合 V-S中选出vj,该点与v0之间权值最小,dist[j] = Min{dist[i]},S = SU{j},final[j] = true。
③修改v0到V-S集合上各顶点的长度,判断方式为:若dist[j] + 边(j,k)权值 < dist[k],则dist[k] = dist[j] + 边(j,k)权值
④重复步骤②③ n-1次,直到全部顶点包含在S中,即final数组全为true
核心思想:贪心算法
算法分析:时间复杂度——O()
适用范围:正权值或无权值图(不可计算负权值图)
3.Floyd算法——各顶点间最短路径问题
基本思想:递推一个n阶方阵序列,
,...,
,...,
,其中
[i][j]表示从顶点vi到vj的长度,k表示绕行第k个顶点的运算步骤,利用
记录节点的中转情况。
步骤:①初始时若v0到vi之间有边,则记录其最短路径为该边权值,若不存在则记∞
②尝试允许经过v1顶点中转,更新顶点间最短路径
③依此尝试允许经过v1,v2,...,vk顶点中转,并不断更新最短路径,,直到允许v(n-1)顶点都经过中转,方阵 [i][j] = Min{
[i][j] ,
[i][k]+
[k][j]}
④经过n次迭代,最终[i][j]就是vi到vj的最短路径长度
核心思想:动态规划问题
算法分析:时间复杂度——O(),空间复杂度——O(
)
适用范围:不能解决带有“负权回路”的图(带负权值边的回路),解决顶点间最短路径问题
//初始化矩阵A和path
...
for(int k=0; k<n; k++)
{
for(int i=0; i<n; i++)
{
for(int j=0; j<n; j++)
{
if(A[i][j]>A[i][k]+A[k][j])
{
A[i][j] = A[i][k]+A[k][j]; //更新最短路径长度
path[i][j] = k; //中转点
}
}
}
}
3.有向无环图描述表达式
有向无环图(DAG):有向图中不存在环
补充:判断有环的方式包括深度优先遍历、拓扑排序和求关键路径(选择题06)
DAG描述表达式步骤:
①将n个操作数依此排列在最底层,并标出各个运算符生成顺序
②按运算符生成顺序加入运算符,并进行“分层”
③按照操作②将整个运算符表示出来,再寻找是否有可以“合体”的部分
4.拓扑排序
1.AOV网
在DAG工程中,项目vi必需在项目vj前发生,利用边<vi,vj>表示这种关系,因此,这种能用顶点表示活动的网络称为AOV网。
2.拓扑排序
由一个DAG图的顶点组成的序列,满足:①每个顶点出现且仅出现一次;②若顶点A出现在顶点B之前,则图中不存在B->A的路径。每个AOV网都有一个或多个拓扑排序路径。
常用的拓扑排序方法:①在AOV网络中选择一个没有前驱的顶点输出
②从网中删除该顶点和其相关的所有边
③重复①②直到当前AOV网络为空,或当前网中不存在无前驱的节点为止(图中有环)
#define MaxVertexNum 100
typedef struct ArcNode //边表节点
{
int adjvex; //该弧所指向的节点的位置
struct ArcNode *nextarc;
//InfoType info;
}ArcNode;
typedef struct VNode //顶点表节点
{
VertexType data;
ArcNode *firstarc; //指向第一条依附于该顶点的弧的指针
}VNode, AdjList[MaxVertexNum];
typedef struct
{
AdjList vertices; //邻接表
int vexnum, arcnum;
}Graph; //以邻接表存储的图
bool TopologicalSort(Graph G)
{
InitStack(S);
for(int i=0; i<G.vexnum; i++)
{
if(indegree[i] == 0) //入度为零则进栈
Push(S,i);
}
int count = 0; //记录当前已输出的顶点数
while(!IsEmpty(S))
{
Pop(S,i);
print[count++] = i;
for(p = G.vertices[i].firstarc; p=p->nextarc)
{
v = p->adjvex;
if(!(--indegree[v]))
Push(S,v); //入度为0则入栈
}
}
if(count<G.vexnum)
return false; //图中有环
else
return true;
}
邻接矩阵 | 邻接表 | |
时间复杂度 | O(|V|+|E|) | O(|V|^2) |
拓扑排序处理AOV网的要点:①没有前驱或前驱已完结的点工程中代表从此活动开始;②若一个顶点有多个后继,拓扑排序的结果通常不唯一,但若干各顶点已排列成一个有序线性序列,则每个顶点有唯一的前驱后继关系,拓扑排序唯一;③对AOV网各顶点地位平等,因此可以按排列结果重新生成AOV网,这种邻接矩阵可以是三角矩阵(一般矩阵而言,若邻接矩阵为三角矩阵则存在拓扑排序,反之则不存在)。
注意:若有向图拓扑有序序列唯一不能证明图中每个顶点的入度和出度最多为1,后者是一个充分不必要条件。(选择题08)
若邻接矩阵存储的有向图中,矩阵对角线以下元素全部为0,该图拓扑排序存在但不定唯一;若主对角线以上元素全为1,对角线以下元素全为0,该拓扑排序存在且唯一。
逆拓扑排序:选择输出没有后继(出度为0)的节点,并剔除有向边
//DFS实现逆拓扑排序
void DFSTraverse(Graph G)
{
for(v=0; v<G.vexnum; v++)
visited[v] = false;
for(v=0; v<G.vexnum; v++)
{
if(!visited[v])
DFS(G, v);
}
}
void DFS(Graph G, int v)
{
visit(v);
visited[v] = true;
for(w = FirstNeighbor(G, v); w>=0; w = NextNeighbor(G, v, w))
{
if(!visited[w])
DFS(G, w);
}
print(v); //在顶点退栈前输出
}
5.关键路径
1.AOE网
带权有向图(DAG)中,以顶点表示事件,有向边表示活动,以边上权值表示完成该活动的开销,称之为用边表示活动的网络,建成AOE网。
性质:①只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始;②只有在进入某顶点的各有向边所代表的活动都已结束,该顶点所代表的事件才能开始。
特点:①AOE网中仅有一个入度为0的顶点,称为开始顶点(源点),仅有一个出度为0的顶点,称为结束顶点(汇点)。
②对多条路径并行的情况,只有所有路径上活动都完成工程才结束。
2.关键路径
从源点到汇点的所有路径中,带有最大路径长度的路径称为关键路径,路径上的活动称为关键活动,活动完成的最小开销就说关键路径长度,且可能不唯一。
需注意,扩大关键路径上活动开销必然会造成整个项目开销增加,但降低关键路径上活动开销可能会导致该活动称为非关键活动,但如果是加快那些包括在所有关键路径上的公共关键活动的话可以保证降低项目开销。
相关概念:
1.事件vk的最早发生时间ve(k)——从源点开始计算
2.事件vk的最迟发生事件vl(k)——从汇点开始计算
3.活动ai的最早开始时间e(i)——看ve(k)
该活动弧的最早开始时间,若<vk,vj>表示该活动ai,则e(i) = ve(k)
4.活动ai的最迟开始时间l(i)——看vl(k)
该活动弧的最晚开始时间 = 弧终点事件的最迟发生事件 - 活动所需时间,若ai为边<vk,vj>,则l(i) = vl(i) - weight(vk, vj)
5.活动ai的最迟开始时间l(i)和其最早开始时间e(i)的差额d(i) = l(i) - e(i)
代表活动的时间余量,若d(i) = 0,证明该活动为关键活动。
关键路径求解算法步骤:
①从源点出发,令ve(源点) = 0,按拓扑有序求其余顶点的最早发生时间ve()
②从汇点出发,令vl(汇点) = ve(汇点),按逆拓扑有序求其各顶点的最迟发生时间vl()
③根据各顶点的ve()求所有弧的最早开始时间e()
④根据各顶点的vl()求所有弧的最迟开始时间l()
⑤求AOE网中所有活动的差额d(),找到d() = 0的活动构成关键路径
五、课后习题(持续更新中)
1.综合题(主要是错题,记录一下复习记得重点记忆)
1.如何对无环有向图中的顶点号重新安排可使该图的邻接矩阵中所有的1都集中到对角线以上?
答: 按各顶点的出度进行排序,n个顶点的有向图,最大出度为n-1,最小出度为0。排序后出度最大的顶点编号为1,出度最小的顶点编号为n,之后进行调整:
只要存在弧<i,j>就不管顶点 j 的出度是否大于顶点 i 的出度,都把 i 编号放在顶点 j 的编号之前,因为只要有 i<=j ,弧<i,j>对应的1才能出现在临街矩阵的上三角区域,当然也可以利用拓扑排序再依此编号(拓扑排序,排完序后再重绘邻接矩阵)。