目录



为什么要有图?

前面学过的 线性表

  • 线性表:局限于一个 直接前驱 和 一个 直接后继 的关系
  • 树:只能有一个直接前驱(父节点)

当我们需要表示 多对多 的关系时,就需要用到

图的举例说明

数据结构与算法——图_代码实现

比如:城市交通图。他就是一个图,对应程序中的图如下所示

数据结构与算法——图_递归_02

图是一种 数据结构,其中节点可以具有 零个或多个相邻元素,两个节点之间的链接称为 ,节点页可以称为 顶点。

图的常用概念

数据结构与算法——图_深度优先遍历_03

  • 顶点(vertex)
  • 边(edge)
  • 路径:路径就是一个节点到达另一个节点所经过的节点连线D -> C 的路径就有两条(针对无向图来说):
  • ​D → B → C​
  • ​D → A → B → C​
  • 无向图顶点之间的连接没有方向
    比如上图它是一个 无向图;比如 A-B,可以是 A->B 也可以是 B <- A
  • 有向图:顶点之间的连接是有方向的。如下图
    数据结构与算法——图_深度优先_04
    那么 A-B,就只能是 A → B,而不能是 B → A
  • 带权图:边有权值时,则叫做带权图,同时也叫
    数据结构与算法——图_代码实现_05
    比如上图中,北京 到 上海这一条边上有一个数值 1463,这个可能是他的距离,这种就叫做 边带权值

图的表示方式

有两种:

  • 二维数组表示:​邻接矩阵
  • 链表表示:​邻接表

邻接矩阵

邻接矩阵是表示图形中 顶点之间相邻关系 的矩阵,对于 n 个顶点的图而言,矩阵的 row 和 col 表示的是 ​​1....n​​ 个点

数据结构与算法——图_代码实现_06

上图是一个无向图,它用矩阵表示如右图:

  • 左侧的 0~5 表示顶点(也就是列,竖看)
  • 横着的 0 -5 表示,左侧的顶点,与其他顶点的关系

比如:​​0,0​​ 的值为 0,则表示 不能直连 ,​​0-1​​ 的值为 1,表示可用直连(注意是直连)。

邻接表

由于邻接矩阵有一个缺点:它需要为每个顶点都分配 n 个边的空间,其实有很多边都是不存在的(比如上面的 0,0 不链接,也需要表示出来),这 会造成空间的一定损失

邻接表 的实现只关心 存在的边,因此没有空间浪费,由 数组 + 链表组成

数据结构与算法——图_代码实现_07

如上图:

  • 左侧(竖向)表示了有 n 个点,用数组存放。
  • 右侧每一个点,都有一条链表,表示:顶点与链表中的点都可以直连
    注意:它并不是表示可以从 1 到 2 到 3 的 路径,只表示与链表中的点可以直连

图的快速入门案例

要求用代码实现如下图结构

数据结构与算法——图_广度优先_08

思路分析:

  • 每一个顶点需要用一个容器来装,这里使用简单的 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


图的深度优先遍历

所谓图的遍历,则是 对节点的访问。一个图有很多个节点,如何遍历这些节点,需要特定策略,一般有两种访问策略:

  1. 深度优先遍历(DFS,Depth First Search)
  2. 广度优先遍历(BFS,Broad First Search)

深度优先遍历基本思想

图的深度优先搜索(Depth First Search),简称 ​​DFS​​。

从初始访问节点出发,初始访问节点可能有多个 邻接节点(直连),深度优先遍历的策略为:

  • 首先访问第一个邻接节点
  • 然后以这个 被访问的邻接节点又作为初始节点
  • 然后循环这个操作。

可以这样理解:每次都在访问完 当前节点 后,首先 访问当前节点的第一个邻接节点

可以看到:这样的访问策略是 优先往纵向 挖掘深入,而 不是 对一个节点的所有邻接节点进行 横向访问

那么显然深度优先搜索是一个 递归过程

深度优先遍历算法步骤

基本思想看完,可能还是不清楚是如何遍历的,看看他的遍历步骤:

  1. 访问初始节点 v,并标记节点 v 为已访问
  2. 查找节点 v 的第一个邻接(直连)节点 w
  3. 如果节点 w 不存在,则回到第 1 步,然后从 v 的下一个节点继续
  4. 如果节点 w 存在:
  1. 未被访问过,则对 w 进行深度优先遍历递归(即把 w 当做另一个 v,执行步骤 123)
  2. 如果已经被访问过:查找节点 v 的 w 邻接节点的下一个邻接节点,转到步骤 3

数据结构与算法——图_深度优先遍历_09

