封面画师:Nengoro(ネんごろぅ)     封面ID:68986081

本文参考视频:小马哥教育(SEEMYGO) 2019年 恋上数据结构与算法(第二季)

源码仓库:mofan212/data-structure-and-algorithm (github.com)

辅助学习网址:数据结构和算法动态可视化     Data Structure Visualizations

0. 前言

本文紧接 【高级数据结构之图】一文

本文紧接 【高级数据结构之图】一文

本文涉及的代码均在 【高级数据结构之图】一文中编写的代码基础上进行编写。

1. AOV 网

AOV网 —— Activity On Vertex Network

一项大的工程常被分为多个小的子工程:

  • 子工程之间可能存在一定的先后顺序,即某些子工程必须在其他子工程完成后才能开始

在现代化管理中,人们常用有向图描述和分析一项工程的计划和实施过程,子工程被称为活动(Activity):

  • 以顶点表示活动、有向边表示活动之间的先后关系,这样的图简称为 AOV网

标准的AOV网必须是一个有向无环图(Directed Graph,简称 DAG):

AOV网

2. 拓扑排序

2.1 基本概念

AOV网

前驱活动: 有向边起点的活动成为终点的前驱活动。只有当一个活动的前驱全部都完成后,这个活动才能进行。

后继活动: 有向边终点的活动成为起点的后继活动。


那什么是拓扑排序(Topological Sort)?

拓扑排序就是将 AOV 网中所有活动排成一个序列,使得每个活动的前驱活动都排在该活动前面。

比如上图的拓扑排序结果为 : A、B、C、D、E、F 或者 A、B、D、C、E、F (结果不一定是唯一的)

2.2 实现思路

可以使用卡恩算法 (Kahn 于1962年提出)完成拓扑排序。

假设 L 是存放拓扑排序结果的列表:

①、把所有入度为 0 的顶点放入 L 中,然后把这些顶点从图中去掉

②、重复操作 ① ,直到找不到入度为 0 的顶点

如果此时 L 中的元素个数和顶点总数相同,说明拓扑排序完成。

如果此时 L 中的元素个数少于顶点总数,说明原图中存在环,无法进行拓扑排序。

拓扑排序示意图

注意: 进行拓扑排序的图都是有向无环图,即是一个标准的AOV网。

还有一点也需要注意,卡恩算法会将图中的顶点溢出,但实际情况是不允许删除的,因此还需要想另外的方式。

2.3 编码实现

Graph接口中添加一个方法List<V> topologicalSort();,表示拓扑排序的方法,然后需要在ListGraph类中实现这个方法。

我们实现拓扑排序时不能像卡恩算法一样直接删除图中的顶点,需要想个办法进行代替。

需要创建一个动态数组,用于存储拓扑排序后的顶点,最后返回这个动态数组。

然后可以创建一个队列和映射,初始化时,将入度为 0 的顶点加入队列中,将入度不为 0 的顶点作为映射的Key,入度作为 Value 存入映射中。

初始化完毕后,从队列中移除一个顶点,将其放入动态数组中,然后遍历这个顶点出去的边,并计算这些边达到的顶点的入度减一后是否为 0 。如果为 0 ,将这些顶点加入队列中;如果不为 0 ,这些顶点的入度减一,存入映射中。重复这个操作,直到队列为空。

最终拓扑排序的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Override
public List<V> topologicalSort() {
List<V> list = new ArrayList<>();
Queue<Vertex<V, E>> queue = new LinkedList<>();
Map<Vertex<V, E>, Integer> ins = new HashMap<>();
// 初始化(将度为 0 的节点放入队列)
vertices.forEach((V v, Vertex<V, E> vertex) -> {
int in = vertex.inEdges.size();
if (in == 0) {
queue.offer(vertex);
} else {
ins.put(vertex, in);
}
});

while (!queue.isEmpty()) {
Vertex<V, E> vertex = queue.poll();
// 放入返回结果中
list.add(vertex.value);

for (Edge<V, E> edge : vertex.outEdges) {
int toIn = ins.get(edge.to) - 1;
if (toIn == 0){
queue.offer(edge.to);
} else {
ins.put(edge.to, toIn);
}
}
}
return list;
}

以上就是拓扑排序的代码,以经过初步测试,可放心 “食用”!😋

3. 生成树

3.1 基本概念

生成树(Spanning Tree),也称为支撑树。

连通图的极小连通子图,它含有图中全部的 n 个顶点,恰好只有 n - 1 条边。如:

极小连通子图

3.2 最小生成树

基本概念

最小生成树(Minimum Spanning Tree,简称 MST),也称为最小权重生成树(Minimum Weight Spanning Tree)、最小支撑树,是所有生成树中权值最小的那棵。

最小生成树的概念适用于有权连通图(无向)。

最小生成树

实际应用

最小生成树在许多领域都有重要的作用,例如:

要在 n 个城市之间铺设光缆,使它们都可以通信。铺设光缆的费用很高,且各个城市之间因为距离不同等因素,铺设光缆的费用也不同,如何使铺设光缆的总费用最低?这里就可以用到最小生成树。

