图论基础(Graph Theory)
图是由结点(Vertex)和边(Edge)组成的抽象的数据结构。结点和结点之间靠边连接起来。
图的分类 1
1、无向图(Undirected Graph),是我们主要研究的部分,我们可以把无向图看成一种特殊的有向图;
2、有向图(Directed Graph),有向图具有不对称的特性。
图的分类 2
1、无权图(Unweighted Graph)
2、有权图(Weighted Graph)
理解图的连通性。
上面这张图,它的连通分量为 。
简单图(Simple Graph):无自环边(self-loop),并且无平行边的图(parallel-edges)。
我们的学习将从简单图开始。
图的表示
本章仅介绍无权图。图这种数据结构可以使用“邻接矩阵”和“邻接表”来表示。
邻接矩阵
无向图的邻接矩阵表示是一个对称矩阵。
邻接表
稀疏图与稠密图
邻接表适合表示稀疏图(Sparse Graph),边较少。
邻接矩阵适合表示稠密图(Dense Graph),边较多。
我们的图是实现了如下接口的 Java 类。
Java 代码:
// 图的接口,统一表示稀疏图与稠密图
public interface Graph {
// 返回顶点的个数
int V();
// 返回边的条数
int E();
// 在顶点 v 和 w 之间添加一条表
void addEdge(int v, int w);
// 顶点 v 与 顶点 w 是否有边连接
boolean hasEdge(int v, int w);
// 显示这个图中的元素
void show();
// 得到顶点 v 的所有邻居顶点
Iterable<Integer> adj(int v);
}
编写相邻结点迭代器
为了方便遍历一个点的所有邻居结点,我们需要实现一个迭代器。
稀疏图(邻接表)的相邻点迭代器
由稀疏图(邻接表)的表示决定了返回一个结点的所有相邻结点是十分容易的。
Java 代码:
public Iterable<Integer> adj(int v) {
assert v >= 0 && v < n;
return g[v];
}
稠密图(邻接矩阵)的相邻点迭代器
由稠密图(邻接矩阵)得到一个结点的所有相邻结点只须遍历邻接矩阵中的一行,把值为 True
的所有顶点拿到即可。
Java 代码:
public Iterable<Integer> adj(int v){
assert v >= 0 && v < n;
Vector<Integer> adjV = new Vector<>();
for (int i = 0; i < n; i++) {
if(g[v][i]){
adjV.add(i);
}
}
return adjV;
}
我们将图的数据以一定格式保存在一个文件中,因此我们要编写一个工具类读这个图文件。
从一个文件读出一个图
我们图的信息存在一个文件中,然后通过读取这个文件的内容得到图的顶点和边的信息,为此我们编写了如下代码。
编写一个读图的工具类
例1:文件 testG1.txt 的内容如下,其中第 1 行的第 1 个数表示这张图有多少个顶点,第 2 个数表示这张图有多少条边。
第 2 行直至最后一行表示的是所有的边,以两个顶点来表示一条边。
13,13
0,5
4,3
0,1
9,12
6,4
5,4
0,2
11,12
9,10
0,6
7,8
9,11
5,3
如果它表示一个无向图,那么画出来就是:
用邻接表表示是这样的:
用邻接矩阵表示是这样的:
深度优先遍历与图的连通分量
和二分搜索树的遍历一样,图的遍历也可以分为深度优先遍历和广度优先遍历。
图的深度优先遍历
从图中任意一个结点开始,遍历它的相邻结点,只要这个相邻的结点还有相邻的结点就继续遍历,遍历完成以后才返回。深度优先遍历可以使用递归来完成。
递归到底的情形在编码的时候不容易发现,其实就是在所有的结点都被遍历完成的时候,就是递归到底、方法应该返回的时候。
实现深度优先遍历有两个关键点:
从一个顶点开始不停地向下试,直到试不下去了为止;
图和树不一样的,树到了叶子结点就走不下去了,在图中要记录每一个结点是否被遍历过。
图的深度优先遍历的应用
应用1:计算图的连通分量;
Java 代码:经过一次深度优先遍历得到连通分量
public class Component {
private Graph graph;
private boolean[] visited;
// 连通分量的个数,同时可以在遍历的过程中,为各个连通分量所在的集合进行标记
private int ccount;
private void dfs(int i) {
// 记录是否被访问过,这个非常关键,深度遍历一开始就要记录这些结点被访问过
visited[i] = true;
Iterable<Integer> adj = graph.adj(i);
for(Integer v:adj){
if(!visited[v]){
dfs(v);
}
}
}
// 构造方法,通过传入一个图,经过了深度优先遍历,计算无权图的连通分量
public Component(Graph graph) {
this.graph = graph;
int vCount = graph.V(); // 图中顶点的个数
// 可以不用显式赋值,因为默认就是 false
visited = new boolean[vCount];
for (int i = 0; i < vCount; i++) {
if (!visited[i]){// 如果没有遍历过,就进行一次深度优先遍历
// depth first search 深度优先遍历
// 注意:深度优先遍历,不是遍历这个结点的所有邻居
// 而是把所有与该点向量的所有结点都遍历一遍
dfs(i);
ccount++; // 经过了一次深度优先遍历以后,连通分量 +1
}
}
}
// 返回这个图的连通分量的个数
public int getCcount() {
return ccount;
}
}
- 应用2:通过计算图的连通分量,还可以很快地判断出两个结点是否相连。
在计算连通分量的同时,给每一个顶点编号,编号的规则是:属于一个连通分量的应该编号一样。在上面的代码中,在每一次记录顶点 i 被访问过以后 visited[i] = true;
,马上给这个顶点编号,编号就用到此时得到的连通分量的最大值。通过给每个顶点标记,我们就能很轻松地实现查询顶点是否相连,只需要查询它们的连通分量的组别就行了。
Java 代码:
public class Component {
private Graph graph;
private boolean[] visited;
private int[] id;
private int ccount;
private void dfs(int i) {
visited[i] = true;
// 在计算连通分量的同时,给每一个顶点编号,编号的规则是:属于一个连通分量的应该编号一样。
id[i] = ccount;
Iterable<Integer> adj = graph.adj(i);
for (Integer v : adj) {
if (!visited[v]) {
dfs(v);
}
}
}
public Component(Graph graph) {
this.graph = graph;
int vCount = graph.V();
visited = new boolean[vCount];
id = new int[vCount];
for (int i = 0; i < vCount; i++) {
visited[i] = false;
id[i] = -1;
}
for (int i = 0; i < vCount; i++) {
if (!visited[i]){
dfs(i);
ccount++;
}
}
}
public int getCcount() {
return ccount;
}
public boolean isConnected(int v, int w) {
assert v >= 0 && v < graph.V();
assert w >= 0 && w < graph.V();
return id[v] == id[w];
}
}
寻路算法
在进行深度优先遍历的过程中形成了一条路径,我们这一节的目标是得到这条路径,但我们要知道,使用深度优先遍历并不能保证这条路径是最短的。
寻路算法是一个常见的算法,实现起来也很简单:在每一次遍历的时候,我们都记录一下,这一次遍历从何而来。
使用深度优先遍历获得两点之间的一条路径
Java 代码:
import java.util.Stack;
import java.util.Vector;
// 寻路算法,只能得到无权图的一条路径,该路径并不是最短路径
// 该算法对有向图依然有效
public class Path {
private Graph graph;
private boolean[] visited;
private int[] from;
private void dfs(Integer v) {
visited[v] = true;
Iterable<Integer> adj = graph.adj(v);
for (Integer i : adj) {
if (!visited[i]) {
from[i] = v;
dfs(i);
}
}
}
public Path(Graph graph, int s) {
assert s >= 0 && s < graph.V();
this.graph = graph;
int vCount = graph.V();
visited = new boolean[vCount];
from = new int[vCount];
for (int i = 0; i < vCount; i++) {
visited[i] = false;
from[i] = -1;
}
// 从 source 开始,做一次深度优先遍历
dfs(s);
}
// 查询从 s 点到 w 点是否有路径
public boolean hasPath(int w) {
assert w >= 0 && w < graph.V();
return visited[w];
}
// 得到从 s 到 w 的一条路径
public void path(int w, Vector<Integer> vec) {
assert hasPath(w);
// 我们要倒着把路径得到,所以应该借助栈来完成该工作
Stack<Integer> stack = new Stack<>();
// 这一段逻辑比较绕,但不难
int p = w;
while (p != -1) {
stack.add(p);
p = from[p];
}
while (!stack.isEmpty()) {
vec.add(stack.pop());
}
}
// 显示从 s 到 w 的一条路径
public void showPath(int w) {
Vector<Integer> vec = new Vector<>();
path(w, vec);
for (int i = 0; i < vec.size(); i++) {
System.out.print(vec.get(i));
if (i == vec.size() - 1) {
System.out.println();
} else {
System.out.print(" -> ");
}
}
}
}
- [ ] 练习 1 :检查有向图中是否有环。
- [ ] 练习 2 :检查有向图是否是二分图。
10.7 图的广度优先遍历和无权图的最短路径
- 和树的广度优先遍历是一致,使用队列作为辅助的数据结构;
-
visited
数组应该是对加入到队列中的元素进行处理,因为它加入到队列了,早晚都会被处理; - 先遍历到的结点比后遍历到结点距离起始结点更近,所以可以解决最短路径的问题;
- 针对无向图和有向图都有效。
10.7.1 图的广度优先遍历可以回答的问题
Java 代码:
import java.util.*;
public class ShortestPath {
private Graph graph;
private int s; // source
private boolean[] visited;
private int[] from; // 记录了从哪个结点访问而来
private int[] ord; // 记录了层序
public ShortestPath(Graph graph, int s) {
this.graph = graph;
assert s >= 0 && s < graph.V();
visited = new boolean[graph.V()];
from = new int[graph.V()];
ord = new int[graph.V()];
for (int i = 0; i < graph.V(); i++) {
visited[i] = false;
from[i] = -1;
ord[i] = -1;
}
this.s = s;
// 特别要注意:使用链表作为队列的时候,出队和入队不能混淆
// 如果入队的方向是从右到左,出队也是从右到左
// 如果入队的方向是从左到右,出队也是从左到右
LinkedList<Integer> queue = new LinkedList<>();
queue.push(s);
visited[s] = true;
ord[s] = 0;
while (!queue.isEmpty()) {
// peek 的意思是:我只是瞅了一眼,并没有真的把它拿出来
int v = queue.pollLast();
for (Integer i : graph.adj(v)) {
if (!visited[i]) {
// 注意:LinkedList 有 push 和 add 两种方法
// 这一点区别是我在使用测试用例的过程中发现了错误,使用 IDEA 的 debug 调试代码的过程中发现的
// push 的源代码点开,你会看到 addFirst
// add 的源代码点开,你会看到 linkLast
queue.push(i);
visited[i] = true;
from[i] = v;
ord[i] = ord[v] + 1;
}
}
}
}
// 图的广度优先遍历可以回答的问题 1 : 判断到结点 w 是否有路径
public boolean hasPath(int w) {
assert w >= 0 && w < graph.V();
return visited[w];
}
// 图的广度优先遍历可以回答的问题 2 :打印从 s 到 w 的最短路径
public void showPath(int w) {
assert hasPath(w);
List<Integer> paths = path(w);
for (int i = 0; i < paths.size(); i++) {
if (i == paths.size() - 1) {
System.out.println(paths.get(i));
} else {
System.out.print(paths.get(i) + " -> ");
}
}
}
// 图的广度优先遍历可以回答的问题 3 :计算从 s 到 w 的最短路径
public List<Integer> path(int w) {
Stack<Integer> stack = new Stack<>();
int cur = w;
while (cur != -1) {
stack.push(cur);
cur = from[cur];
}
List<Integer> ret = new ArrayList<>();
while (!stack.isEmpty()) {
ret.add(stack.pop());
}
return ret;
}
// 图的广度优先遍历可以回答的问题 4 :查看从 s 点到 w 点的最短路径长度,若从 s 到 w 不可达,返回 -1
public int length(int w) {
assert w >= 0 && w < graph.V();
return ord[w];
}
}
关于无权图算法的测试:
// 关于无权图算法的测试
public class Main {
public static void main(String[] args) {
// 稠密图
// DenseGraph graph = new DenseGraph(13, false);
// new ReadGraphUtil(graph, "testG1.txt");
// graph.show();
// 稀疏图
SparseGraph graph = new SparseGraph(13, false);
new ReadGraphUtil(graph, "testG1.txt");
// graph.show();
Component component = new Component(graph);
System.out.println("图的连通分量:" + component.getCcount());
// 通过深度优先遍历,得到一个路径
Path path = new Path(graph, 0);
// 此时的路径不是最短路径
path.showPath(6);
// 广度优先遍历
ShortestPath shortestPath = new ShortestPath(graph, 0);
shortestPath.showPath(3);
}
}
本文源代码
(本节完)