一言以蔽之,我们的图中,边上带有权值。 如果我们的图带上权值的话,存储图的结构就不能用 TreeSet 了,这是因为 set只能帮助我们存储当前顶点的邻接点, 不能存储与这个邻接点 之间 边的权值。 因此,为了可以方便地存储 两个数据,准确说是一对数据,我们很容易想到用 映射,也就是TreeMap
import java.io.File;
import java.io.IOException;
import java.util.Map;
import java.util.Scanner;
import java.util.TreeMap;
public class WeightedGraph {
private int V;
private int E;
private TreeMap<Integer, Integer>[] adj; // key 为邻接点 value 为 权值
public WeightedGraph(String filename) {
File file = new File(filename);
try (Scanner scanner = new Scanner(file)) {
V = scanner.nextInt();
if (V < 0) throw new IllegalArgumentException("V must be non-negative");
adj = new TreeMap[V];
for (int i = 0; i < V; i++)
adj[i] = new TreeMap<Integer, Integer>();
E = scanner.nextInt();
if (E < 0) throw new IllegalArgumentException("E must be non-negetive");
for (int i = 0; i < E; i++) {
int a = scanner.nextInt();
validateVertex(a);
int b = scanner.nextInt();
validateVertex(b);
int weight = scanner.nextInt();
if (a == b)
throw new IllegalArgumentException("self loop is detected");
// 如果有平行边,我们处理平行边,多半是保留权值最小的
if (adj[a].containsKey(b))
throw new IllegalArgumentException("parallel edges are detected");
adj[a].put(b, weight);
adj[b].put(a, weight);
}
} catch (IOException e) {
e.printStackTrace();
}
}
public void validateVertex(int v) {
if (v < 0 || v >= V)
throw new IllegalArgumentException("vertex " + v + "is invalid");
}
public boolean hasEdge(int v, int w) {
validateVertex(v);
validateVertex(w);
return adj[v].containsKey(w);
}
public int V() {
return V;
}
public Iterable<Integer> adj(int v) {
validateVertex(v);
return adj[v].keySet(); // 我们返回所有的键的集合 也就是返回了顶点v的所有邻接点
}
// 返回顶点v邻接的顶点w之间的权值
public int getWeight(int v, int w) {
if (hasEdge(v, w))
return adj[v].get(w);
throw new IllegalArgumentException(String.format("No edge %d--%d", v, w));
}
public int degree(int v) {
validateVertex(v);
return adj[v].size();
}
// 删除一条边 如果用户传来的v和w之间根本不存在边,remove做不了任何事情
public void removeEdge(int v, int w) {
validateVertex(w);
validateVertex(v);
adj[v].remove(w);
adj[w].remove(v);
}
//做一次深拷贝 也就是做一个副本
@Override
public Object clone() {
try {
WeightedGraph cloned = (WeightedGraph) super.clone();
cloned.adj = new TreeMap[V];
for (int v = 0; v < V; v++) {
cloned.adj[v] = new TreeMap<>();
// 对map进行遍历
for (Map.Entry<Integer, Integer> entry : adj[v].entrySet()) {
cloned.adj[v].put(entry.getKey(), entry.getValue());
}
}
return cloned;
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
return null;
}
@Override
public String toString() {
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append(String.format("V = %d E = %d\n", V, E));
for (int v = 0; v < V; v++) {
stringBuilder.append(String.format("%d :", v));
for (Map.Entry<Integer,Integer> entry : adj[v].entrySet())
stringBuilder.append(String.format("(%d: %d)", entry.getKey(), entry.getValue()));
stringBuilder.append("\n");
}
return stringBuilder.toString();
}
public static void main(String[] args) {
WeightedGraph g =new WeightedGraph("g7.txt");
System.out.println(g);
}
}
我们之前已经接触过生成树的概念: 广度优先遍历生成树,深度优先遍历生成树。对于带权图来说,不同的生成树,权值的和不同。应用价值在哪?布线设计,找到最小的耗费,把所有的点连接在一起。 或者保证图连通,并且费用和最小。
在图中找v-1条边,使得费用和最小。一个很自然的想法,我们尽量选择短边。但是我们在找边的过程中,不能任意选择权值小的边,我们当前选择的边不能和已经有的边形成环。这样进行选择其实是一种贪心思想。 而这个算法本身就是大名鼎鼎的 Krusal 算法
图中的顶点分为两个部分,就称为一个切分。这个划分是任意的。只要把图中的顶点分为两个部分就行。
如果一个边的两个端点,属于切分不同的两边,这个边就称为横切边。其实用这个方法,我们可以重新定义二分图。对于二分图,就是我们可以找到一种划分,将图中的顶点分为两个部分,使得图中的所有的边都是横切边。
切分定理:横切边中的最短边或者说权重最小的边,一定属于最小生成树。Krusal算法其实一直在应用切分定理。
Krusal 算法让我们每次都从还没有进行选择边中选择权值最小的。同时当前选择的边 不能和已经选择的边构成环。 因此, 因此,我们需要两步, 第一步,对所有的带权边进行按照权重进行一次从小到大排序。 第二步:环检测判断。 第一步比较容易,我们直接排序就可以了。 第二步环检测稍微复杂一点。
由于我们在不断向 最后的结构边序列添加边, 这个环检测其实是动态的。对于这种环检测的动态判断,其实是判断连通性,如果新加入一条边,就形成了一个环,那么之前没有加入这条边的时候,这条边的两端点都是可以到达的。也就是连通的。 于是我们可以使用dfs 或者 bfs 。 但是这里是动态判断。—对于动态判断两个点是否可达,或者说是不是在同一个集合中,使用并查集
// 复习并查集,首先初始化parent 数组,初始时,每个顶点的父顶点都是自己
public class UF {
private int[] parent;
public UF(int n) {
// 初始化顶点数组,每个顶点的初始父顶点都是自己
parent = new int[n];
for (int i = 0; i < n; i++)
parent[i] = i;
}
public int Find_(int p) {
// 不带路径压缩的循环查找
int cur = p;
while (parent[cur] != cur) {
cur = parent[cur];
}
return cur;
}
public int Find(int p) {
// 带路径压缩的递归查找
if (parent[p] == p)
return p;
parent[p] = Find(parent[p]);
return parent[p];
}
public boolean isConnected(int p, int q) {
// 判断两个顶点是否在同一个集合中
return Find(p) == Find(q);
}
public void UnionElements(int p, int q) {
//把两个顶点所在的集合合并
int proot = Find(p);
int qroot = Find(q);
parent[proot] = qroot;
}
}
在进行具体的求解之前,我们需要定义一下 边这个类,因为我们最后需要得到的是一条一条的边
public class WeightedEdge implements Comparable<WeightedEdge> {
private int v, w, weight;
public WeightedEdge(int v, int w, int weight) {
this.v = v;
this.w = w;
this.weight = weight;
}
public int getV() {
return v; }
public int getW() {
return w; }
@Override
public String toString() {
return String.format("(%d -%d :%d)", v, w, weight);
}
@Override
public int compareTo(WeightedEdge other) {
// 权值越小越靠前
return this.weight - other.weight;
}
}
下面我们就可以进行最小生成树的求解:
import java.util.ArrayList;
import java.util.Collections;
public class Krusal {
private WeightedGraph g;
private ArrayList<WeightedEdge> mst;
public Krusal(WeightedGraph g) {
//首先要判断是否连通
this.g = g;
mst = new ArrayList<WeightedEdge>();
CCadv cc = new CCadv(g);
if (cc.count() > 1) return; //不连通 也就是没有最小生成树
// Kruskal
ArrayList<WeightedEdge> edges = new ArrayList<>();
for (int v = 0; v < g.V(); v++)
for (int w : g.adj(v))
if (v < w) // 这是无向图,为了避免重复
edges.add(new WeightedEdge(v, w, g.getWeight(v, w)));
Collections.sort(edges); // 排序
// 环判断
UF uf = new UF(g.V());
// 核心算法部分
for (WeightedEdge edge : edges) {
// 依次遍历每一条边
int v = edge.getV(); //取出边的两个顶点
int w = edge.getW();
if (!uf.isConnected(v, w)) {
// 如果这两个顶点在已经收集的边集合中不可达
mst.add(edge); //那么这条边就是我们要的边
uf.UnionElements(v, w);// 把这条边添加到结果中之后还要 在并查集中把顶点v,w合并
}
}
}
public ArrayList<WeightedEdge> result() {
// 返回最后的结果
return mst;
}
public static void main(String[] args){
WeightedGraph g = new WeightedGraph("g7.txt");
Krusal krusal = new Krusal(g) ;
System.out.println(krusal.result());
}
}
时间复杂度是 O(E log E) 时间复杂度主要在 排序这里。
图的文件数据如下:
7 12
0 1 2
0 3 7
0 5 2
1 2 1
1 3 4
1 4 3
1 5 5
2 4 4
2 5 4
3 4 1
3 6 5
4 6 7
操作切分,从1 : V-1 开始,每次找当前切分的最短横切边,扩展切分,直到没有切分。
import java.util.ArrayList;
public class Prime {
private WeightedGraph g;
private ArrayList<WeightedEdge> mst;
public Prime(WeightedGraph g) {
this.g = g;
mst = new ArrayList<>();
CCadv cc = new CCadv(g);
if (cc.count() > 1)
return;
// prime 核心算法
boolean[] visited = new boolean[g.V()]; // 生成树的顶点值用 true标记,非生成树的顶点值用 false 标记
visited[0] = true; // 从0开始进行切分
for (int i = 1; i < g.V(); i++) {
// 循环v-1 次找出v-1 条边
WeightedEdge minEdge = new WeightedEdge(-1, -1, Integer.MAX_VALUE);
for (int v = 0; v < g.V(); v++) {
// 遍历每一个顶点
if (visited[v]) {
// 如果v已经是生成树的顶点 那么遍历v的所有邻接点
for (int w : g.adj(v)) {
if (!visited[w] && g.getWeight(v, w) < minEdge.getWeight())
minEdge = new WeightedEdge(v, w, g.getWeight(v, w));
}
}
}
mst.add(minEdge); // 简单粗暴 把收录进行MST的边上的顶点都划为生成树的顶点
visited[minEdge.getV()] = true;
visited[minEdge.getW()] = true;
}
}
public ArrayList<WeightedEdge> result() {
return mst;
}
public static void main(String[] args){
WeightedGraph g = new WeightedGraph("g7.txt");
Prime prime =new Prime(g) ;
System.out.println(prime.result());
}
}
时间复杂度是 : O((v-1) *(V+E)) = O(v * E) 时间复杂度比较高。
我们之前的prime算法实现是这样的: 遍历每一个顶点 —> 判断这个顶点是否是当前生成树的顶点。如果是生成树的顶点—> 遍历这个顶点的每一条横切边。 然后经过一次一次的比较,找出当前权重最小的横切边。 但是这样做,会存在重复遍历问题。
我们每一轮产生的横切边只不过是在前一轮的基础上改变了一些边。我们可以使用优先队列帮我们动态找到当前最小的横切边。
优先队列中存储的边不一定是合法的边。当我们的最小生成树 新收录一个顶点,会导致一些之前的横切边变得不再是横切边。如果我们要在优先队列中删除这些边,会比较麻烦。 一个处理方式是我们依然存储这些边,只是在获取最小权值的边的时候,对边的合法性进行判断。
import java.util.ArrayList;
import java.util.PriorityQueue;
import java.util.Queue;
public class Prime {
private WeightedGraph g;
private ArrayList<WeightedEdge> mst;
public Prime(WeightedGraph g) {
this.g = g;
mst = new ArrayList<>();
CCadv cc = new CCadv(g);
if (cc.count() > 1)
return;
// prime 核心算法
boolean[] visited = new boolean[g.V()]; // 生成树的顶点值用 true标记,非生成树的顶点值用 false 标记
visited[0] = true; // 从0开始进行切分
Queue<WeightedEdge> pq = new PriorityQueue<>();
//添加初始状态下的横切边
for (int w : g.adj(0))
pq.add(new WeightedEdge(0, w, g.getWeight(0, w)));
while (!pq.isEmpty()) {
WeightedEdge minEdge = pq.remove();// 有可能是最小边
if (visited[minEdge.getV()] && visited[minEdge.getW()])// 如果这条边的两个端点都已经被访问过的话,那么这条边不是合法的边
continue;
mst.add(minEdge);
// 生成树新拓展的顶点
int newV = visited[minEdge.getV()] ? minEdge.getW() : minEdge.getV();
visited[newV] = true;
for(int w : g.adj(newV))
if(!visited[w]) // 找新的横切边
pq.add(new WeightedEdge(newV,w, g.getWeight(newV,w))) ;
}
}
public ArrayList<WeightedEdge> result() {
return mst;
}
public static void main(String[] args) {
WeightedGraph g = new WeightedGraph("g7.txt");
Prime prime = new Prime(g);
System.out.println(prime.result());
}
}
时间复杂度 : O(E log E) 手写kursal 需要自己写并查集, 但是手写 prime 我们一般不用自己手写优先队列。其实Kruskal 算法是早于并查集的。