轻松学懂图(上)——相关概念、DFS和BFS
一、概述
本篇博客主要是对图相关的知识点进行总结并会附上个人的心得,让内容尽可能的通俗易懂
二、图相关知识点
-
常见应用场景:
- 关系网络:每个顶点就像是一个人,顶点与顶点直接有一条无向边就表示有建立联系
- 地图:定点作为城市,边作为路径
- …
-
相关概念:
- 出度:以这个节点为起点的边的个数,其实就是说方向向外的边
- 入度:以这个节点为终点的边的个数,其实就是说指向自己的边
- 权值:还记得上面的地图场景吗?权值可以表示两个地点之间路径有多长,也可以理解为开销
-
图的分类:
- 从有向边和无向边这个角度来看,分为
有向图
和无向图
,个人觉得有向图更通用,无向图是有向图中的一个特例。 - 从图复杂程度这个角度来看,又分为简单图、多重图。简单图即没有平行边(就是两个顶点间一来一回两个有向边),多重图又平行边或者自环(自己指向自己的边)
- 从有无权值可分为有权图和无权图(故名思意,这里就不多解释了)
- 然后就是比较常见且稍微特殊的几个图了:
- 完全图(任意两个顶点之间都存在边),包含有向完全图和无向完全图
- 连通图(任意两个顶点之间都是存在路径的,这里是对于无向图来说的),这里涉及到一个
连通分量
的概念,通俗的说:就像是三个村庄构成一个大的无向图,各村之间都不互通,最大的那个村就是这个图的连通分量
- 从有向边和无向边这个角度来看,分为
-
实现方案:
-
邻接矩阵
看起来就像是一个矩形表格:
v1 v2 v3 v1 0 1 1 v2 1 0 1 v3 1 1 0 解释:v1,v2,v3表示顶点,坐标(v1,v2)这个等于1表示 从v1到v2 有一条边,不难看出,邻接矩阵可以用来存放有向边和无向边的信息
优缺点:当顶点数量很多边比较少的时候,会有很多的空间用不到,即容易出现内存浪费的情况,因此,当边比较多的时候适合用邻接矩阵
-
邻接表
如下图所示:
用数组
来存储每个节点的出度信息,每个数组里面用链表存储了它所指向的顶点在数组中的index,这个应该很好理解。
除此之外,还有一个比较少见的逆邻接表,如下图所示:
这个和上面的类似,只不过记录的是每个节点的入度信息
优缺点:
不难看出,这个邻接表相比较邻接矩阵不会出现空间冗余的情况,但是不足之处也很明显,因为数组中存储的是单链表,因此在进行查找的时候就不得不进行遍历,效率会比较低。
图的遍历
与线性表,树之类的数据结构在遍历的时候不太一样的是需要确定一个
起点
然后才可以开始遍历。常见的遍历方式有两种——深度优先搜索(DFS)和广度优先搜索(BFS)。接下来我们来看看他们分别是什么:-
深度优先搜索(DFS)
所谓深度优先即从起点出发,选择起点的一条路径出发,在到达下一个顶点后,继续选择另一条路径走,然后再继续这样下去,当走到某个顶点时,发现不可以继续”往前(之前走过的点不会选择)“走的时候,即没有路的时候就会退回到上一个节点并选择另一条路,就这样如此反复。
如果不够形象的话,大家可以回忆一下曾经学过的树,树的后续遍历和这个过程其实很像,一直走走到头,当不能继续的时候再退回来…相信听到这里,比较细心的小伙伴可能就发现了,
DFS是一个递归的过程
。下面是DFS的示例代码:
/** * 深度优先遍历——递归 * * @param begin 起点 */ @Override public void dfs(V begin) { Vertex<V, E> vertex = vertices.get(begin); if (vertex == null) { return; } dfs(vertex, new HashSet<>()); } /** * * @param vertex 起点 * @param visitedVertices 记录走过的节点 */ public void dfs(Vertex<V, E> vertex, Set visitedVertices) { System.out.println(vertex.value); visitedVertices.add(vertex); for (Edge<V, E> edge : vertex.outEdges) { if (visitedVertices.contains(edge.to)) { continue; } dfs(edge.to, visitedVertices); } }
当然,除了递归的方式,也有非递归的,其实本质上是在用栈这一数据结构模拟递归这一过程,示例代码如下图:
/** * 深度优先遍历——非递归 * * @param begin 起点 */ @Override public void dfs2(V begin) { Vertex<V, E> beginVertex = vertices.get(begin); if (beginVertex == null) { return; } // 保存已经遍历过的顶点 Set<Vertex<V, E>> visitedVertices = new HashSet<>(); // 栈 Stack<Vertex<V, E>> stack = new Stack<>(); // 先访问起点 stack.push(beginVertex); visitedVertices.add(beginVertex); System.out.println(beginVertex); // 循环,利用栈模拟递归 while (!stack.isEmpty()) { // 弹出栈顶元素,遍历它的所有的outEdges Vertex<V, E> vertex = stack.pop(); for (Edge<V, E> edge : vertex.outEdges) { if (visitedVertices.contains(edge.to)) { // 这条边曾经走过 continue; } // 把from放进去的目的是为了能够往回找 stack.push(edge.from); stack.push(edge.to); visitedVertices.add(edge.to); System.out.println(edge.to.value); } } }
-
广度优先搜索(BFS)
所谓广度优先,即从起点开始,先将距离自己距离为“1”的点(通俗的说就是一步就可以到的)先都遍历一遍,然后再遍历距离自己距离为“2”的点,以此类推下去,直至遍历完所有点即结束。其实这个和树的层次遍历特别像,显示第一层,也就是距离为1的那一层,接着是距离为2的那一层。事实上,图的广度优先搜索和树的层次遍历是很像的,我们先回忆一下,树的层次遍历是怎么实现的——用队列先进先出的特性,每遍历一个从队首出来的节点的时候,将其子节点加入到队尾,直至队列为空。图的广度优先搜索也是使用的这样一个方法,示例代码如下图所示:
/** * 深度优先搜索(BFS)——队列 * @param begin 起点 */ @Override public void bfs(V begin) { Vertex<V, E> beginVertex = vertices.get(begin); if (beginVertex == null) { return; } // Set集合,用于存放遍历过的顶点 Set<Vertex<V, E>> visitedVertex = new HashSet<>(); // 队列,用于存放待遍历过顶点 Queue<Vertex<V, E>> queue = new LinkedList<>(); queue.offer(beginVertex); visitedVertex.add(beginVertex); while (!queue.isEmpty()) { Vertex<V, E> vertex = queue.poll(); System.out.println(vertex.value); // 遍历该顶点所有的outEdges for (Edge edge : vertex.outEdges) { // 将他指向的且未入过队的顶点入队 if (visitedVertex.contains(edge.to)) { continue; } queue.add(edge.to); // 入队后,加入visitedVertex集合中 visitedVertex.add(edge.to); } } }
小结
本篇文章简述了图相关的知识点,以及DFS与BFS这两种遍历方式,内容不是很难,在理解这些是如何做的之后,其代码实现还是比较简单的。
之后的内容将会以大家容易接受的方式去说说拓扑排序、Kruskal、Prim、Dijkstra、Bellman-ford以及Floyd算法,尽可能的用大白话让内容通俗易懂,不做搬运工
-