首页 / 第四章
04搜索策略
现实中的许多智能问题——路径规划、任务安排、棋局决策——都没有直接公式可套, 只能在庞大的可能空间里试。如何试得聪明、试得快,就是搜索策略。 本章实现 BFS/DFS/UCS、A*、与或树搜索和博弈树 α-β 剪枝,它们是规划与决策算法的共同骨架。
4.1 搜索的基本概念
搜索是根据问题的实际情况,不断寻找可利用的知识,从而构造一条代价最小的行动路线, 使问题得以解决的过程。它产生的根本原因是:现成的、确定性的求解步骤往往不存在。
4.1.1 状态空间法
状态空间用三元组 (S, O, G) 表示:
S:所有可能状态的集合(初始状态 S₀ ∈ S);O:操作(算符)集合,操作把一个状态变换为另一状态;G ⊆ S:目标状态集合。
求解问题 = 在状态空间图中,寻找一条从 S₀ 到某个目标状态的路径。
例如八数码问题:状态是九宫格布局,操作是空格上下左右移动,目标是指定排列。
4.1.2 问题归约法与与或图
另一种思路是问题归约:把复杂问题分解为若干子问题。若所有子问题都解决父问题才解决, 用与节点(弧间加连线);若任一子问题解决即可,用或节点。 由此得到与或图,搜索目标是找到一棵解树(4.6 节)。
4.1.3 盲目搜索与启发式搜索
| 类别 | 是否利用问题知识 | 代表算法 |
|---|---|---|
| 盲目搜索(无信息) | 按预定顺序展开,不挑节点 | BFS、DFS、一致代价、迭代加深 |
| 启发式搜索(有信息) | 用估价函数挑选"最有希望"的节点 | 最好优先、A*、IDA*、AO* |
4.2 图搜索的通用框架
几乎所有状态空间搜索都可以统一到 OPEN / CLOSED 两表框架:
- 把初始节点放入 OPEN 表;CLOSED 表初始为空;
- 若 OPEN 为空则失败退出;
- 从 OPEN 表取第一个节点 n,移入 CLOSED;
- 若 n 是目标,回溯指针得到解路径,成功退出;
- 扩展 n:生成后继节点,更新指针,按策略排序后放入 OPEN;
- 转第 2 步。
不同算法的区别,本质上只在第 5 步如何排序 OPEN 表。 先用 Python 搭建统一框架,后续算法只替换"取下一个节点"的策略。
"""通用状态空间搜索框架:OPEN/CLOSED 两表法"""
from collections import deque
import heapq
class Graph:
"""带权无向(或有向)图的邻接表表示。"""
def __init__(self):
self.adj = {}
def add_edge(self, u, v, cost=1, bidirectional=True):
self.adj.setdefault(u, []).append((v, cost))
if bidirectional:
self.adj.setdefault(v, []).append((u, cost))
def neighbors(self, node):
return self.adj.get(node, [])
def generic_search(graph, start, goal, pop_func, record_order=False):
"""pop_func(open_container) -> (节点, 累计代价, 父节点表更新用)
不同搜索策略只需提供不同的容器与弹出方式。"""
parent = {start: None}
g_cost = {start: 0}
closed = set()
order = []
open_container = pop_func.init(start)
while open_container:
node, g = pop_func.pop(open_container)
if node in closed:
continue
closed.add(node)
order.append(node)
if node == goal:
path = []
while node is not None:
path.append(node)
node = parent[node]
return list(reversed(path)), g, order
for nxt, c in graph.neighbors(node):
ng = g + c
if nxt not in closed and (nxt not in g_cost or ng < g_cost[nxt]):
g_cost[nxt] = ng
parent[nxt] = node
pop_func.push(open_container, nxt, ng)
return None, None, order
class BFS策略:
def init(self, start): return deque([(start, 0)])
def pop(self, q): return q.popleft()
def push(self, q, node, g): q.append((node, g))
class DFS策略:
def init(self, start): return [(start, 0)]
def pop(self, q): return q.pop()
def push(self, q, node, g): q.append((node, g))
class UCS策略:
"""一致代价:优先展开累计代价最小的节点。"""
def init(self, start): return [(0, id(start), start, 0)]
def pop(self, h):
_, _, node, g = heapq.heappop(h)
return node, g
def push(self, h, node, g):
heapq.heappush(h, (g, id(node), node, g))
if __name__ == "__main__":
g = Graph()
# 一张示例城市公路网(边权=距离)
edges = [("A", "B", 4), ("A", "C", 2), ("B", "D", 3), ("C", "D", 6),
("C", "E", 5), ("D", "E", 1), ("D", "F", 7), ("E", "F", 2)]
for u, v, w in edges:
g.add_edge(u, v, w)
for name, strat in [("广度优先 BFS", BFS策略()),
("深度优先 DFS", DFS策略()),
("一致代价 UCS", UCS策略())]:
path, cost, order = generic_search(g, "A", "F", strat)
print(f"{name}: 路径 {path},代价 {cost},扩展顺序 {order}")
广度优先 BFS: 路径 ['A', 'B', 'D', 'F'],代价 14,扩展顺序 ['A', 'C', 'B', 'E', 'D', 'F'] 深度优先 DFS: 路径 ['A', 'C', 'E', 'F'],代价 9,扩展顺序 ['A', 'C', 'E', 'F'] 一致代价 UCS: 路径 ['A', 'C', 'E', 'F'],代价 9,扩展顺序 ['A', 'C', 'B', 'D', 'E', 'F']
注意 BFS 在边权不等时不能保证最优(它给出的 A→B→D→F 代价为 14), UCS 才找到了真正的最短路径 A→C→E→F,代价 9。DFS 碰巧也找到了该路径,但它不保证最优、也不保证完备。
4.3 盲目搜索
4.3.1 广度优先搜索(BFS)
BFS 按"先产生的节点先扩展"(先进先出)逐层推进。在边权相等时,它找到的路径经过节点最少, 完备且最优;但需要把整层节点保存在内存中,空间复杂度高。
4.3.2 深度优先搜索(DFS)
DFS 按"后产生的节点先扩展"(后进先出)沿一个分支走到底,再回溯。 内存开销小,但可能落入很深的分支而错过浅层的目标,不完备、不最优。 改进方案是有界深度优先与迭代加深搜索(IDS):逐次放宽深度上限重复 DFS,兼顾小内存与完备性。
"""迷宫上的 DFS(递归回溯)与迭代加深搜索 IDS"""
def dfs_path(grid, start, goal, path=None, visited=None):
"""grid: 二维列表,0 可通行,1 障碍。递归 DFS 找一条可行路径。"""
if path is None:
path, visited = [start], {start}
if start == goal:
return path
r, c = start
for dr, dc in [(0, 1), (1, 0), (0, -1), (-1, 0)]:
nr, nc = r + dr, c + dc
if (0 <= nr < len(grid) and 0 <= nc < len(grid[0])
and grid[nr][nc] == 0 and (nr, nc) not in visited):
visited.add((nr, nc))
ans = dfs_path(grid, (nr, nc), goal, path + [(nr, nc)], visited)
if ans:
return ans
return None
def ids(grid, start, goal, max_depth=30):
"""迭代加深:深度限制 0,1,2,... 逐次尝试 DFS。"""
def dls(node, depth, path, visited):
if node == goal:
return path
if depth == 0:
return None
r, c = node
for dr, dc in [(0, 1), (1, 0), (0, -1), (-1, 0)]:
nr, nc = r + dr, c + dc
if (0 <= nr < len(grid) and 0 <= nc < len(grid[0])
and grid[nr][nc] == 0 and (nr, nc) not in visited):
visited.add((nr, nc))
ans = dls((nr, nc), depth - 1, path + [(nr, nc)], visited)
if ans:
return ans
visited.discard((nr, nc))
return None
for limit in range(max_depth):
ans = dls(start, limit, [start], {start})
if ans is not None:
return ans, limit
return None, max_depth
if __name__ == "__main__":
maze = [
[0, 1, 0, 0, 0],
[0, 1, 0, 1, 0],
[0, 0, 0, 1, 0],
[1, 1, 0, 0, 0],
[0, 0, 0, 1, 0],
]
print("DFS 路径:", dfs_path(maze, (0, 0), (4, 4)))
path, used_limit = ids(maze, (0, 0), (4, 4))
print(f"IDS 路径(深度上限 {used_limit}):", path)
4.3.3 一致代价搜索(UCS)
UCS 每次扩展从根到当前代价 g(n) 最小的节点(用优先队列实现),可看作 Dijkstra 算法。 当边代价均为正时,它完备且最优,但仍是无信息搜索,会向各个方向均匀扩展。
| 指标 | BFS | DFS | UCS | IDS |
|---|---|---|---|---|
| 完备性 | 是 | 否 | 是 | 是 |
| 最优性 | 边权相等时 | 否 | 是 | 边权相等时 |
| 时间 | O(b^d) | O(b^m) | 与代价范围有关 | O(b^d) |
| 空间 | O(b^d),大 | O(bm),小 | 大 | O(bd),小 |
注:b 为分支因子,d 为最浅目标深度,m 为最大深度。
4.4 启发式搜索与 A* 算法
启发式搜索利用与问题相关的启发信息(经验知识)引导搜索朝着最有希望的方向前进。 核心工具是估价函数:
g(n):从初始节点到 n 的实际代价(已知)
h(n):从 n 到目标节点的估计代价(启发函数,未知)
- 只看 g(n):退化为一致代价搜索,稳妥但盲目;
- 只看 h(n):称为局部/全局择优,大胆但可能绕远路甚至陷入歧途;
f = g + h:兼顾已付代价与未来期望,就是著名的 A 算法。
◈ A* 算法及其可采纳性
若启发函数满足 h(n) ≤ h*(n)(估计代价永远不高于实际最短代价,称"可采纳的/admissible"), 则 A 算法称为 A* 算法,它一定能找到最优解。 直觉:低估只会让 A* 多搜一些节点,永远不会错过真正的最优路径;高估则可能过早把最优路径上的节点排到后面。
"""A* 算法在带权地图上的实现(直线距离作为启发函数)"""
import heapq
import math
from graph_search import Graph
def astar(graph, start, goal, heuristic):
open_set = [(heuristic(start, goal), 0, start)] # (f, g, node)
parent = {start: None}
g_score = {start: 0}
expanded = 0
while open_set:
f, g, node = heapq.heappop(open_set)
expanded += 1
if node == goal:
path = []
while node is not None:
path.append(node)
node = parent[node]
return list(reversed(path)), g, expanded
for nxt, cost in graph.neighbors(node):
ng = g + cost
if ng < g_score.get(nxt, float("inf")):
g_score[nxt] = ng
parent[nxt] = node
heapq.heappush(open_set, (ng + heuristic(nxt, goal), ng, nxt))
return None, None, expanded
# 城市坐标,用于计算欧氏距离启发函数
COORDS = {
"A": (0, 0), "B": (3, 2), "C": (1, 3), "D": (5, 4),
"E": (4, 7), "F": (6, 8),
}
def euclidean(a, b):
x1, y1 = COORDS[a]; x2, y2 = COORDS[b]
return math.hypot(x1 - x2, y1 - y2)
if __name__ == "__main__":
g = Graph()
for u, v, w in [("A", "B", 4), ("A", "C", 2), ("B", "D", 3), ("C", "D", 6),
("C", "E", 5), ("D", "E", 1), ("D", "F", 7), ("E", "F", 2)]:
g.add_edge(u, v, w)
path, cost, expanded = astar(g, "A", "F", euclidean)
print(f"A* 最短路径:{path},代价 {cost},扩展节点数 {expanded}")
print("(欧氏距离 ≤ 实际道路长度,启发函数可采纳,故结果最优)")
A* 最短路径:['A', 'C', 'E', 'F'],代价 9,扩展节点数 4 (欧氏距离 ≤ 实际道路长度,启发函数可采纳,故结果最优)