加权图这里指无向加权图。
加权图是一种为每条边关联一个权值或成本的图模型。也就是在无向图的基础上,每条边加上权值。
加权图有很多应用,例如航空图中,边表示航线,权值表示距离或是费用。还能表示电路图,边表示导线,权值表示导线长度或是成本等等。
在这些情形下,我们最感兴趣的当然是成本最小化,也就是最小生成树问题。
最小生成树
一副加权无向图的最小生成树(MST)是一棵权值之和最小的生成树。而生成树则是一棵含有所有顶点的无环连通子图。
最小生成树算法:
1.Prim算法 2.Kruskal算法
这两个算法的本质都是贪心算法,都是基于切分定理得到的。
切分定理
在一幅加权图中,给定任意切分,它的横切边中的权重最小者必然属于最小生成树。
上面两个最小生成树算法都是贪心算法,在保证最小生成树的基础上,选择边的算法。
当然,作为一个算法的使用者,我们更关心的是算法实现本身,这里提到切分定理只是表明,这两个算法是有理论依据的,可以放心使用。
当然,在完成最小生成树算法之前,我们第一步首先是要确定数据结构。
我们使用什么样的方式来表示加权无向图呢?
在非加权无向图中,我们使用邻接表矩阵的方式来保存无向图。而对于加权无向图,我们也采用同样的方式。但是不同的是,加权无向图有着非加权无向图的别的特性,那就是我们还需要保存 边的权重的信息。所以我们定义一个Edge边类,其中有weight属性,用来保存权重信息。
增加一个小小的修改就成了这样:
public class Edge { // 图的边
private int v; // 另外一个
private double weight; // 边的权重
}
但是呢,邻接表矩阵用List<>[]数组表示,调用add方法会增加引用。所以我们不妨这样:
public class Edge { // 图的边
private int v; // 其中一个节点
private int w; // 另一个节点
private double weight; // 边的权重
}
将两条边的顶点都保存下来,这样的话,虽然看上去好像增加了冗余的信息,因为邻接表矩阵的下标就是当前的顶点。
其实并非是这样的,例如v-w。那么就存在Edge对象e,那邻接表矩阵v可以指向e,w也可以指向e。这样,我们就可以少创建一半的类。减小了内存的开销。
如图所示:
最终得到如下结构:
边:
public class Edge { // 图的边
private int v; // 其中一个节点
private int w; // 另一个节点
private double weight; // 边的权重
public Edge(int v, int w, double weight) {
this.v = v;
this.w = w;
this.weight = weight;
}
public int either() { // 返回其中一个节点
return v;
}
public int other(int i) { // 已知一个节点,返回另一个节点
if (i == v) return w;
if (i == w) return v;
System.out.println("error! arg expect: " + v + " or " + w + ",but receive:" + i);
return -1;
}
public int compareTo(Edge e) { // 根据权重比较
if (weight > e.weight) return 1;
else if (weight < e.weight) return -1;
return 0;
}
public String toString() {
String s = v + " to " + w + ", weight: " + weight;
return s;
}
public double weight() {
return weight;
}
}
我们只需要小小的修改一下无向图的数据结构就可以了。
图:
public class EdgeWeightGraph {
private List<Edge>[] adj; // 邻接表矩阵
private int V;
private int E;
public EdgeWeightGraph(int V) { // 创建一个V个节点,没有边的图
this.V = V;
this.E = 0;
adj = (List<Edge>[])new List[V];
for (int i = 0; i < V; i++) {
adj[i] = new ArrayList<>();
}
}
public void addEdge(Edge e) {
int v = e.either();
int w = e.other(v);
adj[v].add(e);
adj[w].add(e);
E++;
}
public int V() {
return V;
}
public int E() {
return E;
}
public Iterable<Edge> adj(int v) { // 返回v相连的边
return adj[v];
}
public Iterable<Edge> edges() { // 返回所有边的集合
List<Edge> edges = new ArrayList<>();
for (int i = 0; i < V; i++) {
for (Edge e : adj(i)) {
if (e.other(i) > i) {
edges.add(e);
}
}
}
return edges;
}
public String toString() {
String s = V + " 个顶点, " + E + " 条边\n";
for (int i = 0; i < V; i++) {
s += i + ": ";
for (Edge e : adj(i)) {
s += e.other(i) + " [" + e.weight() + "], ";
}
s += "\n";
}
return s;
}
}
和之前基本相比没有多少变化。
加权无向图的表示方法决定了,我们就可以使用这个图来构建最小生成树了。
Prim算法
思路:
一开始树只有一个顶点,然后向他添加V-1条边。每次总是添加一条不在当前树种的顶点,且权重最小的边,加入到生成树当中。
所以我们该怎么做呢?
我们需要记录什么呢?1.我们需要记录已经被添加进生成树的顶点 2.需要保存生成树
我们需要对边进行判断,如果这个边的两端都已经在生成树中了,那么这个边是无效的,所以我们需要记录生成树的顶点。
保存生成树这是理所当然的,但是我们使用什么方式来保存呢?使用边的集合来代表生成树,比较方便。当然也可以用图
怎么拿到权重最小的边呢? 可以维护一个边的集合,这个集合中都是生成树附近的边,在集合中找到权值最小的边。
例如,在图1-2,2-3,3-1中,刚开始生成树为1,树附近的边的集合就是1-2和1-3.。
如果每次找权值最小的边都遍历一遍集合的话,还挺麻烦的,而且效率不高,所以我们使用一个优先队列来保存,使得优先队列的头部永远是权值最小的边。
先来看实现:
public class LazyPrimMST {
private boolean[] isMark; // 生成树的顶点
private List<Edge> mst; // 生成树的边
private Queue<Edge> pqueue; // 横切边
Comparator<Edge> edgeComparator = new Comparator<Edge>() {
public int compare(Edge e1, Edge e2) {
return e1.compareTo(e2);
}
};
public LazyPrimMST(EdgeWeightGraph g) {
isMark = new boolean[g.V()];
mst = new ArrayList<>();
pqueue = new PriorityQueue<>(edgeComparator);
visit(g, 0);
while (!pqueue.isEmpty()) {
Edge e = pqueue.poll();
int v = e.either();
int w = e.other(v);
if (isMark[v] && isMark[w]) continue; // 无效的边
mst.add(e);
if (!isMark[v]) visit(g, v);
if (!isMark[w]) visit(g, w);
}
}
private void visit(EdgeWeightGraph g, int node) { // 访问当前节点,将附近的边全部加进优先队列中
isMark[node] = true;
for (Edge e : g.adj(node)) {
if (!isMark[e.other(node)]) {
pqueue.add(e);
}
}
}
public double weight() {
double weight = 0;
for (Edge e : edges()) {
weight += e.weight();
}
return weight;
}
public Iterable<Edge> edges() {
return mst;
}
}
思路比较简单,使用遍历的策略,首先我们访问节点0,将节点0附近的边都添加进入优先队列中,在优先队列中,就可以拿到权重最小的边。依此类推,就可以得到最小生成树了。因为优先队列中会不断的添加边,而有些边在节点增加进来之后就会失效,所以我们需要进行判断,如果已经失效(边的两个节点都在最小生成树种),那么我们就跳过即可。
kruskal算法
思路:
遍历所有边,每次找到当前边集合中最小的边,如果加入这个边不会构成环的话,就加入,否则放弃这条边。
思路来说比较简单,每次找到权重最小的边,然后在可以保证是最小生成树的基础上加入就可以了。
实现如下:
public class KruskalMST {
private List<Edge> mst; // MST的边的集合
private Queue<Edge> pqueue; // 边的集合
Comparator<Edge> edgeComparator = new Comparator<Edge>() {
public int compare(Edge e1, Edge e2) {
return e1.compareTo(e2);
}
};
public KruskalMST(EdgeWeightGraph g) {
pqueue = new PriorityQueue<>(edgeComparator);
mst = new ArrayList<>();
edgeAddAll(g);
while (!pqueue.isEmpty() && mst.size() < g.V() - 1) {
Edge e = pqueue.poll();
mst.add(e);
if (hasCycle(g.V())) {
mst.remove(e);
continue;
}
}
}
private void edgeAddAll(EdgeWeightGraph g) {
for (Edge e : g.edges()) {
pqueue.add(e);
}
}
public double weight() {
double weight = 0;
for (Edge e : edges()) {
weight += e.weight();
}
return weight;
}
public Iterable<Edge> edges() {
return mst;
}
private boolean hasCycle(int length) {
EdgeWeightGraph g = new EdgeWeightGraph(length);
for (Edge e : mst) {
g.addEdge(e);
}
EdgeWeightedCycle cycle = new EdgeWeightedCycle(g);
return cycle.hasCycle();
}
}
同样的,我们也使用优先队列来获取权重最小的边,如果这个边加入不会构成环,就加入,否则不加入。
过程如图所示:
判断无向图是否有环,我们可以使用深搜的方式来进行判断。这和有向图中判断是否有环差不多。
prim算法依赖于点,一直寻找离当前生成树最近的点。
kruskal算法依赖于边,在保证生成树的基础上,一直寻找最短的边。
对于我上面实现的方式来说,lazyPrim算法和kruskal算法其实差的并不多。空间复杂度为O(E),时间复杂度为O(ElogE)。
其实lazyPrim算法可以进行改进,我们完全没有必要对一个点附近的所有边都加进优先队列中,而是应该维护一个最小路径,如果改边到达树的最小路径改变了,我们才增加进入优先队列中,而不是无脑的增加进入优先队列,因为对于很多路径,完全没有必要考虑,增加进优先队列中只会成为失效边而已。
一般来说Kruskal算法会比prim算法稍微慢一点,因为他还需要判断图是否有环路。