演算法——圖之無向圖
圖的概念
圖是演算法中是樹的拓展,樹是從上向下的資料結構,結點都有一個父結點(根結點除外),從上向下排列。而圖沒有了父子結點的概念,圖中的結點都是平等關係,結果更加複雜。
圖的分類
圖可以分為無向圖(簡單連線),有向圖(連線有方向),加權圖(連線帶權值),加權有向圖(連線既有方向又有權值)。
這篇討論無向圖。
無向圖的表示方法:
1.鄰接矩陣
2.邊的陣列
3.鄰接表陣列
1.鄰接矩陣
我們可以使用一個V*V的布林矩陣graph來表示圖。當頂點v和頂點w之間有邊相連時,則graph[v][w]和graph[w][v]為true,否則為false。
但是這種方法需要佔用的空間比較大,因為稀疏圖更常見,這就導致了很多空間的浪費。V*V的矩陣很多時候我們是不能接受的。
2.邊的陣列
我們可以使用一個數組來存放所有的邊,這樣的話陣列的大小僅有E。但是因為我們的操作總是需要訪問某個頂點的相鄰節點,對於這種資料型別,要訪問相鄰節點的話必須遍歷整個陣列,造成效率的低下,所以我們在這裡也不使用這個資料結構。
3.鄰接表陣列
我們使用一個連結串列陣列來表示,陣列中每個元素都是連結串列表頭,連結串列中存放對應下標的節點所連線的邊。
這種資料結構使用的空間為V+E。並且可以相當方便的獲取相鄰節點。
如圖:
實現如下:
import java.util.ArrayList; import java.util.List; public class Graph { private List<Integer>[] adj; // 鄰接表 private int V; // 頂點數目 private int E; // 邊的數目 public Graph(int V) { this.V = V; adj = (List<Integer>[])new List[V]; E = 0; for (int i = 0; i < V; i++) { adj[i] = new ArrayList<Integer>(); } } public void addEdge(int v, int w) { adj[v].add(adj[v].size(), w); adj[w].add(adj[w].size(), v); E++; } public List<Integer> adj(int v) { return adj[v]; } public int V() { return V; } public int E() { return E; } public String toString() { String s = V + " 個頂點, " + E + " 條邊\n"; for (int i = 0; i < V; i++) { s += i + ": "; for (Integer node : adj(i)) { s += node + " "; } s += "\n"; } return s; } }
到此為止,我們已經完成了圖的表示。
有了表示,我們就需要使用圖完成一些簡單的應用。
例如,圖的搜尋,圖的連通分量,圖是否有環等等。
首先我們來實現圖的搜尋。
我們在這裡實現一個模板。並不實際進行搜尋。
目標:給定一個起點,在圖中進行搜尋。
方案:1.深度優先搜尋2.廣度優先搜尋。
深搜
原理:這裡打個比喻,搜尋圖中所有的節點,就像走迷宮一樣,需要探索迷宮中所有的通道。探索迷宮所有的通道,我們需要什麼呢?
1.我們需要選擇一條沒有標記的路,並且一邊走一遍鋪上一條繩子。
2.標記走過的路。
3.當走到一個標記的地方時,我們需要回退,根據繩子回退到上一個地方。
4.當回退的地方沒有可以走的路了,就要繼續回退。
也就是說,首先我們需要一直走下去,但是我們一邊走就要一邊做標記,如果走不下去了,就回退,回退到沒有被標記的的路。迴圈往復,我們就能探索整個圖了。
實現:
import java.util.Stack;
public class DepthFirstSearch {
private boolean[] isMarked;
private int begin;
private int count;
private Integer[] edgeTo;
public DepthFirstSearch(Graph g, int begin) {
isMarked = new boolean[g.V()];
edgeTo = new Integer[g.V()];
count = 0;
this.begin = begin;
dfs(g, begin);
}
public void dfs(Graph g, int begin) {
isMarked[begin] = true;
for (Integer i : g.adj(begin)) {
if (!isMarked[i]) {
edgeTo[i] = begin;
count++;
dfs(g, i);
}
}
}
public boolean hasPath(int v) {
return isMarked[v];
}
public int count() {
return count;
}
public String pathTo(int v) {
if (!hasPath(v)) return "";
Stack<Integer> stack = new Stack<>();
stack.push(v);
for (int i = v; i != begin; i = edgeTo[i]) {
stack.push(edgeTo[i]);
}
return stack.toString();
}
}
我們需要一個數組來標記某個節點是否已經走過了,如果走過了,我們就不會再走了。並且我們有一個數組去儲存是從哪個節點到達當前節點。這樣,我們往回追朔的時候,就可以找到一條路徑了。
這是一個模板,並沒有具體的搜尋某個節點,而是將所有節點都搜尋了一遍,在實際過程中,我們可以判斷節點是否找到,找到就停止了。
對於無向圖來說,深搜雖然可以找到一條從v到w的路徑,但是這條路徑是否是最優的並不是可靠的,往往都不是。
如果我們希望找到一條最短的路徑,我們就應該使用廣搜。
廣搜
原理:
廣搜並不是先一條路走到黑,而是慢慢的根據距離進行搜尋。例如,一開始先根據距離是1進行搜尋,先搜尋所有距離為1的地方。如果沒找到,再搜尋距離為2的地方。以此類推。
如果說深搜是一個人在迷宮中搜索,那麼廣搜就是一組人在朝著各個方向進行搜尋。當然不是效率比較高的意思,只是比喻而已。
實現:
import java.util.LinkedList;
import java.util.Queue;
import java.util.Stack;
public class BreadthFirstSearch {
private boolean[] isMarked;
private Integer[] edgeTo;
private int begin;
private int count; // 多少個點連通
public BreadthFirstSearch(Graph g, int begin) {
isMarked = new boolean[g.V()];
edgeTo = new Integer[g.V()];
this.begin = begin;
count = 0;
bfs(g, begin);
}
private void bfs(Graph g, int begin) {
Queue<Integer> queue = new LinkedList<>();
isMarked[begin] = true;
queue.offer(begin);
while (!queue.isEmpty()) {
Integer temp = queue.poll();
for (Integer i : g.adj(temp)) {
if (!isMarked[i]) {
isMarked[i] = true;
count++;
edgeTo[i] = temp;
queue.offer(i);
}
}
}
}
public boolean hasPath(int v) {
return isMarked[v];
}
public int count() {
return count;
}
public String pathTo(int v) {
if (!hasPath(v)) return "";
Stack<Integer> stack = new Stack<>();
stack.push(v);
for (int i = v; i != begin; i = edgeTo[i]) {
stack.push(edgeTo[i]);
}
return stack.toString();
}
}
其實廣搜和深搜的不同就在於搜尋規則的不同,深搜使用的是stack的LIFO(後進先出)的思想,總是搜尋最新的節點。而廣搜則是使用queue的FIFO(先進先出)的規則。
就如同上面的一樣,節點進入佇列的順序是根據距離的,所以我們就可以實現慢慢範圍的擴大搜索。
同樣的,我們也標記了進入節點的前一個節點,用來追蹤路徑。因為我們是根據範圍搜尋的,所以得到的就是最短路徑。
我們可以使用廣搜和深搜來實現很多應用,例如是否有環,是否是二部圖等等。這裡我們就不展開了。
我們上面的圖的節點都是以數字作為標記的,而對於實際應用來講,圖的節點一般都不會是數字,而是String型別的字串等。
要實現這種符號圖,我們只需要將我們的程式碼進行一些擴充套件,使用符號表的方法,將字串對映到某個整數上就可以了。
例如:
我們只需要在將字串對映得到一個數字,也就是使用散列表的方式,儲存成鍵值對,就可以繼續使用上面的程式碼了。