图或树的遍历算法之所以会陷入死循环,其最核心、最普遍的原因在于待遍历的“图”数据结构中,存在着一个或多个“环路”,而遍历算法在执行过程中,又缺少一个有效的“已访问”状态记录机制。这套问题的产生,主要涉及五个关键因素:图结构中存在“环路”、遍历过程中缺少“已访问”状态的记录机制、深度优先搜索的递归实现导致“无限递归”、广度优先搜索的队列处理不当、以及未能正确处理“非连通图”的多个起点。

具体来说,一个“环路”的存在,意味着从图中某个节点出发,沿着一系列的边进行移动,最终,能够再次回到这个出发点。如果一个遍历算法,在访问过一个节点后,没有为其打上“我已经来过这里”的标记,那么,当它顺着环路,再次回到这个已访问过的节点时,就会将其,视为一个全新的、未被发现的节点,并再次,沿着它走过的旧路,重新开始一次新的遍历。这个过程,会周而复始,永不终止,从而将程序,拖入无尽的循环之中。
一、基础概念:理解“图”与“树”
在深入探讨“为何会死循环”之前,我们必须首先,在概念上,对“图”和“树”这两个数据结构,建立一个清晰、准确的认知。
1. 什么是图?
在计算机科学中,图,是一种用于表示“对象”与“对象”之间“关联关系”的、非线性的数据结构。它由两个基本元素构成:
- 顶点:代表了一个独立的“对象”或“实体”。例如,一个社交网络中的“用户”,或一张地图上的“城市”。
- 边:代表了两个顶点之间的“关联关系”。例如,用户之间的“好友关系”,或城市之间的“航线”。根据边的方向性,图又可以分为“有向图”(边有明确的方向,如A指向B)和“无向图”(边没有方向,A与B相互连接)。
2. 什么是树?
树,是一种非常特殊的、受到更多约束的“图”。一个数据结构,要被称为“树”,它必须同时满足两个核心条件:
- 它必须是“连通的”,即从任何一个顶点出发,都能到达其他任何一个顶点。
- 它绝对不能,包含任何“环路”。
3. 核心概念:“环路”
环路,是指在图中,存在一条路径,其起点和终点,是同一个顶点。例如,在一个图中,存在这样的路径:顶点A -> 顶点B -> 顶点C -> 顶点A。
这个“环路”的概念,是理解本文所有问题的“钥匙”。根据定义,树状结构,是绝对不允许存在环路的。而一个通用的、普通的图,则完全可以,包含一个甚至多个复杂的环路。
因此,一个设计正确的遍历算法,在遍历一棵“树”时,通常是“天生安全”的,因为它无论如何行走,都永远不会“回到过去”。但是,当同一个算法,被应用于一个可能包含“环路”的“图”时,如果它没有一套额外的“防范机制”,那么,陷入“死循环”的风险,就变得极高。
二、遍历的双雄:深度优先与广度优先
图的遍历,最经典的,有两种核心算法:深度优先搜索和广度优先搜索。
**1. 深度优先搜索 **
深度优先搜索的策略,如同在探索一个“迷宫”时,始终坚持“一条路走到黑”。
- 执行过程:从一个起始顶点出发,访问它。然后,从它所有“尚未被访问”的邻居中,任选一个,再对这个邻居,进行同样地、深入地探索。直到当前路径的尽头,再也没有“未被访问”的邻居了,程序,才会“回溯”到上一个“岔路口”,去探索另一条“未曾走过”的路。
- 实现方式:这种“后进先出”的回溯行为,天然地,就非常适合,用“递归”或一个显式的“栈”数据结构来实现。
**2. 广度优先搜索 **
广度优先搜索的策略,则更像是在水中,投下一颗石子,所产生的“涟漪”。它是一种“逐层向外”的探索方式。
- 执行过程:从一个起始顶点出发,访问它。然后,一次性地,访问它“所有”的、尚未被访问的邻居(这构成了“第一层”涟漪)。然后,再依次地,从这些“第一层”的邻居出发,去访问它们所有的、尚未被访问的邻居(这构成了“第二层”涟漪),如此反复,直至所有可达的顶点,都被访问完毕。
- 实现方式:这种“先进先出”的逐层处理行为,天然地,就需要借助一个“队列”数据结构来实现。
三、死循环的“元凶”:未被标记的“环路”
现在,让我们来看看,当上述这两种经典的算法,遇到了一个包含了“环路”的、且算法自身又缺乏“防范机制”的图时,会发生怎样的“灾难”。
1. 问题的重现
假设,我们有如下一个简单的、包含了“环路”的有向图:
A -> B
B -> C
C -> A (这条边,构成了 A->B->C->A 的环路)
2. 深度优先搜索的“无限递归”
如果我们,从顶点A开始,进行一次“天真”的(即,没有“已访问”标记的)深度优先搜索,其执行过程(以递归为例)将是:
- 调用
DFS(A):访问A。找到A的邻居B,于是,递归调用DFS(B)。 - 调用
DFS(B):访问B。找到B的邻居C,于是,递归调用DFS(C)。 - 调用
DFS(C):访问C。找到C的邻居A,于是,递归调用DFS(A)。 - 调用
DFS(A):访问A。找到A的邻居B,于是,递归调用DFS(B)。 - ……
我们发现,程序,进入了一个A -> B -> C -> A -> ...的、永不终止的无限递归调用链。其最终的,也必然的结局,就是因为耗尽了“调用栈”的内存空间,而抛出一个“栈溢出”的致命错误。
3. 广度优先搜索的“无限入队”
如果我们,从顶点A开始,进行一次“天真”的广度优先搜索,其执行过程将是:
- 访问A。将A的所有邻居(即B),加入队列。当前队列:
[B]。 - 从队列中,取出B,并访问它。将B的所有邻居(即C),加入队列。当前队列:
[C]。 - 从队列中,取出C,并访问它。将C的所有邻居(即A),加入队列。当前队列:
[A]。 - 从队列中,取出A,并访问它。将A的所有邻居(即B),加入队列。当前队列:
[B]。 - ……
我们发现,程序,同样地,进入了一个在队列中,反复地“存入B、取出B、存入C、取出C、存入A、取出A……”的、永不终止的死循环。这个程序,可能不会像递归那样,因为“栈溢出”而快速地崩溃,但它会永远地运行下去,持续地,消耗着中央处理器的资源和内存。
四、解决方案:建立“已访问”集合
要“斩断”这个由“环路”所导致的“无限循环”,其唯一的、也是最根本的解决方案,就是为我们的遍历算法,赋予“记忆”。
1. 核心思想:“凡走过,必留痕”
这个“记忆”,在算法的实现中,通常,是一个被称为“已访问”的集合(可以使用“哈希集合”或“布尔数组”来实现)。
其核心思想是,在算法的整个生命周期中,维护这份“已访问”列表。在即将访问任何一个“新”的顶点之前,都必须,首先,查阅一下这份“记忆”,看看,我们“之前,是否已经来过这里?”
2. 修正后的深度优先搜索
Java
// visitedSet 是一个在遍历开始前创建的、全局的集合
void correctedDFS(Node node, Set<Node> visitedSet) {
// 第一步:检查“记忆”,如果已访问过,则立即返回,斩断循环
if (visitedSet.contains(node)) {
return;
}
// 第二步:如果未访问,则立即“留下痕迹”,并进行访问
visitedSet.add(node);
System.out.println("访问节点: " + node.name);
// 第三步:继续探索其邻居
for (Node neighbor : node.getNeighbors()) {
correctedDFS(neighbor, visitedSet);
}
}
通过在函数入口处,增加的这短短两三行“检查与标记”的代码,我们就为深度优先搜索,安装上了强大的“环路刹车”。
3. 修正后的广度优先搜索
Java
void correctedBFS(Node startNode) {
Queue<Node> queue = new LinkedList<>();
Set<Node> visitedSet = new HashSet<>();
// 将起点,同时,放入队列和“已访问”集合
queue.add(startNode);
visitedSet.add(startNode);
while (!queue.isEmpty()) {
Node currentNode = queue.poll();
System.out.println("访问节点: " + currentNode.name);
for (Node neighbor : currentNode.getNeighbors()) {
// 在将任何一个新邻居“入队”之前,都必须,先检查它是否已被访问
if (!visitedSet.contains(neighbor)) {
// 如果未访问,则同时,进行“标记”和“入队”
visitedSet.add(neighbor);
queue.add(neighbor);
}
}
}
}
五、在实践中“防范”
要将上述的理论,转化为工程实践中的可靠保障,我们需要流程和工具的支撑。
- 将遍历逻辑“模块化”:应将这些经过了充分验证的、包含了“已访问”集合逻辑的、健壮的图遍历算法,封装为可被团队复用的、通用的“工具类”或“库函数”。
- 编写“边界”单元测试:一个用于测试图算法的、完备的单元测试集,必须,强制性地,包含一个或多个,专门用于检验算法在“有环图”上,是否能正常终止的测试用例。
- 代码审查与规范:团队的编码规范中,应明确指出,在处理任何可能存在“环路”的数据结构时,都必须考虑并实现“已访问”的检查机制。这份规范,可以被沉淀和共享在像 Worktile 这样的通用协作平台的知识库中。而在 PingCode 这样的研发管理平台中,代码审查是其核心的流程环节,审查者,应将“检查是否存在不安全的遍历逻辑”,作为一个重要的审查点。
常见问答 (FAQ)
Q1: “树”的遍历,需不需要“已访问”集合?
A1: 理论上,不需要。因为“树”的严格定义,就是“无环的连通图”。因此,在遍历一棵“完美”的树时,你永远不会,重复地,访问到同一个节点。但是,在工程实践中,为了代码的“健壮性”(以防止输入的数据,并非一棵“严格”的树),增加一个“已访问”的检查,是一种更安全的、防御性的编程习惯。
Q2: 深度优先搜索和广度优先搜索,哪个更容易陷入死循环?
A2: 在都没有“已访问”检查的情况下,两者,在遇到“环路”时,都必然会陷入死循环。只是,其“表现形式”不同:深度优先搜索,通常,会因为“无限递归”,而快速地,以“栈溢出”的方式崩溃;而广度优先搜索,则会因为“无限入队”,而陷入一个不会自动崩溃、但会持续消耗资源的死循环。
Q3: 什么是“有向无环图”?它的遍历有什么特殊之处?
A3: “有向无环图”,是一种特殊的图,它虽然有向,但却保证不存在任何环路。这种数据结构,在工程中,应用极其广泛,例如,用于描述任务的“依赖关系”(A必须在B之前完成),或构建软件的“编译顺序”。对于它,有一种特殊的、极其重要的遍历算法,叫做“拓扑排序”。
Q4: 除了栈溢出或死循环,错误的图遍历还会导致什么问题?
A4: 即便程序,因为某种巧合,没有陷入死循环,一个没有“已访问”检查的遍历,也可能会,对同一个节点,进行“多次重复的访问和处理”。如果,你的业务逻辑,是“每访问一个节点,就将其计数加一”,那么,这种重复访问,就会导致最终的计算结果,完全错误。
文章包含AI辅助创作,作者:十亿,如若转载,请注明出处:https://docs.pingcode.com/baike/5215009