算法学习笔记-图
图
由点集和边集构成的数据结构,不同点之间通过边互相连接,分为有向图和无向图。有向图的边带有方向,只能从一个点走到另一个点,不能反向;无向图的边则没有限制,点之间互相连通。
图的存储方式
以下图为例:
图的存储方式多种多样,这里只介绍一些比较常用的存储方式。
1.邻接表法
用一个Map存储,Key为图中的某个节点,Value为与这个节点直接相邻的所有节点组成的列表(如果带权,可以将节点与权值封装为一个数据结构,同样用列表存储即可)。
2.邻接矩阵法
用一个n*n的矩阵存储,每一行的行号代表图中的某个节点,这一行的所有列存储这个图到其他直接相邻节点的距离(不相邻的距离为+∞,到自己的距离为0),即矩阵的每一个元素都相当于一条边。
3.本文所用的图的通用存储方式及代码(适用于大部分图的算法题,给定的如果不是这种结构可以先转换为该结构再写算法)
图类:
public class Graph {
// 点集,Key为点编号
public HashMap<Integer, Node> nodes;
// 边集
public HashSet<Edge> edges;
public Graph() {
nodes = new HashMap<>();
edges = new HashSet<>();
}
}
点类:
public class Node {
// 节点值
public int value;
// 入度
public int in;
// 出度
public int out;
// 从该点能直接到达的邻居
public ArrayList<Node> nexts;
// 以该点为起点的边
public ArrayList<Edge> edges;
public Node(int value) {
this.value = value;
in = 0;
out = 0;
nexts = new ArrayList<>();
edges = new ArrayList<>();
}
}
边类:
public class Edge {
// 权值
public int weight;
// 起点
public Node from;
// 终点
public Node to;
public Edge(int weight, Node from, Node to) {
this.weight = weight;
this.from = from;
this.to = to;
}
}
图的遍历
宽度优先遍历
思路:
- 利用队列实现;
- 从源节点开始依次按照宽度进队列,然后弹出;
- 每弹出一个节点,把该节点所有没有进过队列的节点进队列;
- 重复操作直到队列为空
代码实现如下:
// 宽度优先遍历
public static void bfs(Node node) {
if (node == null) {
return null;
}
Queue<Node> queue = new LinkedList<>();
// 记录已经访问过的节点
Set<Node> vis = new HashSet<>();
queue.offer(node);
vis.add(node);
while (!queue.isEmpty()) {
Node cur = queue.poll();
System.out.println(cur.value);
for (Node next : cur.nexts) {
if (!vis.contains(next)) {
set.add(next);
queue.offer(next);
}
}
}
}
深度优先遍历
思路:
- 利用栈实现;
- 从源节点开始把节点按照深度放入栈,然后弹出;
- 每弹出一个节点,把该节点和它的下一个没有进过栈的邻接点放入栈(如果还有未访问的邻接节点);
- 重复操作直到栈为空。
代码实现如下:
// 深度优先遍历
public static void dfs(Node node) {
if (node == null) {
return null;
}
// 栈中保存的是当前深度访问的路径
Stack<Node> stack = new Stack<>();
Set<Node> vis = new HashSet<>();
stack.push(node);
vis.add(node);
System.out.println(node.value);
while (!stack.isEmpty()) {
Node cur = stack.pop();
for (Node next : node.nexts) {
// for循环寻找当前节点第一个未被访问到的邻接节点
if (!set.contains(next)) {
// 将当前节点重新压回栈中(用于回溯)
stack.push(cur);
stack.push(next);
set.add(next);
System.out.println(next.value);
// 已经到下一个深度了,直接break退出for循环
break;
}
}
}
}
图的常用算法
拓扑排序
适用的范围:有向图,存在入度为0的节点,不存在环。
步骤:
- 从入度为0的点开始,将其的直接邻接节点的入度减一;
- 寻找下一个入度为0的节点,重复操作。
代码实现如下:
// 拓扑排序
public static List<Node> sortedTopology(Graph graph) {
// Key:某一个node
// Value:这个节点剩余的入度
HashMap<Node, Integer> inMap = new HashMap<>();
// 入度为0的节点才进入队列
Queue<Node> zeroInQueue = new LinkedList<>();
for (Node node : graph.nodes.values()) {
inMap.put(node, node.in);
if (node.in == 0) {
zeroInQueue.offer(node);
}
}
// 拓扑排序的结果
List<Node> result = new ArrayList<>();
while (!zeroInQueue.isEmpty()) {
Node cur = zeroInQueue.poll();
result.add(cur);
for (Node next : node.nexts) {
inMap.put(next, inMap.get(next) - 1);
if (inMap.get(next) == 0) {
zeroInQueue.offer(next);
}
}
}
return result;
}
Kruskal算法
适用范围:无向图,用于寻找最小生成树。
最小生成树:连通所有节点的加权和最小的边集。
算法思路:
- 对所有边按权值从小到大排序;
- 遍历排序后的边集,如果这条边添加后不会形成环,则添加,加入结果集;否则跳过
- 遍历完毕后得到的结果就是最小生成树。
如何判断加入边后是否形成环?
- 初始化时让每个节点各自位于一个节点集合中;
- 每添加一条边,将这条边的两个节点所属的集合合并;
- 如果一条边的两个节点在同一个集合中,则会形成环,否则不会。
代码实现如下:
// Kruskal算法(寻找最小生成树)
// 辅助类,可以用并查集替代(之后补充)
public static class MySets {
public HashMap<Node, List<Node>> setMap;
public MySets(List<Node> nodes) {
// 初始化,每个节点单独形成一个集合
for (Node node : nodes) {
List<Node> set = new ArrayList<>();
set.add(node);
setMap.put(node, set);
}
}
// 判断两个节点是否在同一个集合中
public boolean isSameSet(Node from, Node to) {
return setMap.get(from) == setMap.get(to);
}
// 将两个节点所在的集合合并
public void union(Node from, Node to) {
List<Node> fromSet = setMap.get(from);
List<Node> toSet = setMap.get(to);
for (Node toNode : toSet) {
fromSet.add(toNode);
setMap.put(toNode, fromSet);
}
}
}
// 算法部分
public static class EdgeComparator implements Comparator<Edge> {
@override
public int compare(Edge e1, Edge e2) {
return e1.weight - e2.weight;
}
}
public static Set<Edge> kruskalMST(Graph graph) {
MySets mySets = new MySets(graph.nodes.values());
// 优先队列,用于对边按权排序
PriorityQueue<Edge> priorityQueue = new PriorityQueue<>(new EdgeComparator());
for (Edge edge : graph.edges) {
priorityQueue.add(edge);
}
Set<Edge> result = new HashSet<>();
while (!priorityQueue.isEmpty()) {
Edge edge = priorityQueue.poll();
if (!mySets.isSameSet(edge.from, edge.to)) {
result.add(edge);
mySets.union(edge.from, edge.to);
}
}
return result;
}
Prim算法
适用范围:无向图,同样用于寻找最小生成树。
算法思路:
- 以任一个点为起点,将它加入点集,将以它作为端点的边全部加入边集;
- 对于当前点,选取边集中权值最小的那条边,判断其两端的点是否都在点集中,若不在,将这条边加入结果集;否则选次小的边,再次判断直到有符合条件的边;
- 若边均不符合条件(不是连通图,最小生成森林),再选取一个未加入的点作为起点,重复操作;
- 将新加入结果集的边新连通的那个节点加入点集,并将新连通节点带来的新边加入边集;
- 重复操作直到所有点都被连通,此时的结果集就是最小生成树。
代码实现如下:
// prim算法
public static Set<Edge> primMST(Graph graph) {
// 新边加入优先队列
PriorityQueue<Edge> p = new PriorityQueue<>(new EdgeComparator());
// 已连通的节点
HashSet<Node> set = new HashSet<>();
// 结果集
Set<Edge> result = new HashSet<>();
// for循环用于处理非连通图,连通图可直接去掉
for (Node node : graph.nodes.values()) { // 随便挑一个未连通节点
if (!set.contains(node)) {
// 开始节点
set.add(node);
// 将它的边加入优先级队列
for (Edge edge : node.edges) {
p.add(edge);
}
while (!p.isEmpty()) {
// 当前权值最小的边
Edge edge = p.poll();
Node to = edge.to;
if (!set.contains(to)) {
// 新点,加入集合
set.add(to);
result.add(edge);
for (Edge nextEdge : to.edges) {
// 不去重不影响结果,仅常数时间变大
p.add(nextEdge);
}
}
}
}
}
}
Dijkstra算法
适用范围:没有权值为负的边,用于寻找单源最短路径。
算法思路:
- 初始化一个距离Map,Key为节点,Value为起点到该点的最短距离,初始时节点到自己的距离为0,到其他节点的距离为+∞;
- 从起始节点开始,更新所有节点的最短距离;
- 取当前最近的节点,用它作为起点更新所有节点的最短距离(加上真正起点到这个节点的距离),该节点标记为已使用;
- 重复操作直到所有节点都被使用。
代码实现如下:
// Dijkstra算法
public static HashMap<Node, Integer> dijkstra(Node head) {
// 从head出发到所有点的最小距离
HashMap<Node, Integer> distanceMap = new HashMap<>();
distanceMap.put(head, 0);
// 已经求过距离的节点,存储在vis中,不再使用
HashSet<Node> vis = new HashSet<>();
// 取当前未使用的最近的节点
Node minNode = getMinDistanceAndUnvisNode(distanceMap, vis);
while (minNode != null) {
// 源点到当前节点的距离
int distance = distanceMap.get(minNode);
for (Edge edge : minNode.edges) {
Node to = edge.to;
if (!distanceMap.containsKey(to)) {
// 未访问过的节点,加入
distanceMap.put(to, distance + edge.weight);
} else {
// 已访问的节点,更新
distanceMap.put(to, Math.min(distance + edge.weight, distanceMap.get(to)));
}
}
// 将当前节点设置为已使用
vis.add(minNode);
minNode = getMinDistanceAndUnvisNode(distanceMap, vis);
}
return distanceMap;
}
public static Node getMinDistanceAndUnvisNode(HashMap<Node, Integer> distanceMap, HashSet<Node> vis) {
Node minNode = null;
int minDistance = Integer.MAX_VALUE;
for (Entry<Node, Integer> entry : distanceMap.entrySet()) {
Node node = entry.getKey();
int distance = entry.getValue();
if (!vis.contains(node) && distance < minDistance) {
minNode = node;
minDistance = distance;
}
}
return minNode;
}
用自建堆优化Dijkstra
前面我们使用哈希表来存储源点到各个节点的距离,这样在取最近节点时需要遍历哈希表,效率不高。那么我们可以用小根堆来加速这一获取过程,但是要注意,Dijkstra在运算过程中节点与源点的距离值会改变,也即堆中的值会变化,我们需要在节点值变化时自动重排小根堆维持其堆顶始终为最小值,而原生的小根堆没有这个功能,所以我们需要自建堆实现相应功能。代码如下:
// 堆弹出的信息类
public static class NodeRecord {
public Node node;
public int distance;
public NodeRecord(Node node, int distance) {
this.node = node;
this.distance = distance;
}
}
// 自建小根堆
public static class NodeHeap {
// 堆的底层结构
private Node[] nodes;
// 记录每个节点在数组中的下标
private HashMap<Node, Integer> heapIndexMap;
// 每个节点离源点的距离
private HashMap<Node, Integer> distanceMap;
// 堆的大小
private int size;
public NodeHeap(int size) {
nodes = new Node[size];
heapIndexMap = new HashMap<>();
distanceMap = new HashMap<>();
this.size = 0;
}
public boolean isEmpty() {
return size == 0;
}
public void addOrUpdateOrIgnore(Node node, int distance) {
if (inHeap(node)) {
// 在堆上,更新最小距离
distanceMap.put(node, Math.min(distanceMap.get(node), distance));
// 上浮堆化
insertHeapify(node, heapIndexMap.get(node));
}
if (!isEntered(node)) {
// 未进入过堆的新节点
nodes[size] = node;
heapIndexMap.put(node, size);
distanceMap.put(node, ditance);
insertHeapify(node, size++);
}
// 进过堆已经不在的节点直接忽略(已经得到最短路的节点)
}
// 弹出堆顶
public NodeRecord pop() {
NodeRecord record = new NodeRecord(nodes[0], distanceMap.get(nodes[0]));
swap(0, size - 1);
heapIndexMap.put(nodes[size - 1], -1);
distanceMap.remove(nodes[size - 1]);
nodes[size - 1] = null;
// 下沉堆化
heapify(0, --size);
return nodeRecord;
}
// 插入元素时或某个节点值变小时维护小根堆(上浮)
private void insertHeapify(Node node, int index) {
while (distanceMap.get(nodes[index]) < distanceMap.get(nodes[(index - 1) / 2])) {
swap(index, (index - 1) / 2);
index = (index - 1) / 2;
}
}
// 下沉堆化(弹出节点后将最后一个节点交换到顶部了,将其下沉到合适位置)
private void heapify(int index, int size) {
int left = index * 2 + 1;
while (left < size) {
// 左右子节点中较小的那一个的下标
int smallest = left + 1 < size && distanceMap.get(nodes[left + 1]) < distanceMap.get(nodes[left]) ? left + 1 : left;
// 较小的子节点与当前节点比较
smallest = distanceMap.get(nodes[smallest]) < distanceMap.get(nodes[index]) ? smallest : index;
if (smallest == index) {
// 如果当前节点已经比两个子节点都要小了,退出
break;
}
swap(smallest, index);
index = smallest;
left = index * 2 + 1;
}
}
// node 是否进入过堆
public boolean isEntered(Node node) {
return heapIndexMap.containsKey(node);
}
// node 当前是否在堆中
public boolean inHeap(Node node) {
return isEntered(node) && heapIndexMap.get(node) != -1;
}
// 交换堆中两个节点的位置
private void swap(int index1, int index2) {
heapIndexMap.put(nodes[index1], index2);
heapIndexMap.put(nodes[index2], index1);
Node tmp = nodes[index1];
nodes[index1] = nodes[index2];
nodes[index2] = tmp;
}
}
// 用自建堆优化的Dijkstra算法,size为图中节点数量
// 从head出发,所有能到达的节点,生成到达每个节点的最短路径并返回
public static HashMap<Node, Integer> dijkstra(Node head, int size) {
NodeHeap nodeHeap = new NodeHeap(size);
nodeHeap.addOrUpdateOrIgnore(head, 0);
HashMap<Node, Integer> result = new HashMap<>();
while (!nodeHeap.isEmpty()) {
NodeRecord record = nodeHeap.pop();
Node cur = record.node;
int distance = record.distance;
for (Edge edge : cur.edges) {
nodeHeap.addOrUpdateOrIgnore(edge.to, edge.weight + distance);
}
result.put(cur, distance);
}
return result;
}