1. 概述
1.1 图的构成
图由两个集合构成:点集V,边集E,二元定义为 G = (V,E)。
1.2 有向图和无向图
图分为:有向图,无向图。
- 无向图:每一条边都是双方向性的,( x, y ) 和 ( y, x ) 表示的结果相同。
- 有向图:每一条边都是单方向性的,<x, y> 和 <y, x> 表示的结果不同。
1.3 入度和出度
度分为入度和出度。
- 入度:某个顶点作为终点的次数和。
- 出度:某个顶点作为起点的次数和。
注意:无向图顶点的入度和出度相同。
2. 存储方式
常见图的存储方式有两种:
- 邻接表
- 邻接矩阵
2.1 邻接表
假设点集V中有x个节点,那么需要申请一个容量为x的一维数组,数组中每一个元素代表点集V中的每一个节点。从每一个元素出发,使用链表保存对应节点的所有相邻节点。
2.2 邻接矩阵
假设点集V中有x个节点,那么需要申请一个容量为x^2的二维数组,数组中每一个元素代表边集E中的每一条边(权重)。从每一个元素出发,该元素的横坐标和纵坐标为边的两个顶点。
2.3 两种方式的比较
- 邻接表和邻接矩阵即可以表示有向图,也可以表示无向图。
- 邻接表是在节点的角度上考虑图的。
- 邻接矩阵是在边的角度上考虑图的。
- 当图为稀疏图、顶点较多,即图结构比较大时,更适宜选择邻接表作为存储结构。
- 当图为稠密图、顶点较少时,或者不需要记录图中边的权值时,使用邻接矩阵作为存储结构较为合适。
3. 图的代码定义
下列代码描述了图结构的所有信息,可以实现所有图结构,是图的通用结构模板。
根据不同题目要求,有些数据项可能使用不到,那么在创建图时就不必填入,维持在初始值即可。
3.1 图结构
public class Graph {
// 点集 key——点的编号,value——实际的点
HashMap<Integer, Node> nodes;
// 边集
HashSet<Edge> edges;
public Graph() {
nodes = new HashMap<Integer, Node>();
edges = new HashSet<Edge>();
}
}
注意:在上述代码中,是使用HashMap来存储点集的,在实际刷题过程中,如果为了进一步缩短常数时间的话,实际上可以将HashMap替换成数组结构,数组元素的下标就可以充当HashMap中的key。虽然HashMap增删改查的时间复杂度是O(1),但是它没有数组寻址快。
3.2 点结构
public class Node {
// 节点数据项
int value;
// 节点的入度
int in;
// 节点的出度
int out;
// 从当前点发散出去边连接的直接邻居节点
ArrayList<Node> nexts;
// 从当前点发散出去的边
ArrayList<Edge> edges;
public Node(int value) {
this.value = value;
this.in = 0;
this.out = 0;
nexts = new ArrayList<>();
edges = new ArrayList<>();
}
}
3.3 边结构
public class Edge {
// 边的权
int weight;
// 边的起始节点
Node from;
// 变的目标节点
Node to;
public Edge(int weight, Node from, Node to) {
this.weight = weight;
this.from = from;
this.to = to;
}
}
注意:该结构表示的是有向边,无向边可以用两个有向边表示。
4. 图的转化
图的算法都不是很难,难点在于同一个图可以有多种方式来存储表示。也就意味着,同样一套算法,如果选择不同的方式来存储图,那么它的写法是不一样的。
因此,我们可以选择自己较为喜欢的一种图的存储结构,例如邻接矩阵,然后用邻接矩阵实现关于图操作的所有算法。在遇到使用其他数据结构存储图的题目时,可以将该图的信息提取出来,转化成邻接矩阵来存储。然后通过调用你提前写好的使用邻接矩阵实现的相关算法接口来解决问题,从而不需要在题目使用的数据结构上再次实现一遍算法。
本篇文章中,我仅推荐我经常使用的图的存储方式,见3。我利用我存储图的方式已经实现了大部分算法,你们可以利用我的模板自己再去扩展新的图操作算法。
例如:现在题目使用如下方式来存储一个图,如何将该图转化成用我的存储方式来表示?
代码:
public static Graph createGraph(int[][] matrix) {
Graph graph = new Graph();
for (int[] edgeVals : matrix) {
int weight = edgeVals[0];
int fromVal = edgeVals[1];
int toVal = edgeVals[2];
// 首次出现的节点加入点集
if (!graph.nodes.containsKey(fromVal)) {
graph.nodes.put(fromVal, new Node(fromVal));
}
if (!graph.nodes.containsKey(toVal)) {
graph.nodes.put(toVal, new Node(toVal));
}
Node from = graph.nodes.get(fromVal);
Node to = graph.nodes.get(toVal);
// 构建边,并加入边集
Edge edge = new Edge(weight, from, to);
graph.edges.add(edge);
// 更新节点信息
from.out++;
to.in++;
from.nexts.add(to);
from.edges.add(edge);
}
return graph;
}
5. 图的遍历
图的遍历和二叉树的遍历的区别在于,二叉树不可能产生环,而图可能会产生环。因此,在遍历时,必须添加一个判断环出现的机制,否则遍历就会进入死循环。
5.1 深度优先遍历
步骤:
- 准备一个栈。
- 源节点最先压栈,并打印。
- 迭代固定流程:
- 栈顶元素cur弹栈。
- 遍历cur的直接邻居节点,如果遍历到未注册的直接邻居节点,则cur先压栈,直接邻居节点后压栈,打印并注册直接邻居节点,同时结束遍历。
- 直到栈空,结束迭代。
注意:由于每个节点的直接邻居节点的存放顺序可能不同,所以深度优先遍历会有多种结果。
代码:
public static void depthFirstSearch(Node node) {
if (node == null) {
return ;
}
Stack<Node> stack = new Stack<>();
// 辅助表,压栈的节点都要注册进表,从而检测是否出现环
HashSet<Node> set = new HashSet<>();
stack.push(node);
set.add(node);
// 源节点单独打印
System.out.println(node.value);
while (!stack.isEmpty()) {
Node cur = stack.pop();
for (Node neighbor : cur.nexts) {
if (set.contains(neighbor)) {
continue;
}
// 将当前节点和直接邻居节点压回栈中
stack.push(cur);
stack.push(neighbor);
set.add(neighbor);
System.out.println(neighbor.value);
// 只要有一个新的直接邻居节点压栈,则处理刚压栈的直接邻居节点,其他直接邻居节点等待cur下一次弹栈时处理
break;
}
}
}
5.2 宽度优先遍历
步骤:
- 准备一个队列。
- 源节点最先进入队列。
- 迭代固定流程:
- 队头节点cur出队列。
- 打印cur。
- 遍历cur的直接邻居节点,未注册的所有直接邻居节点入队列,同时进行注册。
- 直到队空,结束迭代。
注意:由于每个节点的直接邻居节点的存放顺序可能不同,所以宽度优先遍历会有多种结果。
代码:
public static void breadthFirstSearch(Node node) {
if (node == null) {
return ;
}
Queue<Node> queue = new LinkedList<>();
// 辅助表,首次入队列的节点都要注册进表,从而检测是否出现环
HashSet<Node> set = new HashSet<>();
queue.add(node);
set.add(node);
while (!queue.isEmpty()) {
Node cur = queue.poll();
System.out.println(cur.value);
// 遍历该节点所有直接邻居
for (Node neighbor : cur.nexts) {
// 判断一个节点是否已经入过了队列
if (set.contains(neighbor)) {
continue;
}
queue.add(neighbor);
set.add(neighbor);
}
}
}
6. 拓扑排序算法
6.1 定义
对一个有向无环图 (Directed Acyclic Graph) G进行拓扑排序,是将G中所有顶点排成一个线性序列,使得图中任意一对顶点 u 和 v ,若边<u, v> ∈ E(G),则 u 在线性序列中出现在 v 之前。通常,这样的线性序列称为满足拓扑次序 (Topological Order) 的序列,简称拓扑序列。简单的说,由某个集合上的一个偏序得到该集合上的一个全序,这个操作称之为拓扑排序。
6.2 应用场景
拓扑排序算法最常用的应用场景就是工程中引入的依赖包的编译顺序。
如果工程能够成功编译,那么必须先编译A包和B包。A包能成功编译必须先编译C包,B包能成功编译必须编译D包。C包成功编译必须编译D包和E包,D包成功编译必须编译E包。
在工程编译中,一定是不能出现循环编译的。任何一个项目,在追溯配置文件时,如果发现循环编译,那么一定会报错。这样,从E包出发到工程,形成了一张有向无环图。
得到依赖包的编译图谱后,如何决定编译顺序呢?使用拓扑排序。
6.3 排序过程
在一个有向无环图中,一定会有1个或者多个入度为0的节点,每次随机选择一个入度为0的节点放入拓扑序列,然后将该节点和该节点产生的影响从图中去除,再进行下一次选择,直到图空为止。
因为每次是随机选择入度为0的节点,所以一个图可能会有多个合理的拓扑序列。
6.4 代码
public static List<Node> potologicalSort(Graph graph) {
// 记录每一个节点的入度
HashMap<Node, Integer> map = new HashMap<>();
// 辅助容器,存放每一个入度为0的节点
Queue<Node> queue = new LinkedList<>();
// 拓扑序列
ArrayList<Node> order = new ArrayList<>();
// 记录所有节点的入度,并将第一批入度为0的节点入队列
for (Node node : graph.nodes.values()) {
map.put(node, node.in);
if (node.in == 0) {
queue.add(node);
}
}
// 队头节点出队列进入拓扑序列,并将该节点的所有入度为0的直接邻居节点入队列
while (!queue.isEmpty()) {
Node cur = queue.poll();
order.add(cur);
for (Node node : cur.nexts) {
// 调整直接邻居节点的入度
map.put(node, map.get(node) - 1);
if (map.get(node) == 0) {
queue.add(node);
}
}
}
return order;
}
7. 生成最小生成树
7.1 最小生成树
7.1.1 适用范围
只有无向图才能生成最小生成树,所以Kruskal算法和Prim算法针对的对象都是无向图。
7.1.2 概念
最小生成树就是将一个所有节点都连通的有权无向图通过删除若干条边,最后形成一个既保证所有节点连通性,所有边的权值又最小的数据结构。
7.2 Kruskal算法
7.2.1 过程
从边的角度出发,先将所有边的权值排序,依次选择权值最小的边加入图中。当加完某一条边后,如果形成了环,那么这条边不加。直到所有边全部尝试加入后结束,此时的图结构就是最小生成树。
这个算法唯一的难点在于,如何在添加一条边后,判断图是否生成环?这就要用到一种集合查询结构:并查集。
7.2.2 并查集分析
并查集判断是否成环的原理是:当一条边加入图时,判断该条边的from和to节点是否存在于同一个集合中,如果存在,说明成环。如果不存在,就将from和to节点分别所在的集合合并。
将并查集机制带入上图实现最小生成树的过程:
-
当未添加任何边时,集合状况:{ A },{ B },{ C },{ D } 。
-
添加了 (B, C) 时,B和C不在同一个集合中,集合状况:{ A },{ B, C },{ D } 。
-
添加了 (A, B) 时,A和B不在同一个集合中,集合状况:{ A, B, C },{ D } 。
-
添加了 (B, D) 时,B和D不在同一个集合中,集合状况:{ A, B, C, D } 。
-
添加了 (A, C) 时,A和C在同一个集合中,集合状况:{ A, B, C, D } 。
-
添加了 (A, D) 时,A和D在同一个集合中,集合状况:{ A, B, C, D } 。
-
添加了 (C, D) 时,C和D在同一个集合中,集合状况:{ A, B, C, D } 。
只要我们实现了上文中描述的集合查询和集合合并的机制,那么就能实现该算法。
7.2.3 并查集实现
本代码并没有实现真实的并查集,而是使用HashMap和ArrayList模拟了一个简易版并查集。
该简易版并查集虽然具备并查集的功能,但是操作的效率远远没有并查集快,并查集对于的以下两个功能的时间复杂度都是O(1)。
public class UnionFind {
// 存储并查集中每一个节点所属的集合
public static HashMap<Node, List<Node>> collectionMap = new HashMap<>();
public UnionFind(List<Node> nodes) {
// 对于每一个节点单独构建一个集合
for (Node node : nodes) {
List<Node> collection = new ArrayList<>();
collection.add(node);
collectionMap.put(node, collection);
}
}
// 判断两个节点是否在同一个集合中
public boolean isSameCollection(Node from, Node to) {
List<Node> fromCollection = collectionMap.get(from);
List<Node> toCollection = collectionMap.get(to);
return fromCollection == toCollection;
}
// 合并两个节点所在的集合
public void union(Node from, Node to) {
List<Node> fromCollection = collectionMap.get(from);
List<Node> toCollection = collectionMap.get(to);
// 将fromCollection中的集合全部移到toCollection中,并重新指向
for (Node node : fromCollection) {
toCollection.add(node);
collectionMap.put(node, toCollection);
}
}
}
7.2.3 代码
Edge比较器,通过比较边的权重从而给边排序。
public class EdgeComparator implements Comparator<Edge> {
@Override
public int compare(Edge edge1, Edge edge2) {
return edge1.weight - edge2.weight;
}
}
算法代码:
public static Set<Edge> kruskal(Graph graph) {
// 存放最小生成树边的集合
Set<Edge> result = new HashSet<>();
// 所有节点构建并查集
UnionFind unionFind = new UnionFind((List<Node>) graph.nodes.values());
// 构建一个小根堆,定义通过Edge的权值来排序
PriorityQueue<Edge> smallRootHeap = new PriorityQueue<>(new EdgeComparator());
// 将图中所有边放入小根堆中进行排序
smallRootHeap.addAll(graph.edges);
// 从小到大所有边依次进入图
while (!smallRootHeap.isEmpty()) {
Edge edge = smallRootHeap.poll();
// 如果边的两个顶点在同一个集合中,则不能加入结果集
if (unionFind.isSameCollection(edge.from, edge.to)) {
continue;
}
unionFind.union(edge.from, edge.to);
result.add(edge);
}
return result;
}
7.3 Prim算法
7.3.1 过程
从点的角度出发,选择图中任意一个节点作为起始节点,并将该节点放入集合中。每次选择从集合内连接到集合外权值最小的边和其对应的集合外的节点加入集合中,当所有节点都加入集合后,该集合就是最小生成树。
7.3.2 代码
public static HashSet<Edge> prim(Graph graph) {
// 存放最小生成树边的集合
HashSet<Edge> result = new HashSet<>();
// 存放最小生成树节点的集合,两个集合同步存储,模拟了一个既包含节点又包含边的集合
HashSet<Node> set = new HashSet<>();
// 构建一个小根堆,定义通过Edge的权值来排序
PriorityQueue<Edge> smallRootHeap = new PriorityQueue<>(new EdgeComparator());
Node begin = null;
// 从点集中随机抽取一个元素作为起始节点
for (Node node : graph.nodes.values()) {
begin = node;
break;
}
if (begin != null) {
// 起始节点先进入集合
set.add(begin);
smallRootHeap.addAll(begin.edges);
while (!smallRootHeap.isEmpty()) {
Edge edge = smallRootHeap.poll();
Node to = edge.to;
// 判断集合中是否包含该节点,如果不包含,添加进入集合
if (!set.contains(to)) {
set.add(to);
result.add(edge);
// 将新节点的所有边添加进入小根堆
smallRootHeap.addAll(to.edges);
}
}
}
return result;
}
注意:
该代码会让重复的边进入小根堆,因为在图中可能有两个节点共用一条边。但是增加了重复节点的判断,所以即使有重复的边也不会影响最后的结果,只是增加了判断节点的次数罢了。
7.4 总结
为什么Kruskal算法需要用到复杂的并查集查询结构来实现集合的查询和合并,而Prim算法不需要?
因为Kruskal算法在构建最小生成树时,可能存在两个局部连通的大局部连在一起的问题,因为它的连通顺序并不像Prim算法那样是一个节点一个节点做连通的。
如下图:
8. Dijkstra算法
8.1 说明
Dijkstra算法是单元最短路径算法,解决的问题是:从规定点开始到后续其他节点的最短距离依次是多少?
适用范围:
-
无向图
-
不能有权值为负数的边
注意:
对于自身节点,最短路径是0;对于不可达节点,最短路径是∞。
8.2 过程
每一次在表中选择距离规定节点最短的那个节点,观察从该节点所出发的边,能否让表上的某些记录变的更少。每一个节点观察完,锁死当前节点,当所有节点都被锁死后,就能得出单元最短路径。
8.3 代码
public static HashMap<Node, Integer> dijkstra(Node target) {
// 存储每个节点到目标节点的距离
HashMap<Node, Integer> distanceMap = new HashMap<>();
// 存储已经被锁的节点
HashSet<Node> lockedNodes = new HashSet<>();
// 目标节点到自身的距离为0
distanceMap.put(target, 0);
// 第一个最小距离节点是target
Node minNode = getUnlockedMinNode(distanceMap, lockedNodes);
// 遍历图中所有节点,等到所有节点上锁后停止
while (minNode != null) {
for (Edge edge : minNode.edges) {
Node to = edge.to;
// 判断to是否是第一次进表
if (!distanceMap.containsKey(to)) {
// 是的话,初始为:to到target当前最小距离 = minNode到target的当前最小距离 + minNode连接to的边的权值
distanceMap.put(to, distanceMap.get(minNode) + edge.weight);
} else {
// 不是的话,和to到target的当前最小距离比较出一个更小的更新表
distanceMap.put(to, Math.min(distanceMap.get(to), distanceMap.get(minNode) + edge.weight));
}
}
// 当前节点上锁
lockedNodes.add(minNode);
// 找下一个最小距离节点
minNode = getUnlockedMinNode(distanceMap, lockedNodes);
}
return distanceMap;
}
// 找出没有被锁,且距离目标节点最小的节点
public static Node getUnlockedMinNode(HashMap<Node, Integer> distanceMap, HashSet<Node> lockedNodes) {
Node minNode = null;
int minDistance = Integer.MAX_VALUE;
for (Map.Entry<Node, Integer> entry : distanceMap.entrySet()) {
Node cur = entry.getKey();
int distance = entry.getValue();
if (!lockedNodes.contains(cur) && distance < minDistance) {
minDistance = distance;
minNode = cur;
}
}
return minNode;
}
8.4 总结
为什么Dijkstra算法作用的图中不可以有权值为负数的边?
因为如果有权值为负数的边,那么在某一时刻,已经被锁死的记录可能就不正确了。
例如:
当在选择D节点时,因为 (C, D) = -30,A到D的当前最短距离-27,A到C的当前最短距离是3。更新表时发现,A可以通过到D再到C这样距离就是 -30 - 27 = -57,比原来的3更小。但是3是已经被锁死的记录,是按照算法正常逻辑已经确定的A到C的最短路径为3,所以这样一来,就会让原来已经被锁死的记录不正确。
8.5 优化方案
8.5.1 说明
Dijkstra算法还可以使用堆进行优化,优化点在于,经典Dijkstra算法找距离target最小的节点是通过遍历的方式找的,而优化后的算法是使用小根堆排出距离target最小的节点然后直接从堆顶弹出的。问题在于,系统堆和堆中的数据是有约束的,如果系统堆中的数据忽然改变,系统就要重新调整整个堆结构,系统堆是通过全局扫描来调整堆的,代价非常高,这样的代价其实和遍历是差不多的。
如果要将堆应用到Dijkstra的优化中,那么假如当前最小距离的节点是D,当堆弹出D的时候,可能还在堆中的C因为D的某条边更新了自身数据。这个时候,我们需要手动构建堆,当堆中某节点更新数据时,我们在该节点上做heapInsert或者heapify操作,这样的堆调整是局部调整,代价很低。
为什么系统堆不能进行局部调整?因为如果在系统堆中修改某个节点的数据,系统再自动局部调整,那么系统堆就不是一个严密的黑盒了。系统堆只提供已确定数据的若干个节点给你构成大根堆或者小根堆,然后在堆顶给你弹出节点。不存在你在原本系统堆中改节点的数据,堆自动局部调整的操作。
8.5.2 代码
该优化方案尤其需要掌握,因为它教你了在没办法使用系统堆的场景下,如何自己手动去实现堆。
堆代码:
public class DistanceHeap {
// 节点数据
private Node[] data;
// 每一个节点到根节点的距离
private HashMap<Node, Integer> distanceMap;
// 每一个节点在data中的下标,已经出堆的节点的下标为-1
private HashMap<Node, Integer> indexMap;
// 堆的容量
private int size;
public DistanceHeap(int size) {
this.data = new Node[size];
this.distanceMap = new HashMap<>();
this.indexMap = new HashMap<>();
this.size = size;
}
// 判断当前堆是否为空
public boolean isEmpty() {
return size == 0;
}
// 堆中两个节点的交换
private void swap(int i, int j) {
// indexMap中的下标的交换
indexMap.put(this.data[i], j);
indexMap.put(this.data[j], i);
// 堆中的交换
Node tmp = this.data[i];
this.data[i] = this.data[j];
this.data[j] = tmp;
}
// 判断一个节点是否进入过堆
private boolean isEnteredHeap(Node node) {
return indexMap.containsKey(node);
}
// 判断一个节点现在是否在堆中
private boolean isInHeap(Node node) {
return isEnteredHeap(node) && indexMap.get(node) != -1;
}
public void addOrUpdateOrIgnore(Node node, int distance) {
// 如果当前已经在堆中,判断是否需要更新数据
if (isInHeap(node)) {
distanceMap.put(node, Math.min(distanceMap.get(node), distance));
heapify(indexMap.get(node), size);
}
// 判断是否是第一次进堆,如果是,初始化数据
if (!isEnteredHeap(node)) {
data[size] = node;
indexMap.put(node, size);
distanceMap.put(node, distance);
heapInsert(size ++);
}
}
// 给Dijkstra提供的信息类
static class NodeRecord {
// 节点
Node node;
// 节点距离目标节点的距离
int distance;
public NodeRecord(Node node, Integer distance) {
this.node = node;
this.distance = distance;
}
}
// 弹出堆顶节点
public NodeRecord poll() {
// 堆顶节点信息
NodeRecord nodeRecord = new NodeRecord(data[0], distanceMap.get(0));
// 堆顶和堆尾的节点交换
swap(0, -- size);
// 删除堆顶节点的信息
distanceMap.remove(data[size]);
indexMap.put(data[size], -1);
data[size] = null;
// 重新调整堆
heapify(0, size);
return nodeRecord;
}
// 按照节点到根节点的距离向上调整堆
private void heapInsert(int index) {
while (distanceMap.get(data[index]) < distanceMap.get(data[(index - 1) / 2])) {
swap(index, (index - 1) / 2);
index = (index - 1) / 2;
}
}
// 按照节点到根节点的距离向下调整堆
private void heapify(int index, int heapSize) {
int leftChildIndex = index * 2 + 1;
while (leftChildIndex <= heapSize) {
int leastIndex = leftChildIndex + 1 <= heapSize && distanceMap.get(data[leftChildIndex + 1]) < distanceMap.get(data[leftChildIndex])
? leftChildIndex + 1 : leftChildIndex;
leastIndex = distanceMap.get(data[leastIndex]) < distanceMap.get(data[index]) ? leastIndex : index;
if (leastIndex == index) {
break;
}
swap(index, leastIndex);
index = leastIndex;
leftChildIndex = index * 2 + 1;
}
}
}
优化后Dijkstra代码:
public static Map<Node, Integer> dijkstra(Node target, int size) {
HashMap<Node, Integer> distanceMap = new HashMap<>();
DistanceHeap distanceHeap = new DistanceHeap(size);
// 根节点先入堆
distanceHeap.addOrUpdateOrIgnore(target, 0);
while (!distanceHeap.isEmpty()) {
// 获取堆顶节点数据
DistanceHeap.NodeRecord curRecord = distanceHeap.poll();
Node cur = curRecord.node;
int distance = curRecord.distance;
// 将堆顶所有直接邻居节点入堆或者更新数据或者忽略
for (Edge edge : cur.edges) {
distanceHeap.addOrUpdateOrIgnore(edge.to, edge.weight + distance);
}
distanceMap.put(cur, distance);
}
return distanceMap;
}