圖論基礎(初級)
引言
圖論(Graph theory)是數學研究領域的一大分支,它以圖爲研究對象,而圖是一種由頂點和邊構成的離散數據結構,它的頂點代表事物,邊表示各個事物之間的某種特殊關係。圖的應用領域十分廣泛,幾乎所有學科中的問題都可以使用圖來建模求解。
圖的定義及分類
圖是由頂點和邊組成的:(可以無邊,但至少包含一個頂點)
- 一組頂點:通常用 V(vertex) 表示頂點集合
- 一組邊:通常用 E(edge) 表示邊的集合
圖可以分爲有向圖和無向圖,在圖中:
(v, w)
表示無向邊,即 v 和 w 是互通的<v, w>
表示有向邊,該邊始於 v,終於 w。
圖可以分爲有權圖和無權圖:
- 有權圖:每條邊具有一定的權重(weight),通常是一個數字
- 無權圖:每條邊均沒有權重,也可以理解爲權爲 1
圖又可以分爲連通圖和非連通圖:
- 連通圖:所有的點都有路徑相連
- 非連通圖:存在某兩個點沒有路徑相連
圖中的頂點有度的概念:
- 度(Degree):所有與它連接點的個數之和
- 入度(Indegree):存在於有向圖中,所有接入該點的邊數之和
- 出度(Outdegree):存在於有向圖中,所有接出該點的邊數之和
圖的實現方式(無權圖)
圖的實現方式主要分爲兩種,一種爲鄰接矩陣,另一種爲鄰接表。
鄰接矩陣
在使用鄰接矩陣來表示圖的時候,對於一個擁有v個節點的圖,我們創建一個v*v
的二維布爾數組,如果從i到j有邊,則將數組的第i行第j列值賦爲true,如果是無向圖的話,同時還要將第j行i列賦爲true。
圖示:
代碼定義:
爲了後續的一些操作的實現,此處使用了接口。
UnweightedGraph接口:
public interface Graph {
int getVertexNum();
int getEdgeNum();
void addEdge(int v, int w);
boolean hasEdge(int v, int w);
Iterable<Integer> adj(int v);
void show();
}
public class Dense_Graph implements Graph {
private int vertexNum;//頂點數
private int edgeNum;//邊數
private boolean directed;//表示是否爲有向圖
private boolean[][] g;//鄰接矩陣
public Dense_Graph(int vertexNum, boolean directed) {//初始化
this.vertexNum = vertexNum;
this.directed = directed;
this.edgeNum = 0;
g = new boolean[vertexNum][vertexNum];//初始化二維數組
}
public int getVertexNum() {//返回頂點數
return vertexNum;
}
public int getEdgeNum() {//返回邊數
return edgeNum;
}
public void addEdge(int v, int w) {//添加一條邊
assert v >= 0 && v < vertexNum && w >= 0 && w < vertexNum;
//如果v和w已經有邊則不進行添加,用於處理平行邊
if (!hasEdge(v, w)) {
g[v][w] = true;//在鄰接矩陣中,添加邊就是將兩個節點對應的數組值設爲true
if (!directed) {
//如果是無向圖
g[w][v] = true;//無向圖意味着二者互通
}
edgeNum++;//邊數加一
}
}
public boolean hasEdge(int v, int w) {
return g[v][w];//判斷v和w之間是否有邊
}
public Iterable<Integer> adj(int v) {//迭代器,用於迭代某一個節點相鄰的所有節點
assert v >= 0 && v < vertexNum;
Vector<Integer> adjV = new Vector<>();//創建一個vector對象,用於保存結果
for (int i = 0; i < vertexNum; i++) {
if (g[v][i]) {//如果v與i相通
adjV.add(i);//將i添加
}
}
return adjV;//返回迭代器
}
public void show() {//用於打印整個圖
for (int i = 0; i < vertexNum; i++) {
for (int j = 0; j < vertexNum; j++) {
System.out.print(g[i][j] + "\t");
}
System.out.println();
}
}
}
鄰接表
在使用鄰接表來表示一張圖的時候,我們通常用鏈表數組來存儲圖的信息,即如果我們要創建一個v個節點的圖,則就有v個鏈表,這v個鏈表組成數組,數組的索引表示哪個節點,鏈表裏面的值代表與該節點相連的其它節點。
圖示:
代碼定義:
public class Sparse_Graph implements Graph {
private int vertexNum;//頂點數
private int edgeNum;//邊數
private boolean directed;//是否是有向圖
private Vector<Integer>[] g;//使用鄰接表存儲圖
public Sparse_Graph(int vertexNum, boolean directed) {//初始化鄰接表
this.vertexNum = vertexNum;
this.directed = directed;
this.edgeNum = 0;
g = new Vector[vertexNum];//初始化g數組
for (int i = 0; i < vertexNum; i++) {
g[i] = new Vector<>();//爲每一個vector初始化
}
}
public int getVertexNum() {
return vertexNum;
}
public int getEdgeNum() {
return edgeNum;
}
public void addEdge(int v, int w) {//添加邊
assert v >= 0 && v < vertexNum;
assert w >= 0 && w < vertexNum;
if (!hasEdge(v, w)) {//如果邊存在則不添加
g[v].add(w);
if (!directed && v != w) {//如果是無向圖
g[w].add(v);
}
edgeNum++;
}
}
public boolean hasEdge(int v, int w) {
for (int i = 0; i < g[v].size(); i++) {
if (g[v].elementAt(i) == w) {
return true;
}
}
return false;
}
public Iterable<Integer> adj(int v) {//返回v相鄰節點的迭代器
assert v >= 0 && v < vertexNum;
return g[v];//直接返回v這個鏈表即可。
}
public void show() {//打印圖
for (int i = 0; i < vertexNum; i++) {
System.out.print(i + ": ");
for (int j = 0; j < g[i].size(); j++) {
System.out.print(g[i].elementAt(j) + "\t");
}
System.out.println();
}
}
}
小結
以上就是圖的兩種表現方式的實現,從它們的實現中也可以看出,鄰接矩陣適合用於表現稠密圖,而鄰接表適合於稀疏圖,所以在面對具體的實現的時候我們要視情況選取圖的實現方式。
圖的基本操作算法
圖是一個相當大的領域,而與之有關的算法完全可以寫一本不薄的書,所以我們這邊也不可能將之說一遍,這裏我們先只講最基本的兩個算法,一個是DFS,另一個是BFS,也就是深度優先搜索和廣度優先搜索,其它的算法大都是這兩個的變種,所謂萬變不離其宗,我們先把這兩個吃透了,遇到其它的也就不怕了。
DFS算法及其應用
定義
深度優先搜索算法(Depth-First-Search,簡寫DFS),主要是用於對樹或圖的遍歷。它的核心思想就是從一個未訪問過的節點出發,不斷搜索未訪問過的節點,如果沒有未訪問過的節點了,則返回上一層,接着重複該操作。通俗點就是從一點出發,一直沿着一條路走,如果到底了則返回找其它的路走。
圖示:
求連通分量
基於dfs,我們可以很容易的求得一張圖的連通分量,所謂連通分量就是指一張圖中有幾個部分是連在一起的。
圖示:
在上圖中,該圖分爲三個部分,所以連通分量爲3
思路:
- 對於一張圖,首先我們循環遍歷每一個節點元素,如果遇到未訪問過的,則將其標上記號。
- 接着使用dfs算法對與該節點連接的所有節點進行遍歷操作,一邊遍歷,一邊標上記號,以表示訪問過了。
- dfs結束就表示對一個連通分量遍歷完了,之後將記錄數加一。
- 重複以上操作,直到圖中所有元素均被遍歷過爲止。
代碼:
public class Component {
private int count;//連通分量的個數
private boolean[] visted;//用於保存該元素是否被訪問過了
private Graph graph;//傳入的圖
private int[] id;//表示該節點所屬的連通分量是哪一個
public Component(Graph graph) {
this.graph = graph;
count = 0;
id = new int[graph.getVertexNum()];
Arrays.fill(id, -1);//填充id爲-1
visted = new boolean[graph.getVertexNum()];
//搜索連通分量
for (int i = 0; i < graph.getVertexNum(); i++) {//遍歷一遍圖
if (!visted[i]) {//如果該節點沒有訪問過
dfs(i);//使用dfs對與該節點相連的節點全部訪問一遍
count++;//訪問完count加一,同時進入下一個循環
}
}
}
public int getCount() {//獲取連通分量的個數
return count;
}
private void dfs(int v) {
visted[v] = true;//設置該節點已經訪問過了
id[v] = count;//在同一個連通分量裏面的節點,id是一致的。
for (int g : graph.adj(v)) {//使用我們之前實現的迭代器,對與該節點相鄰的節點遍歷
if (!visted[g]) {//如果該節點未訪問過就進行訪問,否則繼續循環
dfs(g);
}
}
}
public boolean isConnected(int v, int w) {//判斷兩個節點是否連通,只需要判斷二者的連通分量id是否一致即可
assert v >= 0 && v < graph.getVertexNum();
assert w >= 0 && w < graph.getVertexNum();
return id[v] == id[w];
}
}
路徑搜索(無權圖)
基於dfs算法,我們也能對兩個節點間的路徑進行搜索,我們只需要在對接點進行遍歷訪問的時候,將訪問到的節點保存起來即可。
代碼:
public class Path {
private Graph graph;
private boolean visted[];//某節點是否訪問過了
private int[] from;//保存的路徑
private boolean find;//保存從起點到終點的路徑是否已經找到了
public Path(Graph graph) {//初始化,傳進來一張圖
assert graph != null;
this.graph = graph;
}
//深度優先搜索
private void dfs(int v, int w) {//查找從起點v到終點w之間的路徑
assert v >= 0 && v < graph.getVertexNum();
visted[v] = true;//表示該節點已經訪問過了。
if (v == w) {//如果當前訪問的就是終點,則返回
find = true;//已經找到了
return;
}
for (int i : graph.adj(v)) {//對v周圍節點進行訪問
if (!visted[i]) {//如果未訪問過則進行訪問
if (find)//如果已經找到了路徑則無需再遍歷
return;
from[i] = v;//保存是從哪個節點訪問這個被訪問節點的
dfs(i, w);
}
}
}
public boolean hasPath(int v, int w) {//判斷v和w之間是否有路徑
assert w >= 0 && w < graph.getVertexNum();
find = false;//將該變量初始化爲未找到
visted = new boolean[graph.getVertexNum()];//每次遍歷前都要初始化
from = new int[graph.getVertexNum()];
Arrays.fill(from, -1);//填充
dfs(v, w);
return visted[w];//如果訪問過了則表示有路徑,否則就沒有
}
public Vector<Integer> getpath(int v, int w) {//獲取從v到w的路徑
Vector<Integer> path = new Vector<>();//最終返回的路徑
if (hasPath(v, w)) {//先判斷有沒有路徑
Stack<Integer> stack = new Stack<>();//設置一個棧
int p = w;
while (p != v && p != -1) {
stack.push(p);//將路徑從後向前放入棧中
p = from[p];
}
stack.push(p);
while (!stack.empty()) {
path.add(stack.pop());//將路徑順序再倒過來
}
}
return path;//返回路徑
}
public void showpath(int v, int w) {
Vector<Integer> vector = getpath(v, w);//獲取路徑
if (vector != null) {
for (int i = 0; i < vector.size(); i++) {//打印
System.out.print(vector.elementAt(i));
if (i != vector.size() - 1) {
System.out.print("->");
}
}
System.out.println();
} else {
System.out.println(v + "到" + w + "之間沒有路徑");
}
}
}
BFS算法及其應用
定義
廣度優先搜索(Breadth-First-Search,縮寫BFS),它也是圖論中的一種遍歷算法,但它的遍歷方式與DFS有所不同,DFS是從一個節點開始走到底,再回來接着從另一個節點遍歷,而BFS則是從一個節點出發開始不斷遍歷離它最近的節點,直到它周圍的節點都遍歷完了,再遍歷更外層的節點。
圖示:
最短路徑(無權圖)
之前的DFS可以搜索兩個節點之間的路徑,但是這個路徑並不一定是最短的路徑,而基於BFS的思想,我們可以十分方便的找到兩個節點之間的最短路徑。
代碼:
public class ShortestPath {
private Graph graph;//圖
private boolean[] visted;//是否被訪問
private int[] from;//保存一個節點到起點的路徑中,它的父節點是哪個
private int[] ord;//計數,保存該節點到起點最短路徑的長度
public ShortestPath(Graph graph) {//初始化
assert graph != null;
this.graph = graph;
}
private void bfs(int v, int w) {//使用廣度優先,遍歷與起點v相連的節點
PriorityQueue<Integer> queue = new PriorityQueue<>();
queue.add(v);//將起點添加到隊列中
visted[v] = true;//設爲已經訪問過
while (!queue.isEmpty()) {//直到隊列爲空
int s = queue.poll();//將隊列頂的元素拋出
Vector<Integer> vector = (Vector<Integer>) graph.adj(s);//將該節點元素周圍的所有元素進行訪問
for (int i : vector) {
if (!visted[i]) {//如果該節點元素沒有訪問
visted[i] = true;//設置爲已訪問
from[i] = s;//保存是從哪個節點訪問這個被訪問節點的
ord[i] = ord[s] + 1;//計數加一
queue.add(i);//將該節點加入到隊列中,在下一次while循環中將開始訪問該節點周圍所有的節點
}
if (i == w) {//如果i這個節點已經是終點,則不需要再訪問其他節點了。
return;
}
}
}
}
public int MinLength(int v, int w) {//返回兩個節點間最短路徑的長度
if (hasPath(v, w)) {
return ord[w];
} else {
return 0;
}
}
public boolean hasPath(int v, int w) {//v與w之間是否有路徑
assert w >= 0 && w < graph.getVertexNum();
//初始化
visted = new boolean[graph.getVertexNum()];
from = new int[graph.getVertexNum()];
ord = new int[graph.getVertexNum()];
Arrays.fill(from, -1);
Arrays.fill(ord, 0);
bfs(v, w);
return visted[w];//如果訪問過了則表示有路徑,否則就沒有
}
public Vector<Integer> getPath(int v, int w) {
//獲取從v到w的路徑
Vector<Integer> path = new Vector<>();//最終返回的路徑
if (hasPath(v, w)) {//先判斷有沒有路徑
Stack<Integer> stack = new Stack<>();//設置一個棧
int p = w;
while (p != v && p != -1) {
stack.push(p);//將路徑從後向前放入棧中
p = from[p];
}
stack.push(p);
while (!stack.empty()) {
path.add(stack.pop());//將路徑順序再倒過來
}
}
return path;//返回路徑
}
public void showPath(int v, int w) {
Vector<Integer> vector = getPath(v, w);//獲取路徑
if (vector != null) {
for (int i = 0; i < vector.size(); i++) {//打印
System.out.print(vector.elementAt(i));
if (i != vector.size() - 1) {
System.out.print("->");
}
}
System.out.println();
} else {
System.out.println(v + "到" + w + "之間沒有路徑");
}
}
}
小結
不管是DFS還是BFS,它們雖然實現的方式不一樣,DFS主要是使用遞歸或棧來實現,而BFS則使用隊列來實現,但它們目標都是將節點一個個遍歷,直到所有的節點都訪問過爲止,而在此基礎上又衍生出了許多不同的算法,例如基於BFS的Flood Fill,A*,B*,Dijklas,Prime
,而基於DFS的拓撲排序 ,記憶化搜索,IDA*
等,儘管有些算法的實現上較爲複雜,但主體思想還是不變的。
總結
以上就是對圖基礎的簡單介紹,後面會有對圖論的進階,但事實上要想靠一兩篇文章就將圖這個分支介紹完那是不可能的事情,事實上圖論是一個極其龐大的分支,所以我們只能儘可能先把基礎打好,一點點再向更難的分支前行。