工作台课程

CS221 · 人工智能:原理与技术

Lecture 6 · UCS 与 A* 搜索

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)——互相依赖,没有合法的计算顺序,递归会无限循环。

解决方案(视角反转):

  1. 不算未来代价,改算过去代价(past cost)——从起点走到 $s$ 的最小代价;
  2. 过去代价递增的顺序处理状态。

提示

为什么换成过去代价就没有循环依赖了?因为”过去代价递增”给全体状态强行排出了一个处理次序:离起点近(便宜)的先定案,离起点远(昂贵)的后定案。环不再是问题——绕环一圈只会让代价变大(前提:代价非负),所以一个已定案的便宜状态绝不会被后来发现的绕远路推翻。这个次序不是图结构给的(拓扑序),而是代价本身给的。


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)}$$

逐个不等号的理由:

  1. 红色路径”起点到 $t$”这一段至少花 $\text{PastCost}(t)$(最小代价的定义),而 $u$ 之后的剩余段代价非负,直接舍掉——非负性假设在此处用到
  2. 归纳假设:$t$ 弹出时优先级 = $\text{PastCost}(t)$;
  3. $t$ 被探索时更新过它的所有后继,所以 frontier 中 $u$ 的优先级 $\le \text{priority}(t) + \text{Cost}(t,u)$;
  4. 本轮弹出的是 $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),当且仅当

  1. 修改后的代价非负:$\text{Cost}(s, a) + h(\text{Succ}(s, a)) - h(s) \ge 0$;
  2. 终点处启发式为零:$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$ 越便宜但越松。设计目标是找一个恰好变得容易求解的松弛点——比如删墙之后恰好出现闭式解。

说明

  1. 闭式解:松弛后答案能直接写出公式——如删墙后的曼哈顿距离;
  2. 更小的搜索问题:松弛后状态空间坍缩到可以用 DP/UCS 预先精确求解——如电车问题中删掉”票数有限”的约束后,状态从(位置, 票数)坍缩回位置;
  3. 独立子问题:松弛把问题拆成互不干扰的小块——如 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。


参考资料