数据结构与算法——图
为什么要有图?
前面学过的 线性表 和 树:
- 线性表:局限于一个 直接前驱 和 一个 直接后继 的关系
- 树:只能有一个直接前驱(父节点)
当我们需要表示 多对多 的关系时,就需要用到 图
图的举例说明
比如:城市交通图。他就是一个图,对应程序中的图如下所示
图是一种 数据结构,其中节点可以具有 零个或多个相邻元素,两个节点之间的链接称为 边,节点页可以称为 顶点。
图的常用概念
-
顶点(vertex)
-
边(edge)
-
路径
:路径就是一个节点到达另一个节点所经过的节点连线D -> C
的路径就有两条(针对无向图来说):D → B → C
D → A → B → C
-
无向图
:顶点之间的连接没有方向比如上图它是一个 无向图;比如
A-B
,可以是A->B
也可以是B <- A
-
有向图
:顶点之间的连接是有方向的。如下图那么
A-B
,就只能是A → B
,而不能是B → A
-
带权图:边有权值时,则叫做带权图,同时也叫 网
比如上图中,北京 到 上海这一条边上有一个数值 1463,这个可能是他的距离,这种就叫做 边带权值
图的表示方式
有两种:
- 二维数组表示:
邻接矩阵
- 链表表示:
邻接表
邻接矩阵
邻接矩阵是表示图形中 顶点之间相邻关系 的矩阵,对于 n 个顶点的图而言,矩阵的 row 和 col 表示的是 1....n
个点
上图是一个无向图,它用矩阵表示如右图:
- 左侧的 0~5 表示顶点(也就是列,竖看)
- 横着的 0 -5 表示,左侧的顶点,与其他顶点的关系
比如:0,0
的值为 0,则表示 不能直连 ,0-1
的值为 1,表示可用直连(注意是直连)。
邻接表
由于邻接矩阵有一个缺点:它需要为每个顶点都分配 n 个边的空间,其实有很多边都是不存在的(比如上面的 0,0 不链接,也需要表示出来),这 会造成空间的一定损失。
而 邻接表 的实现只关心 存在的边,因此没有空间浪费,由 数组 + 链表组成。
如上图:
-
左侧(竖向)表示了有 n 个点,用数组存放。
-
右侧每一个点,都有一条链表,表示:顶点与链表中的点都可以直连
注意:它并不是表示可以从 1 到 2 到 3 的 路径,只表示与链表中的点可以直连。
图的快速入门案例
要求用代码实现如下图结构
思路分析:
- 每一个顶点需要用一个容器来装,这里使用简单的 String 类型来表示 A、B ... 等节点
- 这些所有的顶点,我们用一个 List 来存储
- 它对应的矩阵使用一个二维数组来表示,节点之间的关系
代码实现:
/**
* 邻接矩阵 图
*/
public class GraphTest {
@Test
public void graphTest() {
int n = 5;
String vertexValue[] = {"A", "B", "C", "D", "E"};
Grap grap = new Grap(n);
for (String value : vertexValue) {
grap.insertVertex(value);
}
// 设置顶点关系
/*
// A 与 C、B 直连
grap.insertEdge(0, 1, 1);
grap.insertEdge(0, 2, 1);
// B 与 C、A、E、D 直连
grap.insertEdge(1, 0, 1);
grap.insertEdge(1, 2, 1);
grap.insertEdge(1, 3, 1);
grap.insertEdge(1, 4, 1);
// C 与 B、A 直连
grap.insertEdge(2, 0, 1);
grap.insertEdge(2, 1, 1);
// D 与 B 直连
grap.insertEdge(3, 1, 1);
// E 与 B 直连
grap.insertEdge(4, 1, 1);
*/
// 上面这种写法是双向的,由于内部已经处理过双向边了,所以只需要设置 5 条单向的即可
// a,b a,c b,c b,d b,e
grap.insertEdge(0, 1, 1);
grap.insertEdge(0, 2, 1);
grap.insertEdge(1, 2, 1);
grap.insertEdge(1, 3, 1);
grap.insertEdge(1, 4, 1);
grap.showGraph();
System.out.println("边:" + grap.getNumOfEdges());
System.out.println("下标 1:" + grap.getValueByIndex(1));
}
class Grap {
/**
* 存放所有的顶点
*/
private List<String> vertexs;
/**
* 矩阵:存放边的关系(顶点之间的关系)
*/
private int[][] edges;
/**
* 存放有多少条边
*/
private int numOfEdges = 0;
/**
* @param n 有几个顶点
*/
public Grap(int n) {
//初始化
vertexs = new ArrayList<>(n);
edges = new int[n][n];
}
/*
*=============
* 有两个核心方法:插入顶点,设置边的关系
*/
/**
* 插入顶点
*
* @param vertex
*/
public void insertVertex(String vertex) {
vertexs.add(vertex);
}
/**
* 添加边的关系
*
* @param v1 第一个顶点对应的矩阵下标
* @param v2 第二个顶点对应的矩阵下标
* @param weight 他们之间的关系:0|不直连,1|直连
*/
public void insertEdge(int v1, int v2, int weight) {
edges[v1][v2] = weight;
// 由于是无向图,反向也可以连通
edges[v2][v1] = weight;
numOfEdges++; // 边增加 1
}
/*
*=============
* 下面写几个图的常用方法
*/
/**
* 获取顶点的数量
*/
public int getNumOfVertex() {
return vertexs.size();
}
/**
* 获取边的数量
*
* @return
*/
public int getNumOfEdges() {
return numOfEdges;
}
/**
* 根据下标获得顶点的值
*
* @param i
* @return
*/
public String getValueByIndex(int i) {
return vertexs.get(i);
}
/**
* 显示图的矩阵
*/
public void showGraph() {
System.out.printf(" ");
for (String vertex : vertexs) {
System.out.printf(vertex + " ");
}
System.out.println();
for (int i = 0; i < edges.length; i++) {
System.out.printf(vertexs.get(i) + " ");
for (int j = 0; j < edges.length; j++) {
System.out.printf(edges[i][j] + " ");
}
System.out.println();
}
}
}
}
首先编写了两个核心方法:插入顶点、设置边关系,其次编写了几个辅助获取信息的方法。
测试输出如下
A B C D E
A 0 1 1 0 0
B 1 0 1 1 1
C 1 1 0 0 0
D 0 1 0 0 0
E 0 1 0 0 0
边:5
图的深度优先遍历
所谓图的遍历,则是 对节点的访问。一个图有很多个节点,如何遍历这些节点,需要特定策略,一般有两种访问策略:
- 深度优先遍历(DFS,Depth First Search)
- 广度优先遍历(BFS,Broad First Search)
深度优先遍历基本思想
图的深度优先搜索(Depth First Search),简称 DFS
。
从初始访问节点出发,初始访问节点可能有多个 邻接节点(直连),深度优先遍历的策略为:
- 首先访问第一个邻接节点
- 然后以这个 被访问的邻接节点又作为初始节点
- 然后循环这个操作。
可以这样理解:每次都在访问完 当前节点 后,首先 访问当前节点的第一个邻接节点。
可以看到:这样的访问策略是 优先往纵向 挖掘深入,而 不是 对一个节点的所有邻接节点进行 横向访问。
那么显然深度优先搜索是一个 递归过程
深度优先遍历算法步骤
基本思想看完,可能还是不清楚是如何遍历的,看看他的遍历步骤:
- 访问初始节点 v,并标记节点 v 为已访问
- 查找节点 v 的第一个邻接(直连)节点 w
- 如果节点 w 不存在,则回到第 1 步,然后从 v 的下一个节点继续
- 如果节点 w 存在:
- 未被访问过,则对 w 进行深度优先遍历递归(即把 w 当做另一个 v,执行步骤 123)
- 如果已经被访问过:查找节点 v 的 w 邻接节点的下一个邻接节点,转到步骤 3
以上图作为例子:添加节点的顺序为 A、B、C、D、E。
-
添加节点的顺序为 A、B、C、D、E。那么第一个初始节点就是 A
-
访问 A,输出 A,并标记为已访问
-
查找 A 的下一个邻接节点:
从
0,0
开始找,直到找到0,1 = 1
即 B,如果 B 没有被访问过,则以 B 为基础递归 B。
-
递归:访问 B,输出 B,并标记为已访问
-
查找 B 的下一个邻接节点:
从
1,0
开始找,直到找到1,2 = 1
即 C,如果 C 没有被访问过,则以 C 为基础递归 C
-
递归:访问 C ,输出 C,并标记为已访问
-
查找 C 的下一个邻接节点:
从
2,0
开始找,找到2,4
都没有找到有直连的;这里会退出递归 C,从而回到递归 B -
由于循环并未结束:会判断找到的 C,已经被访问过。则从 B 为基础查找下一个:
也就是从
1,2+1
即1,3 = 1
,即B,D
B 直连 D -
递归访问 D,输出 D,并标记为已访问
从
3,0
开始找,找到3,4
都没有找到与 D 直连的下一个,则退出 D 递归 -
回到了递归 B,由于循环未结束,会判断 D ,已经被访问过,则从 B 为基础查找下一个
也就是从
1,3+1
即1,4 = 1
,即B,E
B 直连 E -
递归访问 E,输出 E,并标记为已访问
-
查找 E 的下一个节点,由于 E 是最后一个,退出递归 E,回到了递归 B
-
回到了递归 B,由于循环未结束,会判断 E,已经被访问过,则从 B 为基础查找下一个(这里已经是找完了),也未找到(已经找完了)
-
回到了递归 A,由于循环未结束,会判断 B,已经被访问过,则从 A 为基础查找下一个
查找到 C,A 与 C 直连,由于 C 已经被访问过,则继续以 A 为基础查找下一个,把 A 可能链接的点查找完成,没有,则退出递归 A
-
这时,已经跳出了第一次初始点 A 的深度优先查找 ,按照插入顶点的顺序,下一个节点为 B,从 B 开始深度优先查找
这里先判定:是否已经访问过,肯定已经访问过了,直接跳过 深度优先查找
-
由于 B 已经被访问过,那么直接下一个 C,发现 C 也被访问过
-
以此类推,后面的都被访问过了,则直接完成。
思路小节:
- 先从一个初始节点开发深度优先查找
- 然后找到该节点的第一个邻接节点,找到则继续深度优先
- 如果找不到,则会 回溯:那么尝试该节点的其他路径是否可以连通。
- 直到回溯到最顶层,然后退出该次 深度优先查找函数。挑选下一个初始节点如果没有访问过,则调用深度优先函数
到这里都应该明白它的工作原理了,如果还是不明白就请看下面的代码应该就可以懂了。
代码实现
在上面原先的代码基础上进行功能添加。
/**
* 存放顶点是否已经访问过,下标对应顶点插入列表的下标
*/
private boolean isVisiteds[];
/**
* 深度遍历
*/
public void dfs() {
for (int i = 0; i < vertexs.size(); i++) {
// 如果已经访问过,则跳过
if (isVisiteds[i]) {
continue;
}
// 没有访问过,则以此节点为基础进行深度遍历
dfs(i);
}
}
/**
* 深度优先遍历
*
* @param i 当前是以,顶点插入列表中的哪一个顶点进行深度优先查找
*/
public void dfs(int i) {
// 输出自己,并标记为已访问过
System.out.print(vertexs.get(i) + " -> ");
isVisiteds[i] = true;
// 查找此节点的第一个邻接节点
int w = getFirstNeighbor(i);
// 如果找到了 w ,则对 w 进行深度优先遍历
while (w != -1) {
// 已经访问过,
if (isVisiteds[w]) {
w = getNextNeighbor(i, w);
} else {
dfs(w);
}
}
}
/**
* 查找第一个邻接节点
*
* @param i
* @return 如果找到,则返回具体的下标
*/
private int getFirstNeighbor(int i) {
for (int j = i; j < vertexs.size(); j++) {
if (edges[i][j] == 1) {
return j;
}
}
return -1;
}
/**
* 如果 w 节点被访问过,则查找 i 节点的下一个 邻接节点(就不是第一个节点了)
* 联系代码就可以知道其的功能定位了
* @param i
* @param w
* @return
*/
private int getNextNeighbor(int i, int w) {
for (int j = w + 1; j < vertexs.size(); j++) {
if (edges[i][j] == 1) {
return j;
}
}
return -1;
}
测试代码
@Test
public void dfsTest() {
int n = 5;
String vertexValue[] = {"A", "B", "C", "D", "E"};
Grap grap = new Grap(n);
for (String value : vertexValue) {
grap.insertVertex(value);
}
// a,b a,c b,c b,d b,e
grap.insertEdge(0, 1, 1);
grap.insertEdge(0, 2, 1);
grap.insertEdge(1, 2, 1);
grap.insertEdge(1, 3, 1);
grap.insertEdge(1, 4, 1);
grap.showGraph();
System.out.println();
grap.dfs();
}
测试输出
A B C D E
A 0 1 1 0 0
B 1 0 1 1 1
C 1 1 0 0 0
D 0 1 0 0 0
E 0 1 0 0 0
A -> B -> C -> D -> E ->
这里的难点,一定不要以为直接按照添加的顶点顺序输出就行,虽然这里结果看上去是添加的顶点顺序,实际上它是有查找第一个邻接节点,不存在则回溯到上一层,直到回溯到初始节点。 这里有一个回溯的流程。如果是一个比较复杂的图,输出的结果就不一定是更添加顶点的顺序了。
简单总结如下:
- 每次只找第一个邻接节点(纵向)
- 找不到,则返回到上一层。然后开始 横向找非第一个邻接节点
- 然后不断的找第一个,然后回溯(横向找下一个)的流程
通过上面的例子,你可能会发现:在循环的时候,把 A 作为参数调用 深度优先搜索,整个图就遍历完成了,那为什么还需要外面一层循环呢?
这个问题你想象一下:你看一个地铁图的时候,假设有 2 个地铁站 G、H,没有和其他节点连接,只有 G→H
相连了,那么上面的列子,最外层的循环就起作用了。简单说就是:当一个点,不能间接的到达某一个点,那么就需要外层的这个循环来工作。
图的广度优先遍历
图的广度优先搜索(DFS,Broad First Search),类似于一个 分层搜索的过程,广度优先遍历需要使用一个队列以保持访问过的节点的顺序,以便按这个顺序来访问这些节点的邻接节点。
算法步骤
- 访问初始节点 v ,并标记节点 v 为已访问
- 节点 v 入队列
- 当队列非空时,继续执行,否则算法结束(仅对 v 的节点算法结束)。
- 出队列,取得队头的节点 u
- 查找节点 u 的第一个邻接节点 w
- 若节点 u 的邻接节点 w 不存在,则跳转到步骤 3;否则执行以下三个步骤:
- 若节点 w 尚未被访问,则访问节点 w 并标记为已访问
- 节点 w 入队列
- 查找节点 u 的继 w 邻接节点后的下一个邻接节点 w,转到步骤 6
这里可以看到,与深度优先不同的是:
广度优先
:找到第一个邻接节点,访问之后,会继续寻找这个节点的下一个邻接节点(非第一个)深度优先
:每次只找第一个邻接节点,然后以找到的节点作为基础找它的第一个邻接节点,如果找不到才回溯到上一层,寻找找它的下一个邻接节点(非第一个)
就如同上图:
- 左侧的是广度优先,先把 A 能直连的都访问完,再换下一个节点
- 右图是深度优先,每次都只访问第一个直连的节点,然后换节点继续,访问不到,则回退到上一层,找下一个直连节点
代码实现
记住这个步骤:
- 访问初始节点 v ,并标记节点 v 为已访问
- 节点 v 入队列
- 当队列非空时,继续执行,否则算法结束(仅对 v 的节点算法结束)。
- 出队列,取得队头的节点 u
- 查找节点 u 的第一个邻接节点 w
- 若节点 u 的邻接节点 w 不存在,则跳转到步骤 3;否则执行以下三个步骤:
- 若节点 w 尚未被访问,则访问节点 w 并标记为已访问
- 节点 w 入队列
- 查找节点 u 的继 w 邻接节点后的下一个邻接节点 w,转到步骤 6
下面的代码实现也是这个步骤来实现的
在原先的代码基础上增加功能。
/**
* 对整个节点进行 广度优先 遍历
*/
public void bsf() {
for (int i = 0; i < vertexs.size(); i++) {
// 如果已经访问过,则跳过
if (isVisiteds[i]) {
continue;
}
System.out.println("新的节点广度优先"); // 换行 1
// 没有访问过,则以此节点为基础进行深度遍历
bsf(i);
}
}
/**
* 对单个节点为初始节点,进行广度优先遍历
*
* @param i
*/
private void bsf(int i) {
// 访问该节点,并标记已经访问
System.out.print(getValueByIndex(i) + " → ");
isVisiteds[i] = true;
// 将访问过的添加到队列中
LinkedList<Integer> queue = new LinkedList<>();
queue.addLast(i); // 添加到末尾
int u; // 队列头的节点
int w; // u 的下一个邻接节点
// 当队列不为空的时候,查找节点 u 的第一个邻接节点 w
while (!queue.isEmpty()) {
// System.out.println(); // 换行 2
u = queue.removeFirst();
w = getFirstNeighbor(u);
// w 存在的话
// while (w != -1) {
// // 如果 w 已经被访问过
// if (isVisiteds[w]) {
// // 则:以 u 为初始节点,查找 w 的下一个邻接节点
// w = getNextNeighbor(u, w);
// }
// // 如果 w 没有被访问过,则访问它,并标记已经访问
// else {
// System.out.print(getValueByIndex(w) + " → ");
// isVisiteds[w] = true;
// queue.addLast(w); // 访问过的一定要入队列
// }
// }
// 上面这样写,容易阅读,但是会存在多一次循环的问题,改写成下面这样
while (w != -1) {
// 如果没有被访问过,则访问,并标记为已经访问过
if (!isVisiteds[w]) {
System.out.print(getValueByIndex(w) + " → ");
isVisiteds[w] = true;
queue.addLast(w); // 访问过的一定要入队列
}
// 上面访问之后,就需要获取该节点的下一个节点
// 否则,下一次还会判断一次 w,然后去获取下一个节点,只获取,但是没有进行访问相关操作
// 相当于每个节点都会循环两次,这里减少到一次
w = getNextNeighbor(u, w);
}
}
}
}
/**
* 查找第一个邻接节点
*
* @param i
* @return 如果找到,则返回具体的下标
*/
private int getFirstNeighbor(int i) {
for (int j = i; j < vertexs.size(); j++) {
if (edges[i][j] == 1) {
return j;
}
}
return -1;
}
/**
* 如果 w 节点被访问过,则查找 i 节点的下一个 邻接节点(就不是第一个节点了)
* 联系代码就可以知道其的功能定位了
* @param i
* @param w
* @return
*/
private int getNextNeighbor(int i, int w) {
for (int j = w + 1; j < vertexs.size(); j++) {
if (edges[i][j] == 1) {
return j;
}
}
return -1;
}
测试代码
/**
* 图的广度优先遍历
*/
@Test
public void bfsTest() {
int n = 5;
String vertexValue[] = {"A", "B", "C", "D", "E"};
Grap grap = new Grap(n);
for (String value : vertexValue) {
grap.insertVertex(value);
}
// a,b a,c b,c b,d b,e
grap.insertEdge(0, 1, 1);
grap.insertEdge(0, 2, 1);
grap.insertEdge(1, 2, 1);
grap.insertEdge(1, 3, 1);
grap.insertEdge(1, 4, 1);
grap.showGraph();
System.out.println();
grap.bsf();
}
测试输出
A B C D E
A 0 1 1 0 0
B 1 0 1 1 1
C 1 1 0 0 0
D 0 1 0 0 0
E 0 1 0 0 0
A → B → C → D → E →
当只打开 换行 1 的时候,输出如下信息
System.out.println("新的节点广度优先"); // 换行 1
A → B → C → D → E →
然后同时打开 换行 1、2,输出信息如下
新的节点广度优先
A →
B → C →
D → E →
可以看到:
- 先输出了 A:因为 A 是初始节点
- 然后以 A 为基础,找与 A 直连的,B、C,由于后面没有了,则会退出一个小循环
- 然后从队列中取出头:也就是 B,因为前面记录了访问顺序,找与 B 直连的,进入小循环
- 输出了与 B 直连的:D、E
通过这个过程可以看到:广度优先,他是一层一层的查找的。
图的深度优先 VS 广度优先
由于前面讲解的点较少,恰好输出顺序一致,现在来对比下多一点的点。如下图
/**
* 构建数量较多的点的 图
*
* @return
*/
private Grap buildGrap2() {
String vertexValue[] = {"1", "2", "3", "4", "5", "6", "7", "8"};
Grap grap = new Grap(vertexValue.length);
for (String value : vertexValue) {
grap.insertVertex(value);
}
// a,b a,c b,c b,d b,e
grap.insertEdge(0, 1, 1);
grap.insertEdge(0, 2, 1);
grap.insertEdge(1, 3, 1);
grap.insertEdge(1, 4, 1);
grap.insertEdge(3, 7, 1);
grap.insertEdge(4, 7, 1);
grap.insertEdge(2, 5, 1);
grap.insertEdge(2, 6, 1);
grap.insertEdge(5, 6, 1);
return grap;
}
/**
* 图的深度优先遍历:点数量较多的测试
*/
@Test
public void dfsTest2() {
Grap grap = buildGrap2();
grap.showGraph();
System.out.println();
grap.dfs();
}
/**
* 图的广度优先遍历:点数量较多的测试
*/
@Test
public void bfsTest2() {
Grap grap = buildGrap2();
grap.showGraph();
System.out.println();
grap.bsf();
}
测试输出:深度优先
1 2 3 4 5 6 7 8
1 0 1 1 0 0 0 0 0
2 1 0 0 1 1 0 0 0
3 1 0 0 0 0 1 1 0
4 0 1 0 0 0 0 0 1
5 0 1 0 0 0 0 0 1
6 0 0 1 0 0 0 1 0
7 0 0 1 0 0 1 0 0
8 0 0 0 1 1 0 0 0
1 -> 2 -> 4 -> 8 -> 5 -> 3 -> 6 -> 7 ->
测试输出:广度优先
1 2 3 4 5 6 7 8
1 0 1 1 0 0 0 0 0
2 1 0 0 1 1 0 0 0
3 1 0 0 0 0 1 1 0
4 0 1 0 0 0 0 0 1
5 0 1 0 0 0 0 0 1
6 0 0 1 0 0 0 1 0
7 0 0 1 0 0 1 0 0
8 0 0 0 1 1 0 0 0
1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
可以看到输出的遍历结果明显不同。