目录
注
本笔记参考:《数据结构(C语言版)(第2版)》
接下来将记录的是几个常用的算法。
最小生成树
【情景】
现在需要在n个城市之间建立通信网。已知:
- 连通n个城市仅需n - 1条线路;
- n个城市之间最多可能设置n(n - 1) / 2条线路;
- 连通一条线路需要付出相应的经济代价。
【需求】
需要在上述可能的n(n - 1) / 2条线路中选择n - 1条线路,使得总的耗费最少。
【分析】
可以用一个连通图来展示上述情景:
由上述这张网可以建立许多不同的生成树。其中,最合理的通信网是代价之和最小的生成树,这棵生成树就是该连通网的最小生成树。
在进行最小生成树的生成时,多数会用到MST性质:假设N = (V, E)是一张连通网,U是顶点集V的一个非空子集。若(u, v)是一条具有最小权值(代价)的边,其中u ∈ U,v ∈ V - U,则必定存在一棵包含边(u, v)的最小生成树。
接下来出现的普利姆算法和克鲁斯卡尔算法都利用了MST性质。
1. 普利姆算法(加点法)
假设N = (V, E)表示连通网G₅,TE是N上最小生成树中边的集合。则普利姆算法的构造过程如图所示(该算法从顶点V₁开始):
该算法寻找对某一顶点而言权值最小的边,再在最小边对应的另一结点上重复该过程。在这一过程中,U中的顶点在不断增加,所以普利姆算法也可以被称为“加点法”。
注意:选择最小边时,若存在多条同样权值的边可选,则任选其一。
该算法需要一个数组承接信息,因此设计辅助数组closedge:
//---辅助数组,用以记录从顶点集U到V-U的权值的最小边---
struct
{
VerTexType adjvex; //最小边在U中的那个顶点
ArcType lowcost; //最小边上的权值
}closedge[MVNum];
【参考代码:Min函数】
int Min(int vexnum)
{
int k = 0;
for (int i = 0; i < vexnum; i++)
if (closedge[i].lowcost != 0)
k = i;
for (int i = 0; i < vexnum; i++)
if (closedge[i].lowcost != 0 && closedge[k].lowcost > closedge[i].lowcost)
k = i;
return k;
}
【参考代码:MiniSpanTree_Prim函数,以邻接矩阵存储(算法本体)】
void MiniSpanTree_Prim(AMGraph G, VerTexType u)
{//从u出发构造G的最小生成树T,并输出T的各条边
int k = LocateAMGVex(G, u); //k为顶点u的下标
for (int j = 0; j < G.vexnum; j++) //对V-U的每个顶点,初始化closeedge[j]
if (j != k)
closedge[j] = { u, G.arcs[k][j] }; //{顶点, 权值}
closedge[k].lowcost = 0; //初始化,初始时U = {u}
for (int i = 1; i < G.vexnum; i++) //选择剩余的n-1个顶点,生成n-1条边
{
k = Min(G.vexnum); //从closedge内存储的各条边中,选出最小边closedge[k]
VerTexType u_0 = closedge[k].adjvex; //u_0是最小边的顶点
VerTexType v_0 = G.vexs[k]; //v_0是最小比边的另一个顶点,v_0∈V - U
cout << u_0 << v_0 << endl;
closedge[k].lowcost = 0; //将第k个顶点并入U集
for (int j = 0; j < G.vexnum; j++) //将新顶点并入U,再选择新的最小边
{
if (G.arcs[k][j] < closedge[j].lowcost) //若k到j的边,其权值比原本的closedge[j].lowcost小
closedge[j] = { G.vexs[k], G.arcs[k][j] };
}
}
}
【算法分析】
设网中有n个顶点,则:
- 进行初始化的循环语句的频度是:n;
- 第二个循环语句的频度是:n - 1,其中还有两个内循环:
- Min函数求最小值的频度是:n - 1;
- 重新选择具有最小值的边,其频度是:n。
由此可知,普利姆算法的时间复杂度为O(n²)。该算法与网的边数无关,适合稠密图。
2. 克鲁斯卡尔算法(加边法)
依旧通过连通网G₅对算法进行说明:
克鲁斯卡尔算法同样需要辅助数组,不同的是,需要两个不同的数组完成不同的工作:
//------存储边,及其两个顶点的信息------
struct
{
VerTexType Head; //边的起点
VerTexType Tail; //边的终点
ArcType lowcost; //边的权值
}Edge[MVNum];
//------标识两个连通分量------
int Vexset[MVNum];
【参考代码:MiniSpanTree_Kruskal函数,以邻接矩阵存储(算法本体)】
//------克鲁斯卡尔算法(无向网,用邻接矩阵存储)------
void MiniSpanTree_Kruskal(AMGraph G)
{
SortEdge(G.arcnum); //将数组Edge中的各个元素按权值从小到大排序
for (int i = 0; i < G.vexnum; i++) //初始化时,各个结点各为一个连通向量
Vexset[i] = i;
for (int i = 0; i < G.arcnum; i++) //依次查看数组Edge中的边
{
int v_head = LocateAMGVex(G, Edge[i].Head); //v_head是边的始点Head的下标
int v_tail = LocateAMGVex(G, Edge[i].Tail); //v_tail是边的终点Tail的下标
int cc_head = Vexset[v_head]; //获取始点所在的连通分量
int cc_tail = Vexset[v_tail]; //获取终点所在的连通分量
if (cc_head != cc_tail) //若边的两个顶点属于不同的连通分量
{
cout << Edge[i].Head; //输出此边
cout << Edge[i].Tail << endl;
for (int j = 0; j < G.arcnum; j++)
if (Vexset[j] == cc_tail) //统一两个集合的编号
Vexset[j] = cc_head;
}
}
}
【算法分析】
若已学习过堆排序,通过“堆”来存放网中的边,对于包含e条边的网,上述算法排序时间是O(elog₂e)。(但是笔者还未学习到此,故没有将Sort函数的实现展示出来)
在上述算法中,最耗时的操作是合并两个不同的连通分量,若数据结构合理,该步骤的执行时间是O(log₂e)。则整个for循环的执行时间会是O(elog₂e)。
综上所述,克鲁斯卡尔算法的时间复杂度可以达到O(elog₂e)。其只与网的边有关,更适合对稀疏图求最小生成树。
最短路径
假设在计算机上建立一个交通咨询系统,可以用图的结构标识实际的交通网络。其中:
- 顶点:表示城市;
- 边:表示城市间的交通联系。
若有人要从A城到B城,不同的人对于路径的选择有不同的要求:譬如要节省交通费用,要速度最快、里程度最少等等。为了在图上能够表示相关信息,就需要对边赋予权,权值就是两城市之间的距离、交通费用等。
在上述情况下,对于路径的度量就被转变为了对权值之和的度量。一般而言,交通图都存在方向,因此,交通网往往通过带权有向网表示。习惯上,① 带权优先网的第一个顶点被称为源点,② 最后一个顶点被称为终点。
1. 单源点的最短路径(迪杰斯特拉算法)
接下来将会讨论:给定带权有向图G和源点v,求从v到G中其余各顶点的最短路径。迪杰斯特拉算法是一个按路径长度递增的次序产生最短路径的算法。
在求解过程中,将把网N = (V, E)的顶点分为两组:
- S:已求出的最短路径的终点集合(初始时只包含源点v₀);
- V - S:尚未求出的最短路径的顶点集合(初始时为V - {v₀})。
现在约定,在最短路径问题下所述的路径长度均指其权值。
该算法依照各个顶点与v₀之间的间最短路径长度递增的次序,将集合V - S中的顶点加入到集合S中去。在此过程中,总保持:
从v₀到集合S中各个顶点的路径长度 ≤ 从v₀到集合V - S中各个顶点的路径长度
【例1】
总结有向图G₆中从v₀到各个顶点的最短路径:
源点 | 终点 | 最短路径 | 路径长度 |
---|---|---|---|
v₀ | v₂ | (v₀, v₂) | 10 |
v₄ | (v₀, v₄) | 30 | |
v₃ | (v₀, v₄, v₃) | 50 | |
v₅ | (v, v₄, v₃, v₅) | 60 | |
v₁ | 无 | ∞ |
当路径不存在时,默认该条路径长度为无限大。
上述从v₀到各个顶点的路径长度就是按照递增的顺序进行排列的(其中,从v₀到v₁没有路径)。
为了实现该算法,需要引入辅助的数据结构:
由于每当一个新顶点被加入集合S中时,对于V - S中的各个顶点而言,都会多出一个“中转”顶点,一条“中转”路径,此时需要更新V - S中各个顶点的最短路径长度。
【参考代码】
bool S[MVNum]; //记录从源点v_0到终点v_i是否已被确定最短路径
int Path[MVNum]; //记录从源点v_0到终点v_i,其当前的最短路径上vi的直接前驱顶点的序号
int D[MVNum]; //记录从源点v_0到终点v_i的当前最短路径长度
void ShortestPath_DIJ(AMGraph G, int v_0)
{
//---初始化---
int n = G.vexnum; //用n存储G中顶点的个数
for (int v = 0; v < n; v++)
{
S[v] = false; //初始化S
D[v] = G.arcs[v_0][v]; //初始化D(为v_0到各个终点的路径长度)
if (D[v] < MaxInt) //若v_0与当前终点之间存在路径
Path[v] = v_0;
else
Path[v] = -1; //路径不存在,赋值为-1
}
S[v_0] = true; //将v_0加入集合S
D[v_0] = 0; //源点到源点的距离是0
//---求到各个终点的最短路径---
for (int i = 1; i < n; i++) //对剩余n - 1个顶点求最短路径
{
int min = MaxInt;
int v = 0; //保管终点
for (int j = 0; j < n; j++)
{
if (!S[j] && D[j] < min) //选择当前的最短路径
{
v = j;
min = D[j];
}
}
S[v] = true;
for (int j = 0; j < n; j++) //更新从v_0出发到集合V - S上的所有顶点的最短路径长度
{
if (!S[j] && (D[v] + G.arcs[v][j] < D[j]))
{
D[j] = D[v] + G.arcs[v][j]; //更新路径长度
Path[j] = v; //更新j的前驱
}
}
}
}
在上述算法中,若IntMax定义过大,可能在最后的if处造成溢出。或者,可以将数组D的类型改为unsigned int类型,通过增大存储空间,来防止溢出。
通过上述算法处理有向图G₆,其结果用表格表示:
v = 0 | v = 1 | v = 2 | v = 3 | v = 4 | v = 5 | |
---|---|---|---|---|---|---|
S | true | false | true | true | true | true |
D | 0 | MaxInt | 10 | 50 | 30 | 60 |
Path | -1 | -1 | 0 | 4 | 0 | 3 |
【算法分析】
该算法的主循环执行了n - 1次,而每次进行主循环,其执行时间都是O(n),故该算法的时间复杂度为O(n²)(对邻接矩阵和邻接表都如此)。
若只需要寻找源点到某一顶点的最短路径,而仍用迪杰斯特拉算法,则时间复杂度不变(一样复杂)。
2. 每一个顶点之间的最短路径(弗洛伊德算法)
若要求每一个顶点之间的最短路径,可以使用两种方法:
- 调用n次的迪杰斯特拉算法;
- 使用弗洛伊德算法。
实际上,两种方式的时间复杂度都为O(n³),但弗洛伊德算法的形式更简单。
同样的,该算法也需要辅助的数据结构:
弗洛伊德算法不需要标记最短路径是否已被确认,因此不存在数组S。
若将弗洛伊德算法和迪杰斯特拉算法进行比较,会发现弗洛伊德算法也是在不断地修正中得到最终的最短路径:
- 算法通过初始化确定顶点之间的路径状况;
- 通过遍历寻找每个源点;
- 类似于迪杰斯特拉算法,弗洛伊德算法通过将原本的路径长度与“中转”路径长度比较,确定较小值;
- 循环,直到所有源点遍历结束。
【参考代码】
int Path[MaxInt][MaxInt]; //记录最短路径上顶点v_j的前一个顶点的序号
int D[MaxInt][MaxInt]; //记录顶点v_i和v_j之间的最短路径长度
void ShortestPath_Floyd(AMGraph G)
{
for (int i = 0; i < G.vexnum; i++)
{
//---初始化---
for (int j = 0; j < G.vexnum; j++)
{
D[i][j] = G.arcs[i][j];
if (D[i][j] < MaxInt)
Path[i][j] = i;
else
Path[i][j] = -1;
}
D[i][i] = 0;
}
//---遍历,寻找每个源点的最短路径---
for (int v = 0; v < G.vexnum; v++)
{
for (int i = 0; i < G.vexnum; i++)
{
for (int j = 0; j < G.vexnum; j++)
{
if (D[i][v] + D[v][j] < D[i][j]) //若从i经k到j的一条路径更短
{
D[i][j] = D[i][v] + D[v][j]; //更新D
Path[i][j] = Path[v][j]; //更改j的前驱为v
}
}
}
}
}
接下来通过G₇简单说明上述算法:
若把该算法的G₇的初始化结果即最终结果通过表格的形式显示,则如:
D | Path | ||||||||
---|---|---|---|---|---|---|---|---|---|
序号(下标) | 0 | 1 | 2 | 3 | 0 | 1 | 2 | 3 | |
0 | 0 | 1 | ∞ | 4 | -1 | 0 | -1 | 0 | |
1 | ∞ | 0 | 5 | 2 | -1 | -1 | 1 | 1 | |
2 | 3 | 9 | 0 | 8 | 2 | 2 | -1 | 2 | |
3 | ∞ | ∞ | 6 | 0 | -1 | -1 | 3 | -1 |
而当算法结束,就可以得到其最终的最短路径结果:
若需要通过表格2获取关于某一最短路径的信息,例如获得顶点0到顶点3的最短路径信息,可以参考:
- 最短路径长度:D[0][3] = 3。表示顶点0和顶点3之间的最短路径长度是3。
- 最短路径:Path[0][3] = 1(在当前最短路径中,顶点3的前驱是顶点1),Path[0][1] = 0。表示从顶点0到顶点3的最短路径是<0, 1>,<1, 3>。
拓扑排序
AOV-网
一个无环的有向图被称为有向无环图(Directed Acycline Graph),简称DAG图。这种图通常被用于描述一项工程或者系统的进行过程。一般地,一项工程可以可以被分为若干个被称为活动的子工程,子工程之间往往有着某些约束,譬如子过程开始的先后顺序。
以课程的学习为例:必修课可以被分为基础课和专业课。基础课独立于其他课程,而专业课的学习却存在先后顺序,例如:
课程编号 | 课程名称 | 先修课程 |
---|---|---|
C₁ | 程序设计基础 | 无 |
C₂ | 离散数学 | C₁ |
C₃ | 数据结构 | C₁、C₂ |
C₄ | 汇编语言 | C₁ |
C₅ | 高级语言程序设计 | C₃、C₄ |
C₆ | 计算机原理 | C₁₁ |
C₇ | 编译原理 | C₃、C₅ |
C₈ | 操作系统 | C₃、C₆ |
C₉ | 高等数学 | 无 |
C₁₀ | 线性代数 | C₉ |
C₁₁ | 普通物理 | C₉ |
C₁₂ | 数值分析 | C₁、C₉、C₁₀ |
如图所示:
类似于上图这种,通过顶点表示活动,使用弧表示活动间优先关系的有向图被称为:顶点表示活动的网(Activity On Vertex Network),简称AOV-网。
注意:在AOV-网中不应该出现有向环,因为若存在有向环,则代表着有活动是以自己为先决条件的。若这样设计,则工程将会无法进行,程序陷入死循环。
为了防止环的出现,就需要对有向图图中的顶点进行拓扑排序:若网中的顶点都在其的拓扑有序序列中,则该AOV-网中必定不存在环。
拓扑排序,就是将AOV-网中的所有顶点排列为一个线性序列,序列满足:
【例如】
当然,在上图中,拓扑有序序列并不止展示出来的两个。
若要输出拓扑排序序列,需要:
- 寻找图中无前驱的顶点,输出该顶点;
- 删除该顶点和以它为尾的弧;
- 重复上述步骤,直到不存在无前驱的顶点。
如此,一串拓扑排序序列的输出如图所示:
同样地,该算法也需要使用辅助的数据结构:
注:接下来的图将用邻接表进行存储。
【参考代码:FindInDegree函数(求顶点的入度)】
void FindInDegree(ALGraph G)
{
ArcNode* p = NULL;
for (int i = 0; i < G.vexnum; i++) //遍历邻接表
{
if(G.vertices[i].firstarc)
p = G.vertices[i].firstarc;
while (p)
{
indegree[p->adjvex]++; //计算入度
p = p->nextarc;
}
}
}
【参考代码:TopologicalSort函数(算法本体)】
int indegree[MVNum]; //用于存放顶点的入度,若顶点没有前驱,则将入度置为0。
int topo[MVNum] = { 0 }; //记录拓扑序列的顶点序号。
//------拓扑排序本体------
Status TopologicalSort(ALGraph G, int topo[])
{
FindInDegree(G); //求出各顶点的入度,将其存放在数组indegree中
LinkStack S; //此次所有的是链栈
InitStack(S); //将栈初始化
for (int i = 0; i < G.vexnum; i++)
{
if (!indegree[i])
Push(S, i); //将入度为0的顶点入栈
}
int top = 0; //对输出的顶点进行计数
while (S)
{
int i = 0;
Pop(S, i);
topo[top++] = i;
ArcNode* p = G.vertices[i].firstarc;
while (p)
{
indegree[p->adjvex]--;
if (!indegree[p->adjvex]) //将入度为0者入栈
Push(S, p->adjvex);
p = p->nextarc;
}
}
if (top < G.vexnum)
return false;
else
return true;
}
【算法分析】
上述算法所需时间主要集中在三个方面:
- 求各顶点入度(FindInDegree函数),时间复杂度为O(e);
- 建立入度为0的顶点栈,时间复杂度为O(n)(若有向图无环,每个顶点入栈一次,出栈一次);
- 入度减1的操作在循环中总共执行了e次,时间复杂度为O(e)。
综上所述,总的时间复杂度为O(n + e)。
关键路径
AOE-网
与AOV-网相对的,也存在以边表示活动的网,被称为AOE-网(Activity On Edge)。AOE-网是一个带权的有向无环图,例如:
通常,AOE-网被用来估算工程的完成时间,或者判断哪些活动会是影响工程进度的关键。
在上图中,存在着从V₀ ~ V₈共9个事件。其中,每个事件表示在其之前的活动已经结束,而在其之后的活动可以开始。例如:V₄表示a₄和a₅已经完成,a₇和a₈可以开始。
一个工程的开始和结束是唯一的,因此,(在正常情况下)AOE-网只有一个入度为0的点,即源点,也只有一个出度为0的点,即汇点。AOE-网中存在着一些概念:
概念 | 解释 |
---|---|
路径的带权路径长度(简称路径长度) | 一条路径各弧上的权值之和 |
关键路径 | 即从源点到汇点,其中带权路径长度最长的路径 |
关键活动 | 是关键路径上的活动 |
在上图中,各个概念对应着:
- 源点:V₀;
- 汇点:V₈;
- 关键路径:(V₀, V₁, V₄, V₆, V₈) 或 (V₀, V₁, V₄, V₇, V₈);
- 关键活动:(a₁, a₄, a₇, a₁₀) 或 (a₁, a₄, a₈, a₁₁)。(所谓关键活动,只要其中的某项活动能够缩短一天,则整个工程也能缩短一天)
为了找到关键路径,就需要4个能够进行描述的量:
由上可知:对于关键活动aₓ,必定存在 e(x) = l(x) 。把这个结论反过来,将每一个e(x) = l(x)的活动aₓ找出来,就可以形成关键路径。因此,现在的问题就转换为求出上述四个量的问题:
逆拓扑排序可以通过反向查找数组topo实现。
【参考代码】
int ve[MVNum] = { 0 }; //事件的最早发生时间
int vl[MVNum] = { 0 }; //事件的最迟发生时间
Status CriticalPath(ALGraph G)
{
int topo[MVNum] = { 0 };
if (!TopologicalSort(G, topo)) //存在有向环
return false;
int n = G.vexnum;
for (int i = 0; i < n; i++)
ve[i] = 0; //设定最早发生时间的初值
//---求最早发生时间---
for (int i = 0; i < n; i++)
{
int k = topo[i]; //取得拓扑排序中的顶点序号
ArcNode* p = G.vertices[k].firstarc; //p指向k的第一个邻接顶点
while (p != NULL) //更新k的所有邻接顶点的最早发生时间
{
int j = p->adjvex; //取得邻接顶点的序号
if (ve[j] < ve[k] + p->info) //更新顶点j的最早发生时间
ve[j] = ve[k] + p->info;
p = p->nextarc; //p指向k的下一个邻接顶点
}
}
for (int i = 0; i < n; i++) //设定最迟发生时间的初值
vl[i] = ve[n - 1];
//---求最迟发生时间---
for (int i = n - 1; i >= 0; i--)
{
int k = topo[i]; //取得逆拓扑序列中的顶点序号
ArcNode* p = G.vertices[k].firstarc;
while (p != NULL)
{
int j = p->adjvex;
if (vl[k] > vl[j] - p->info) //更新顶点k的最迟发生时间
vl[k] = vl[j] - p->info;
p = p->nextarc;
}
}
//---判断每一活动是否是关键活动---
for (int i = 0; i < n; i++)
{
ArcNode* p = G.vertices[i].firstarc; //p指向i的第一个邻接顶点
while (p != NULL)
{
int j = p->adjvex; //取得邻接顶点的序号
int e = ve[i]; //取得活动<vi, vj>的最早开始时间
int l = vl[j] - p->info; //计算活动<vi, vj>的最迟开始时间
if (e == l) //若为关键活动,输出
cout << G.vertices[i].data << G.vertices[j].data << endl;
p = p->nextarc;
}
}
}
【算法分析】
为了求出ve(i)、ve(i)、e 和 l这四个量,就需要遍历每个顶点和每个顶点边表中的所有边结点进行查找,因此,求关键路径的时间复杂度为O(n + e)。
若一个工程有不止一条关键路径,仅仅提高其中一条关键路径上关键活动的速度,是不足以缩短整个工程的工期的。