如果图的每一条边的权值都互不相同,那么最小生成树将只有一个,否则可能会有多个最小生成树。

求最小生成树有两个经典的算法:

  • Prim (普里姆算法)
  • Kruskal (克鲁斯克尔算法)

3.3 Prim 算法

切分定理

切分(Cut):把图中的节点分成两部分,称为一个切分。

比如下图中就有个切分 C = (S, T),S = {A, B, D},T = {C, E}

一个切分

横切边(Crossing Edge):如果一个边的两个顶点,分别属于切分的两部分,这个边成为横切边。

比如上图的边 BC、BE、DE 就是横切边。

切分定理: 给定任意切分,横切边中权值最小的边必然属于最小生成树。

Prim算法执行流程

假设 G = (V, E) 是有权的连通图(无向), A 是 G 中最小生成树的边集

算法从 S = { u0 } (u0 ∈ V),A = { } 开始,重复执行以下操作,到 S = V 为止

找到切分 C = (S, V - S) 的最小横切边 (u0 , v0 ) 并入集合 A ,同时将 v0 并入集合 S

执行过程图示:

Prim执行流程1

Prim执行流程2

Prim执行流程3

3.4 Prim 的实现

父类的修改

在实现 Prim 算法之前,需要先在接口Graph中添加方法,假设命名为mst(),表示生成最小生成树。

然后一个问题就出来了,返回值是什么?

因为生成了最小生成树,返回值得是边信息(起点、终点和权值),那使用 Set 集合,比如Set<Vertex<V, E>>这样吗?

这样确实可以,但是这么做会暴露我们实现类的细节,不建议这么搞。

其实只需要在接口中创建一个表示边信息的内部类EdgeInfo就解决问题了。

除此之外,在求得最小生成树的时候还涉及到权值的比较,而在以后求最短路径的时候还会涉及到权值的相加,为了统一管理这两个地方,可以在将原本的接口修改为抽象类,然后添加一个接口WeightManager,用于管理那两个方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public abstract class Graph<V, E> {
protected WeightManager<E> weightManager;

public Graph() { }

public Graph(WeightManager<E> weightManager) {
this.weightManager = weightManager;
}

// 省略其他抽象方法
...

// 最小生成树
public abstract Set<EdgeInfo<V, E>> mst();

public interface WeightManager<E> {
int compare(E w1, E w2);
E add(E w1, E w2);
}

public static class EdgeInfo<V, E> {
private V from;
private V to;
private E weight;

protected EdgeInfo(V from, V to, E weight) {
this.from = from;
this.to = to;
this.weight = weight;
}

public V getFrom() {return from;}

public void setFrom(V from) {this.from = from;}

public V getTo() {return to;}

public void setTo(V to) {this.to = to;}

public E getWeight() {return weight;}

public void setWeight(E weight) {this.weight = weight;}

@Override
public String toString() {
return "EdgeInfo{" +
"from=" + from +
", to=" + to +
", weight=" + weight +
'}';
}
}
}

其他修改

为了能够更好地实现 Prim算法,修改ListGraph中的Edge内部类,给这个内部类添加一个方法,以便可以简单地获取到边信息:

1
2
3
EdgeInfo<V, E> info() {
return new EdgeInfo<>(from.value, to.value, weight);
}

除此之外,在获取最小生成树时,需要比较权值,我们选择添加比较器,因此ListGraph类需要增加一个成员变量和构造方法:

1
2
3
4
5
6
7
8
9
private Comparator<Edge<V, E>> edgeComparator = (Edge<V, E> e1, Edge<V, E> e2) -> {
return weightManager.compare(e1.weight, e2.weight);
};

public ListGraph() { }

public ListGraph(WeightManager<E> weightManager) {
super(weightManager);
}

这些都是为 Prim 算法的准备,接下来就是重头戏了。 👇

Prim 实现

先说说思路:

1、先获取图中的一个顶点(注意图不存在顶点的情况)

2、创建两个 HashSet,edgeInfos用于存储边信息,最后返回;addedVertices用于存储已经 “经过” 的顶点

3、将最开始获取的顶点加入到集合 addedVertices

4、获取选择顶点的 outEdges 集合heap,并删除权值最小的那条边

5、将这条边存储到集合edgeInfos中,这条边到达的顶点存储到addedVertices

6、获取上一步得到的边的所有 outEdges ,存入集合heap中,重复执行 456 三步,直到集合为空或edgeInfos的长度等于图顶点数减一(addedVertices的长度等于图中的顶点数)。


在思路分析中需要获取集合中权值最小的边,可以使用二叉堆来实现。在 JDK 中提供了优先级队列,优先级队列默认就是用最小堆实现的,也恰好满足我们的需要,但是不能同时根据集合和比较器创建一个优先级队列,当然也可以将集合中的元素一个一个添加到优先级队列中,但是为了效率,我们可以自己编写一个符合要求的最小堆(代码省略)。

最终 Prim 算法代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Override
public Set<EdgeInfo<V, E>> mst() {
return prim();
}

