# Lecture 6 · UCS 与 A* 搜索

> **CS221: Artificial Intelligence — Principles and Techniques** · Stanford · Autumn 2025
> 📅 Oct 8 · 💻 [`ucs_astar.py`](https://github.com/stanford-cs221/autumn2025-lectures/blob/main/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`，四个节点、双向边构成多个环）：

```mermaid
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. 按**过去代价递增**的顺序处理状态。

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

---

## 2. 一致代价搜索（UCS）

### 2.1 核心思想

```mermaid
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 算法实现

```python
@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
                )
```

> [!note] 为什么需要 backpointer？
> 优先队列只告诉我们每个状态的最优**代价**，不告诉我们**路径**。`Backpointer` 记录"当前已知最优路径中，$s$ 的前一个状态与所迈的那一步"；到达终点后沿 backpointer 逆着走回起点，反转即得完整解。这与上一讲动态规划里"缓存中存整个 Solution"是同一信息的更省内存的存法。

### 2.3 优先队列实现

```python
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  # 队列为空
```

> [!note] 惰性删除（lazy deletion）
> 二叉堆不支持高效的"修改任意元素优先级"，所以 `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$ 的路径都不比它便宜。

```mermaid
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$

> [!warning] 易错点：负代价会毁掉 UCS
> 证明的第一步依赖"剩余路径代价非负可以舍掉"。若存在负代价边，"先弹出的一定更便宜"不再成立：某条暂时昂贵的路径可能在后面靠负边反超，而 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$）。

```python
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
                )
```

> [!note] 讲义的实现方式
> `ucs_astar.py` 里的写法更能体现"A* 只是换了代价的 UCS"：定义一个代理类 `ModifiedSearchProblem`，把每个后继的代价透明地替换为 $\text{Cost}'$，然后**原封不动地调用 `uniform_cost_search`**，最后回溯时再把代价还原为原始值。上面的实现把这层代理内联进了循环，两者等价。

### 3.3 例子：数轴搜索

数轴上的搜索问题：**起点 0，终点 2**，每步可左移或右移一格，代价均为 1（数轴无界，所以状态有无穷多个，还有来回走的环——正好 DP 处理不了）：

```python
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$——逐点合法，逐步违约。

> [!warning] 易错点
> 教科书里常说"$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$ 本身要便宜。

> [!tip] 直觉
> 把 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}})$ 是终点坐标。

```python
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}$$

```python
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。

> [!tip] 直觉
> 松弛法的妙处在于把"设计启发式"这个玄学问题转化为"选择删哪些约束"这个建模问题。删得越少，$h$ 越贴近真实（搜索省得越多），但松弛问题越难算；删得越多，$h$ 越便宜但越松。设计目标是找一个**恰好变得容易求解**的松弛点——比如删墙之后恰好出现闭式解。

> [!note] 松弛的三种典型形态（讲义分类）
> 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}$ 意味着先解原问题——本末倒置）。总时间 = 探索状态数 × 单状态开销，两头都要算账。

> [!warning] 易错点
> 可采纳性依赖于**动作集合**：曼哈顿距离在只能上下左右的网格上可采纳，可一旦允许对角线移动（一步同时改变行和列），走对角一步真实代价 1 而曼哈顿距离下降 2——高估了，不可采纳。换了问题的动作空间，启发式必须重新审查。

---

## 5. 可视化对比

🔗 **UCS 动画**：[youtube.com/watch?v=z6lUnb9ktkE](https://www.youtube.com/watch?v=z6lUnb9ktkE)
🔗 **A\* 动画**：[youtube.com/watch?v=huJEgJ82360](https://www.youtube.com/watch?v=huJEgJ82360)

```mermaid
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. 总结

```mermaid
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 · 强化学习]]）。

---

## 复习自测

> [!question]- Q1：为什么动态规划处理不了环，而 UCS 可以？两者各自靠什么确定"处理状态的顺序"？
> DP 递归计算 FutureCost，要求"先算后继再算自己"，这个依赖顺序就是图的拓扑序——有环则拓扑序不存在，递归无限循环。UCS 改算 PastCost，处理顺序由**代价本身**给出：按过去代价从小到大定案。代价非负时，绕环只会更贵，已定案的状态不会被推翻，所以环无害。

> [!question]- Q2：UCS 正确性证明中，"代价非负"这个假设用在哪一步？构造一个负边反例说明 UCS 会出错。
> 用在不等式链第一步：把红色路径中 $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——负边让"后到的路径"反超了已定案的结果。

> [!question]- Q3：一致性和可采纳性分别是什么？为什么本讲的 A* 实现需要一致性而不只是可采纳性？
> 可采纳性：$h(s) \le \text{FutureCost}(s)$，逐点不高估。一致性：$h(s) \le \text{Cost}(s,a) + h(s')$ 且 $h(\text{end}) = 0$，相邻状态间的估值落差不超过单步真实代价（更强，可推出可采纳性）。本讲 A* 基于 UCS，每个状态弹出即定案、不再重开；只有一致性能保证修改后代价非负、UCS 前提成立。仅可采纳的 $h$ 可能让某状态过早定案，需要允许重复展开的树搜索版本才能兜底。

> [!question]- Q4：网格问题允许对角线移动（一步代价 1，行列各变 1）后，曼哈顿距离还可采纳吗？该用什么替代？
> 不可采纳：对角走一步真实代价 1，但曼哈顿距离下降 2，即 $h$ 高估了真实未来代价（如从 $(0,0)$ 到 $(3,3)$ 真实最优 3 步，曼哈顿距离却是 6）。应改用切比雪夫距离 $\max\{|r - r_{\text{end}}|, |c - c_{\text{end}}|\}$——它是"允许对角移动且无墙"这一松弛的精确解，天然一致。

> [!question]- Q5：为什么"松弛问题的 FutureCost"自动是一致的启发式？取两个一致启发式的平均值 $\frac{h_1 + h_2}{2}$ 还一致吗？取 $\max$ 呢？
> 松弛问题本身是合法搜索问题，其 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](https://github.com/stanford-cs221/autumn2025-lectures/blob/main/ucs_astar.py)
- 📄 [Dijkstra's Algorithm (1956)](https://en.wikipedia.org/wiki/Dijkstra%27s_algorithm) — UCS 的起源
- 📄 [A Formal Basis for the Heuristic Determination of Minimum Cost Paths (Hart, Nilsson, Raphael, 1968)](https://ieeexplore.ieee.org/document/4082128) — A* 原始论文
- 🎥 [UCS 可视化](https://www.youtube.com/watch?v=z6lUnb9ktkE)
- 🎥 [A* 可视化](https://www.youtube.com/watch?v=huJEgJ82360)
- 📖 Russell & Norvig, *Artificial Intelligence: A Modern Approach*, Ch. 3 — 启发式搜索与 8 数码启发式的经典论述
