CS221 · 人工智能:原理与技术
Lecture 5 · 搜索算法 I
源文件:lecture-05.md
Lecture 5 · 搜索算法 I
CS221: Artificial Intelligence — Principles and Techniques · Stanford · Autumn 2025
📅 Oct 6 · 💻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)——思考、问题求解、规划——其本质特征是:答案不是一步查表得到的,而是通过在可能性空间中探索得到的。
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 · 语言模型)。
提示
反射式模型与搜索的分界线在于计算与问题难度的关系。反射式预测器对难题和易题花同样的算力;搜索则允许”难题多算、易题少算”。这正是近年大模型推理(reasoning model)的核心思想:与其把所有智能压进一次前向传播,不如在推理时展开一棵搜索树。
Rich Sutton 的苦涩教训
…利用计算能力的通用方法最终是最有效的,且优势巨大。
能够任意扩展的两种方法是搜索和学习。
Sutton 回顾了 70 年 AI 历史后指出:依赖人类知识的精巧方法短期占优,但最终总是被”能随算力增长而变强”的通用方法超越。而能随算力任意扩展的方法恰好只有两类:搜索(用算力探索更多可能性)与学习(用算力拟合更多数据)。
启示:搜索在现代 AI 中越来越重要(尤其是语言模型的测试时计算),但搜索与学习不是二选一——学习负责从数据中获得代价函数/策略/启发式,搜索负责在给定这些组件后找到好的动作序列。本课程后面会反复看到两者的协同(如 Lecture 8 · 强化学习 中学习价值函数、Lecture 11 · 博弈 II:TD 学习与同时博弈 中学习评估函数供搜索使用)。
1. 搜索问题(Search Problem)
1.1 形式化定义
一个搜索问题(search problem)由三个要素组成:起始状态、后继函数(给出每个状态可用的动作及其代价与产生的新状态)、终止判定。
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$ 是序列长度(本身也是待优化的——解可长可短)。
提示
这个接口刻意做到了建模与求解分离:SearchProblem 只描述”世界长什么样”(有哪些状态、动作、代价),完全不关心”怎么找解”。于是同一个求解算法(穷举、动态规划、束搜索……)可以不加修改地套用在旅行问题、魔方、语言生成等任何符合接口的问题上。这与前几讲”模型(假设类)与优化器分离”的思想一脉相承。
1.2 例子:旅行问题
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$ 的最少时间是多少?
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
例子
候选路径:全程步行 $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 状态设计的权衡
问题:如果电车票数量有限怎么办?只知道”当前位置”就不足以判断还能不能坐电车了。
解决:状态不只是位置,还要包含剩余票数:
@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
设计原则:
- 状态是对过去动作历史的充分摘要:它应包含”为了最优地选择未来动作所需的全部信息”,而无须记住具体是怎么走到这里的;
- 但不要包含多余信息——许多算法的复杂度与状态数量成正比,状态里每多一个维度,状态空间就可能按乘法膨胀。
注意
状态设计的两个极端都会坏事。信息不足(如票数有限却只记位置)会导致后继函数无法正确定义——同一个”状态”下可行动作不同;信息过多(如把完整路径塞进状态)会让每条路径都成为独立状态,缓存彻底失效,动态规划退化回穷举搜索。判断标准:未来只依赖状态,不依赖到达状态的方式——这正是马尔可夫性质,将在 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$ 出发的最优解 = 在所有”第一步”中,选择”第一步代价 + 之后走最优”总和最小的那个。这是最优子结构:最优解的任何后缀本身也是(子问题的)最优解。
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 实现
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 问题:指数爆炸
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$ 是解的最大深度(最长动作序列的长度)。指数的来源是:不同路径可以汇合到同一个状态,但穷举搜索”不认得”它来过,会把该状态下方的整棵子树反复重算。
注意
穷举搜索还隐含一个假设:搜索图是无环的(或解的深度有界)。若存在环(如既能前进又能后退),递归会无限展开。讲义中的补救办法是把”已走步数”计入状态并设置上限,但这会放大状态空间;系统性的解决方案是下一讲 Lecture 6 · UCS 与 A* 搜索 的一致代价搜索。
3. 动态规划(Dynamic Programming)
3.1 核心思想
动态规划 = 穷举搜索 + 缓存(memoization)
关键观察:$\text{FutureCost}(s)$ 只依赖 $s$ 本身,与”怎么走到 $s$”无关(这正是 1.3 节状态设计原则的回报)。所以同一个状态无论从多少条路径到达,其最优未来解都相同——算一次,缓存起来,之后直接查表。
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)$,因为每个状态展开一次全部后继)。
提示
指数加速的本质是把搜索树折叠成搜索图。穷举搜索看到的是一棵树——$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$ 次,返回其中总代价最小的一条。
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$ 才能撞上好解。
说明
Best-of-N 正是当下 LLM 测试时扩展的主力做法之一:对同一道题采样 $N$ 个回答,用验证器(单元测试、答案校验器、奖励模型)挑最好的。Large Language Monkeys(2024, arXiv:2407.21787)系统地测量了这条路线:问题的覆盖率(至少采到一个正确解的概率)随 $N$ 增大近似按幂律持续提升,采样上千次仍未饱和——“苦涩教训”在测试时计算上的直接体现。
4.3 束搜索(Beam Search)
思路:不再各条路径独立到底,而是逐步齐头并进:维护一个大小为 beam_width(束宽,记 $k$)的候选集合,每一步把所有候选各自展开全部后继,再从展开结果中只保留代价最小的 $k$ 个。
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
可视化:
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)$,与束宽线性相关;
- ❌ 无最优性保证:剪枝依据的是前缀代价,而一个”开局昂贵、后程便宜”的最优路径可能在早期就被剪掉,且永远无法挽回。
提示
束宽 $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_{ 于是一条完整生成 $x_{1:T}$ 的总代价为 $$\text{cost}(x_{1:T}) = \sum_{t=1}^{T} -\log p_\theta(x_t \mid x_{ 其中 $p_\theta$ 是参数为 $\theta$ 的语言模型给出的条件概率,$x_{ 说明 测试时计算的威力: 注意 对语言模型而言,”总代价最小(似然最大)的序列”未必是”最好的回答”——极端追求高似然会产出空洞、重复的文本(这也是实践中很少用大束宽做开放生成的原因)。所以现代做法是把外部信号(验证器、奖励模型)注入代价函数,让搜索优化”正确/有用”而不仅是”像人话”。 关键要点: 下一讲预告:如果图中有环(A → B → C → A),动态规划的递归就没有了合法的计算顺序,怎么办?→ Lecture 6 · UCS 与 A* 搜索 题目 状态必须包含”最优选择未来动作所需的全部信息”:票数决定了电车动作是否可用,不放进状态则后继函数无法定义。但把完整路径放进状态会让每条路径成为独立状态——动态规划的缓存永远不会命中(没有两条路径”汇合”到同一状态),复杂度退化回穷举搜索的指数级。状态要充分且最小。 题目 代价 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$)反而更贵,因为第一步步行比电车便宜——最优解需要全局比较,贪心不行。 题目 因为 $\text{FutureCost}(s)$ 只依赖状态 $s$ 本身,与到达路径无关,所以每个状态只需计算一次、之后查缓存。前提一:图无环(递归有良定义的展开顺序);前提二:状态可哈希且数量能装进内存,并且确实存在大量路径汇合到同一状态——否则缓存无用武之地。 题目 Best-of-N 占优的情形:需要大规模并行(rollout 相互独立)、前缀代价对最终质量预示性差(中途剪枝容易误杀)、有可靠的终局验证器可以挑选。束搜索占优的情形:前缀代价信息量大(坏开头基本注定坏结尾)、算力预算固定且想把它集中花在有希望的前缀上。极端情形对照:束宽 1 的束搜索 = 贪心;N 无穷的 best-of-N 渐近最优但代价不可控。 题目 总代价是负对数似然之和,最小化它等价于最大化整条序列的概率 $p_\theta(x_{1:T})$。但”模型最可能说的话”未必是最好的回答:高似然序列往往安全、重复、空洞(似然与人类偏好不一致)。因此实践中要么用采样引入多样性,要么把验证器/奖励模型加进代价,让搜索优化正确性而非纯似然。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
search.py 用一个小模型(Qwen3-0.6B)实际跑通了这个问题,并且多加了一层:一个验证器(verifier),对通过校验的完整生成额外奖励(代价 $-100$)。这样搜索目标就从”模型认为最像的话”变成”既流畅又被验证为正确的话”——这正是”采样 + 验证”式测试时计算的最小可运行范例。5.2 应用束搜索
problem = LanguageModelSearchProblem(prompt="The capital of France is")
solution, _ = beam_search(problem, beam_width=5)
print(solution.steps)
# 可能输出:"The capital of France is Paris."
6. 总结
mindmap
root((搜索算法))
建模
SearchProblem 接口
状态 / 动作 / 代价
目标:最小化总代价
精确方法
穷举搜索:指数时间
动态规划:线性于状态数
缓存 = 指数加速
近似方法
Best-of-N:随机采样取最优
束搜索:每步保留前 k 个候选
权衡:速度 vs 质量
应用
路径规划
语言模型生成
测试时计算
复习自测
参考资料