CS221 · 人工智能:原理与技术
Lecture 6 · UCS 与 A* 搜索
源文件:lecture-06.md
Lecture 6 · UCS 与 A* 搜索
CS221: Artificial Intelligence — Principles and Techniques · Stanford · Autumn 2025
📅 Oct 8 · 💻ucs_astar.py
承上启下
上一讲 Lecture 5 · 搜索算法 I:
- 搜索问题的形式化定义:状态、动作、代价、终止条件;
- 精确算法:穷举搜索($O(b^D)$ 指数时间)、动态规划(缓存把复杂度降到状态数级别,但要求图无环);
- 近似算法:best-of-N、束搜索(放弃最优性保证换取可行性)。
本讲:填上上一讲留下的坑——允许有环图的精确搜索算法:
- UCS(Uniform Cost Search,一致代价搜索):按”过去代价”递增的顺序处理状态;
- A* 搜索:带启发式函数的 UCS,用对终点的”预判”大幅减少探索量。
下一讲 Lecture 7 · 马尔可夫决策过程:动作结果不再确定(掷骰子式的转移),”最小化代价”升级为”最大化期望效用”。
1. 为什么需要新算法?
1.1 动态规划的假设
动态规划计算未来代价(future cost):从 $s$ 到终止状态的最小代价,递归定义为
$$\text{FutureCost}(s) = \begin{cases} 0 & \text{IsEnd}(s) \\ \displaystyle\min_{(a,\, c,\, s') \in \text{Succ}(s)} \left[ c + \text{FutureCost}(s') \right] & \text{否则} \end{cases}$$
其中 $(a, c, s')$ 遍历 $s$ 的后继:动作 $a$、单步代价 $c$、新状态 $s'$。
问题:$\text{FutureCost}(s)$ 依赖 $\text{FutureCost}(s')$,必须先算后继、再算自己。这要求依赖关系能排成一个线性次序——也就是要求图是无环的(DAG),按拓扑序处理。
1.2 如果有环怎么办?
讲义中的例子(DiamondSearchProblem,四个节点、双向边构成多个环):
graph LR
A --> |1| B
A --> |100| C
B --> |1| A
B --> |1| C
B --> |100| D
C --> |100| A
C --> |1| B
C --> |1| D
D --> |100| B
D --> |1| C
style A fill:#c8e6c9,stroke:#2e7d32
style D fill:#ffe0b2,stroke:#e65100
问题:要算 $\text{FutureCost}(B)$ 需要 $\text{FutureCost}(C)$(走 B→C),要算 $\text{FutureCost}(C)$ 又需要 $\text{FutureCost}(B)$(走 C→B)——互相依赖,没有合法的计算顺序,递归会无限循环。
解决方案(视角反转):
- 不算未来代价,改算过去代价(past cost)——从起点走到 $s$ 的最小代价;
- 按过去代价递增的顺序处理状态。
提示
为什么换成过去代价就没有循环依赖了?因为”过去代价递增”给全体状态强行排出了一个处理次序:离起点近(便宜)的先定案,离起点远(昂贵)的后定案。环不再是问题——绕环一圈只会让代价变大(前提:代价非负),所以一个已定案的便宜状态绝不会被后来发现的绕远路推翻。这个次序不是图结构给的(拓扑序),而是代价本身给的。
2. 一致代价搜索(UCS)
2.1 核心思想
graph LR
Start["起点"] --> |past cost| S["state"]
S --> |future cost| End["终点"]
style Start fill:#c8e6c9,stroke:#2e7d32
style S fill:#e1f5fe,stroke:#0277bd
style End fill:#ffe0b2,stroke:#e65100
定义:对每个状态 $s$,
- 过去代价 $\text{PastCost}(s)$:从起点 $s_{\text{start}}$ 到 $s$ 的最小代价路径的代价;
- 未来代价 $\text{FutureCost}(s)$:从 $s$ 到终点的最小代价路径的代价。
任何经过 $s$ 的最优路径总代价即 $\text{PastCost}(s) + \text{FutureCost}(s)$。
UCS 策略(即 Dijkstra 算法,1956 年提出、1959 年发表):
- 假设:所有代价非负($\text{Cost}(s,a) \ge 0$)——这是正确性的必要条件;
- 按过去代价递增的顺序处理状态;
- 用优先队列(priority queue)维护待探索状态。
算法运行时,状态被划分成三个集合:
- 已探索(explored):已从队列弹出,$\text{PastCost}$ 已最终确定,永不再碰;
- 边界(frontier):已被某条路径触达、在优先队列中,当前优先级是”目前已知的最优到达代价”,可能还会被更便宜的路径更新;
- 未探索(unexplored):尚未被任何路径触达。
UCS 每轮从 frontier 弹出优先级最小的状态移入 explored,并把它的后继送入/更新到 frontier——explored 集合像水波一样从起点按代价一圈圈向外扩。
2.2 算法实现
@dataclass
class Backpointer:
prev_state: Any # 上一个状态
action: Any # 采取的动作
cost: float # 该动作的代价
def uniform_cost_search(problem: SearchProblem) -> tuple[Solution | None, int]:
frontier = PriorityQueue() # 待探索状态(按 past_cost 排序)
backpointers: dict[Any, Backpointer] = {} # 记录如何到达每个状态
num_explored = 0
# 初始化:起点的过去代价为 0
start_state = problem.start_state()
frontier.update(start_state, 0.0)
while True:
# 取出过去代价最小的状态
state, past_cost = frontier.remove_min()
if state is None:
return None, num_explored # 无解
num_explored += 1
# 到达终点?回溯构造解
if problem.is_end(state):
steps = []
while state != start_state:
bp = backpointers[state]
steps.insert(0, Step(bp.action, bp.cost, state))
state = bp.prev_state
return Solution(steps=steps), num_explored
# 扩展后继
for successor in problem.successors(state):
new_cost = past_cost + successor.cost
if frontier.update(successor.state, new_cost):
# 找到更好的路径 → 更新 backpointer
backpointers[successor.state] = Backpointer(
prev_state=state,
action=successor.action,
cost=successor.cost
)
说明
优先队列只告诉我们每个状态的最优代价,不告诉我们路径。Backpointer 记录”当前已知最优路径中,$s$ 的前一个状态与所迈的那一步”;到达终点后沿 backpointer 逆着走回起点,反转即得完整解。这与上一讲动态规划里”缓存中存整个 Solution”是同一信息的更省内存的存法。
2.3 优先队列实现
class PriorityQueue:
def __init__(self):
self.heap = [] # 最小堆
self.priorities = {} # state → priority
def update(self, state: Any, new_priority: float) -> bool:
"""插入或更新 state 的优先级(如果更小)"""
old_priority = self.priorities.get(state)
if old_priority is None or new_priority < old_priority:
self.priorities[state] = new_priority
heapq.heappush(self.heap, (new_priority, state))
return True # 更新了
return False
def remove_min(self):
"""移除并返回优先级最小的 (state, priority)"""
while len(self.heap) > 0:
priority, state = heapq.heappop(self.heap)
if self.priorities[state] == priority: # 非过期条目
self.priorities[state] = -inf # 标记为已处理
return state, priority
return None, None # 队列为空
说明
二叉堆不支持高效的”修改任意元素优先级”,所以 update 直接把新条目压进堆,旧条目留在原地成为过期条目;remove_min 弹出时用 priorities 字典校验,过期的直接丢弃。弹出后把优先级标成 $-\infty$,相当于打上”已处理(DONE)”标记——之后任何 update 都不可能给出比 $-\infty$ 更小的值,保证已探索状态不会被重新放回队列。
复杂度:设状态数 $n$、边数 $m$,每条边至多触发一次入堆,总时间 $O((n + m)\log n)$——比动态规划的 $O(n + m)$ 多一个对数因子,这是为”支持环”付出的代价。
2.4 正确性证明
定理:当 UCS 将状态 $s$ 从 frontier 移入 explored 时,其优先级恰等于真实的最小过去代价:$\text{priority}(s) = \text{PastCost}(s)$。
证明(对弹出次序归纳):
- 基础:起点第一个弹出,$\text{priority}(s_{\text{start}}) = 0 = \text{PastCost}(s_{\text{start}})$ ✅
- 归纳步:假设此前弹出的所有状态该性质都成立。现在弹出 $s$,其优先级对应某条已发现的路径(下图蓝色路径)。需要证明:任何从起点到 $s$ 的路径都不比它便宜。
graph LR
Start["起点"] --> |"经过已探索区域"| T["t(已探索)"]
T --> |"Cost(t,u)"| U["u(在 frontier)"]
U -.-> |"红色路径其余部分"| S["s"]
Start -.-> |"蓝色路径:priority(s)"| S
style Start fill:#c8e6c9,stroke:#2e7d32
style T fill:#90caf9,stroke:#1976d2
style S fill:#ffe0b2,stroke:#e65100
任何到 $s$ 的路径(红色)总要在某处首次离开 explored 区域:设它经过已探索状态 $t$ 后到达 frontier 中的状态 $u$($u$ 可能就是 $s$)。于是
$$\text{cost(red)} \;\ge\; \text{PastCost}(t) + \text{Cost}(t,u) \;=\; \text{priority}(t) + \text{Cost}(t,u) \;\ge\; \text{priority}(u) \;\ge\; \text{priority}(s) \;=\; \text{cost(blue)}$$
逐个不等号的理由:
- 红色路径”起点到 $t$”这一段至少花 $\text{PastCost}(t)$(最小代价的定义),而 $u$ 之后的剩余段代价非负,直接舍掉——非负性假设在此处用到;
- 归纳假设:$t$ 弹出时优先级 = $\text{PastCost}(t)$;
- $t$ 被探索时更新过它的所有后继,所以 frontier 中 $u$ 的优先级 $\le \text{priority}(t) + \text{Cost}(t,u)$;
- 本轮弹出的是 $s$,说明 $s$ 是 frontier 中优先级最小的,$\text{priority}(s) \le \text{priority}(u)$。
因此蓝色路径的代价就是真实的 $\text{PastCost}(s)$。$\blacksquare$
注意
证明的第一步依赖”剩余路径代价非负可以舍掉”。若存在负代价边,”先弹出的一定更便宜”不再成立:某条暂时昂贵的路径可能在后面靠负边反超,而 UCS 已经把状态盖棺定论了。对比记忆:动态规划允许任意代价但不允许环;UCS 允许环但要求代价非负。两者都不满足时(有环 + 负边)需要 Bellman-Ford 等更重的算法。
| 环 | 负代价 | 时间复杂度 | |
|---|---|---|---|
| 动态规划 | ❌ 不允许 | ✅ 允许 | $O(n+m)$ |
| UCS | ✅ 允许 | ❌ 不允许 | $O((n+m)\log n)$ |
3. A* 搜索
3.1 动机
UCS 按 $\text{PastCost}(s)$ 递增探索,从起点向四面八方均匀扩散——它完全不知道终点在哪个方向,朝反方向的便宜状态照样探索。
理想情况:按 $\text{PastCost}(s) + \text{FutureCost}(s)$(经过 $s$ 的最优路径总代价)排序探索,这样只有最优路径上的状态会被碰到。
实际:$\text{FutureCost}(s)$ 恰恰是我们要求的东西,不可能免费得到。但可以用一个廉价的启发式函数(heuristic) $h(s) \approx \text{FutureCost}(s)$ 来近似它。
3.2 A* 算法
思路(Hart, Nilsson & Raphael, 1968):A = 在修改过代价的问题*上跑 UCS。原始代价 $\text{Cost}(s, a)$ 被替换为
$$\text{Cost}'(s, a) = \text{Cost}(s, a) + h(\text{Succ}(s, a)) - h(s)$$
即每走一步,额外奖惩”启发式值的变化量”:朝终点靠近($h$ 下降)的动作变便宜,远离终点($h$ 上升)的动作变昂贵。
等价视角:在修改后的问题上,状态 $s$ 的过去代价为 $\text{PastCost}(s) + h(s) - h(s_{\text{start}})$(见 3.6 的望远镜求和),而 $h(s_{\text{start}})$ 是常数不影响排序——所以 A 就是按 $\text{PastCost}(s) + h(s)$ 递增探索*的 UCS:既考虑已经花了多少($\text{PastCost}$),也预估还要花多少($h$)。
def astar_search(problem: SearchProblem, heuristic: Heuristic) -> tuple[Solution | None, int]:
"""A* 搜索:在 UCS 基础上加启发式函数"""
frontier = PriorityQueue()
backpointers: dict[Any, Backpointer] = {}
num_explored = 0
start_state = problem.start_state()
frontier.update(start_state, heuristic(start_state)) # 初始优先级 = h(start)
while True:
state, priority = frontier.remove_min()
if state is None:
return None, num_explored
num_explored += 1
if problem.is_end(state):
# 回溯构造解(与 UCS 相同)
steps = []
while state != start_state:
bp = backpointers[state]
steps.insert(0, Step(bp.action, bp.cost, state))
state = bp.prev_state
return Solution(steps=steps), num_explored
# 扩展后继(使用修改后的代价)
for successor in problem.successors(state):
# new_priority = past_cost + h(new_state)
# 但 past_cost = priority - h(state)
new_priority = priority - heuristic(state) + successor.cost + heuristic(successor.state)
if frontier.update(successor.state, new_priority):
backpointers[successor.state] = Backpointer(
prev_state=state,
action=successor.action,
cost=successor.cost
)
说明
ucs_astar.py 里的写法更能体现”A 只是换了代价的 UCS”:定义一个代理类 ModifiedSearchProblem,把每个后继的代价透明地替换为 $\text{Cost}'$,然后原封不动地调用 uniform_cost_search*,最后回溯时再把代价还原为原始值。上面的实现把这层代理内联进了循环,两者等价。
3.3 例子:数轴搜索
数轴上的搜索问题:起点 0,终点 2,每步可左移或右移一格,代价均为 1(数轴无界,所以状态有无穷多个,还有来回走的环——正好 DP 处理不了):
class LineSearchProblem(SearchProblem):
def start_state(self) -> int:
return 0
def successors(self, state: int) -> list[Step]:
return [
Step(action="left", cost=1, state=state - 1),
Step(action="right", cost=1, state=state + 1)
]
def is_end(self, state: int) -> bool:
return state == 2
def line_heuristic(state: int) -> float:
"""到终点的距离(一维下曼哈顿距离与欧氏距离相同)"""
return abs(state - 2) # 距离目标还差几步
效果:
- UCS 从 0 向两侧对称扩散,探索 $\{-2, -1, 0, 1, 2\}$ 共 5 个状态——向左的探索纯属浪费,但 UCS 无从知道;
- A 配合
line_heuristic:向左一步修改后代价 $1 + (3-2) = 2$,向右一步 $1 + (1-2) = 0$,搜索被”推”向右侧,只探索 $\{0, 1, 2\}$ 共 3 个状态——节省 40%*。
3.4 一致性(Consistency)
问题:是否随便什么 $h$ 都能用?
反例:
A --1--> B --1--> C (终点)
h(A) = 0, h(B) = 0, h(C) = 1000 ❌
修改后的代价 $\text{Cost}'(B, \text{go\_to\_C}) = 1 + (1000 - 0) = 1001$:通往终点的最后一步被抬成天价,A 会绕开最优路径。更糟的是,若 $h$ 骤降还可能造出负的*修改代价,破坏 UCS 的前提。
定义(一致性):启发式 $h$ 是一致的(consistent),当且仅当
- 修改后的代价非负:$\text{Cost}(s, a) + h(\text{Succ}(s, a)) - h(s) \ge 0$;
- 终点处启发式为零:$h(s_{\text{end}}) = 0$。
条件 1 的等价形式(三角不等式):
$$h(s) \le \text{Cost}(s, a) + h(\text{Succ}(s, a))$$
即”从 $s$ 直接估到终点”不能比”先真实走一步、再从新状态估到终点”更贵——估计值的下降速度不能超过真实代价的积累速度。
3.5 可采纳性(Admissibility)
定义:$h$ 是可采纳的(admissible),当 $h(s) \le \text{FutureCost}(s)$ 对所有 $s$ 成立。
含义:启发式永远不高估真实的未来代价——它是一个乐观的下界估计。
关系:一致性 ⇒ 可采纳性(沿从 $s$ 出发的最优路径逐步套用三角不等式,$h$ 一路望远镜式缩掉,剩下 $h(s) \le$ 路径总代价 $= \text{FutureCost}(s)$)。反之不成立:可以构造每个点都不高估、但相邻点之间落差超过单步代价的 $h$——逐点合法,逐步违约。
注意
教科书里常说”$h$ 可采纳则 A 最优”,那是对树搜索(允许重复展开状态)版本说的。本讲的实现基于 UCS,每个状态只探索一次(弹出即定案),此时需要更强的一致性*条件才能保证最优——只有可采纳性时,一个状态可能在其真实最优过去代价确定之前就被弹出。实践中一致性几乎总是同时满足的(松弛法构造的启发式天然一致,见第 4 节),但概念上要分清这两级条件。
3.6 正确性与效率
命题(正确性):如果 $h$ 一致,A* 返回最优解。
证明:考虑任何一条从起点到终点的路径 $s_0 = s_{\text{start}} \to s_1 \to \cdots \to s_T = s_{\text{end}}$,其修改后总代价是望远镜求和(telescoping sum)——中间项两两相消:
$$\sum_{t=1}^{T} \text{Cost}'(s_{t-1}, a_t) = \sum_{t=1}^{T} \text{Cost}(s_{t-1}, a_t) + \underbrace{h(s_T)}_{=\,0} - h(s_0) = \text{原始总代价} - h(s_{\text{start}})$$
即每条完整路径的修改后代价 = 原始代价减去同一个常数 $h(s_{\text{start}})$。常数平移不改变哪条路径最优,而一致性保证修改后代价非负,UCS 的正确性定理适用于修改后的问题——所以 A* 在修改后问题上找到的最优解,就是原问题的最优解。$\blacksquare$
命题(效率):A* 只探索满足下式的状态 $s$:
$$\text{PastCost}(s) \le \text{PastCost}(s_{\text{end}}) - h(s)$$
对比 UCS 探索所有 $\text{PastCost}(s) \le \text{PastCost}(s_{\text{end}})$ 的状态:$h(s)$ 相当于给每个状态的”探索门槛”加了折扣——$h$ 越大(越接近真实 FutureCost),被挡在门外的状态越多,搜索越快。
特殊情况(两个极端锚定了整条谱线):
- $h(s) = 0$(毫无信息)→ A* 完全退化为 UCS;
- $h(s) = \text{FutureCost}(s)$(完美信息)→ A* 只探索最优路径上的状态(此时每步修改后代价:最优动作为 0,非最优动作为正);
- 实践中的 $h$ 落在两者之间,越靠右越好、但计算 $h$ 本身要便宜。
提示
把 A* 想成”戴了偏光镜的 UCS”。UCS 的探索波前是以起点为圆心的同心圆;$h$ 把每个方向的”表观距离”拉伸或压缩——朝终点的方向变近、背离终点的方向变远——于是波前变成朝目标拉长的椭圆。$h$ 的质量决定椭圆有多”瘦”:完美的 $h$ 把椭圆压成一条线(最优路径本身)。
4. 如何设计启发式?松弛问题
4.1 松弛(Relaxation)
思路:好的启发式不靠拍脑袋。通用配方是松弛(relaxation)——删掉原问题的一些约束(等价于把某些动作的代价调低,$\text{Cost}_{\text{rel}}(s,a) \le \text{Cost}(s,a)$),得到一个更容易求解的问题,然后令
$$h(s) = \text{FutureCost}_{\text{rel}}(s)$$
即”简化世界里的真实最优代价”作为”真实世界的乐观估计”。
例子:网格搜索
原问题:在网格上移动(上下左右),不能穿墙(#),每步代价 1。
松弛 1:曼哈顿距离——删掉”不能穿墙”的约束。没有墙的网格中,最优解有闭式解:
$$h_{\text{Man}}(r, c) = |r - r_{\text{end}}| + |c - c_{\text{end}}|$$
其中 $(r, c)$ 是当前行列坐标,$(r_{\text{end}}, c_{\text{end}})$ 是终点坐标。
def manhattan_heuristic(state: tuple[int, int]) -> float:
r, c = state
r_end, c_end = (4, 4) # 终点坐标
return abs(r - r_end) + abs(c - c_end)
松弛 2:欧几里得距离——进一步松弛:不仅可穿墙,还可以沿任意方向直线移动(不再限于四方向格步):
$$h_{\text{Euc}}(r, c) = \sqrt{(r - r_{\text{end}})^2 + (c - c_{\text{end}})^2}$$
def euclidean_heuristic(state: tuple[int, int]) -> float:
r, c = state
r_end, c_end = (4, 4)
return ((r - r_end)**2 + (c - c_end)**2) ** 0.5
为什么松弛得到的 $h$ 一定合法:
- 可采纳:松弛只会删约束、降代价,所以 $\text{FutureCost}_{\text{rel}}(s) \le \text{FutureCost}(s)$——原问题的任何可行解在松弛问题里照样可行且不更贵;
- 一致:$\text{FutureCost}_{\text{rel}}$ 作为一个真实搜索问题的未来代价,自动满足三角不等式 $\text{FutureCost}_{\text{rel}}(s) \le \text{Cost}_{\text{rel}}(s,a) + \text{FutureCost}_{\text{rel}}(s') \le \text{Cost}(s,a) + \text{FutureCost}_{\text{rel}}(s')$,且终点处为 0。
提示
松弛法的妙处在于把”设计启发式”这个玄学问题转化为”选择删哪些约束”这个建模问题。删得越少,$h$ 越贴近真实(搜索省得越多),但松弛问题越难算;删得越多,$h$ 越便宜但越松。设计目标是找一个恰好变得容易求解的松弛点——比如删墙之后恰好出现闭式解。
说明
- 闭式解:松弛后答案能直接写出公式——如删墙后的曼哈顿距离;
- 更小的搜索问题:松弛后状态空间坍缩到可以用 DP/UCS 预先精确求解——如电车问题中删掉”票数有限”的约束后,状态从(位置, 票数)坍缩回位置;
- 独立子问题:松弛把问题拆成互不干扰的小块——如 8 数码难题中允许方块重叠后,每个方块独立地走到目标位,$h$ = 各方块曼哈顿距离之和。
启发式的组合:若 $h_1, h_2$ 都一致,则 $h(s) = \max\{h_1(s), h_2(s)\}$ 仍然一致——不同松弛给出不同方向的下界,取逐点最大值得到一个不低于任何单个成员的更强启发式。
4.2 比较不同启发式
在四方向网格问题上,逐点有 $0 \le h_{\text{Euc}}(s) \le h_{\text{Man}}(s) \le \text{FutureCost}(s)$(欧氏松弛删掉的约束更多,所以估计更松)。由 3.6 的效率命题,$h$ 逐点越大探索越少:
| 启发式 | 与真实 FutureCost 的关系 | 探索状态数 |
|---|---|---|
| $h = 0$(无信息) | 最松的下界 | 最多(= UCS) |
| 欧几里得距离 | 较松(允许穿墙 + 任意方向) | 较少 |
| 曼哈顿距离 | 较紧(只允许穿墙) | 更少 |
| $h = \text{FutureCost}$(完美) | 精确 | 最少(只有最优路径) |
支配(dominance):若两个一致启发式满足 $h_1(s) \ge h_2(s)$ 对所有 $s$ 成立,则称 $h_1$ 支配 $h_2$,A* 用 $h_1$ 探索的状态集是用 $h_2$ 的子集。
权衡:更紧的启发式探索更少状态,但每次计算 $h$ 本身可能更贵(极端情况:$h = \text{FutureCost}$ 意味着先解原问题——本末倒置)。总时间 = 探索状态数 × 单状态开销,两头都要算账。
注意
可采纳性依赖于动作集合:曼哈顿距离在只能上下左右的网格上可采纳,可一旦允许对角线移动(一步同时改变行和列),走对角一步真实代价 1 而曼哈顿距离下降 2——高估了,不可采纳。换了问题的动作空间,启发式必须重新审查。
5. 可视化对比
🔗 UCS 动画:youtube.com/watch?v=z6lUnb9ktkE
🔗 A* 动画:youtube.com/watch?v=huJEgJ82360
graph TD
subgraph UCS
A1["从起点向四周<br/>均匀扩散"]
end
subgraph A_star["A*"]
A2["朝终点方向<br/>集中探索"]
end
style A1 fill:#e3f2fd,stroke:#1976d2
style A2 fill:#e8f5e9,stroke:#2e7d32
6. 总结
mindmap
root((UCS 与 A*))
动机
动态规划不支持环
改按过去代价定序
UCS
优先队列维护 frontier
按 PastCost 递增探索
要求代价非负
即 Dijkstra 算法
A*
UCS 加启发式 h
按 PastCost + h 探索
一致性:三角不等式
可采纳性:不高估
h 越紧探索越少
启发式设计
松弛:删约束降代价
曼哈顿 / 欧几里得距离
一致启发式可取 max 组合
权衡:计算代价 vs 探索节省
关键要点:
- UCS 是处理有环图的精确搜索算法(即 Dijkstra 算法):按过去代价递增顺序探索,优先队列实现,弹出即定案;正确性依赖代价非负(对比:DP 允许负代价但不允许环);
- A* = 在修改代价 $\text{Cost}'(s,a) = \text{Cost}(s,a) + h(\text{Succ}(s,a)) - h(s)$ 的问题上跑 UCS,等价于按 $\text{PastCost}(s) + h(s)$ 探索;
- 一致性($h(s) \le \text{Cost}(s,a) + h(\text{Succ}(s,a))$ 且 $h(s_{\text{end}}) = 0$)保证修改后代价非负,从而 A* 正确;望远镜求和说明修改只是给所有完整路径的代价平移了常数 $-h(s_{\text{start}})$;
- 可采纳性($h \le \text{FutureCost}$)由一致性推出;A* 只探索 $\text{PastCost}(s) \le \text{PastCost}(s_{\text{end}}) - h(s)$ 的状态,$h$ 越紧越省;
- 设计启发式的通用配方是松弛:删约束 → 简化问题的精确 FutureCost 自动是一致启发式;多个一致启发式可取逐点 max 组合。
下一讲预告:搜索假设”动作的结果确定”。如果动作结果是非确定性的(如掷骰子、真实机器人打滑)怎么办?”最小化总代价”将让位于”最大化期望效用” → Lecture 7 · 马尔可夫决策过程(其中的价值迭代与 $\text{FutureCost}$ 递归一脉相承,$\text{FutureCost}$ 演化为价值函数——这条线一直通到 Lecture 8 · 强化学习)。
复习自测
题目
DP 递归计算 FutureCost,要求”先算后继再算自己”,这个依赖顺序就是图的拓扑序——有环则拓扑序不存在,递归无限循环。UCS 改算 PastCost,处理顺序由代价本身给出:按过去代价从小到大定案。代价非负时,绕环只会更贵,已定案的状态不会被推翻,所以环无害。
题目
用在不等式链第一步:把红色路径中 $u$ 之后的剩余段代价舍掉时,需要”剩余段 $\ge 0$”。反例:$A \xrightarrow{1} B$,$A \xrightarrow{2} C$,$C \xrightarrow{-2} B$,终点 B。UCS 先以 priority 1 弹出 B 并定案,但真实最优是 $A \to C \to B$ 代价 0——负边让”后到的路径”反超了已定案的结果。
题目
可采纳性:$h(s) \le \text{FutureCost}(s)$,逐点不高估。一致性:$h(s) \le \text{Cost}(s,a) + h(s')$ 且 $h(\text{end}) = 0$,相邻状态间的估值落差不超过单步真实代价(更强,可推出可采纳性)。本讲 A* 基于 UCS,每个状态弹出即定案、不再重开;只有一致性能保证修改后代价非负、UCS 前提成立。仅可采纳的 $h$ 可能让某状态过早定案,需要允许重复展开的树搜索版本才能兜底。
题目
不可采纳:对角走一步真实代价 1,但曼哈顿距离下降 2,即 $h$ 高估了真实未来代价(如从 $(0,0)$ 到 $(3,3)$ 真实最优 3 步,曼哈顿距离却是 6)。应改用切比雪夫距离 $\max\{|r - r_{\text{end}}|, |c - c_{\text{end}}|\}$——它是”允许对角移动且无墙”这一松弛的精确解,天然一致。
题目
松弛问题本身是合法搜索问题,其 FutureCost 满足自己的三角不等式:$h(s) \le \text{Cost}_{\text{rel}}(s,a) + h(s')$;又因松弛只降代价($\text{Cost}_{\text{rel}} \le \text{Cost}$),对原代价三角不等式仍成立,终点处为 0——一致。平均值也一致(两个不等式取平均即可),但没有必要:$\max\{h_1, h_2\}$ 同样一致(对实现 max 的那个分量套其三角不等式,另一分量只会更小)且逐点 $\ge$ 平均值,支配它——组合启发式永远取 max。
参考资料
- 💻 ucs_astar.py
- 📄 Dijkstra’s Algorithm (1956) — UCS 的起源
- 📄 A Formal Basis for the Heuristic Determination of Minimum Cost Paths (Hart, Nilsson, Raphael, 1968) — A* 原始论文
- 🎥 UCS 可视化
- 🎥 A* 可视化
- 📖 Russell & Norvig, Artificial Intelligence: A Modern Approach, Ch. 3 — 启发式搜索与 8 数码启发式的经典论述