算法——图之加权图

加权图这里指无向加权图。

加权图是一种为每条边关联一个权值或成本的图模型。也就是在无向图的基础上,每条边加上权值。

加权图有很多应用,例如航空图中,边表示航线,权值表示距离或是费用。还能表示电路图,边表示导线,权值表示导线长度或是成本等等。

在这些情形下,我们最感兴趣的当然是成本最小化,也就是最小生成树问题。

最小生成树

一副加权无向图的最小生成树(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算法稍微慢一点,因为他还需要判断图是否有环路。

点赞