一 图的基本概念
图(Graph)G由顶点集合V(G)和边集合E(G)构成
- 注意:线性表可以是空表,树可以是空树,但图不能是空图。就是说图不能一个顶点也没有,但边集可以为空。即V一定非空,E可以为空。
1 无向图
在图G中,如果代表边的顶点对是无序的,则称G为无向图。边记为(v,w),v,w互为邻接点。
2 有向图
如果表示边的顶点对是有序的,则称G为有向图。有向边(弧)记为<v,w>,其中v,w是顶点,v称为弧尾,w称为弧头,<v,w>称为从v到w的弧。
3 顶点的度
无向图:顶点 i 连接的边数称为该顶点的度。(全部顶点的度的和=边数的2倍,因为每条边关联两个顶点)
有向图:顶点 i 的入度与出度之和为顶点的度。(全部顶点的入度和=全部顶点的出度和=边数)
- 入度:以顶点 i 为终点的入边的数目称为该顶点的入度。
- 出度:以顶点 i 为始点的出边的数目,称为该顶点的出度。
4 完全图
无向图:每两个顶点之间都存在着一条边,称为完全无向图,包含有n(n-1)/2条边。
有向图:每两个顶点之间都存在着方向相反的两条边,称为完全有向图,包含有**n(n-1)**条边。
5 稠密图、稀疏图
当一个图接近完全图时,则称为稠密图。
相反,当一个图含有较少的边数(即当e << n(n-1))时,则称为稀疏图。
6 子图
设有两个图G=(V,E)和G’=(V’,E’),若V’是V的子集,即V’⊆V,且E’是E的子集,即E’⊆E,则称G’是G的子图。
7 路径和路径长度和回路
在一个图G=(V,E)中,从顶点i到顶点j的一条路径为顶点序列 i,i1,i2,…,im,j。
路径长度指一条路径上经过的边的数目。
第一个顶点和最后一个顶点相同的路径称为回路或环。
8 简单路径、简单回路
路径序列中,顶点不重复出现的路径称为简单路径。
除第一个顶点和最后一个顶点外,其余顶点不重复出现的回路称为简单回路。
9 连通、连通图、连通分量
无向图中,若从顶点i到顶点j有路径,则称顶点i和j是连通的。
若图中任意两个顶点都连通,则称为连通图,否则称为非连通图。
无向图G中的极大连通子图称为G的连通分量。显然,任何连通图的连通分量只有一个,即本身,而非连通图有多个连通分量。
10 强连通图、强连通分量
在有向图中,若从顶点i到顶点j有路径,则称从顶点i到j是连通的。
若图G中的任意两个顶点i和j都连通,即从顶点i到j和从顶点j到i都存在路径,则称图G是强连通图。
有向图G中的极大强连通子图称为G的强连通分量。显然,强连通图只有一个强连通分量,即本身。非强连通图有多个强连通分量。
- 注意:在无向图中讨论连通性,在无向图中讨论强连通性。
11 权和网
图中每一条边都可以附带有一个对应的数值,这种与边相关的数值称为权。边上带有权的图称为带权图,也称作网。
12 距离
如果从顶点u出发到顶点v的最短路径存在,则称此路径长度为u到v的距离,若u到v无路径,则距离记为∞
二 图的存储和基本操作
图主要有两种存储结构:邻接矩阵、邻接表。
邻接矩阵
邻接矩阵是表示顶点之间相邻关系的矩阵。设G=(V,E)是具有n (n>0)个顶点的图,顶点的编号依次为0~n-1。则图G的邻接矩阵A表示为n×n的二维数组。
-
A[i][j] = 1表示<i,j>∈E(G)或(i,j)∈E(G)。
-
带权图 A[i][j] = wij表示(i,j)∈E(G)或<i,j>∈E(G),0或∞表示(i,j)∉E(G)或<i,j>∈E(G)
const int N = 100; //顶点数目
typedef struct {
int no; //顶点编号
infoType info; //顶点其他信息
}VertexType;
typedef struct {
int edges[N][N]; //邻接矩阵
int n,e; //顶点数、边数
VertexType v[N]; //存放顶点信息
}MGraph;
- 适合稠密图
邻接表
对图中每个顶点i建立一个单链表,将顶点i的所有邻接点链起来。
typedef struct ANode
{
int adjvex; //该边的终点编号
struct ANode *nextarc; // 下一条边的指针
//InfoType info; //该边的权值等信息
}ArcNode; //边结点类型
typedef struct Vnode
{
Vertex data; //顶点信息
ANode *firstarc; //指向第一条边的指针
}VNode; //头结点类型
typedef struct
{
VNode adjList[MAXV] ; //邻接表
int n,e; //图中顶点数n和边数e
}AdjGraph;
- 适合稀疏图
图的基本操作
对邻接矩阵操作很简单,这里介绍邻接表的基本操作
void CreateAdj(AdjGraph *&G) //创建图的邻接表
{
int i,j,k,w;
ArcNode *p;
G = (AdjGraph *)malloc(sizeof(AdjGraph));
cout << "请输入图的顶点数和弧的数目:";
cin >> G->n >> G->e;
for (i=0; i < G->n; i++){
//给邻接表中所有头结点的指针域置初值
G->adjList[i].firstarc = NULL;
G->adjList[i].data = i;
}
cout<<"请输入每条边的起始点和终止点的编号及边的权值,以空格隔开:\n";
for(k = 0; k < G->e; ++k){ //输入各边,构造邻接表
cin >> i >> j >> w; //输入一条边依附的两个顶点的编号
p=(ArcNode *)malloc(sizeof(ArcNode)); //生成一个新的边结点*p
p->adjvex=j; // 邻接点序号为j
p->weight=w; //头插入到边结点链表中
p->nextarc = G->adjList[i].firstarc;
G->adjList[i].firstarc=p;
}
}
void DispAdj(AdjGraph *G) //输出邻接表G
{ int i;
ArcNode *p;
for (i=0;i < G->n; i++)
{
p = G->adjList[i].firstarc;
printf("%3d: ",i);
while (p != NULL)
{
printf("%3d[%d]→",p->adjvex,p->weight);
p = p->nextarc;
}
printf("\n");
}
}
void DestroyAdj(AdjGraph *&G) //销毁邻接表
{
int i; ArcNode *pre,*p;
for (i=0;i < G->n;i++) //扫描所有的单链表
{
pre=G->adjList[i].firstarc;//p指向第i个单链表的首结点
if (pre != NULL)
{
p = pre->nextarc;
while (p != NULL) //释放第i个单链表的所有边结点
{
free(pre);
pre = p;
p = p->nextarc;
}
free(pre);
}
}
free(G); //释放头结点数组
}
三 图的遍历
从给定图中任意指定的顶点(称为初始点)出发,按照某种搜索方法沿着图的边访问图中的所有顶点,使每个顶点仅被访问一次,这个过程称为图的遍历,图的遍历得到的顶点序列称为图遍历序列。
深度优先搜索(DFS)
设置一个visited[]全局数组,visited[i] = false表示顶点i没有访问; visited[i] = true表示顶点i记经访问过。
(1)从图中某个初始顶点v出发,首先访问初始顶点v。
(2)选择一个与顶点v相邻且没被访问过的顶点w,再从w出发进行深度优先搜索,直到图中与当前顶点v邻接的所有顶点都被访问过为止。
bool visited[MAX_V]; //记录顶点访问信息
void DFS(AdjGraph *G,int v){ //从顶点v出发,开始遍历
ArcNode *p;
int w;
visit(v); //访问结点v的数据域
visited[v] = true; //该节点已被访问
p = G->adjList[v].firstarc; //p指向顶点v的第一条边结点
while (p != NULL)
{
w = p->adjvex; //取出p中下一个节点的编号
if(visited[w] == 0) //如果w没被访问过,则访问
DFS(G,w); //若w顶点未访问,递归访问它
p = p->nextarc; //继续寻找下一个节点
}
}
void DFSTravel(AdjGraph * G){
for(int i = 0;i < G->n;i++)
if(visited[i]) DFS(G,i);
}
广度优先搜索(BFS)
1、访问初始点v,接着访问v的所有未被访问过的邻接点v0,v1, v2,…,vn
2、按照v0,v1,…,vn的次序,访问每一个顶点的所有未被访问过的邻接点。
3、依次类推,直到图中所有和初始点v有路径相通的顶点都被访问过为止。
bool visited[MAX_V];
void BFS(AdjGraph * G,int v){
ArcNode *p;
int w,i;
visit(v); //访问结点v的数据域
visited[v] = true; //置节点已被访问
push(qu,v); //将节点v入队
while(!IsEmpty(qu)){ //队列不为空则循环
pop(qu.v); //取出队头元素
for(p = G->adjList[v].firstarc;p != NULL; p = p->nextarc){ //遍历v的所有邻接点
w = p->adjvex;
if(!visited[w]){ //如果节点w没有被访问过~没有入队
visit(w); //访问该节,并入队
visited[w] = true;
push(qu,w);
}
}
}
}
void BFSTravel(AdjGraph * G){
InitQueue(qu); //初始化队列qu
for(int i = 0;i < G->n;i++)
if(visited[i]) BFS(G,i);
}
四 生成树和最小生成树
生成树
一个连通图的生成树是一个极小连通子图,它含有图中全部n个顶点和构成一棵树的(n-1)条边
在一课生成树上任意添加一条边,必定会构成一个环
由深度优先遍历得到的生成树称为深度优先生成树
由广度优先遍历得到的生成树称为广度优先生成树
- 注意:一个连通图的生成树不是唯一的
最小生成树
对于带权连通图G,可能有多棵不同生成树。
每棵生成树的所有边的权值之和可能不同。
其中权值之和最小的生成树称为图的最小生成树。
求最小生成树算法有Prim、Kruskal两个算法。
Prim算法
此算法可以称为"加点法”,每次迭代选择权值最小的边对应的点,加入到最小生成树中。算法从某一个顶点s开始,逐渐长大覆盖整个连通网的所有顶点。
过程:
- 1、初始化集合u = {s},s到其他顶点的边作为后选边
- 2、重复以下步骤n-1次,使得其他n-1个顶点被加入到u中
- 2.1 从候选边中挑选出权值最小的边(u0,v0),该边在v = V-U中对应顶点为v0,将v0加入u中(v是所有没有被加入到u的顶点的集合)
- 2.2 枚举v0中所有边(v0,l),如果l在v中,且权值小于(u0,l),则利用(v0,l)更新(u0,l)
const int INF = 0x3f3f3f3f; //定义正无穷
bool st[N];
int prim(MGraph G){
memset(dist,0x3f,sizeof dist); //将所有候选边初始化为正无穷
dist[1] = 0; //将顶点1加入到集合u中
st[1] = true; //表示顶点1已被加入u中
int res = 0; //最小生成树权值之和
for(int i = 2;i <= n;i++) dist[i] = G[1][i]; //用顶点1去更新dist(即初始化1到其邻接点边的权值)
//循环n-1次
for(int i = 1;i < n;i++){
int minimum = INF; //用来记录候选边集合dist中权值最小的边
int t = -1; //t记录v中到u权值最小的顶点
//2.1 找到dist中权值最小的边
for(int k = 2;k <= n;k++){
if(!st[k] && dist[k] < temp){ //如果k不在u中,且k到u的距离小于minimum就将下标赋给t
minimum = dist[k]; //候选边中最小权值更新为dist[k]
t = k;
}
}
//如果t==-1,意味着在集合v找不到边连向集合u,该图不是连通图,没有最小生成树,返回INF
if(t == -1) return INF;
//否则,将顶点t加入u
st[t] = true;
res += minimum;
//2.2 利用t去更新dist
for(int k = 2;k <= n;k++) dist[k] = min(dist[k],G[t][k]);
}
return res;
}
prim算法事件时间复杂度为O(V2),不依赖于边集E,因此适用于求解稠密图的最小生成树。
Kruskal算法
此算法可称为"加边法",将图中所有的边按照权值大小做升序排序,从权值最小的边开始选择,只要此边不和已选择的边构成环路,就可以选择它组成最小生成树。
过程:
- 1、置u的初值等于V(即包含有G中的全部顶点),TE的初值为空集(即图T中每一个顶点都构成一个连通分量)。
- 2、将图G中的边按权值从小到大的顺序依次选取
- 若选取的边未使生成树T形成回路,则加入TE
- 否则,将该边舍弃,一直到TE包含n-1条边(n个顶点)为止
struct Edge{
int a,b,w; //一条边有两个顶点,一个权值
}edges[M];
void kruskal(){
int res = 0; //最小生成树的权值之和
int cnt = 0; //当前边的数量
//将所有边升序排序
sort(edges,edges+m);
for(int i = 1;i <= n;i++) p[i] = i;
for(int i = 0; i < m;i++){
int a = edges[i].a;b = edges[i].b,w = edges[i].w;
//利用并查集查找顶点a与顶点b是否在同一个集合中
a = find(a),b = find(b);
//如果在同一个集合中,就不能选该边,否则就会构成环路
if(a != b){
p[a] = b; //将a所在的集合和b所在的集合进行合并
res += w;
cnt++; //边数+1
}
}
//如果边数不等于n-1的话,此图不适连通图,没有最小生成树
if(cnt < n - 1) res = INF;
return res;
}
思考:如何判定新加入的边不会构成环?
五 最短路径
1) 对于不带权的图,若从顶点u到顶点v存在多条路径,则把经过边数最少的那条路径称为最短路径。
2) 对于带权图,若从顶点u到顶点v存在多条路径,把带权路径长度最短的那条路径称为最短路径。
两种算法:求单源最短路径: Dijkstra算法
求任意两点之间的最短路径: Floyd算法
Dijkstra算法
设G=(V,E)是一个带权有向图, 把图中顶点集合V分成两组:
- 第1组为已求出最短路径的顶点集合(用S表示)
- 第2组为其余未求出最短路径的顶点集合(用U表示)
设置两个辅助数组dist[],paht[]
- dist[]:记录从源点v0到其他各顶点的最短路径长度
- path[]:path[i]表示从源点到顶点i之间的最短路径的前驱结点,算法结束后,可根据其值追溯的源点v0到顶点vi的最短路径。
假设从顶点0出发,g表示邻接矩阵,步骤如下(不考虑path):
1、初始化:S={0},dist[i] = g[0][i](0到顶点i的距离)
2、从U中选取一个距离顶点0最小的顶点u,把u加入S中(该选定的距离就是1->u的最短路径长度)
3、以u为中间点,修改从u出发到U上各顶点j的最短路径长度
若dist[u] + g[u][j] < dist[j],则更新dist[j] = dist[u] + g[u][j]
4、重复2、3操作n-1次,直到所有顶点都加入S
算法结束之后,dist[i]:表示从0到i的最短路径长度
const int INF = 0x3f3f3f3f;
void Dijkstra(int g[][]){
memset(dist,0x3f,sizeof dist);
memset(path,-1,sizeof path);
st[0] = true; //代表0已加入S
for(int i = 1;i < n;i++){ //初始化dist和path
dist[i] = g[0][1];
if(g[0][i] < INF) path[i] = 0; //如果0能直接到达i,记录i的前驱结点为0
}
//循环n-1次,找出0到其余n-1个结点的最短路径
for(int i = 1;i < n;i++){
int u = 0; //记录U到S的权值最小的顶点
int minimum = INF; //用来辅助查找dist最小值
//找出dist中找到权值最小的点
for(int j = 0;j < n;j++){
if(!st[j] && dist[j] < minimum){
u = j;
minimum = dist[j];
}
}
st[u] = true; //将u加入S
//以u为中间点,更新dist
for(int j = 0;j < n;j++){
if(!st[j]){
if(g[u][j] < INF && dsit[u] + g[u][j] < dist[j]){
dist[j] = dist[u] + g[u][j];
path[j] = u; //j结点的前驱结点记为u
}
}
}
}
//结束,此时dist[i]为顶点0到顶点i的最短路径
}
Floyd算法
Floyd算法又称为插点法,是一种利用动态规划的思想寻找给定的加权图中多源点之间最短路径的算法,与Dijkstra算法类似。
假设有向图G采用邻接矩阵存储。设置一个二维数组A用于存放当前顶点之间的最短路径长度,分量A[i][j]表示当前顶点i—>j的最短路径长度,递推产生一个矩阵序列:A0—>A1—>…Ak—>A(n-1)
其中Ak[i][j]表示:i—>j的路径上所经过的顶点编号不大于k的最短路径长度
步骤:
-
1、初始化:A-1[i][j] = g[i][j]
-
2、考虑从i到j的最短路径经过编号为k的顶点的情况:
- Ak[i][j] = min(Ak-1[i][k]+Ak-1[k][j],Ak-1[i][j]) (0 ≤ k ≤ n-1 )
void Floyd(int g[][]){
for(int k = 0; k < n;k++) //以顶点k为中转点
for(int i = 0;i < n;i++) //i为行,j为列,每次以k为中转点都需要比那里一遍矩阵
for(int j = 0;j < n;j++)
if(g[i][j] < g[i][k] + g[k][j]){//以k为中转点的路径最短
g[i][j] = g[i][k]+g[k][j]; //更新i到j的路径长度
path[i][j] = k; //i到j要经过k
}
}
六 AOV网和拓扑排序
1. AOV网
用DAG图表示一个工程,其顶点表示活动,有向边<Vi,Vj>表示活动Vi必须Vj进行这样一种关系,将这种有向图称为顶点表示活动的网络,记为AOV网。
2.拓扑排序
由一个有向无环图的顶点组成的序列中,满足以下条件时,称为该图的一个拓扑排序
- 1、每个顶点有且出现一次
- 2、若顶点A的相对位置在B前面,则图中不存在B到A的路径
求AOV网拓扑排序步骤:
- 1、从AOV网中选择一个没有前驱的节点进行输出
- 2、从网中删除该顶点和以它为起点的所有有向边
- 3、重复1、2知道AOV网为空或当前网中不存在无前驱的节点为止
int q[N]; //队列
bool topo(AdjGraph g){
int tt = -1; //队头
int hh = 0; //队尾
for(int i = 1;i <= n;i++){
if(indegree[i] == 0) //将所有入度为0的点入队
q[++tt] = i;
}
while(tt <= hh){ //如果队列不空
int t = q[hh++]; //取出队头元素
ANode * p = g->adjList[i].firstarc; //找到第一个邻接点
while(p != NULL){ //将所有t指向的顶点的入度减1
int j = p->adjvex; //邻接点的编号
//顶点j入度-1后为0,则将j入队
if(--indegree[j] == 0) q[++tt] = j;
p = p->nextarc; //寻找下一个邻接点
}
}
return tt == n-1; //如果tt下标!=n-1,表示图中有环路
//否则,将q从0到n-1输出即可得到一个拓扑排序
}
七 AOE网和关键路径
1. AOE网
带权有向无环图中,以顶点表示事件,有向边代表活动,边上的权值表示完成该活动所需代价,图中入度为0的顶点表示工程的开始事件,出度为0的顶点表示工程的结束事件,这样的图称为AOE网。
AOE网中只有一个入度为0的顶点,称为源点。
AOE网中只有一个出度为0的顶点,称为汇点。
AOE网和AOV网都是有向无环图,不同点在于:AOE网边有权值;AOV网的边没有权值,仅表示顶点间前后关系。
2. 关键路径
在AOE网中,从源点到汇点的所有路径中具有最大路径长度的路径称为关键路径,关键路径上的活动称为关键活动。
1.事件vk的最早发生时间ve(k):从源点v1到汇点vk的最长路径长度。事件vk的最早发生时间决定了所有从vk开始的活动能够开工的最早时间。
ve(源点) = 0;
ve(k) = max{ ve(j) + cost(vj,vk) } (vj为vk的任意前驱,cost(vj,vk)表示<vj,vk>上的权值)
2.事件vk的最迟发生时间vl(k):指在不推迟整个工程完成的前提下,该事件最迟必须发生时间。
vl(汇点) = ve(汇点)
vl(k) = min{ vl(j) - cost(vk,vj) } (vj为vk的任意后继)
3.活动ai的最早开始时间e(i):指该活动弧的起点表示的事件的最早发生时间。
若<vk,vj>表示活动ai,有e(i) = ve(k)
4.活动ai的最迟开始事件l(i):指该活动弧的终点所表示事件的最迟发生时间与该活动所需时间之差。
若<vk,vj>表示活动ai,有l(i) = vl(j) - cost(vk,vj)
5.活动ai的时间余量d(i):指在不增加完成整个工程所需总时间的情况下,活动ai可以拖延的时间。ai时间余量为0,表示该活动必须如期进行
d(i) = l(i) - e(i)
求关键路径算法步骤:
- 1)从源点出发,令 ve(源点) = 0,按拓扑顺序求出所有事件的最早开始时间ve()
- 2)从汇点出发,令 vl(汇点) = ve(汇点),按拓扑逆序求出所有事件的最迟发生时间vl()
- 3)根据各顶点的 ve() 值求出所有活动的最早开始时间e()
- 4)根据各顶点的 vl() 值求出所有活动的最迟开始时间l()
- 5)求AOE网中所有活动的差额d(),找出所有d() = 0的活动构成关键路径
例子:求下图的关键路径
求解过程:
进行拓扑排序,假设为:ABCDEFGHI
则逆序为:IHGFEDCBA
1)按顺序计算所有事件的最早开始事件ve()
ve(A) = 0
ve(B) = ve(A) + 6 = 6
ve(C ) = ve(A) + 4 = 4
ve(D) = ve(A) + 5 = 5
ve(E) = max{ ve(B) + 1,ve(C ) + 1} = 7
ve(F) = ve(E) + 9 = 16
ve(G) = ve(E) + 7 = 14
ve(H) = ve(D) + 2 = 7
ve(I) = max{ ve(F) + 2,ve(G) + 4,ve(H) + 4 } = 18
2)按逆序计算所有事件的最晚开始时间le(v)
le(I) = ve(I)=18
le(H) = le(I) - 4 = 14
le(G) = le(I) - 2 = 14
le(F) = le(I) - 4 = 16
le(E) = min{ le(F) - 9,le(G) - 7 } = 7
le(D) = le(H) - 2 =12
le(C ) = le(E) - 1 = 6
le(B) = le(E) - 1 = 6
le(A) = min( le(B) - 6,le(C ) - 4,le(D) - 5 } = { 0,2,7} = 0
3)求出各活动的最早开始时间e()、最晚开始时间l()和时间余量d()
活动a1:e(a1) = ve(A) = 0,l(a1) = le(B) - 6 = 0, d(a1) = 0
活动a2:e(a2) = ve(A) = 0,l(a2) = le(C ) - 4 = 2,d(a2) =2
活动a3:e(a3) = ve(A) = 0,l(a3) = le(D) - 5 =7, d(a3) = 7
活动a4:e(a4) = ve(B) = 6,l(a4) = le(E)- 1 = 6, d(a4) = 0
活动a5:e(a5) = ve(C ) = 4,l(a5) = le(E) - 1= 6,d(a5)=2
活动a6:e(a6) = ve(D) = 5,l(a6) = le(H) - 2=12,d(a6)=7
活动a7:e(a7) = ve(E) = 7,l(a7) = le(F) - 9= 7, d(a7)=0
活动a8:e(a8) = ve(E) = 7,l(a8) = le(G) - 7 = 7,d(a8)=0
活动a9:e(a9) = ve(H) = 7,l(a9) = le(G) - 4 = 10,d(a9)=3
活动a10:e(a10) = ve(F) = 16,l(a10) = le(I) - 2 = 16,d(a10)=0
活动a11:e(a11) = ve(G) = 14,l(a11) = le(I) - 4 = 14,d(a11)=0
由此可知,关键活动有a11、a10、a8、a7、a4、a1,因此关键路径有两条:ABEFI 和 ABEGI。
关键路径的长度为: 18