# Lecture 5 · 搜索算法 I

> **CS221: Artificial Intelligence — Principles and Techniques** · Stanford · Autumn 2025
> 📅 Oct 6 · 💻 [`search.py`](https://github.com/stanford-cs221/autumn2025-lectures/blob/main/search.py)

---

## 承上启下

前四讲（[[Lecture 1 · 张量、梯度与监督学习]] 到 [[Lecture 4 · 深度学习]]）解决的是**机器学习**：给定训练数据 $\{(x_i, y_i)\}$，通过优化损失函数得到一个预测器 $f_w$。这类预测器是**反射式（reflex-based）**的——输入进来，一次前向计算，输出就出去，中间没有"思考"的过程。

本讲开始进入课程的第二大模块：**基于状态的模型（state-based models）**。搜索是其中最基础的一种——在**确定性**世界中，寻找一个最优的**动作序列**。下一讲 [[Lecture 6 · UCS 与 A* 搜索]] 将把本讲的精确算法推广到有环图；再往后，[[Lecture 7 · 马尔可夫决策过程]] 会引入随机性，[[Lecture 10 · 博弈 I：Minimax 与 α-β 剪枝]] 会引入对手。

---

## 从学习到推理

**上周（机器学习）**：

- 学习算法：训练数据 `{(输入, 输出)}` → 预测器
- 预测器：输入 → 输出（回归输出实数，分类输出类别）

预测器是**反射式**的（percepts → actions）：每次预测的计算量是固定的，不会因为问题更难而"多想一会儿"。但现实世界的很多问题需要**推理（reasoning）**——思考、问题求解、规划——其本质特征是：答案不是一步查表得到的，而是通过在可能性空间中**探索**得到的。

```mermaid
graph LR
    P["感知 Perceive"] --> R["推理 Reason"]
    R --> A["行动 Act"]
    A -.反馈.-> L["学习 Learn"]
    L -.改进.-> R

    style R fill:#ffe0b2,stroke:#e65100
    style L fill:#e1f5fe,stroke:#0277bd
```

**本周主题：搜索**（确定性世界中的一种推理形式）。

**典型例子**（它们的共同点是：解是一个**序列**，而非单个标签）：

- 求解魔方：需要找到一系列旋转步骤，任何单步都不构成解；
- 从 A 点到 B 点的最短路径：需要输出一条完整路线，且希望总耗时最小；
- 语言模型的测试时计算（test-time compute）：生成文本本质上是在逐 token 地搜索一条"好"的词序列（见第 5 节与 [[Lecture 17 · 语言模型]]）。

> [!tip] 直觉
> 反射式模型与搜索的分界线在于**计算与问题难度的关系**。反射式预测器对难题和易题花同样的算力；搜索则允许"难题多算、易题少算"。这正是近年大模型推理（reasoning model）的核心思想：与其把所有智能压进一次前向传播，不如在推理时展开一棵搜索树。

---

## Rich Sutton 的苦涩教训

🔗 [The Bitter Lesson (2019)](http://www.incompleteideas.net/IncIdeas/BitterLesson.html)

> *...利用计算能力的通用方法最终是最有效的，且优势巨大。*
> *能够任意扩展的两种方法是**搜索和学习**。*

Sutton 回顾了 70 年 AI 历史后指出：依赖人类知识的精巧方法短期占优，但最终总是被"能随算力增长而变强"的通用方法超越。而能随算力任意扩展的方法恰好只有两类：**搜索**（用算力探索更多可能性）与**学习**（用算力拟合更多数据）。

**启示**：搜索在现代 AI 中越来越重要（尤其是语言模型的测试时计算），但搜索与学习不是二选一——学习负责从数据中获得代价函数/策略/启发式，搜索负责在给定这些组件后找到好的动作序列。本课程后面会反复看到两者的协同（如 [[Lecture 8 · 强化学习]] 中学习价值函数、[[Lecture 11 · 博弈 II：TD 学习与同时博弈]] 中学习评估函数供搜索使用）。

---

## 1. 搜索问题（Search Problem）

### 1.1 形式化定义

一个**搜索问题（search problem）**由三个要素组成：起始状态、后继函数（给出每个状态可用的动作及其代价与产生的新状态）、终止判定。

```python
class SearchProblem:
    def start_state(self) -> Any:
        """返回初始状态"""
        raise NotImplementedError
    
    def successors(self, state: Any) -> list[Step]:
        """从 state 出发的所有后继：(动作, 代价, 新状态)"""
        raise NotImplementedError
    
    def is_end(self, state: Any) -> bool:
        """state 是否为终止状态"""
        raise NotImplementedError

@dataclass(frozen=True)
class Step:
    action: Any     # 采取的动作
    cost: float     # 该动作的代价
    state: Any      # 动作后到达的状态
```

用数学记号写出来：搜索问题是四元组 $(s_{\text{start}}, \text{Succ}, \text{IsEnd}, \text{Cost})$，其中 $s_{\text{start}}$ 是起始状态，$\text{Succ}(s)$ 给出状态 $s$ 的全部后继，$\text{IsEnd}(s)$ 判断是否终止，$\text{Cost}(s, a) \ge 0$ 是在状态 $s$ 采取动作 $a$ 的代价。

**目标**：找到一个**解（solution）**——从起始状态到某个终止状态的动作序列 $(a_1, \dots, a_T)$，使**总代价**最小：

$$\min_{a_1, \dots, a_T} \ \sum_{t=1}^{T} \text{Cost}(s_{t-1}, a_t), \quad \text{s.t. } s_0 = s_{\text{start}},\ \text{IsEnd}(s_T)$$

这里 $s_t$ 是执行前 $t$ 个动作后到达的状态，$T$ 是序列长度（本身也是待优化的——解可长可短）。

> [!tip] 直觉
> 这个接口刻意做到了**建模与求解分离**：`SearchProblem` 只描述"世界长什么样"（有哪些状态、动作、代价），完全不关心"怎么找解"。于是同一个求解算法（穷举、动态规划、束搜索……）可以不加修改地套用在旅行问题、魔方、语言生成等任何符合接口的问题上。这与前几讲"模型（假设类）与优化器分离"的思想一脉相承。

### 1.2 例子：旅行问题

```mermaid
graph LR
    1 --> |walk, 1| 2
    1 --> |tram, 2| 2["2 (×2)"]
    2 --> |walk, 1| 3
    2 --> |tram, 2| 4["4 (×2)"]
    3 --> |walk, 1| 4
    4 --> |walk, 1| 5
    4 --> |tram, 2| 8["8 (×2)"]
    
    style 1 fill:#c8e6c9,stroke:#2e7d32
    style 8 fill:#ffe0b2,stroke:#e65100
```

- 街道上有编号 1 到 $n$ 的街区；
- 步行从 $i$ 到 $i+1$ 需要 1 分钟；
- 魔法电车从 $i$ 到 $2i$ 需要 2 分钟；
- 问题：从 1 到 $n$ 的最少时间是多少？

```python
class TravelSearchProblem(SearchProblem):
    def __init__(self, num_locs: int):
        self.num_locs = num_locs
    
    def start_state(self) -> int:
        return 1
    
    def successors(self, state: int) -> list[Step]:
        successors = []
        if state + 1 <= self.num_locs:
            successors.append(Step(action="walk", cost=1, state=state + 1))
        if 2 * state <= self.num_locs:
            successors.append(Step(action="tram", cost=2, state=2 * state))
        return successors
    
    def is_end(self, state: int) -> bool:
        return state == self.num_locs
```

> [!example] 手算一下（num_locs = 8）
> 候选路径：全程步行 $1 \to 2 \to \cdots \to 8$ 代价 7；全程电车 $1 \xrightarrow{2} 2 \xrightarrow{2} 4 \xrightarrow{2} 8$ 代价 6；混合 $1 \xrightarrow{\text{walk},1} 2 \xrightarrow{\text{tram},2} 4 \xrightarrow{\text{tram},2} 8$ 代价 **5**——最优。可见"贪心地一直坐电车"并不最优，第一步步行更便宜，这正是需要系统性搜索而非局部贪心的原因。

### 1.3 状态设计的权衡

**问题**：如果电车票数量有限怎么办？只知道"当前位置"就不足以判断还能不能坐电车了。

**解决**：状态不只是位置，还要包含剩余票数：

```python
@dataclass(frozen=True)
class TravelState:
    loc: int        # 当前位置
    tickets: int    # 剩余票数

class LimitedTravelSearchProblem(SearchProblem):
    def start_state(self) -> TravelState:
        return TravelState(loc=1, tickets=self.starting_tickets)
    
    def successors(self, state: TravelState) -> list[Step]:
        successors = []
        # 步行总是可以
        if state.loc + 1 <= self.num_locs:
            successors.append(Step(
                action="walk", cost=1,
                state=TravelState(loc=state.loc + 1, tickets=state.tickets)
            ))
        # 电车需要有票
        if state.tickets > 0 and 2 * state.loc <= self.num_locs:
            successors.append(Step(
                action="tram", cost=2,
                state=TravelState(loc=2 * state.loc, tickets=state.tickets - 1)
            ))
        return successors
```

**设计原则**：

- 状态是**对过去动作历史的充分摘要**：它应包含"为了最优地选择未来动作所需的全部信息"，而无须记住具体是怎么走到这里的；
- 但不要包含多余信息——许多算法的复杂度与状态数量成正比，状态里每多一个维度，状态空间就可能按乘法膨胀。

> [!warning] 易错点
> 状态设计的两个极端都会坏事。信息不足（如票数有限却只记位置）会导致后继函数无法正确定义——同一个"状态"下可行动作不同；信息过多（如把完整路径塞进状态）会让每条路径都成为独立状态，缓存彻底失效，动态规划退化回穷举搜索。判断标准：**未来只依赖状态，不依赖到达状态的方式**——这正是马尔可夫性质，将在 [[Lecture 7 · 马尔可夫决策过程]] 中正式登场。

---

## 2. 穷举搜索（Exhaustive Search）

### 2.1 核心概念：未来代价

定义**未来代价** $\text{FutureCost}(s)$：从状态 $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'$。公式的含义是：从 $s$ 出发的最优解 = 在所有"第一步"中，选择"第一步代价 + 之后走最优"总和最小的那个。这是**最优子结构**：最优解的任何后缀本身也是（子问题的）最优解。

```mermaid
graph TD
    S["state"] --> S1["successor 1<br/>cost + future_cost"]
    S --> S2["successor 2<br/>cost + future_cost"]
    S --> S3["successor 3<br/>cost + future_cost"]
    S1 --> F["取最小值"]
    S2 --> F
    S3 --> F
    F --> R["future_cost(state)"]
    
    style R fill:#ffe0b2,stroke:#e65100
```

### 2.2 实现

```python
def exhaustive_search(problem: SearchProblem) -> tuple[Solution, int]:
    num_explored = 0
    
    def future_solution(state: Any) -> Solution:
        nonlocal num_explored
        num_explored += 1
        
        if problem.is_end(state):
            return Solution(steps=[])  # 基础情况
        
        # 尝试所有后继
        successors = problem.successors(state)
        solutions = []
        for first_step in successors:
            future_steps = future_solution(first_step.state).steps
            solutions.append(Solution(steps=[first_step] + future_steps))
        
        # 返回最优解
        return min(solutions, key=lambda x: x.cost)
    
    return future_solution(problem.start_state()), num_explored
```

### 2.3 问题：指数爆炸

```python
problem = TravelSearchProblem(num_locs=4)
solution, num_explored = exhaustive_search(problem)
# num_explored = 9 > 4（状态被重复探索）

problem = TravelSearchProblem(num_locs=17)
solution, num_explored = exhaustive_search(problem)
# num_explored = 32563（指数级增长！）
```

**时间复杂度**：$O(b^D)$，其中 $b$ 是**分支因子**（每个状态平均的后继数，旅行问题中 $b \le 2$），$D$ 是解的最大深度（最长动作序列的长度）。指数的来源是：不同路径可以汇合到同一个状态，但穷举搜索"不认得"它来过，会把该状态下方的整棵子树反复重算。

> [!warning] 易错点
> 穷举搜索还隐含一个假设：搜索图是**无环的**（或解的深度有界）。若存在环（如既能前进又能后退），递归会无限展开。讲义中的补救办法是把"已走步数"计入状态并设置上限，但这会放大状态空间；系统性的解决方案是下一讲 [[Lecture 6 · UCS 与 A* 搜索]] 的一致代价搜索。

---

## 3. 动态规划（Dynamic Programming）

### 3.1 核心思想

**动态规划 = 穷举搜索 + 缓存（memoization）**

关键观察：$\text{FutureCost}(s)$ 只依赖 $s$ 本身，与"怎么走到 $s$"无关（这正是 1.3 节状态设计原则的回报）。所以同一个状态无论从多少条路径到达，其最优未来解都相同——算一次，缓存起来，之后直接查表。

```python
def dynamic_programming(problem: SearchProblem) -> tuple[Solution, int, dict]:
    num_explored = 0
    cache: dict[Any, Solution] = {}  # 状态 → 最优解
    
    def future_solution(state: Any) -> Solution:
        # 检查缓存
        if state in cache:
            return cache[state]
        
        nonlocal num_explored
        num_explored += 1
        
        if problem.is_end(state):
            best_solution = Solution(steps=[])
        else:
            successors = problem.successors(state)
            solutions = []
            for first_step in successors:
                future_steps = future_solution(first_step.state).steps
                solutions.append(Solution(steps=[first_step] + future_steps))
            best_solution = min(solutions, key=lambda x: x.cost)
        
        # 缓存结果
        cache[state] = best_solution
        return best_solution
    
    solution = future_solution(problem.start_state())
    return solution, num_explored, cache
```

### 3.2 效果对比

| num_locs | 穷举搜索探索数 | 动态规划探索数 |
|:---:|:---:|:---:|
| 4 | 9 | 4 |
| 10 | ~1000+ | 10 |
| 17 | 32,563 | 17 |
| 100 | 不可行 | 100 |

动态规划将探索数从**指数级** $O(b^D)$ 降为**状态数** $O(|\mathcal{S}|)$（$\mathcal{S}$ 为状态集合；更精确地说，时间为 $O(|\mathcal{S}| \cdot b)$，因为每个状态展开一次全部后继）。

> [!tip] 直觉
> 指数加速的本质是把**搜索树折叠成搜索图**。穷举搜索看到的是一棵树——$2^D$ 片叶子；动态规划看到的是一张图——只有 $|\mathcal{S}|$ 个节点。折叠的许可来自两个条件的合取：**最优子结构**（子问题的解可复用）+ **重叠子问题**（大量路径汇合到同一状态）。这一思想由 Bellman 在 1950 年代提出，其连续/随机版本就是 [[Lecture 7 · 马尔可夫决策过程]] 中的价值迭代。

### 3.3 何时使用动态规划？

✅ **适用场景**：

- 状态数量适中，缓存能装进内存；
- 存在大量到达同一状态的不同路径——缓存的每次命中都省掉一整棵子树。

❌ **不适用场景**：

- 状态数量巨大（如围棋约 $10^{170}$ 种局面），缓存本身就存不下；
- 几乎每条路径都通向不同状态（如状态里含完整生成历史的语言模型），缓存永远不命中，DP 退化为穷举；
- 图中有环——递归没有良定义的计算顺序（详见 [[Lecture 6 · UCS 与 A* 搜索]]）。

---

## 4. 近似搜索

### 4.1 问题

精确搜索（穷举/动态规划）的时间复杂度至少是 $O(|\mathcal{S}|)$——每个状态至少要碰一次。

但如果状态本身是组合对象，状态数会爆炸：

- 状态是"位置的集合"：$n$ 个位置就有 $2^n$ 个状态；
- 状态是"已生成的词序列"：词表大小为 $V$、长度为 $T$ 时有 $V^T$ 个状态。

此时任何精确算法都**不可行**。

**解决方案**：近似搜索——只探索状态空间的一小部分，用启发式规则决定看哪里。代价是可能错过最优解，但在精确解无望的问题上，"较好的解"远胜于"没有解"。

### 4.2 Best-of-N 搜索

**思路**：用某个策略（最简单的是均匀随机）从头到尾走一条完整路径（称为一次 **rollout**），独立重复 $N$ 次，返回其中总代价最小的一条。

```python
def uniform_policy(problem: SearchProblem, state: Any) -> Step:
    """从 state 的后继中均匀随机选一个"""
    successors = problem.successors(state)
    return random.choice(successors)

def rollout(problem: SearchProblem, policy, max_steps: int = 10) -> Solution:
    """从起始状态按照 policy 采样一条路径"""
    state = problem.start_state()
    steps = []
    while not problem.is_end(state) and len(steps) < max_steps:
        step = policy(problem, state)
        steps.append(step)
        state = step.state
    return Solution(steps=steps)

def best_of_n(problem: SearchProblem, policy, num_candidates: int) -> tuple[Solution, int]:
    solutions = [rollout(problem, policy) for _ in range(num_candidates)]
    return min(solutions, key=lambda x: x.cost), num_candidates
```

**性质**：

- ✅ **理论保证**：只要每条最优路径被采到的概率非零，$N \to \infty$ 时以概率 1 找到最优解；
- ✅ **易并行化**：$N$ 条 rollout 相互独立，可以完全并行地跑在不同机器上，采样效率随算力线性扩展；
- ✅ **只需能"打分"**：不要求逐步的引导信号，只要事后能比较完整解的好坏（一个验证器/代价函数）即可；
- ❌ 完全不利用问题结构：好路径与坏路径都被平等采样，最坏情况下需要指数级的 $N$ 才能撞上好解。

> [!note] 与大模型的联系
> Best-of-N 正是当下 LLM 测试时扩展的主力做法之一：对同一道题采样 $N$ 个回答，用验证器（单元测试、答案校验器、奖励模型）挑最好的。*Large Language Monkeys*（2024, [arXiv:2407.21787](https://arxiv.org/abs/2407.21787)）系统地测量了这条路线：问题的覆盖率（至少采到一个正确解的概率）随 $N$ 增大近似按幂律持续提升，采样上千次仍未饱和——"苦涩教训"在测试时计算上的直接体现。

### 4.3 束搜索（Beam Search）

**思路**：不再各条路径独立到底，而是**逐步齐头并进**：维护一个大小为 `beam_width`（束宽，记 $k$）的候选集合，每一步把所有候选各自展开全部后继，再从展开结果中只保留代价最小的 $k$ 个。

```python
def beam_search(problem: SearchProblem, beam_width: int) -> tuple[Solution, int]:
    num_explored = 0
    candidates = [Solution(steps=[])]  # 从空解开始
    
    while True:
        # 扩展所有候选
        new_candidates = []
        for candidate in candidates:
            state = candidate.steps[-1].state if candidate.steps else problem.start_state()
            if problem.is_end(state):
                new_candidates.append(candidate)
            else:
                for successor in problem.successors(state):
                    new_candidates.append(Solution(steps=candidate.steps + [successor]))
                    num_explored += 1
        
        # 保留最好的 beam_width 个
        new_candidates.sort(key=lambda x: x.cost)
        candidates = new_candidates[:beam_width]
        
        # 如果所有候选都到终点了，停止
        if all(problem.is_end(c.steps[-1].state) for c in candidates):
            break
    
    return candidates[0], num_explored
```

**可视化**：

```mermaid
graph TD
    S["起点"] --> A1["候选1"]
    S --> A2["候选2"]
    S --> A3["候选3"]
    S -.被剪枝.-> X1["候选4 ×"]
    
    A1 --> B1["候选1.1"]
    A1 --> B2["候选1.2"]
    A2 --> B3["候选2.1"]
    A3 -.被剪枝.-> X2["候选3.1 ×"]
    
    style S fill:#c8e6c9,stroke:#2e7d32
    style X1 fill:#ffcdd2,stroke:#c62828
    style X2 fill:#ffcdd2,stroke:#c62828
```

**性质**：

- ✅ 比 best-of-N 更有结构：坏前缀在中途就被淘汰，算力集中在当前看起来最有希望的前缀上；
- ✅ 计算量可控：每步至多展开 $k \cdot b$ 个候选，总时间约 $O(k \cdot b \cdot D)$，与束宽线性相关；
- ❌ **无最优性保证**：剪枝依据的是前缀代价，而一个"开局昂贵、后程便宜"的最优路径可能在早期就被剪掉，且永远无法挽回。

> [!tip] 直觉
> 束宽 $k$ 是一个在"贪心"与"穷举"之间连续调节的旋钮：$k=1$ 时束搜索退化为**贪心搜索**（每步只选局部最优）；$k \to \infty$ 时退化为逐层穷举。与 best-of-N 相比，束搜索用"中途剪枝"换取了对好前缀的聚焦，但也因此丧失了 best-of-N 的两个优点——完全并行与渐近最优保证。两者没有绝对优劣，取决于前缀代价对最终质量的预示能力。

---

## 5. 测试时计算：语言模型中的搜索

### 5.1 语言生成即搜索

语言模型生成文本 = 在巨大的**词序列空间**中搜索。状态是"提示词 + 已生成的前缀"，动作是"选择下一个 token"，单步代价取该 token 的**负对数概率**：

$$c_t = -\log p_\theta(x_t \mid x_{<t})$$

于是一条完整生成 $x_{1:T}$ 的总代价为

$$\text{cost}(x_{1:T}) = \sum_{t=1}^{T} -\log p_\theta(x_t \mid x_{<t}) = -\log p_\theta(x_{1:T})$$

其中 $p_\theta$ 是参数为 $\theta$ 的语言模型给出的条件概率，$x_{<t}$ 表示前 $t-1$ 个 token。**最小化总代价 = 最大化整条序列的对数似然**——搜索的语言与概率的语言在这里严丝合缝地对上了。

```python
class LanguageModelSearchProblem(SearchProblem):
    def start_state(self) -> str:
        return self.prompt  # 起始状态是提示词
    
    def successors(self, state: str) -> list[Step]:
        # 用模型计算下一个 token 的概率
        input_ids = self.tokenizer.encode(state, return_tensors="pt")
        with torch.no_grad():
            logits = self.model(input_ids).logits[0, -1, :]
        probs = F.softmax(logits, dim=-1)
        
        # 返回所有可能的下一个 token（按概率排序）
        successors = []
        for token_id in torch.argsort(probs, descending=True)[:self.top_k]:
            next_token = self.tokenizer.decode([token_id])
            cost = -torch.log(probs[token_id]).item()  # 负对数概率
            successors.append(Step(
                action=next_token,
                cost=cost,
                state=state + next_token
            ))
        return successors
    
    def is_end(self, state: str) -> bool:
        return state.endswith(".") or len(state) > 100
```

> [!note] 讲义中的完整版本
> `search.py` 用一个小模型（Qwen3-0.6B）实际跑通了这个问题，并且多加了一层：一个**验证器（verifier）**，对通过校验的完整生成额外奖励（代价 $-100$）。这样搜索目标就从"模型认为最像的话"变成"既流畅又被验证为正确的话"——这正是"采样 + 验证"式测试时计算的最小可运行范例。

### 5.2 应用束搜索

```python
problem = LanguageModelSearchProblem(prompt="The capital of France is")
solution, _ = beam_search(problem, beam_width=5)
print(solution.steps)
# 可能输出："The capital of France is Paris."
```

**测试时计算的威力**：

- 同一个模型，投入更多推理算力（更大的束宽、更多的采样次数、更长的思维链）通常能换来更好的输出质量；
- OpenAI o1 系列等推理模型把这条曲线做成了产品：训练时学到策略，测试时大量"思考"；
- 代价是延迟与费用：质量与速度的权衡从训练时延伸到了推理时（LLM 推理的系统层面优化见 CS336 的 [[Lecture 10 · 推理]]）。

> [!warning] 易错点
> 对语言模型而言，"总代价最小（似然最大）的序列"未必是"最好的回答"——极端追求高似然会产出空洞、重复的文本（这也是实践中很少用大束宽做开放生成的原因）。所以现代做法是把**外部信号**（验证器、奖励模型）注入代价函数，让搜索优化"正确/有用"而不仅是"像人话"。

---

## 6. 总结

```mermaid
mindmap
  root((搜索算法))
    建模
      SearchProblem 接口
      状态 / 动作 / 代价
      目标：最小化总代价
    精确方法
      穷举搜索：指数时间
      动态规划：线性于状态数
      缓存 = 指数加速
    近似方法
      Best-of-N：随机采样取最优
      束搜索：每步保留前 k 个候选
      权衡：速度 vs 质量
    应用
      路径规划
      语言模型生成
      测试时计算
```

**关键要点**：

- **搜索问题**用（状态、动作、代价、终止条件）四要素形式化了"找最优动作序列"，实现了建模与算法的解耦；
- **穷举搜索**沿 $\text{FutureCost}$ 递归展开整棵搜索树，能找到精确解，但时间 $O(b^D)$ 指数爆炸；
- **动态规划** = 穷举搜索 + 缓存：利用"未来只依赖状态"这一性质把树折叠成图，复杂度降为 $O(|\mathcal{S}|)$，前提是无环且状态数可容纳；
- 状态数巨大时改用**近似搜索**：best-of-N（独立采样、可并行、渐近最优）与束搜索（逐步剪枝、聚焦好前缀、无保证）；
- **学习 + 搜索**协同：代价（如 $-\log p_\theta$）从数据中学习，搜索在给定代价下找最优序列——这是测试时计算的原型。

**下一讲预告**：如果图中有环（A → B → C → A），动态规划的递归就没有了合法的计算顺序，怎么办？→ [[Lecture 6 · UCS 与 A* 搜索]]

---

## 复习自测

> [!question]- Q1：电车票有限的旅行问题里，为什么必须把剩余票数放进状态？如果再把"已经走过的完整路径"也放进状态会怎样？
> 状态必须包含"最优选择未来动作所需的全部信息"：票数决定了电车动作是否可用，不放进状态则后继函数无法定义。但把完整路径放进状态会让每条路径成为独立状态——动态规划的缓存永远不会命中（没有两条路径"汇合"到同一状态），复杂度退化回穷举搜索的指数级。状态要**充分且最小**。

> [!question]- Q2：`TravelSearchProblem(num_locs=8)` 的最优解代价是多少？给出一条最优路径。
> 代价 5。例如 $1 \xrightarrow{\text{walk},1} 2 \xrightarrow{\text{tram},2} 4 \xrightarrow{\text{tram},2} 8$。全程电车（$1\to2\to4\to8$，代价 $2+2+2=6$）反而更贵，因为第一步步行比电车便宜——最优解需要全局比较，贪心不行。

> [!question]- Q3：动态规划为什么能把 $O(b^D)$ 降到 $O(|\mathcal{S}|)$？这个加速依赖哪两个前提？
> 因为 $\text{FutureCost}(s)$ 只依赖状态 $s$ 本身，与到达路径无关，所以每个状态只需计算一次、之后查缓存。前提一：图无环（递归有良定义的展开顺序）；前提二：状态可哈希且数量能装进内存，并且确实存在大量路径汇合到同一状态——否则缓存无用武之地。

> [!question]- Q4：Best-of-N 与束搜索各在什么情形下更占优？
> Best-of-N 占优的情形：需要大规模并行（rollout 相互独立）、前缀代价对最终质量预示性差（中途剪枝容易误杀）、有可靠的终局验证器可以挑选。束搜索占优的情形：前缀代价信息量大（坏开头基本注定坏结尾）、算力预算固定且想把它集中花在有希望的前缀上。极端情形对照：束宽 1 的束搜索 = 贪心；N 无穷的 best-of-N 渐近最优但代价不可控。

> [!question]- Q5：语言模型生成中令单步代价为 $-\log p_\theta(x_t \mid x_{<t})$，那么"最小化总代价"等价于什么？为什么实践中不总是想要这个最优解？
> 总代价是负对数似然之和，最小化它等价于最大化整条序列的概率 $p_\theta(x_{1:T})$。但"模型最可能说的话"未必是最好的回答：高似然序列往往安全、重复、空洞（似然与人类偏好不一致）。因此实践中要么用采样引入多样性，要么把验证器/奖励模型加进代价，让搜索优化正确性而非纯似然。

---

## 参考资料

- 💻 [search.py](https://github.com/stanford-cs221/autumn2025-lectures/blob/main/search.py)
- 📖 [The Bitter Lesson (Rich Sutton, 2019)](http://www.incompleteideas.net/IncIdeas/BitterLesson.html)
- 📄 [Large Language Monkeys: Scaling Inference Compute with Repeated Sampling (2024)](https://arxiv.org/abs/2407.21787) — best-of-N 在 LLM 上的覆盖率随 N 幂律增长
- 📄 动态规划起源：Richard Bellman (1950s)
