1. 程式人生 > >演算法——圖之加權圖

演算法——圖之加權圖

加權圖這裡指無向加權圖。

加權圖是一種為每條邊關聯一個權值或成本的圖模型。也就是在無向圖的基礎上,每條邊加上權值。

加權圖有很多應用,例如航空圖中,邊表示航線,權值表示距離或是費用。還能表示電路圖,邊表示導線,權值表示導線長度或是成本等等。

在這些情形下,我們最感興趣的當然是成本最小化,也就是最小生成樹問題。

最小生成樹

一副加權無向圖的最小生成樹(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演算法稍微慢一點,因為他還需要判斷圖是否有環路。