以上图作为例子:添加节点的顺序为 A、B、C、D、E。

  1. 添加节点的顺序为 A、B、C、D、E。那么第一个初始节点就是 A
  2. 访问 A,输出 A,并标记为已访问
  3. 查找 A 的下一个邻接节点:
    0,0 开始找,直到找到 0,1 = 1 即 B,
    如果 B 没有被访问过,则以 B 为基础递归 B。
  4. 递归:访问 B,输出 B,并标记为已访问
  5. 查找 B 的下一个邻接节点:
    1,0 开始找,直到找到 1,2 = 1 即 C,
    如果 C 没有被访问过,则以 C 为基础递归 C
  6. 递归:访问 C ,输出 C,并标记为已访问
  7. 查找 C 的下一个邻接节点:
    2,0 开始找,找到 2,4 都没有找到有直连的;这里会退出递归 C,从而回到递归 B
  8. 由于循环并未结束:会判断找到的 C,已经被访问过。则从 B 为基础查找下一个:
    也就是从 1,2+11,3 = 1,即 B,D B 直连 D
  9. 递归访问 D,输出 D,并标记为已访问
    3,0 开始找,找到 3,4 都没有找到与 D 直连的下一个,则退出 D 递归
  10. 回到了递归 B,由于循环未结束,会判断 D ,已经被访问过,则从 B 为基础查找下一个
    也就是从 1,3+11,4 = 1 ,即 B,E B 直连 E
  11. 递归访问 E,输出 E,并标记为已访问
  12. 查找 E 的下一个节点,由于 E 是最后一个,退出递归 E,回到了递归 B
  13. 回到了递归 B,由于循环未结束,会判断 E,已经被访问过,则从 B 为基础查找下一个(这里已经是找完了),也未找到(已经找完了)
  14. 回到了递归 A,由于循环未结束,会判断 B,已经被访问过,则从 A 为基础查找下一个
    查找到 C,A 与 C 直连,由于 C 已经被访问过,则继续以 A 为基础查找下一个,把 A 可能链接的点查找完成,没有,则退出递归 A
  15. 这时,已经跳出了第一次初始点 A 的深度优先查找 ,按照插入顶点的顺序,下一个节点为 B,从 B 开始深度优先查找
    这里先判定:是否已经访问过,肯定已经访问过了,直接跳过 深度优先查找
  16. 由于 B 已经被访问过,那么直接下一个 C,发现 C 也被访问过
  17. 以此类推,后面的都被访问过了,则直接完成。

思路小节:

  1. 先从一个初始节点开发深度优先查找
  2. 然后找到该节点的第一个邻接节点,找到则继续深度优先
  3. 如果找不到,则会 回溯:那么尝试该节点的其他路径是否可以连通。
  4. 直到回溯到最顶层,然后退出该次 深度优先查找函数。挑选下一个初始节点如果没有访问过,则调用深度优先函数

到这里都应该明白它的工作原理了,如果还是不明白就请看下面的代码应该就可以懂了。

代码实现

在上面原先的代码基础上进行功能添加。

        /**
* 存放顶点是否已经访问过,下标对应顶点插入列表的下标
*/
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 ->


这里的难点,一定不要以为直接按照添加的顶点顺序输出就行,虽然这里结果看上去是添加的顶点顺序,实际上它是有查找第一个邻接节点,不存在则回溯到上一层,直到回溯到初始节点。 这里有一个回溯的流程。如果是一个比较复杂的图,输出的结果就不一定是更添加顶点的顺序了。

简单总结如下:

  1. 每次只找第一个邻接节点(纵向)
  2. 找不到,则返回到上一层。然后开始 横向找非第一个邻接节点
  3. 然后不断的找第一个,然后回溯(横向找下一个)的流程

通过上面的例子,你可能会发现:在循环的时候,把 A 作为参数调用 深度优先搜索,整个图就遍历完成了,那为什么还需要外面一层循环呢?

这个问题你想象一下:你看一个地铁图的时候,假设有 2 个地铁站 G、H,没有和其他节点连接,只有 ​​G→H​​ 相连了,那么上面的列子,最外层的循环就起作用了。简单说就是:当一个点,不能间接的到达某一个点,那么就需要外层的这个循环来工作。

图的广度优先遍历

图的广度优先搜索(DFS,Broad First Search),类似于一个 分层搜索的过程,广度优先遍历需要使用一个队列以保持访问过的节点的顺序,以便按这个顺序来访问这些节点的邻接节点。

算法步骤

  1. 访问初始节点 v ,并标记节点 v 为已访问
  2. 节点 v 入队列
  3. 当队列非空时,继续执行,否则算法结束(仅对 v 的节点算法结束)。
  4. 出队列,取得队头的节点 u
  5. 查找节点 u 的第一个邻接节点 w
  6. 若节点 u 的邻接节点 w 不存在,则跳转到步骤 3;否则执行以下三个步骤:
  1. 若节点 w 尚未被访问,则访问节点 w 并标记为已访问
  2. 节点 w 入队列
  3. 查找节点 u 的继 w 邻接节点后的下一个邻接节点 w,转到步骤 6

这里可以看到,与深度优先不同的是:

  • ​广度优先​​:找到第一个邻接节点,访问之后,会继续寻找这个节点的下一个邻接节点(非第一个)
  • ​深度优先​​:每次只找第一个邻接节点,然后以找到的节点作为基础找它的第一个邻接节点,如果找不到才回溯到上一层,寻找找它的下一个邻接节点(非第一个)

数据结构与算法——图_深度优先_10

就如同上图:

  • 左侧的是广度优先,先把 A 能直连的都访问完,再换下一个节点
  • 右图是深度优先,每次都只访问第一个直连的节点,然后换节点继续,访问不到,则回退到上一层,找下一个直连节点

代码实现

记住这个步骤:

  1. 访问初始节点 v ,并标记节点 v 为已访问
  2. 节点 v 入队列
  3. 当队列非空时,继续执行,否则算法结束(仅对 v 的节点算法结束)。
  4. 出队列,取得队头的节点 u
  5. 查找节点 u 的第一个邻接节点 w
  6. 若节点 u 的邻接节点 w 不存在,则跳转到步骤 3;否则执行以下三个步骤:
  1. 若节点 w 尚未被访问,则访问节点 w 并标记为已访问
  2. 节点 w 入队列
  3. 查找节点 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 →


数据结构与算法——图_代码实现_11

当只打开 换行 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 广度优先

由于前面讲解的点较少,恰好输出顺序一致,现在来对比下多一点的点。如下图

数据结构与算法——图_递归_12

 /**
* 构建数量较多的点的 图
*
* @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 →


可以看到输出的遍历结果明显不同。