DFS为何找不到路? 图搜索与树搜索完备性解惑
2025-04-03 01:54:03
DFS 为啥有时找不到路?图搜索 vs 树搜索完备性剖析
刚接触图算法那会儿,特别是看《人工智能:现代方法》(AIMA) 这本书时,关于深度优先搜索 (DFS) 的完备性,书里有段话估计让不少人头大:
深度优先搜索的性质很大程度上取决于使用的是图搜索还是树搜索版本。图搜索版本会避免重复状态和冗余路径,在有限状态空间 中是完备 的,因为它最终会扩展每个节点。而树搜索版本则不完备 [...]。可以在不增加额外内存成本的情况下修改深度优先树搜索,使其检查新状态是否与从根到当前节点的路径上的状态重复;这能在有限状态空间中避免无限循环,但不能避免冗余路径的扩散。
等等,“树”不就是一种特殊的“图”吗?为啥用在图上的 DFS 是完备的,用在树上(或者说按“树”的方式去搜)反而就不完备了呢?还有,“无限循环”和“冗余路径”这两个概念,听起来好像差不多,到底区别在哪?
别急,咱们这就把这事儿掰扯清楚。
核心差异:图搜索 vs. 树搜索
要搞懂完备性的差别,首先得明白“图搜索”(Graph Search) 和“树搜索”(Tree Search) 这两种策略的根本不同。这跟你要搜索的那个“图”本身是不是一棵“树”没必然联系,关键在于搜索算法本身如何对待访问过的节点 。
1. 树搜索 (Tree Search)
- 特点 :它不管状态空间图里实际有没有环。它就认死理,把搜索过程展开的空间严格看作一棵树。
- 行为 :就算你通过不同路径到达了同一个状态(节点),树搜索也可能把它当作全新的节点来重新扩展。它不维护一个全局的“已访问”列表 来记录所有探索过的状态。
- 后果 :如果状态空间图里有环,树搜索可能会沿着环无限地“向下”探索,根本停不下来。或者,它可能会反复进入同一个状态的不同实例,做大量重复工作。
2. 图搜索 (Graph Search)
- 特点 :它更聪明,知道状态空间中可能存在重复状态和环路。
- 行为 :它会维护一个数据结构(通常叫
visited
集或者closed list
),记录下所有已经访问并扩展过 的状态。在扩展一个新节点之前,它会检查这个节点的状态是不是已经在visited
集里了。如果在,就直接跳过,不再从这个节点继续向下探索。 - 后果 :能有效避免重复探索同一个状态,也能防止在环路里死循环。
关键点: 区分是“图搜索”还是“树搜索”,看的是算法策略 ,而不是那个被搜索的图的结构 。你完全可以用“树搜索”的策略去搜一个带环的图,也(虽然有点怪)可以用“图搜索”的策略去搜一个没有环的树。
拆解完备性:为什么结果不同?
现在我们清楚了两种搜索策略的区别,再来看完备性就好理解了。“完备性”指的是:如果一个解存在,这个算法保证能找到它吗?
树搜索 DFS 的“不完备”陷阱
问题就出在树搜索处理环路 的方式上。考虑一个简单的有限状态空间,包含一个环:
A -> B -> C -> A (假设目标是 G,但 G 在别处,比如 A -> D -> G)
如果使用树搜索版本的 DFS ,从 A 开始:
- 访问 A。
- 深入到 B。
- 深入到 C。
- 从 C 又可以回到 A。此时,树搜索不记得 它曾经访问过 A,它只管沿着路径走。
- 再次从 A 深入到 B...
- 再次从 B 深入到 C...
- ... 无限循环下去 ...
看出来没?如果图中存在环路,并且 DFS 一头扎进了这个环,树搜索策略会让它在这个环里永远打转,根本没机会去探索图中可能存在的、通往目标 G 的其他分支(比如 A -> D -> G
)。
结论: 因为树搜索 DFS 无法识别并跳出环路,它可能陷入无限循环,从而错失在其他路径上的解。因此,在包含环路的(有限或无限)状态空间中,树搜索 DFS 不完备 。即使状态空间是有限的,无限循环本身就意味着你永远也探索不完所有状态。
伪代码示例 (体现树搜索逻辑):
# 伪代码 - 树搜索 DFS (可能陷入无限循环)
def tree_dfs(node, goal):
if node == goal:
return [node] # 找到目标
# 不记录全局访问状态,只管往下走
for neighbor in get_neighbors(node):
path = tree_dfs(neighbor, goal)
if path: # 如果子路径找到了目标
return [node] + path
return None # 这个分支下没找到
图搜索 DFS 的“完备”保证 (在有限状态空间)
图搜索版本就好多了,因为它有 visited
集这个“记忆”。还是上面那个例子:
A -> B -> C -> A (目标 G 在 A -> D -> G)
visited = {} # 初始化为空
使用图搜索版本的 DFS ,从 A 开始:
- 访问 A。将 A 加入
visited
集。visited = {A}
。 - 深入到 B。将 B 加入
visited
集。visited = {A, B}
。 - 深入到 C。将 C 加入
visited
集。visited = {A, B, C}
。 - 从 C 尝试回到 A。检查 A 是否在
visited
集里?是的! 于是,这条路不走了,回溯。 - C 没有其他邻居了,回溯到 B。
- B 没有其他邻居了,回溯到 A。
- 从 A 探索另一条路,比如到 D。将 D 加入
visited
集。visited = {A, B, C, D}
。 - 从 D 深入到 G。找到目标!
看到关键了吗?visited
集阻止了算法重新进入已经探索过的状态 A,从而打破了无限循环 。
结论: 在一个有限 的状态空间中,图搜索 DFS 每访问一个新节点,就会把它加入 visited
集。因为状态总数是有限的,visited
集的大小也是有限的。算法保证不会重复访问和扩展同一个节点,所以它最终必然会探索完所有从起点可达的节点。如果存在一条通往目标的路径,图搜索 DFS 一定 能在有限步骤内找到它。因此,在有限状态空间 中,图搜索 DFS 是完备的 。
注意: 如果状态空间是无限的,即使是图搜索 DFS 也可能不完备,因为它可能沿着一条无限长的路径一直走下去,而解在另一条分支上。
伪代码示例 (体现图搜索逻辑):
# 伪代码 - 图搜索 DFS (有限状态空间内完备)
visited = set() # 全局或通过参数传递
def graph_dfs(node, goal):
if node in visited:
return None # 已经访问过,剪枝
visited.add(node) # 标记为已访问
if node == goal:
return [node] # 找到目标
for neighbor in get_neighbors(node):
path = graph_dfs(neighbor, goal)
if path: # 如果子路径找到了目标
return [node] + path
# 注意:在严格的图搜索定义中,节点完成扩展后才加入 closed list
# 但在 DFS 实现中,为避免环路,常在进入时就标记 visited
return None # 这个节点及其子树下没找到
区分“无限循环”与“冗余路径”
这两个概念很容易混淆,咱们来仔细辨析一下:
-
无限循环 (Infinite Loop):
- 指的是算法反复遍历同一个节点序列 ,形成一个闭环,导致搜索无法前进到状态空间的其他部分。
- 这是树搜索 DFS 在带环图中不完备的直接原因 。它阻止了算法探索潜在的解路径。
- 例如:在 A -> B -> C -> A 的环中,树搜索会 A, B, C, A, B, C, ... 这样无限地进行下去。
-
冗余路径 (Redundant Path):
- 指的是通过不同的路径到达了同一个状态(节点) 。
- 例如,假设状态空间中有两条路径都能到达节点 D:
A -> B -> D
和A -> C -> D
。 - 树搜索 会分别探索这两条路径,即使它们最终都到达了 D。这意味着从 D 出发的子树可能会被探索两次(或更多次,如果还有其他路径到 D),造成效率低下 ,但它本身不一定导致“不完备”(除非其中一条路径进入了无限循环)。
- 图搜索 通过
visited
集可以避免对 D 的重复探索。一旦 D 经由A -> B -> D
被访问并标记,当算法通过A -> C -> D
再次尝试访问 D 时,会发现 D 已在visited
集中,于是这条路径在到达 D 时就被剪枝了,避免了冗余探索。
简单说: 无限循环是“原地打转”,卡死不动;冗余路径是“殊途同归”,走了冤枉路。图搜索通过 visited
集同时解决了这两个问题(对于已关闭节点而言)。
优化与进阶:改良版树搜索
AIMA 书中还提到了一个改良版的树搜索 DFS :
可以在不增加额外内存成本的情况下修改深度优先树搜索,使其检查新状态是否与从根到当前路径 上的状态重复。
这个改良版是怎么回事呢?
- 原理: 它不像图搜索那样维护一个包含所有已访问节点的全局
visited
集 。它只关心当前正在探索的这条路径 (从起始点到当前节点的路径)。在扩展一个新节点next_node
时,它会检查next_node
是否已经存在于当前路径栈 上。 - 作用: 这个检查足以防止无限循环 。因为如果
next_node
已经在当前路径上了,说明遇到了一个环,再往下走就会无限循环。此时阻止继续深入,就能打破循环。# 检查 C 的邻居 A: # 当前路径是 A -> B -> C # A 在当前路径中吗?是的! # 好,不走这条路了,回溯。
- 局限: 它不能防止冗余路径 。因为它只看“当前路径”。
- 比如,先走了
A -> B -> D
,探索完 D 的子树后回溯。 - 然后算法可能走
A -> C -> D
。当到达 D 时,当前路径是A -> C -> D
。D 不在A -> C -> D
这个路径上(它在末端,不算在“已走过的路径”里回溯检查)。之前的路径A -> B -> D
已经被忘掉了(因为树搜索不维护全局状态)。所以,D 及其子树会被再次探索。
- 比如,先走了
- 优点: 相比完整的图搜索,它的内存开销小。通常 DFS 的递归实现隐式地维护了当前路径栈,所以这个检查几乎是“免费”的,不需要额外的、可能很大的
visited
数据结构。 - 缺点: 效率可能低于图搜索,因为它会探索冗余路径。
伪代码示例 (改良版树搜索 DFS - 防循环,不防冗余):
# 伪代码 - 改良树搜索 DFS (检测当前路径重复)
def path_checking_dfs(node, goal, current_path):
if node == goal:
return [node]
# 检查是否在当前路径上造成循环
if node in current_path:
return None # 发现循环,剪枝
# 将当前节点加入路径,进行探索
current_path.append(node)
for neighbor in get_neighbors(node):
result_path = path_checking_dfs(neighbor, goal, current_path)
if result_path:
# 找到解,在当前节点从路径中移除前返回
current_path.pop() # 清理路径栈
return [node] + result_path
# 回溯:从当前路径中移除本节点
current_path.pop()
return None # 此路不通
何时选择?
- 图搜索 DFS: 需要保证在有限状态空间中的完备性,并且能接受存储
visited
集的内存开销。它是最常用、最可靠的选择。 - (纯)树搜索 DFS: 很少直接用在可能带环的图上,除非你确定图就是一棵树,或者有其他机制保证不会无限循环。
- 改良版(路径检查)树搜索 DFS: 当内存极其受限,无法存储完整的
visited
集,但又需要避免无限循环时。需要接受可能因冗余路径导致的性能下降。
现在,你应该能清楚地区分图搜索和树搜索策略,理解为什么它们在处理 DFS 的完备性时表现不同,并且明白无限循环和冗余路径的区别了吧。核心在于算法如何记录和利用“已经走过哪里”的信息。