private Set<EdgeInfo<V, E>> prim() {
Iterator<Vertex<V, E>> it = vertices.values().iterator();
if (!it.hasNext()) return null;
Set<EdgeInfo<V, E>> edgeInfos = new HashSet<>();
Set<Vertex<V, E>> addedVertices = new HashSet<>();
Vertex<V, E> vertex = it.next();
addedVertices.add(vertex);

// 自己编写一个最小堆
MinHeap<Edge<V, E>> heap = new MinHeap<>(vertex.outEdges, edgeComparator);
// int edgeSize = vertices.size() - 1;
int verticesSize = vertices.size(); // 少一个减一操作
// 这里的循环着重理解
while (!heap.isEmpty() && addedVertices.size() < verticesSize) {
Edge<V, E> edge = heap.remove();
if (addedVertices.contains(edge.to)) continue;
edgeInfos.add(edge.info());
addedVertices.add(edge.to);
heap.addAll(edge.to.outEdges);
}
return edgeInfos;
}

以上就是 Prim 算法的代码,以经过初步测试,可放心 “食用”!😉

3.5 Kruskal 算法

执行过程: 按照边的权重顺序(从小到大)将边加入生成树中,直到生成树中含有 V - 1 条边(V 是顶点数量)。

需要注意的是: 若加入该边会与生成树形成环,则不加入该边。从第三条边开始,可能会与生成树形成环。

Kruskal执行流程1

Kruskal执行流程2

3.7 Kruskal 的实现

在实现 Kruskal 算法时,首选得判断图中没有顶点的情况。

然后将从所有边中依次选出权重最低的边,这里同样可以用到 最小堆 来实现,我们就采用实现 Prim 算法中的最小堆实现。

但是在依次选权重最低的边时需要注意会不会形成闭环的情况。如果是链表,判断有没有环可以使用快慢指针,而在这可以使用 并查集

并查集?并查集不是用来查找某个元素在哪个集合中的数据结构吗?怎么还能用来判断是否存在闭环呢? 😕

其实很简单。求最小生成树时,最终需要返回边信息,在边信息中也隐藏了顶点信息。每一条边就隐藏了两个顶点(起始与终点),我们将最小生成树中边的顶点信息存入并查集中的一个集合里(在并查集中 union 这两个顶点),每次选择权值最小的边时,判断这条边的顶点信息是否存在同一个集合中。如果存在,表示会产生闭环,则放弃这条边,寻找下一条权值最小的边;如果不存在,就表示这条边是最小生成树中的边。


最终 Kruskal 算法代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Override
public Set<EdgeInfo<V, E>> mst() {
return kruskal();
}
private Set<EdgeInfo<V, E>> kruskal() {
int edgeSize = vertices.size() - 1;
// 图中没有顶点的时候
if (edgeSize == -1) return null;
Set<EdgeInfo<V, E>> edgeInfos = new HashSet<>();
MinHeap<Edge<V, E>> heap = new MinHeap<>(edges, edgeComparator);
UnionFind<Vertex<V, E>> uf = new UnionFind<>();
vertices.forEach((V v, Vertex<V, E> vertex) -> {
uf.makeSet(vertex);
});

while (!heap.isEmpty() && edgeInfos.size() < edgeSize) {
Edge<V, E> edge = heap.remove();
// 判断选择的边是否构成环 可以使用并查集解决
if (uf.isSame(edge.from, edge.to)) continue;
edgeInfos.add(edge.info());
uf.union(edge.from, edge.to);
}
return edgeInfos;
}

以上就是 Kruskal 算法的代码,以经过初步测试,可放心 “食用”!😄

3.8 复杂度对比

Kruskal 算法复杂度分析:

Kruskal复杂度


Prim 算法的复杂度会根据实现方式发生改变,如果没有选择最小堆实现,选择斐波那契堆实现,复杂度还可以再小一点。

我们实现的 Prim 算法采用了最小堆,但是最小堆的数据规模是变化的,数据规模逐渐增大,最坏情况下堆的规模是 E (边的数量)。这样的话,最坏情况下 Prim 算法的复杂度为 O(ElogE)

但我认为, Prim 算法相比于 Kruskal 算法会更优一点,因为 Prim 算法中堆的规模是逐渐增大的,而 Kruskal 算法中堆的规模一开始就是 E 。


在求得最小生成树的时候,Prim 算法采用了集合 HashSet 来存储最小生成树中的顶点,而 Kruskal 算法采用了并查集存储最小生成树的顶点,这两个算法为什么会有这个区别呢?

在 Prim 算法中放入集合 HashSet 中的顶点都是彼此相连的,可以存在于一个集合中。

Kruskal 算法中最小生成树的顶点往往不是连在一起的,而是分布于多个集合(这里的集合指数的聚集,并不是指集合 Set)中。如果使用集合 Set 来存储这些顶点,就需要多个集合 Set 来存储,而且 Kruskal 算法在运行过程中,还会将这些集合彼此合并或者判断是否有闭环生成,因此选择集合 Set 来存储这些数据就会变得异常麻烦,选择并查集来存储这些顶点就会完美地处理前面提到的问题。