设为“置顶或星标”,第一时间送达干货。
转自景禹
小禹禹,你们好呀,景禹今天给你们说一说图的遍历方法!
小禹禹: 好呀好呀,图的遍历方法都包含哪些呢?
景禹: 图的遍历方法包括 深度优先遍历(搜索) 和 广度优先遍历(搜索) 两种方式。小禹禹能给我说一下树的四种遍历方式吗?
聪明的小禹禹: 树的四种遍历方式分别为:前序遍历、中序遍历和后序遍历、层序遍历。这四种遍历方式小禹禹掌握的可熟悉了。(不熟悉的小伙伴可以看之前的文章: 一文横扫二叉树的所有遍历方法)
小禹禹可真棒,那我们正式开始学习图的遍历方法喽!
深度优先搜索
算法思想
深度优化遍历( Depth First Search ),也有称为 深度优化搜索 ,简称为 DFS 。事实上,我们在树的遍历中早已涉及DFS,层序遍历、中序遍历和后序遍历都属于深度优先遍历的方式,因为这些遍历方式本质上都归结于栈。为了讲清楚DFS,我们先来看两个概念。
右手原则: 在没有碰到重复顶点的情况下,分叉路口始终是向右手边走,每路过一个顶点就做一个记号。
左手原则: 在没有碰到重复顶点的情况下,分叉路口始终是向左手边走,每路过一个顶点就做一个记号。
本文约定以右手原则进行深度优先遍历。废话不多说,我们以下图说明深度优先搜索。
原则上,我们可以从图中的任何一个顶点开始,进行深度优先遍历,假设我们从顶点A开始,遍历过程中的每一步如下:
第一步:从顶点A开始,将顶点A标记为已访问顶点。
第二步:根据约定的右手原则,访问顶点B,并将B标记为已访问顶点。
第三步:右手原则,访问顶点C
第四步:右手原则,访问顶点D
第五步:右手原则,访问顶点E
第六步:右手原则,访问顶点F
第七步:右手原则,应该先访问顶点F的邻接顶点A,但发现A已被访问,则访问除A之外的最右侧顶点G。
第八步:右手原则,先访问顶点B,顶点B已被访问;再访问顶点D,顶点D已经被访问;最后访问顶点H。
第九步:发现顶点H的邻接顶点均已被访问,则退回到顶点G;
第十步:顶点G的邻接顶点均已被访问,则退回到顶点F;
第十一步:顶点F的邻接顶点已被访问,则退回到顶点E;
第十二步:顶点E的邻接顶点均已被访问,则退回到顶点D;
第十三步:顶点D的邻接顶点I尚未被访问,则访问顶点I;
第十四步:顶点I的邻接顶点均已被访问,则退回到顶点D;
顶点D的邻接顶点均已被访问,退回到顶点C;顶点C的邻接顶点均已被访问,则退回到顶点B;顶点B的邻接顶点均已被访问,则退回到顶点A,顶点A为起始顶点,深度优先搜索结束。
小禹禹:景禹,你是不是给我讲的有点儿啰嗦了?
景禹:哈哈!觉得啰嗦就对了,上面所说的这些过程不正就是递归的过程吗?
小禹禹:欧迪斯奈,原来如此。
为了更清楚的理解图的深度优先搜索和二叉树的前序遍历、中序遍历、后序遍历均属于一类方法,我们对最终的遍历结果图做一定的位置调整:
细心的小禹禹一定发现,这就是我们的前序遍历过程呀!相信看到这里的小禹禹一定对深度优先搜索豁然开朗了。
为了更加清楚图的深度优先搜索,我们将上面的过程总结为以下三个步骤:
-
首先选定一个未被访问过的顶点V作为起始顶点(或者访问指定的起始顶点V),并将其标记为已访问过; -
然后搜索与顶点V邻接的所有顶点,判断这些顶点是否被访问过,如果有未被访问过的顶点W;再选取与顶点W邻接的未被访问过的一个顶点并进行访问,依次重复进行。当一个顶点的所有的邻接顶点都被访问过时,则依次回退到最近被访问的顶点。若该顶点还有其他邻接顶点未被访问,则从这些未被访问的顶点中取出一个并重复上述过程,直到与起始顶点V相邻接的所有顶点都被访问过为止。 -
若此时图中依然有顶点未被访问,则再选取其中一个顶点作为起始顶点并进行遍历,转(2)。反之,则遍历结束。
DFS的实现
小禹禹:景禹,这一次我终于对深度优先搜索理解了!景禹能告诉我怎么实现吗?
深度优先遍历(搜索)最简单的实现方式就是递归,由于图的存储方式不同,递归的实现当然也略有差异。
邻接矩阵存储 递归 实现
当图采用邻接矩阵进行存储时,递归的实现操作为:
// 邻接矩阵的深度有限递归算法
#define TRUE 1
#define FALSE 0
#define MAX 256
typedef int Boolean; // 这里我们定义Boolean为布尔类型,其值为TRUE或FALSE
Boolean visited[MAX]; // 访问标志的数组
//进行递归的遍历包含顶点i的连通图
void DFS(MGraph G, int i)
{
visited[i] = TRUE; // 访问过的顶点设置为TRUE
printf("%c ", G.vexs[i]); // 打印顶点
for( j=0; j < G.numVertexes; j++ )
{
if( G.arc[i][j]==1 && !visited[j] )
{
DFS(G, j); // 对为访问的邻接顶点递归调用
}
}
}
// 邻接矩阵的深度遍历操作
void DFSTraverse(MGraph G)
{
int i;
// 初始化所有顶点状态都是未访问过状态
for( i=0; i < G.numVertexes; i++ )
{
visited[i] = FALSE;
}
//防止图为非联通的情况,遍历整个图
for( i=0; i < G.numVertexes; i++ )
{
if( !visited[i] ) // 若是连通图,只会执行一次
{
DFS(G, i);
}
}
}
邻接矩阵存储 栈 实现
对于上面的递归,我们也可以采用栈来实现,为了清楚的理解利用栈实现的深度优先搜索的执行过程,我们一起来看一看下面的动画(配合动画看代码):
动画演示:
实现代码:
void DFS_Stack(MGraph G, int i)
{
int node;
int count = 1;
printf("%c ", G.vexs[i]); // 打印已访问顶点
visited[i] = TRUE;
node = i;
push(i); //开始的节点入栈
while(count < G.numVertexes) //still has node not visited
{
/* 所有被访问的节点依次入栈,只有node当找不到下一个相连的节点时,才使用出栈节点 */
for(j=0; j < G.numVertexes; j++)
{
if(G.arc[node][j] == 1 && visited[j] == FALSE)
{
visited[j] = TRUE;
printf("%c ", G.vexs[j]);
count++;
push(j); //push node j
break;
}
}
if(j == G.numVertexes) //与node相连的顶点均已被访问过,所以需要从stack里取出node的上一个顶点,再看该顶点的邻接顶点是否未被访问
node = pop();
else //找到与node相连并且未被访问的顶点,
node = j;
}
}
邻接表存储递归实现
邻接表存储下图的深度优先搜索代码实现,与邻接矩阵的思想相同,只是实现略有不同:
// 邻接表的深度有限递归算法
#define TRUE 1
#define FALSE 0
#define MAX 256
typedef int Boolean; // 这里我们定义Boolean为布尔类型,其值为TRUE或FALSE
Boolean visited[MAX]; // 访问标志的数组
void DFS(GraphAdjList GL, int i)
{
EdgeNode *p;
visited[i] = TRUE;
printf("%c " GL->adjList[i].data);
p = GL->adjList[i].firstEdge;
while(p)
{
if( !visited[p->adjvex] )
{
DFS(GL, p->adjvex);
}
p = p->next;
}
}
// 邻接表的深度遍历操作
void DFSTraverse(GraphAdjList GL)
{
int i;
for( i=0; i < GL->numVertexes; i++ )
{
visited[i] = FALSE; // 初始化所有顶点状态都是未访问过状态
}
for( i=0; i < GL->numVertexes; i++ )
{
if( !visited[i] ) // 若是连通图,只会执行一次
{
DFS(GL, i);
}
}
}
广度优先搜索
算法思想
广度优先遍历(Breadth First Search),又称为广度优先搜索,简称BFS。树的层序遍历方式便是一种广度优先搜索方式。为了清晰地理解广度优先搜索,我们同样以深度优先搜索的例子一起走一遍,这不过,我们对图中顶点的位置做了调整,这样看起来更清楚。
假定从顶点A开始进行广度优先搜索,则遍历的顺序为:
第一步:访问顶点A;
第二步:访问顶点A的所有未被访问的邻接顶点,顶点B和顶点F;
第三步:访问顶点B和顶点F的所有未被访问的邻接顶点,顶点C、I、G、E;
第四步:访问顶点C、I、G、E 的所有邻接顶点中未被访问的顶点,顶点D和顶点H。
BFS的实现
小禹禹:广度优先遍历的步骤好少呀!
景禹:当然不是了,景禹只是给你们展示了一层一层遍历的过程,并没有展示每一层具体如何被访问,这就要考虑到 BFS 的实现了。
那么要实现对图的广度优先遍历,显然和树的层序遍历一样,采用队列来实现。
动画演示:
实现代码:
// 邻接矩阵的广度遍历算法
void BFSTraverse(MGraph G)
{
int i, j;
Queue Q;
for( i=0; i < G.numVertexes; i++ )
{
visited[i] = FALSE;
}
initQueue( &Q );
for( i=0; i < G.numVertexes; i++ )
{
if( !visited[i] )
{
printf("%c ", G.vex[i]);
visited[i] = TRUE;
EnQueue(&Q, i);
while( !QueueEmpty(Q) )
{
DeQueue(&Q, &i);
for( j=0; j < G.numVertexes; j++ )
{
if( G.art[i][j]==1 && !visited[j] )
{
printf("%c ", G.vex[j]);
visited[j] = TRUE;
EnQueue(&Q, j);
}
}
}
}
}
}
BFS应用案例:单词接龙
LeetCode 127. Word Ladder 单词接龙(Medium)
题目描述
给定两个单词(beginWord 和 endWord)和一个字典,找到从 beginWord 到 endWord 的最短转换序列的长度。转换需遵循如下规则:
每次转换只能改变一个字母。转换过程中的中间单词必须是字典中的单词。
说明:
如果不存在这样的转换序列,返回 0。所有单词具有相同的长度。所有单词只由小写字母组成。字典中不存在重复的单词。你可以假设 beginWord 和 endWord 是非空的,且二者不相同。
示例
输入: beginWord = “hit”, endWord = “cog”, wordList = [“hot”,”dot”,”dog”,”lot”,”log”,”cog”]
输出: 5
解释: 一个最短转换序列是 “hit” -> “hot” -> “dot” -> “dog” -> “cog”, 返回它的长度 5。
题目解析
拥有一个 beginWord
和一个 endWord
,分别表示图上起始顶点和结束顶点(图中用红色表示)。我们希望利用一些中间顶点(单词)从起始顶点到结束顶点,中间节点是 wordList
给定的单词。我们对这个单词接龙每个步骤的唯一条件是相邻单词只可以改变一个字母。
当然现在问题的关键就构造根据 wordlist
中的单词构建出上面这样的一幅图,可我们该如何构造呢?我们可以对 wordlist
中的单词之间只改变一个字母就可以转化为另一个的单词之间建立一条连边,但是直接遍历 wordlist
并构建出上面的图是很困难的,所以我们考虑两个单词的共有部分作为连接点,什么意思呢,看下图。
我们对给定的 wordList
做一个预处理,将单词中的某个字母用 *
代替。也就是上图中样子。
既然要构建图,免不了谈及存储结构,对于这道题目最好的存储结构就是邻接表(关于图的存储结构可以参考之前的文章:图解:什么是图?(以“图”话图) )。
邻接表中,我们以单词中的某个字母用 *
代替后则字符串作为关键字 key
,每一个与 key
邻接的单词(顶点)为除 *
所占的字符以外,另外两个字符相同的单词。我们以示例中输入为例,一步一步的看一下该题目中邻接表的构建过程。
hot
中的每一个字符替换后加入邻接表中;将其他字符依次以相同的方式加入邻接表中,则可以得到由 wordList
所构建的邻接表:
有了这个邻接表,我们便可以通过 BFS 遍历邻接表,判断是否存在从单词(顶点) hit
到 cog
的路径,为了清晰的展示算法执行过程,可以将邻接表转化为图的形式:
后面就是进行广度优先搜索了,采用队列进行实现即可。完整的实现代码如下:
public class Solution {
public static int ladderLength(String beginWord, String endWord, List<String> wordList) {
if (!wordList.contains(endWord)){
return 0;
}
int len = beginWord.length();
HashMap<String, ArrayList<String>> allComboDict = new HashMap<>();
//创建邻接链表
wordList.forEach(curWord -> {
for (int i = 0; i < len; i++) {
String comboWord = curWord.substring(0, i) + "*" + curWord.substring(i + 1, len);
ArrayList<String> comboWordList = allComboDict.getOrDefault(comboWord, new ArrayList<>());
comboWordList.add(curWord);
allComboDict.put(comboWord, comboWordList);
}
});
// 广度优先遍历队列
Queue<Pair<String, Integer>> queue = new LinkedList<>();
HashMap<String, Boolean> hasVistedList = new HashMap<>();
queue.add(new Pair<>(beginWord, 1));
hasVistedList.put(beginWord, true);
// 广度优先遍历,逐个取出队列中元素进行操作
while (!queue.isEmpty()) {
Pair<String, Integer> node = queue.remove();
String currWord = node.getKey();
int level = node.getValue();
for (int i = 0; i < len; i++) {
String currComboWord = currWord.substring(0, i) + "*" + currWord.substring(i + 1, len);
ArrayList<String> currComboWordList = allComboDict.getOrDefault(currComboWord, new ArrayList<>());
for (String word : currComboWordList) {
if (word.equals(endWord))
return level + 1;
if (!hasVistedList.containsKey(word)){
queue.add(new Pair<>(word, level + 1));
hasVistedList.put(word, true);
}
}
}
}
return 0;
}
}
总结
图的遍历方式包括深度优先搜索(DFS)和广度优先搜索(BFS),其中 DFS 使用递归或栈进行实现,而 BFS 则采用队列进行实现。对比树的四种遍历方式,前序遍历、中序遍历和后序遍历均类似于 DFS,而层序遍历类似于 BFS,前中后序也均可采用栈的方式进行实现,层序遍历可以采用队列的方式进行实现。
这样看来,知识的融会贯通多么重要,总体而言,掌握下面的两条链,你便可以解决好多问题。
DFS → 前中后序 → 栈 → 线性表
BFS → 层序遍历 → 队列 → 链表
推荐阅读
• C++是如何从代码到游戏的?• 告诉你一个学习编程的诀窍(建议收藏)• 自学编程的八大误区!克服它!• 新手如何有效的刷算法题(LeetCode)• 10款VS Code插件神器,第7款超级实用!• 在拼多多上班,是一种什么样的体验?我tm心态崩了呀!• 写给小白,从零开始拥有一个酷炫上线的网站!
欢迎关注我的公众号“五分钟学算法”,如果喜欢,麻烦点一下“在看”~
原文始发于微信公众号(五分钟学算法):动画解析:图的遍历方式有哪些?