文章目录
1.图的定义和术语
- 顶点:在图中的数据元素通常称为顶点。V是顶点的有穷非空集合。
- 有向图(A):VR是两个顶点之间的关系的集合。若<v,w>∈VR,则<v,w>表示从v到w的一条弧,且称v为弧尾巴,w为弧头,此时的图称为有向图。
- 无向图(V):若<v,w>∈VR必有<w,v>∈VR,即VR是对称的,,则以无序对**(v,w)代替这两个有序对**,表示v和w之间的一条边,此时的图称为无向图。
- 完全图:对于无向图,e的取值范围是0到n(n-1)/2,有n(n-1)/2条边的无向图称为完全图。
- 有向完全图:对于有向图,e的取值范围是0到n(n-1),有n(n-1)条边的无向图称为完全图。
- 稀疏图和稠密图::有很少条边或弧的图称为稀疏图,反之称为稠密图。
- 网(带权图):有时图的边或弧具有与它相关的数,这种与图的边或弧相关的数叫做权,这些权可以表示从一个顶点到另一个顶点的距离或耗费。这种带权的图通常称为网。
- 子图:假设有两个图G={V,{E}}和G1={V1,{E1}},如果V1⊆V且E1⊆E,则称G1为G的子图
- 邻接点:对于无向图,如果边(v,v’)∈E,则称顶点v和v’互为邻接点。
- 无向图的度:顶点v的度是和v相关联的边的数目,记为TD(V)。
- 有向图的度:以顶点v为头的弧的数目称为v的入度,称为ID(V);以v为尾的弧的数目称为v的出度,记为OD(v)
- 一般的,如果顶点vi的度记为TD(vi),那么一个有n个顶点,e条边或弧的图,满足如下关系:
- 连通、连通图和连通分量:如果从顶点v到顶点v’有路径,则称v和v’是连通的。如果对于图中任意两个顶点vi,vj都是连通的,则称G是连通图。连通分量指的是无向图中的极大连通子图。
- 强连通图:在有向图中,如果对于每一对vi,vj∈V,vi≠vj,从vi到vj和从vj到vi都存在路径,则称G是强连通图。有向图的极大强连通子图称做有向图的强连通分量。
- 生成树:一个连通图的生成树是一个极小连通子图,它含有图中全部顶点,但只有足以构成一棵树的n-1条边
- 一棵有n个顶点的·生成树有且仅有n-1条边。如果一个图有n个顶点和小于n-1条边,则是非连通图。如果它多于n-1条边,则一定有环。但是,有n-1条边的图不一定是生成树
- 有向树和生成森林:如果一个有向图恰有一个顶点的入度为0,其余顶点的入度为1,则是一棵有向树,一个有向图的生成森林由若干有向树组成,含有图中全部顶点,但只有足以构成若干颗不想交的有向树的弧。
2.图的存储结构
(1)数组表示法
- 用两个数组分别存储数据元素的信息和数据元素之间关系的信息
#define INFINITY INT_MAX //最大值∞
#define MAX_VERTEX_NUM 20 //最大顶点个数
typedef enum {DG,DN,UDG,UDN} Graphkind;
typedef struct ArdCell
{
VRType adj;
InfoType *info
}ArcCell,AdjMatrix[MAX_VERTEX_NUM][MAX_VERTEX_NUM];
typedef struct
{
VertexType vexs[MAX_VERTEX_NUM]; //顶点向量
AdjMatrix arcs; //邻接矩阵
int vexnum,arcnum; //图的当前顶点数和弧数
GraphKind kind; //图的种类标志
}MGragh;
(2)邻接表
- 邻接表是图的一种链式存储结构。在邻接表中,对图的每个顶点建立一个单链表。第i个单链表中的结点表示依附于顶点vi的边。
- 每个结点由3个域组成,其中邻接点域指示与顶点vi邻接的点在图中的位置,链域指示下一条边或弧的结点;数据域存储和边或弧相关的信息(如权值等)每个链表上附设一个表头结点。在表头结点中,除了设有炼域指向链表中的第一个结点外,还设有存储顶点vi的名或其他有关信息的数据域
#define MAX_VERTEX_NUM 20 //最大顶点个数
typedef char* vertextType;
typedef struct ArcNode
{
int adjvex; //该弧指向的顶点的位置
struct ArcNode *nextarc; //指向下一条弧
InfoType* info; //该弧相关信息的指针
}ArcNode;
typedef struct VNode {
vertextType data; //结点信息
ArcNode* firstarc; //指向第一条依附在该顶点的弧的指针
}VNode, AdjList[MAX_VERTEX_NUM];
typedef struct
{
AdjList vertices;
int vexnum, arcnum;
int kind;
}ALGraph;
- 若无向图有n个顶点,e条边,则它的邻接表需n个头结点和2e个表结点,显然,在边稀疏 的情况下,用邻接表表示图比邻接矩阵节省存储空间,当和边相关的信息较多时更是如此。
- 在无向图的邻接表中,顶点vi的度恰为第i个链表中的结点数。在有向图的邻接表中结点数代表入度,想要判断出度需要遍历整个邻接表
- 在邻接表上容易找到任一顶点的第一个邻接点和下一个邻接点,但要判定任意两个顶点是否有边或弧相连,则需搜索第i个或第j个链表,因此,不及邻接矩阵方便
(3)十字链表
- 十字链表是有向图的另一种链式存储结构。可以看成是将有向图的邻接表和逆邻接表结合。在十字链表中,对应于有向图中每一条弧有一个结点,对应于每个顶点也有一个结点:
- 在弧结点中有5个域:其中尾域和头域分别指示弧尾和弧头这两个顶点在图中的位置,链域hlink指向弧头相同的下一条弧,而链域tlink指向弧尾相同的下一条弧,info域指向该弧的信息。
- 弧头相同的弧在同一链表上,弧尾相同的弧也在同一链表上,它们的头结点即为顶点结点。
- 头结点由三个域构成:其中data域存储和顶点相关的信息(如顶点名称),firstin和firstout为两个链域,分别指向以该顶点为弧头或狐尾的第一个弧结点
- 有向图的十字链表
- 十字链表的存储表示:
#define MAX_VERTEX_NUM 20
typedef struct ArcBox
{
int tailvex,heedvex;
struct ArcBox *hlink,*tlink;
InfoType *info;
}ArcBox;
typedef struct VexNode
{
VertexType data;
ArcBox *firstin,*firstout;
}VexNode;
typedef struct {
VexNode xlist[MAX_VERTEX_NUM];
int vexnum,arcnum; //有向图的当前顶点数和弧数
}OLGraph;
- 建立十字链表
bool CreateDG(OLGraph& G)
{
cin>>G.vexnum>>G.arcnum;
//构造表头向量
for(int i=0;i<G.vexnum;++i)
{
cin>>G.xlist[i].data;
G.xlist[i].firstin = NULL;
G.xlist[i].firstout = NULL;
}
//输入各弧并构造十字链表
for(int k = 0;k<G.arcnum;++k)
{
int v1,v2,weight;
cin>>v1>>v2>>weight;
ArcBox* p = (ArcBox*)malloc(sizeof(ArcBox));
*p={v1,v2,G.xlist[v2].firstin,G.xlist[v1].firstout,weight};
G.xlist[v2].firstin = G.xlist[v1].firstout = p;
}
}
- 在十字链表中既容易找到以vi,也容易找到以vi为头的弧,因而容易求得顶点的出度和入度。同时,建立十字链表的时间复杂度和建立邻接表是相同的。
(4)邻接多重表
- 邻接表多重表是无向图的另一种链式存储结构。
- 虽然邻接表是无向图的一种很有效的存储结构,在邻接表中容易求得顶点和边的各种信息。但是,在邻接表中每一条边(vi,vj)有两个结点,分别在第i个和第j个链表中,这给某些图的操作带来不便。例如在某些图的应用问题中需要对边进行某种操作,如对已被搜索过的边做记号或删除一条边等,此时需要找到表示同一条边的两个结点。因此,在进行这一类操作无向图的问题中采用邻接多重表做存储结构更为适宜。
- 在邻接多重表中,每一条边用一个结点表示,它由如下所示的6个域组成:
(1)mark为标志域,可用以标记该边是否被搜索过;
(2)ivex和jvex为该边衣依附的两个顶点在图中的位置
(3)ilink指向下一条依附于顶点ivex的边;jlink指向下一条依附于顶点jvex的边
(4)info为信息 - 每个结点由两个域组成:
- 无向图的邻接多重表
#define MAX_VERTEX_NUM 20
typedef enum {unvisited,visited} VisitIf;
typdef struct EBox
{
VisitIf mark; //访问标记
int ivex,jvex;
struct EBox *ilink,*jlink;
InfoType *info;
}EBox;
typedef struct VexBox
{
VertexType data;
EBox *firstedge;
}VexBox;
typedef struct
{
VexBox adjmulist[MAX_VERTEX_NUM];
int vexnum,edgenum;
}AMLGraph;
3.图的遍历
(1)深度优先搜素(DFS)
- 深度优先遍历类似于数的先根遍历,是数的先根遍历的推广。
- 假设初始状态时图中所有顶点未曾被访问,则深度优先搜索可从图中某个顶点v出发,然后依次从v的未被访问的邻接点出发深度优先遍历图,直至图中所有和v有路径相通的顶点都被访问到;若此时尚有顶点未被访问,则另选图中一个未曾被访问的顶点作为起点,重复上述过程,直至图中所有顶点都被访问为止。
bool visited[MAX]; //访问标志数组
void DFSTraverse(Graph G)
{
int v;
for(v=0;v<G.vexnum;++v)
visited[v]=false;
for(v=0;v<G.vexnum;++v)
{
if(!visited[v])
DFS(G,v);
}
}
//从v开始深度遍历图
void DFS(Graph G,int v)
{
visited[v] = true;
visitFunc(v);
for(v的邻接结点)
{
if(!visited[w])
DFS(G,w);
}
}
- 遍历图的过程实质是对每个顶点查找其邻接点的过程。当用二维数组表示邻接矩阵作图的存储结构时,查找每个顶点的邻接点所需时间为O(n2) 。而当以邻接表作图的存储结构时,找邻接点所需时间为O(e),e为边的数。总时间复杂度为O(n+e)
(2)广度优先搜索(BFS)
- 广度优先搜索遍历类似于树的按层次遍历的过程
- 假设从图中某顶点v出发,在访问了v之后依次访问v的各个未曾访问过的邻接点,然后分别从这些邻接点出发依次访问它们的邻接点,并使“先被访问的结点的邻接点都被访问到”。
- 换句话说,广度优先搜索遍历图的过程是以v为起始点,由近至远,依次访问和v有路径相通且路径长度为1,2,…的顶点
bool visited[MAX]; //访问标志数组
void BFSTraverse(Graph G)
{
int v;
for(v=0;v<vexnum;++v)
visited[v] = false;
for(v=0;v<vexnum;++v)
{
if(!visited[v])
{
visited[v] = true;
EnQueue(Q,v);
BFS(G,v);
}
}
}
//从结点v开始广度遍历图
void BFS(Graph G,int v)
{
while(!QueueEmpty())
{
Dequeue(Q,v);
for(v的邻接结点)
{
if(!visited[w])
{
visited[w] = true;
Visit(w);
Enqueue(Q,w);
}
}
}
}
4.图的连通问题
(1)无向图的连通分量和生成树
- 对于非连通图,则需从多个顶点出发进行搜索,而每一次从一个新的起始点出发进行搜索的过程中得到访问序列恰为各个连通分量中的顶点集
- 设E(G)为连通图G中所有边的集合,则从图中任一顶点出发遍历图时,必定将E(G)分成两个集合T(G)和B(G),其中T(G)是遍历图过程中历经的边的集合,B(G)是剩余边的集合,显然,T(G)和图(G)中所有的顶点一起构成连通图G的最小连通子图(生成树),深度优先得到的是深度优先生成树,广度优先得到的是广度优先生成树。
- 对于非连通图,构成的是生成森林。
- 假设用孩子兄弟链表存储生成森林,则算法为:
bool visited[MAX]; //访问标志数组
void DFSForest(Graph G)
{
int v;
bool first = true;
for(v=0;v<G.vexnum;++v)
{
visited[v]=false;
}
for(v=0;v<G.vexnum;++v)
{
if(!visited[v])
{
//分配孩子结点
p = (CSTree)malloc(sizeof(CSNode))
*p = {GetVex(G,w),nullptr,nullptr};
if(!T)
T=p;
else
q->nextsibling = p;
q = p;
DFS(G,v);
}
}
}
//从v开始深度遍历图
void DFS(Graph G,int v,CSTree& T)
{
visited[v] = true;
bool first = true;
visitFunc(v);
for(v的邻接结点)
{
if(!visited[w])
{
//分配孩子结点
p = (CSTree)malloc(sizeof(CSNode))
*p = {GetVex(G,w),nullptr,nullptr};
if(first)
{
T->lchild = p;
first = false;
}
else
{
q->nextsibling = p;
}
q = p;
DFS(G,w,q);
}
}
}
(2)有向图的强连通分量
- 深度优先搜索是求有向图的强连通分量的一个新的有效方法。假设以十字链表作有向图的存储结构,则求强连通分量的步骤如下:
(3)最小生成树
- 最小生成树问题:假设要在n个城市之间建立通信联络网,则连通n个城市只需要n-1条线路。这时,自然会考虑这样一个问题,如何在最节省经费的前提下建立这个通信网。
- 构造最小生成树可以有多种算法。其中多数算法利用了最小生成树的下列一种简称为MST的性质:假设N=(V,{E})是一个连通网,U是顶点集V的一个非空子集,若(u,v)是一条具有最小权值(代价)的边,其中u∈U,v∈V-U,则必存在一棵含边(u,v)的最小生成树
- 证明
- 普里姆(Prim)算法和克鲁斯卡尔(Kruskal)算法是两个利用MST性质构造最小生成树的算法
① 普里姆(Prim)算法
- 假设N = (V,{E})是连通网,TE是N上最小生成树中边的集合。算法从U={u0}(u0∈V),TE={ }开始,重复执行下述操作:在所有u∈U,v∈V-U的边(u,v)∈E中找一条代价最小的边(u0,v0)并入集合TE,同时v0并入U直至U=V为止。此时TE中必有n-1条边,则T为N的最小生成树
- 为实现这个算法需附设一个辅助数组closedge,以记录从U到V-U具有最小代价的边。它包括两个域,其中lowcost存储该边上的权,adjvex域存储该边依附的在U中的顶点。
//辅助数组
typedef struct
{
int adjvex;
VRType lowhost;
}Closedge[MAX_VERTEX_NUM];
int minimum(Closedge cl,int size)
{
int min = INT_MAX;
int index;
for (int i = 0; i < size; ++i)
{
if (cl[i].lowhost != 0 && cl[i].lowhost <= min)
{
min = cl[i].lowhost;
index = i;
}
}
return index;
}
void MiniSpanTree_PRIM(MGragh G,int u)
{
Closedge closedge;
//初始化辅助数组
for (int i = 0; i < G.vexnum; ++i)
{
if(i!=u)
closedge[i] = { u,G.arcs[u][i].weight };
}
closedge[u] = { -1,0 }; //初始,U={u}
//选择剩余结点计算
for (int i = 0; i < G.vexnum-1; ++i)
{
int k = minimum(closedge,G.vexnum);
cout << closedge[k].adjvex << " " << k << endl;
closedge[k].lowhost = 0; //将k加入U
//更新辅助数组
for (int j = 0; j < G.vexnum; ++j)
{
if (closedge[j].lowhost > G.arcs[k][j].weight)
closedge[j] = { k, G.arcs[k][j].weight };
}
}
}
- 由上可知,prim算法的时间复杂度为O(n2),与网中的边数无关,因此使用于求边稠密的网的最小生成树。
② 克鲁斯卡尔(Kruskal)算法
- Kruskal算法恰恰相反,它的时间复杂度为O(eloge),因此它相对prim算法更适合于求边稀疏的网的最小生成树
- Kruskal算法从另一个途径求网的最小生成树,Prim算法是每次划分为两个集合,每次取两个集合之间的代价最小边,而Kruskal算法是每次直接去代价最小边,然后判断依附边的两个结点是不是在同一个集合。但是实质都是性质MST。
- 假设连通网N=(V,{E}),则令最小生成树的初始状态为只有n个顶点而无边的非连通图T=(V,{}),图中每个顶点自成一个连通分量。在E中选择代价最小的边,若该边依附的顶点落在T中不同的连通分量上,则将此边加入到T中,否则舍去此边而选择下一条代价最小的边。一次类推,直至T中所有顶点都在同一连通分量为止。
- 该方法的难点:
- 怎么判断两个结点是否属于连通分量
- 怎么将两个结点合并成一个连通分量
- 显然可以用并查集(数与等价类)来解决上述两个难点。
bool cmp(ArdCell a, ArcCell b)
{
return a.weight < b.weight;
}
void InitMFset(MGragh G, MFSet& set)
{
set.n = G.vexnum;
for (int i = 0; i <set.n; ++i)
{
set.nodes[i].parent = -1;
}
}
void MiniSpanTree_Kruskal(MGragh G)
{
int i, j,edgenum=0;
sort(edge, edge + G.arcnum, cmp);
InitMFset(G, set);
for (i = 0; i < G.arcnum; ++i)
{
//找到两个结点的根结点
int index1 = better_find_mfset(set, edge[i].u);
int index2 = better_find_mfset(set, edge[i].w);
//判断两个结点是否属于同一个连通分量
if (index1 == index2)
continue;
//合并
better_merge_mfset(set, index1, index2);
cout << edge[i].u << " " << edge[i].w << endl;
++edgenum;
if (edgenum == G.vexnum - 1)
break;
}
}
(4)关节点和重连通分量
- 关节点和重连通图:假若在删去顶点v以及和v相关联的各边之后,将图的一个连通分量分割成两个或两个以上的连通分量,则称顶点v为该图的一个关节点。一个没有关节点的连通图称为是重连通图。在重连通图上,任意一对顶点之间至少存在两条路径。
- 连通度:若在重连通图上至少删去k个顶点才能破坏图的连通性,则称此图的连通度为k。
- 关节点和重连通在实际中有较多应用。显然,一个表示通信网络的图的连通度越高,其系统越可靠,无论是哪一站点出现故障或遭到外界破坏,都不影响系统的正常工作;又如,一个航空网是重连通的,则当某条航线因天气等某种原因关闭时,旅客仍可从别的航线绕道而行。反之,在战争中,若要摧毁敌方的运输线,仅需破坏其运输网中的关节点即可。
- 利用深度优先搜索便可求得图的关节点,并由此可判别图是否是重连通的。
- 找出连通图中所有的关节点:
- 具体算法(邻接表存储)
int count = 1;
int visited[MAX]; //访问标志数组
void FindArticul(Graph G)
{
visited[0] = 1; //设定图上0号顶点为生成树的根
int v;
for(1=0;v<G.vexnum;++v)
visited[v] = 0;
p = G.vertices[0].firstarc;
v = p->adjvex;
DFS(G,v); //从第v顶点出发深度优先查找关节点
if(count<G.vexnum) //生成树的根至少有两棵子树
{
cout<<0<<G.vertices[0].data; //根是关节点,输出
while(p->nextarc)
{
p = p->nextarc;
v = p->adjvex;
if(visited[v]==0)
DFS(G,v);
}
}
}
//从v开始深度遍历图,查找并输出关节点
void DFS(Graph G,int v)
{
visited[v] = min = ++count;
for(p=G.vertices[v].firstarc;p;p=p->nextarc)
{
w = p->adjvex; //得到v的邻接结点
//如果w未曾被访问
if(visited[w]==0)
{
DFSAriticul(G,w); //返回前求得low[w];
if(low[w]<min)
min = low[w];
if(low[w]>=visited[v])
cout<<v<<G.veretices[v].data; //输出关节点
}
else if(visited[w]<min) //w已访问,w是v在生成树上的祖先
min = visited[w];
}
low[v0]=min;
}
5.有向无环图及其应用
- 有向无环图:一个无环的有向图称作有向无环图,简称DAG图。DAG图是一类较有向树更一般的特殊有向图。
- 有向无环图是描述含有公共子式的表达式的有效工具,例如下述表达式:
- 检验有向无环图:
(1)拓扑排序
- 拓扑排序:由某个集合上的一个偏序得到该集合上的一个全序。
- 偏序和全序的定义:
- 直观的看,偏序集合中仅有部分成员之间可比较,而全序指集合中全体成员之间均可比较。
第二个图全序称为拓扑有序,所以从偏序定义得到拓扑有序的操作便是拓扑排序 - AOV网:用顶点表示活动,用弧表示活动间的优先关系的有向图称为顶点标识活动的网络,简称AOV网。在网中,若从顶点i到顶点j有一条有向路径,则i是j的前驱;j是i的后继。若<i,j>是网中一条弧,则i是j的直接前驱;j是i的直接后继
- 在AOV网中,不应该出现有向环,因为存在环意味着某项活动应以自己为先决条件,这样是荒谬的。因此,对给定的AOV-网应首先判定网中国是否存在环。检测的办法是对有向图构造其顶点的拓扑有序序列,若网中所有顶点都在它的拓扑有序序列中,则AOV网必不存在环。
- 如何进行拓扑排序?解决的办法很简单:
- 在有向图中选一个没有前驱的顶点且输出之。
- 从图中删除该顶点和所有以他为尾的弧
- 重复上述两步,直至全部顶点均已输出,或者当前图中不存在无前驱的顶点为止,后一种情况则说明有向图中存在环。
- 针对上述操作,我们可采用邻接表有向图的存储结构,且在头结点增加一个存放顶点入度数。入度为零的顶点即为没有前驱的顶点,删除顶点及以它为尾的弧的操作,则换以弧头顶点的入度减1来实现
- 为了避免重复检测入度为零的顶点,可另设一栈暂存所有入度为零的顶点
bool TopologicalSort(ALGraph G)
{
Stack s;
InitStack(s);
//首次
for (int i = 0; i < G.vexnum; ++i)
{
if (G.vertices[i].rudu == 0)
Push(s, i);
}
//对输出的结点计数
int count = 0;
while (!Empty(s))
{
int i = 0;
Pop(s, i);
cout << i << G.vertices[i].data;
++count;
//对该节点的弧进行删减,并将删除后没有前驱的结点入栈
for (p = G.vertices[i].firstarc; p; p = p->nextarc)
{
G.vertices[p.adjvex].rudu--;
if (G.vertices[p.adjvex].rudu == 0)
Push(s, k);
}
}
if (count < G.vexnum)
return false;
}
(2)关键路径
- 对AOV网相对应的是AOE网即边表示活动的网。AOE网是一个带权的有向无环图,其中,顶点表示事件,弧表示活动,权表示活动持续的时间。通常,AOE网可用来估算工程的完成时间。
- 由于整个工程只有一个开始点和一个完成点,故在正常情况下,网中只有一个入度为零的点和一个出度为零的点。
- 和AOV网不同,对AOE网有待研究的问题是:
- 完成整项工程至少需要多少时间
- 哪些活动是影响工程进度的关键?
- 由于在AOE网中有些活动可以并行地进行,所以完成工程的最短时间是从开始点到完成点的最长路径的长度。路径长度最长的路径叫做关键路径。
- 假设开始点是v1,从v1到vi的最长路径长度叫做事件vi的最早发生时间(最长路径才能保证在此之前的事件全全部完成)。这个时间决定了所有以vi为尾的弧所表示的活动的最早开始时间。
- 我们常用e(i)表示活动ai的最早开始时间,还可以定义一个活动的最迟开始时间l(i),这是在不推迟整个工程完成的前提下,活动ai最迟必须开始进行的时间。两者之差l(i)-e(i)意味着完成活动ai的时间余量。我们把l(i)=e(i)的活动叫做关键活动。显然,关键路径上的所有活动都是关键活动,因此提前完成非关键活动并不能加快工程的速度。
- 因此,分析关键路径的目的是辨别哪些是关键活动,以便争取提高关系活动的功效,缩短整个工期。
- 简单来说,关键路径就是一条耗时最长的路径,该路径上的关键活动时间只要变化,整个关键路径就会变化,而不在关键路径上的非关键路径可以弹性的伸缩,我们主要解决在找出关键路径,优化关键活动的时间。
- 算法主要思想
这两个递推公式的计算必须分别在拓扑有序和逆拓扑有序的前提下进行。 - 算法主要步骤:
int ve[MAX_VERTEX_NUM]; //存储各顶点事件的最早发生时间
int vl[MAX_VERTEX_NUM]; //存储各顶点事件的最晚发生时间
Stack T;
bool TopologicalSort(ALGraph G)
{
Stack s;
InitStack(s);
memset(ve, 0, G.vexnum - 1);
//首次
for (int i = 0; i < G.vexnum; ++i)
{
if (G.vertices[i].rudu == 0)
Push(s, i);
}
//对输出的结点计数
int count = 0;
while (!Empty(s))
{
int i = 0;
Pop(s, i);
Push(T, i);
++count;
//对该节点的弧进行删减,并将删除后没有前驱的结点入栈
for (ArcNode* p = G.vertices[i].firstarc; p; p = p->nextarc)
{
int k = p->adjvex;
if (--G.vertices[k].rudu == 0)
Push(s, k);
if (ve[j] + *(p->info) > ve[k])
ve[k] = ve[j] + *(p->info);
}
}
if (count < G.vexnum)
return false;
}
bool CriticalPath(ALGraph G)
{
//先进行拓扑排序
if (!TopologicalSort(G))
return false;
for (int i = 0; i < G.vexnum; ++i)
vl[i] = ve[i];
//按拓扑逆序求各定点的vl值
while (!Empty(T))
{
int i = 0;
Pop(T, i);
for (ArcNode* p = G.vertices[i].firstarc; p; p = p->nextarc)
{
int k = p->adjvex;
dut = *(p->info);
if (vl[k] - dut < vl[j])
vl[j] = vl[k] - dut;
}
}
//求ee,和关键活动
for(j=0;j<G.vexnum;++j)
for (ArcNode* p = G.vertices[j].firstarc; p; p->nextarc)
{
int k = p->adjvex
dut = *(p->info);
ee = ve[j];
el = vl[k] - dut;
char tag = (ee == el) ? '*':' ';
cout << j << k << dut << ee << el << tag;
}
}
- 注意:若网中有几条关键路径,那么, 单是提高一条关键路径上的关键活动的速度,还不能导致整个工程缩短工期,而必须提高同时在几条关键路径上的活动的速度。
6.最短路径
(1)从某个源点到某个终点的最短路径
① 深度优先搜索算法
- 从起点开始访问所有深度遍历路径,则到达终点结点的路径有多条,取其中路径权值最短的一条则为最短路径
#define inf INT_MAX
int visited[100] = {0};
//源和目标结点
int src,dest;
int minpath = inf;
//cur是当前节点,dst是距离
void DFS(int cur,int dst)
{
//如果距离已经大于之前算过的,就没必要往下遍历了
if(minpath<dst) return;
if(cur == dest)
{
if(minpath>dst)
{
minpath = dst;
return;
}
}
for(int i = 0;i < n;++i)
{
if(visited[i]==0 && edge[cur][i]!=inf)
{
visited[i] = 1;
DFS(i,dst+edge[cur][i]);
visited[i] = 0;
}
}
}
(2)从某个源点到其余顶点的最短路径
① 迪杰斯特拉算法
- 每次找到离源点最近的一个顶点,然后以该顶点为中心进行扩展,最终得到源点到其余所有点的最短路径
- 基本步骤:
- 设置标记数组book[]:将所有的顶点分为两部分,已知最短路径的顶点集合P和未知最短路径的顶点集合Q,很显然最开始集合P只有源点一个顶点。book[i]为1表示在集合P中;
- 设置最短路径数组dst并不断更新:初始状态下,dst[i]=edge[s][i,很显然此时dst[s]=0,book[s]=1.此时,在集合Q中可选择一个离源点s最近的顶点u加入到P中。并依据以u为新的中心点,对每一条边进行松弛操作(松弛是指由顶点s–>j的途中可以经过点u,并令dst[j]=min(dst[j],dst[u]+edge[u][j])),并令book[u]=1;
- 在集合Q中再次选择一个离源点s最近的顶点v加入到P中。并依据v为新的中心点,对每一条边进行松弛操作(即dst[j]=min(dst[j],dst[v]+edge[v][j])),并令book[v]=1;
- 重复3,直至集合Q为空。
#define nmax
#define inf INT_MAX
int dst[nmax];
int book[nmax];
void Dijstra(int u)
{
int i;
for(i=0;i<n;++i)
{
dst[i] = edge[u][i];
book[i] =0;
}
book[u] = 1;
for(i=0;i<n-1;++i)
{
int min = inf;
for(int j = 0;j<n-1;++j)
{
if(book[j]==0 && dst[j]<min)
{
min = dst[j];
u = j;
}
book[u] = 1;
//更新最短路径数组
for(int k=1;k<=n;k++)
{
if(book[k]==0&&dst[k]>dst[u]+edge[u][k]&&edge[u][k]<inf)
dst[k]=dst[u]+edge[u][k];
}
}
}
}
- Dijkstra算法的局限性
像上图,如果用dijkstra算法的话就会出错,因为如果从1开始,第一步dist[2] = 7, dist[3] = 5;在其中找出最小的边是dist[3] = 5;然后更新dist[2] = 0,最终得到dist[2] = 0,dist[3] = 5,而实际上dist[3] = 2;所以如果图中含有负权值,dijkstra失效
② Bellman-Ford算法(解决负权边)
- 所有的边进行n-1轮松弛,因为在一个含有n个顶点的图中,任意两点之间的最短路径最多包含n-1条边。换句话说,第1轮在对所有的边进行松弛操作后,得到从1号顶点只能经过一条边到达其余各定点的最短路径长度,第2轮在对所有的边进行松弛操作后,得到从1号顶点只能经过两条边到达其余各定点的最短路径长度,…
- 在从distk-1[u]递推到distk[u]的时候,Bellman-Ford算法的本质是对每条边<u, v>进行判断:设边<u, v>的权值为w(u, v),如果边<u, v>的引入会使得distk-1[v]的值再减小,就要修改distk-1[v],即:如果distk-1[u] + w(u, v) < distk-1[v],,那么distk[v] = distk-1[u] + w(u, v),这个称为一次松弛
所以可以得到递推公式:
初始:dist0[u] = INF dist0[v0] = 0(v0是源点)
递推:对于每条边(u, v) distk[v] = min(distk-1[v], distk-1[u] + w(u, v))(松弛操作,迭代n-1次) - 此外,Bellman-Ford算法可以检测一个图是否含有负权回路:如果迭代n-1次后,再次迭代,如果此时还有dist会更新,说明存在负环。
#include<bits/stdc++.h>
using namespace std;
#define nmax 1001
#define inf 999999999
int n,m,s[nmax],e[nmax],w[nmax],dst[nmax];
int main ()
{
while(cin>>n>>m&&n!=0&&m!=0){
for(int i=1;i<=m;i++){
cin>>s[i]>>e[i]>>w[i];
}
for(int i=1;i<=n;i++)
dst[i]=inf;
dst[1]=0;
//主要算法:
for(int i=1;i<=n-1;++i){
for(int j=0;j<m;++j){
if(dst[e[j]]>dst[s[j]]+w[j]){
dst[e[j]]=dst[s[j]]+w[j];
}
}
}
int flag=0;
//判断是否存在负环
for(int i=0;i<m;++i){
if(dst[e[i]]>dst[s[i]]+w[i]){
flag=1;
}
}
if(flag) cout<<"此图有负权回路"<<endl;
else{
for(int i=1;i<=n;i++){
if(i==1) cout<<dst[i];
else cout<<' '<<dst[i];
}
cout<<endl;
}
}
}
- 时间复杂度为:O(m*n)
③Bellman-Ford算法的改进—SPFA算法
虽然Bellman-Ford算法的思路很简洁,但是O(VE)的时间复杂度确实很高,在很多情况下并不尽如意。仔细思考后会发现,Bellman-Ford算法的每轮操作都需要操作所有边,显然这其中会有大量无意义的操作,严重影响了算法的性能。
于是注意到,只有当某个顶点u的dst[u]值改变时,从它出发的边的邻接点v的dst[v]值才有可能被改变。
由此可以进行一个优化:建立一个队列,每次将队首顶点u取出,然后对从u出发的所有边u->v进行松弛操作,如果d[v](这一轮的d[v]可能就是下一轮的d[u])改变了,如果v不在队列中,就把v加入队列。这样操作直到队列为空(图中没有从从源点可达的负环),或是某个顶点的入队次数超过V-1(说明图中存在从源点可达的负环)
void spfa()
{
queue<int>q;
q.push(1);
vis[1]=1;
while(!q.empty())
{
int t=q.front();
q.pop();
vis[t]=0;
for(int i=head[t];i!=-1;i=e[i].next){
int s=e[i].to;
if(dis[s]>dis[t]+e[i].val){
dis[s]=dis[t]+e[i].val;
if(vis[s]==0){
q.push(s);
vis[s]=1;
}
}
}
}
}
(3)多源点最短路径
①弗洛伊德算法
- 最开始只允许经过1号顶点进行中转,接下来只允许经过1号和2号顶点进行中转…允许经过1~n号所有顶点进行中转,来不断动态更新任意两点之间的最短距离。即求从i号顶点到j顶点只经过前k号点的最短距离。
#define inf INT_MAX
void Floyd ()
{
for(int k=0;k<n;++k){
for(int i=0;i<n;i++i){
for(int 0=1;j<n;++j){
if(edge[k][j]<inf&&edge[i][k]<inf&&edge[i][j]>edge[i][k]+edge[k][j]){
edge[i][j]=edge[i][k]+edge[k][j];
}
}
}
}
7.最大流量(网络流)问题
最大流量问题是跟一个特殊的有向图有关,这个有向图称为流量网络,或者简称网络,它包含以下的特征:
(1)包含1个没有输入边的顶点;该顶点称为源点,用数字1标识
(2)包含1个没有输出边的顶点;该顶点称为汇点,用数字n标识
(3)每条有向边(i,j)的权重uij是一个正整数(这个数字表示该边所代表的链路把物质i送到j的数量上限),称为该边的容量
网络图示例:
流量守恒要求:
该模型假设源点和汇点分别是物质流唯一的出发地和目的地,所有其他的顶点只能改变流的方向,但不能消耗或者添加物质。换句话说,进入中间顶点的物质总量必须等于离开的物质总量。
由流量守恒我们知道源点的总输出流等价于汇点的总输入流,我们称之为流的值,标记为v,这就是我们想要最大化的目标
因此,一个给定网络的(可行)流(flow)是实数值xij对边(i,j)的分配,使得网络满足流量守恒约束和容量约束
对于每条边(i,j)∈E来说,0<=xij<=uij
因此,最大流量问题可以用下面这个最优问题正式定义:
对于每条边(i,j)∈E来说,0<=xij<=uij
根据约束:对每个顶点(除源点和汇点)来说,进的流量=出的流量
使得源点的流达到最大化
我们用到了迭代改进的思想:
我们总是可以从流量0开始(也就是对于网络的每条边(i,j),都设置xij)。然后,在每次迭代时, 试着找到一条可以传输更多的流量的,从源点到汇点的路径。这样的路径被称为流量增益路径。如果找到了一条流量增益路径,我们沿着路径调整边上的流量,以得到更大的流量值,并试着为新的流量找到一条新的增益路径,如果不能找到流量增益路径,我们就认为当前流量已经是最优的了。这个解的最大流量问题的一般性模板被称为增益路径法,也被称为Ford-FulKerson。
为了求流量x的流量增益路径,我们需要考虑无向图中具有如下特征的任意连续顶点i和j:
- 它们以从i和j的有向边连接,该边具有正的未使用流量rij = uij-xij(使得我们可以把通过改变的流量最多增加rij个单位)
- 它们以从j到i的有向边连接,该边具有正的流量xji(使得我们可以把通过该边的流量最多减少xji个单位)
第一类称为前向边,第二类称为后向边
对于一个给定的流量增益路径,设r是路径中所有前向边的流量增益路径,设r是路径中所有前向边的未使用容量rij和所有后向边的流量ji的最小值。很容易看出,如果我们在每条前向边的当前流量上增加r,在每条后向边的流量上减去r,就会得到一个可行流量。的确,设i是一条流量增益增益路径上的一个中间顶点。在顶点上存在前向边和后向边的4种可能组合:
无论哪种情况,在完成边上给出的流量调整以后,顶点i仍然满足流量守恒要求。
基于所有边的容量都是整数的假设,r也一定是一个正整数。因此,在增量路径法的每次迭代中,流量的值至少增加1。由于流量最大值的上界已经确定,增益路径法在有限次迭代后一定会停止。令人惊讶的是,最终的流量一定是最大化的,而且和增量路径的变化次序无关。
幸运的是,有若干生成流量增益路径的高效方法,它们都可以避免上例中显示的性能下降。其中最简单的一种方法利用广度优先查找,用数量最少的边来生成增益路径。这种增量路径法称为最短增益路径法或者先标记先扫描法。这里的标记意味着用两个记号来标记一个新的(未标记)的顶点。第一个标记指出从源点到被标记顶点还能增加多少流量。第二标记指出了另一个顶点的名字 ,就是从该顶点访问到被标记顶点的。方便起见也可以为第二个标记加上+或者-符号,用来分别指出该顶点是通过前向边还是后向边访问到的。因此,源点总是可以标记为∞,-。对于其他顶点, 则要按照下述方法计算它的标记:
具体算法如下
#include <iostream>
#include<string.h>
#include<algorithm>
#include<vector>
#include<stack>
#include<map>
#include<set>
#include<queue>
#include<list>
using namespace std;
#define nmax 100
#define inf 999999
typedef struct
{
int x; //当前流量
int u; //总容量
}edge;
typedef struct
{
bool mark = false;
int l;
int lastV;
}v;
edge graph[nmax][nmax];
v vex[nmax];
int n, m;
int dest;
int ShortestAugmentingPath(int src)
{
int x = 0;
queue<int> q;
q.push(src);
vex[src].l = inf;
vex[src].mark = true;
while (!q.empty())
{
int cur = q.front();
q.pop();
//从i到j的每一条边————前向边
for (int i = 1; i <= n; ++i)
{
if (graph[cur][i].u != inf && !vex[i].mark)
{
int r = graph[cur][i].u - graph[cur][i].x;
//如果有可增加容量,则标记
if (r > 0)
{
vex[i].mark = true;
vex[i].l = min(vex[cur].l, r);
vex[i].lastV = cur;
q.push(i);
}
}
}
//从j到i的每一条————后向边
for (int i = 1; i <= n; ++i)
{
if (graph[i][cur].u != inf && !vex[i].mark)
{
if (graph[i][cur].x > 0)
{
vex[i].mark = true;
vex[i].l = min(vex[cur].l, graph[i][cur].x);
vex[i].lastV = -cur;
q.push(i);
}
}
}
//如果汇点被标记了,沿着找到的增益路径进行增益
if (vex[dest].mark)
{
int tmp = dest;
int l = vex[tmp].l;
while (tmp != src) //没有回到源点
{
//如果是前向边
if (vex[tmp].lastV > 0)
graph[vex[tmp].lastV][tmp].x += l;
else
graph[tmp][-vex[tmp].lastV].x -= l;
tmp = abs(vex[tmp].lastV);
}
//除了源点,擦去所有顶点的标记
for (int i = 2; i <= n; ++i)
{
vex[i].mark = false;
}
//清空队列
while (!q.empty())
{
q.pop();
}
//加入源点
q.push(src);
}
}
for (int i = 1; i <= n; ++i)
{
if (graph[src][i].u != inf)
{
x += graph[src][i].x;
}
}
return x;
}
int main()
{
cin >> n >> m>>dest;
for (int i = 1; i <= n; ++i)
{
for (int j = 1; j <= n; ++j)
{
graph[i][j].x = 0;
graph[i][j].u = inf;
}
}
for (int i = 1; i <= m; ++i)
{
int u, v;
cin >> u >> v;
cin >> graph[u][v].u;
}
cout<<ShortestAugmentingPath(1);
return 0;
}
算法使用示例
8.二分法最大匹配
(1)二分图
在一个二分图中,所有的顶点都可以分为两个不想交的集合V和U,两个集合大小不一定相等,但每条边都连接两个集合中各一个顶点。换句话说,一个二分图的顶点可以染成两种颜色,使得每条边两头顶点的颜色是不同的,这样的图也称为二色图。不难证明,当且仅当图中不存在奇数长度的回路时,图是二色图
(2) 迭代改进解决最大匹配
设M是二分图G={V,U,E}的一个匹配。
一般来说,我们通过构造构造一条简单路径来增加当前匹配的规模。这条路径一头连接V中的自由顶点,另一头连接U中的自由顶点,路径上的边交替出现在E-M和M中。也就是说,路径上的第一条边不属于M,第二条边属于M,以此类推,直到最后一条不属于M的边。这种路径称为M的增益路径。
由于增益路径的长度总是为奇数,把位置为奇数的边加入M,把位置为偶数的边从M中删除,就可以生成一个新的匹配,该匹配比M多一条边,对匹配的这种调整称为增益
不断找增益路径,直到找不到增益路径后,就能得到最大匹配。
(3)算法实现
现在提供一个具体的算法来实现这个思路。该算法用一种类似广度优先查找的图遍历方法来搜索匹配M的一个增益路径,它从V或者U中选择一个集合,从该集合的所有自由顶点开始搜索(合理的做法是选择较小的顶点集合)。
回想一下,增益路径如果存在,它是一条连接V中自由顶点和U中自由顶点的奇数长度的路径,除非它只包含一条边,否则,它沿“之”字形,把V中的一个顶点和U中另一个顶点相连,然后再沿“之”字形,沿着M所定义的唯一一条可能边连回V,依次类推,直到遇到U中的一个自由顶点。根据这个现象,在对图进行类似广度优先查找遍历时,可以根据下面的规则来标记顶点:
- (队列的第一个顶点w在V中)如果u是邻接w的一个自由顶点,它被作为增益路径的另一个端点,因此标记停止,对匹配的增益可以开始了。我们沿着顶点的标记回溯,交替把它的边加入当前匹配或者从当前匹配中删除,以得到所求的增益路径。
如果u不是自由顶点,而且通过一条不在M中的边和w向连,则把u标记为“w”,除非u已经被标记过。 - (队列的第一个顶点w在U中)在这种情况下,w已经被匹配了,我们把它在V中的对偶标记为“w”。
更生动的理解可以参考这篇博客:算法讲解:二分图匹配
#define nmax 100
#define inf 999999
int line[nmax][nmax];
int girl[nmax], used[nmax];
int n,m;
bool found(int x)
{
for (int i = 1; i <= n; ++i)
{
if (line[x][i] && !used[i])
{
used[i] = 1;
if (girl[i] == 0 || found(girl[i]))
{
girl[i] = x;
return true;
}
}
}
return false;
}
int main()
{
cin >> n;
for (int i = 1; i <= n; ++i)
{
for (int j = 1; j <= n; ++j)
{
line[i][j] = 0;
}
}
for (int i = 1; i <= m; ++i)
{
int u, v;
cin >> line[u][v];
}
memset(girl, 0, sizeof(girl));
//最大匹配数
int sum = 0;
for (int i = 1; i <= m; ++i)
{
memset(used, 0, sizeof(used));
if (found(i))
sum++;
}
return 0;
}
9.二分图带权最大匹配
具体看这篇博客:
KM算法详解+模板
10.稳定婚姻问题
(1)问题定义
稳定婚姻讲的是什么问题呢?
婚介所登记了N位男孩和N位女孩,每个男孩都对N个女孩的喜欢程度做了排序,每个女孩都对N个男孩的喜欢程度做了排序,你作为月老,能否给出稳定的牵手方案?
稳定的定义:如果男孩i和女孩a牵手,但男孩i对女孩b更喜欢,而女孩b的男朋友j拼不过男孩i,则没有力量阻碍男孩i和女孩b的私奔,这即是不稳定的。
也就是说其实其本质是二分图匹配的一种特殊情况,图中每个男孩都能跟每个女孩进行匹配,每个女孩也能跟每个男孩进行匹配,只是优先级不同。
(2) 算法描述
1962 年,美国数学家 David Gale 和 Lloyd Shapley 发明了一种寻找稳定婚姻的策略。不管男女各有多少人,不管他们各自的偏好如何,应用这种策略后总能得到一个稳定的婚姻搭配。具体算法描述如下:
-
第一轮,每个男人都选择自己名单上排在首位的女人,并向她表白。这种时候会出现两种情况:
- 该女士还没有被男生追求过,则该女士接受该男生的请求。
- 若该女生已经接受过其他男生的追求,那么该女生会将该男士与她的现任男友进行比较,若更喜欢她的男友,那么拒绝这个人的追求,否则,抛弃其男友
-
第一轮结束后,有些男人已经有女朋友了,有些男人仍然是单身。
-
在第二轮追女行动中,每个单身男都从所有还没拒绝过他的女孩中选出自己最中意的那一个,并向她表白,不管她现在是否是单身。这种时候还是会遇到上面所说的两种情况,还是同样的解决方案。直到所有人都不再是单身。
using namespace std;
#define nmax 5
#define inf 999999
int match[nmax];
int man[nmax][nmax] = {
{0,0,0,0,0},
{0,2,3,1,0},
{0,2,1,3,0},
{0,0,2,3,1},
{0,1,3,2,0},
};
int woman[nmax][nmax] = {
{0,0,0,0,0},
{0,0,3,2,1},
{0,0,1,2,3},
{0,0,2,3,1},
{0,1,0,3,2},
};
int refuse[nmax][nmax];
int mark[nmax]; //判断男生是否心有所属
//女生i是否已经被选择
int choose[nmax];
bool Judgemark(int n )
{
for (int i = 1; i <= n; ++i)
if (mark[i] == 0)
return false;
return true;
}
void GaleShapley(int n)
{
while (!Judgemark(n))
{
//遍历男生
for (int i = 1; i <= n; ++i)
{
if (mark[i] != 0) { continue; }
//找出优先级最高且未曾拒绝过的女生
int min = inf;
int womanIndex = -1;
for (int j = 1; j <= n; ++j)
{
if (man[i][j] < min && refuse[i][j] == 0)
{
womanIndex = man[i][j];
womanIndex = j;
}
}
//如果女生还没有男生追求过,则接受追求
if (choose[womanIndex] == 0)
{
choose[womanIndex] = i;
mark[i] = 1;
}
//如果追求过
else
{
int FirstMan = choose[womanIndex];
//如果女生更喜欢原配,则拒绝他
if (woman[womanIndex][FirstMan] >= woman[womanIndex][i])
{
refuse[i][womanIndex] = 1;
}
else
{
choose[womanIndex] = i;
mark[i] = 1;
mark[FirstMan] = 0;
}
}
}
}
}
void Print(int N) {
for (int i = 1; i <= N; i++)
cout << "Boy " << i << " matches " << "Girl " << choose[i] << endl;
cout << endl;
}
int main()
{
memset(refuse, 0, sizeof(refuse));
memset(choose, 0, sizeof(choose));
GaleShapley(nmax);
Print(nmax);
return 0;
}
11. 旅行商问题
(1)问题描述
按照非专业的说法:
这个问题要求找出一条n个给定的城市间的最短路径,使我们在回到出发的城市之前,对每个城市都只访问一次。
专业的说法:
q求一个带权图的最短哈密顿回路。哈密顿回路即对图的每个顶点都只穿越一次的回路
(2)穷举查找
即蛮力法,使用DFS深度遍历图,如果遇到有更小的方案则更新
#include <iostream>
#include<algorithm>
#include<vector>
using namespace std;
#define nmax 105
#define inf 999999
int graph[nmax][nmax];
int mark[nmax];
int mindist = inf;
typedef struct
{
int dist;
vector<int> vec;
}path;
vector<int> vec;
vector<path> pa;
int n, m;
bool cmp(const path& a, const path& b)
{
return a.dist < b.dist;
}
void DFS(int cur, int dist) {
bool boundary = true;
for (int i = 0; i < n; ++i) {
if (mark[i] == 0 && graph[cur][i] != inf) {
boundary = false;
mark[i] = 1;
vec.emplace_back(i);
DFS(i, dist + graph[cur][i]);
vec.pop_back();
mark[i] = 0;
}
}
//如果到了边界
if (boundary && graph[cur][0]!=inf) {
//检查是不是遍历了全部景点
for (int i = 0; i < n; ++i) {
if (mark[i] == 0)
return;
}
if (mindist >= dist + graph[cur][0]) {
mindist = dist + graph[cur][0];
vec.emplace_back(0);
path p;
p.vec = vec;
p.dist = mindist;
pa.emplace_back(p);
vec.pop_back();
}
}
}
int main()
{
cin >> n >> m;
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
graph[i][j] = inf;
}
}
for (int i = 0; i < m; ++i) {
int u, v;
cin >> u >> v;
cin>>graph[u][v];
graph[v][u] = graph[u][v];
}
memset(mark, 0, sizeof(mark));
mark[0] = 1;
vec.emplace_back(0);
DFS(0, 0);
sort(pa.begin(), pa.end(), cmp);
int dist = pa[0].dist;
cout << dist << endl;
for (auto& p : pa) {
if (p.dist != dist)
break;
cout << p.vec[0];
for (int i = 1; i < p.vec.size(); ++i) {
cout << "->" << p.vec[i];
}
cout << endl;
}
return 0;
}