搜索这件事,说到底只有一句话: 把问题写成「状态 + 转移」,再从一个状态出发,沿着转移找另一个状态。 四种走法的差别只在一件事上:下一个看谁。按层看是广度优先,一头扎到底是深度优先, 按代价看是 Dijkstra,再给个方向感就是 A*。
上一节把关系存成了图。 这一节让图动起来——从一个点出发,沿着边走,找另一个点。听起来像导航, 其实就是「求解」这件事的本来面目。 这一节是整本书里最像「算法」的一节,但它要讲的结论比算法大: 优化问题、下棋、规划、推理,都能写成「在状态图上找一条路」。
一、什么是「状态 + 转移」
先看一个具体的东西:迷宫。 状态就是「我站在哪个格子」,转移就是「往上下左右走一格」。这两句话一写出来, 图就有了——每个格子是一个点,能走通的两个格子之间连一条边。 这个套路能套在任何地方,只要你能回答三个问题:
| 要素 | 要回答什么 | 八数码的例子 |
|---|---|---|
| 状态 | 「当前局面」用什么表示 | 九个格子里 1 到 8 和空格的位置 |
| 转移 | 一步能变成哪些局面 | 空格和上下左右相邻的块换位 |
| 目标 | 哪个局面算成功 | 123456780 这个排列 |
| 代价 | 走一步算多少 | 每步都算 1 |
| 只要这四行填得出来,后面全部算法都能直接用,不用改一行。迷宫、八数码、下棋、拼图、调度、 | ||
| 路径规划——看着毫不相干,底下是同一道题。 | ||
| 这也是「状态空间搜索」这个名字的由来:所有可能的局面构成一个巨大的图, | ||
| 你的任务是在里面找一条从起点到终点的路。 |
二、两种走法:排队还是插队
搜索的过程永远是同一套循环: 从「待看的点」里取出一个,看它是不是终点,不是就把它的邻居加进「待看的点」。 差别只在「待看的点」用哪种容器。
| 容器 | 取出顺序 | 叫法 | 性质 |
|---|---|---|---|
| 队列(先进先出) | 最早进来的先看 | 广度优先 | 步数最少 |
| 栈(后进先出) | 最晚进来的先看 | 深度优先 | 内存只跟深度有关 |
| 优先队列(最小代价先看) | 代价最小的先看 | Dijkstra | 代价最小 |
| 队列保证「近的先看」,所以第一次碰到终点时,那条路的步数一定最少。Demo 里广度优先走了 58 步, | |||
| 代价 176。注意「步数最少」不等于「代价最小」——它把便宜的格子和贵的格子一视同仁。 | |||
| 栈保证「远的先看」,于是一头扎到底,撞墙才回头。Demo 里深度优先走出来的路150 步, | |||
| 代价 423——比广度优先长了一倍多,代价翻了一倍半。但它有个实在的好处: | |||
| 不用记住所有待看的点,内存只跟栈的深度有关。所以「有没有路」这种问题用它, | |||
| 「哪条路最好」就不能用它。 |
三、把每格的代价算进去
迷宫里的格子不是一样好走的:有的地是平路,有的是泥。Demo 给每格随机配了 1 到 5 的代价, 于是「步数最少」和「总代价最小」变成了两个不同的问题。
| 找法 | 看了多少格 | 路径步数 | 路径代价 |
|---|---|---|---|
| 广度优先 | 685 | 58 | 176 |
| 深度优先 | 468 | 150 | 423 |
| Dijkstra | 685 | 60 | 152 |
| Dijkstra 多走了两步,代价从 176 降到 152。这就是「绕远走便宜的路」:多两步, | |||
| 但避开了那些代价 5 的泥地。 | |||
| 它的做法只改了一处: | |||
| 「待看的点」不再按进来的先后排,而是按「目前已花的代价」排。代价最小的先出队, | |||
| 出队的点代价就再也不会变——这就是它能保证最优的原因。 | |||
| 代价是它翻的格子数:全图 689 个能走的格子里翻了 685 个,几乎抄了一遍。这笔账不小, | |||
| 下一段就来省它。 |
四、给搜索一个方向感
Dijkstra 像水波,四面均匀地扩。 可目标在右下角,往左上扩出去的格子基本白看。A 就加一项: 每格的优先级不再是「已花的代价」,而是「已花代价 + 到终点的估计」。 估计用曼哈顿距离(横竖各差多少格,加起来)。 它一定不会大于真实剩余代价,因为每走一格至少花 1。这个性质叫可采纳,有了它, A 找到的路仍然最优。 然后是最有意思的一段:Demo 里标准 A* 一格都没省,685 对 685。
| 估计放大倍数 k | 看了多少格 | 路径代价 | 还是最优吗 |
|---|---|---|---|
| 0(Dijkstra) | 685 | 152 | 基准 |
| 0.5 | 685 | 152 | 是 |
| 1.0(标准 A*) | 685 | 152 | 是 |
| 2.0 | 483 | 152 | 是 |
| 3.0 | 108 | 162 | 不是,多了 10 |
| 5.0 | 89 | 161 | 不是,多了 9 |
| 为什么没剪掉?因为这张图上每格的平均代价是 3.01,而曼哈顿估计是按每步 1 算的。 | |||
| 估计值只有真实剩余代价的三分之一,队伍几乎没被压动,A* 就退化成 Dijkstra 了。 | |||
| 这就是这一节真正要讲的一对矛盾:估计越贴近真实,剪得越狠; | |||
| 可一旦估过头,就再也保证不了最优。k 到 2 还在安全区(估计仍不高估),格子掉到 483、 | |||
| 代价还是 152;k 到 3 就破了可采纳性,格子只剩 108,代价却涨到 162。 | |||
| 「快」和「对」的这条线,是搜索里所有技巧的共同来源。游戏里寻路常常故意把估计放大一点, | |||
| 宁可要 90% 的时候最优、换来十倍的速度——这叫「加权 A*」,是个工程决定,不是数学结论。 |
五、状态一多就守不住了
上面那张地图只有 900 格。 换成八数码,状态数是 9! 等于 36 万;换成 4 乘 4 的华容道,是 16! 约两万亿。搜索算法本身没错, 是图太大了,大到存不下、也走不完。 这个现象叫组合爆炸。它才是搜索这一行真正的敌人:
| 问题 | 状态数 | 能不能穷举 |
|---|---|---|
| 3 乘 3 井字棋 | 约 5500 个有效局面 | 能,一张表就够 |
| 八数码 | 362880 | 能,但要省着存 |
| 国际象棋 | 约 10 的 46 次方 | 不能 |
| 围棋 | 约 10 的 170 次方 | 不能,宇宙原子才 10 的 80 次方 |
| 所以现实里的搜索几乎都不是「走完整个图」。它们各有各的省法: | ||
| 剪枝(第 1 章的 alpha-beta 就是)、启发(这一节的 A*)、或者干脆不保证最优(贪心、束搜索)。 | ||
| 深度学习那条路更绝:它不算了,改成「看一眼,猜一个」。 |
六、「最优化」在计算机里指什么
这一节的标题里有「最优化」三个字,值得单独说清。它有两个意思,而且经常被混着用。
| 说法 | 意思是 | 例子 |
|---|---|---|
| 在解空间里找最好的 | 把候选当成状态,走过去 | 迷宫、八数码、下棋 |
| 调参数让目标最小 | 调一组数,算出来 | 最小二乘、神经网络训练 |
| 这一节讲的是第一种。第二种是前面几节(回归)和后面几节(神经网络)在做的事。 | ||
| 两种看着不像,其实可以统一:调参数的时候,「一组参数」就是一个状态, | ||
| 「改一个参数」就是一个转移,梯度下降就是在参数空间里找一条下坡的路。 | ||
| 这就是为什么这一节放在第 5 章:它把「求解」这件事讲成了「走路」。后面讲遗传算法、讲神经网络, | ||
| 你会发现它们也在这张图上走,只是走路的方式越来越随意、越来越依赖概率。 |
七、动手 Demo:同一张地图,四种走法
核心就是 search 这一个函数,四种走法共用它:k=0 是 Dijkstra,k=1 是标准 A,
k 取 3 就是「加权 A」。广度优先和深度优先在它上面,只是把优先队列换成队列和栈。
地图是 30 乘 30、22% 的格子是墙、每格另有 1 到 5 的通行代价。
注意看输出里「看了多少格」那一列——它是这一节的关键指标。
DIRS = ((-1, 0), (1, 0), (0, -1), (0, 1))
def nbrs(p): # 上下左右能走的格子
r, c = p
for dr, dc in DIRS:
nr, nc = r + dr, c + dc
if 0 <= nr < SZ and 0 <= nc < SZ and not blocked[nr, nc]:
yield (nr, nc)
def search(k=0.0):
"""k 是估计的放大倍数:k=0 是 Dijkstra,k=1 是标准 A*。"""
g = {START: 0} # 起点到各点的最小代价
prev = {START: None} # 记录「从哪来」,用来回推路径
pq = [(0.0, START)] # 优先队列,按优先级出队
done, seen = set(), 0
while pq:
_, p = heapq.heappop(pq)
if p in done:
continue
done.add(p); seen += 1
if p == GOAL:
break
for n in nbrs(p):
nd = g[p] + int(COST[n])
if nd < g.get(n, 1e18):
g[n] = nd
prev[n] = p
h = abs(n[0] - GOAL[0]) + abs(n[1] - GOAL[1])
heapq.heappush(pq, (nd + k * h, n))
return seen, rebuild(prev)
实跑输出:
--------------------------------------------------------------------
在 30 乘 30 的地图上找一条路
--------------------------------------------------------------------
==============================================================
[1] 先说这张地图
30 乘 30 共 900 个格子,其中 211 个是墙。每格另有 1 到 5 的通行代价,
越贵越难走。从左上角 (0,0) 走到右下角 (29,29),只能上下左右走。
先看地图的样子(# 是墙,· 是通路,只印上面 18 行):
S···#··#·#··········#·······#·
·#··#····#·······#···#·······#
#····#·#······#·###·#·····#··#
·······#·#····#·#········#····
·············#···##····#··#···
··#····#··#·#···##············
·#····#···············#·······
#···##·····#···#······#·#···#·
·#··············###····#···#··
······#···#··##·······##······
··#·#···#·····#·#···#····#··#·
····#·········#·······##·····#
······#····#·##··##·##···#····
···##··········##·····##······
··####····###··············#·#
····#····##·##···#···##··#····
·#··········##·#·#········#·#·
···#······#·##····#··#·##·#·##
**同一张图,不同的找法会走出不同的路,花不同的力气。**
下面四种找法,看它们各自「翻了多少个格子」才找到终点。
[2] 广度优先:一层一层往外摊
从 S 出发,先把一步能到的格子全看了,再看两步能到的,以此类推。
它按「步数」一层层推,**第一次碰到 G 时,那条路的步数一定最少。**
看了 685 个格子(全图 900 个),路径 58 步,代价 176
这里的「最少」指步数,不是代价——它把 1 和 5 一视同仁。
[3] 深度优先:一条道走到黑
看了 468 个格子,路径 150 步,代价 423
**DFS 找到的路又长又绕**,因为它一头扎进去,撞墙才回头。
可它有个好处:不用存「所有待看的格子」,内存只跟栈的深度有关。
所以「有没有路」这种问题用它,**「哪条路最好」就不能用它。**
[4] Dijkstra:把代价算进去
每次从「眼下最便宜」的格子里挑一个往外扩。
**第一次碰到 G,那条路的代价一定最小。**
看了 685 个格子,路径 60 步,代价 152
跟广度优先比:步数从 58 变成 60,代价从 176 降到 152。
多走两步,换回 24 的代价——**这就是「绕远走便宜的路」。**
但也请注意它翻了多少格子:全图 689 个能走的格子里,它翻了 685 个,
几乎把整张地图抄了一遍。这笔账不小。
[5] A*:给搜索加一个方向感
Dijkstra 是四面均匀地扩,像水波。可目标在右下角,
往左上扩出去的格子基本是白看的。
A* 给每个格子算一个优先级:**已走代价 + 到终点的估计**。
估计取曼哈顿距离。因为每格代价至少是 1,这个估计一定不大于真实剩余代价,
这个性质叫「可采纳」,它保证了 A* 找到的路仍然最优。
看了 685 个格子,路径 60 步,代价 152
代价确实还是 152,格子却从 685 变成 685,只少了 0%。
为什么没剪掉多少?因为这张图上每格的平均代价是 3.01,
而曼哈顿估计是按每步 1 算的——估计值只有真实剩余代价的 33%。
**估计太保守,队伍几乎没被压动,A* 就退化成 Dijkstra 了。**
[6] 把估计放大,看它在哪一步开始出错
在优先级里把估计乘一个倍数 k。k=0 就是 Dijkstra,k=1 是标准 A*。
k 看了多少格 路径代价 还是最优吗
----------------------------------------------
0.0 685 个 152 最优(基准)
0.5 685 个 152 是
1.0 685 个 152 是
2.0 483 个 152 是
3.0 108 个 162 不是,多了 10
5.0 89 个 161 不是,多了 9
看这三段:
k 从 0 到 1,格子数几乎不动,代价一直是 152。因为估计仍远低于真实,
只是把出队的顺序微微调了一下。
k 到 2,格子掉到 483 个,代价**还是** 152。
因为真实平均代价 3.01,估计乘 2 之后依然不高估,可采纳性还没丢。
k 到 3,格子只剩 108 个,代价涨到 162。
这时候估计已经高估了真实剩余,可采纳性破了,**快是快了,可答案不再是最好。**
**这一节要讲的一对矛盾就在这儿:**
估计越贴近真实,剪得越狠;可一旦估过头,就再也保证不了最优。
A* 的全部讲究,全落在这一条线上。
[7] 五种找法摆一起
找法 看了多少格 路径步数 路径代价 最优吗
----------------------------------------------------------------
广度优先 685 个 58 176 步数最优
深度优先 468 个 150 423 不保证
Dijkstra 685 个 60 152 代价最优
A*(k=1) 685 个 60 152 代价最优
A*(k=3) 108 个 60 162 不保证
**这五行里没有一行是「又快又对」的免费午餐:**
广度优先快在步数上,代价差;深度优先两样都不占;
Dijkstra 对,但把地图翻了个遍;标准 A* 对,可这张图上没占着便宜;
k=3 的 A* 快,代价却比最优多了 10。
最后说这一节真正想落到的事:
上面这四种,在算法书上叫「图搜索」,在人工智能书上叫「状态空间搜索」。
名字不同,做的事一模一样——**都是把问题写成「状态 + 转移」,**
再从一种状态出发沿着转移去找另一种状态。
迷宫是这样,八数码是这样,下棋的每一步也是这样。
**「最优化」在计算机里的第一层意思,就是在状态图上找一条最好的路。**
先把一句话记住:g[n] 记的是真实已花代价,优先队列的键可以是别的。这两件事必须分开,
否则会出大问题。我第一版就是混在一起写的:把「已花代价 + 估计」当成已花代价往下传,
结果 A* 的代价算成了 155,比 Dijkstra 的 152 还差——一个本来保证最优的算法给出了更差的答案。
这属于「参数对了、逻辑错了」,跑起来不报错,只能靠对答案发现。
第二个坑在「看了多少格」这个指标上。它算的是出队次数,
而同一个格子可能被多次塞进队列(每次找到更便宜的走法就塞一次)。
所以代码里要用 done 集合挡掉重复出队,否则这个数会虚高好几倍。
第三段那张 k 表是这一节的核心证据。k 从 0 到 1,
格子数纹丝不动——因为曼哈顿估计太保守,压不动队列。k 到 2 才见效(483),k 到 3 就翻车(162)。
这条曲线说明「启发函数的强弱」不是玄学,是可以量出来的。
最后那张五行表里没有一行是「又快又对」的免费午餐。广度优先快在步数上、代价差;
深度优先两样都不占;Dijkstra 对,但把地图翻了个遍;标准 A 对,可这张图上没占着便宜。
所以选算法从来不是选「最好的那个」,而是选「在你的数据上不吃亏的那个」。*
30 秒实验
- 把墙的比例从 22% 调到 5%,重跑。 格子的平均代价不变,但通路变多,Dijkstra 翻的格子会变多(能到的地方更多了), A* 和它的差距也会变。这一步能让你看见: 「看了多少格」这个指标跟地图的形状关系极大,不是算法的固有属性。
- 把
COST全部设成 1(每格一样贵),重跑。这时候 Dijkstra 的代价就等于步数, 和广度优先的结果完全一样——因为「代价最小」退化成了「步数最少」。顺手看 A: 每格都是 1 的时候,曼哈顿估计正好等于真实剩余代价,它会把格子数压到很低。*同一个估计函数, 换个数据,强弱完全变了。 - 把
COST的取值范围从 1 到 5 改成 1 到 2,重跑第三段。 平均代价从 3 掉到 1.5,标准 A* 应该开始明显省格子了。 这说明「A* 好不好用」取决于「估计跟真实差多少」,跟算法本身无关。
常见误区
- 以为 A* 一定比 Dijkstra 快。它只是「在有好的估计函数时」更快。 估计函数弱的时候,它跟 Dijkstra 一模一样(Demo 里 685 对 685); 估计函数估过头的时候,它更快但答案不对。
- 以为搜索就是「遍历」。遍历是「把所有点看一遍」,搜索是「找到一个点就停」。 两者的终止条件完全不同,所以复杂度分析也不能混着说。
- 以为「最优」是个绝对的说法。Demo 里 Dijkstra 的最优是「总代价最小」, 广度优先的最优是「步数最少」,A(k=3) 连最优都不是。 说「这个算法最优」之前,一定要先说清「按什么最优」。*
- 以为状态空间搜索只用于游戏和迷宫。编译器做寄存器分配、快递公司排路线、芯片布线、 甚至证明数学定理,底下都是这一套。凡是「一步一步选,选到最后有个结果」的事,都能这么写。
自测三问(点开对答案)
1. 为什么广度优先第一次碰到终点时的步数一定最少? —— 因为它按「离起点的步数」一层一层往外扩。 **所有第 1 步能到的点先被看完,才轮到第 2 步能到的。**所以第一次碰到终点时, **之前看过的点全都比它更近,不可能存在一条更短的、还没被发现的路。**反过来, 深度优先一头扎进去,第一次碰到的终点可能是绕了很远才到的,**它没有「近的先看」这个保证。** 2. A* 的估计函数为什么必须「不高估」? —— 因为高估会让某个方向的格子**被排到后面去**,而那条路本来可能更便宜。 **一旦队列按错的顺序出队,先出队的点就「定死」了代价,后面再也改不回来。** Demo 里 k=3 就是这样:格子从 685 降到 108,代价从 152 涨到 162。 **不高估(可采纳)保证了「出队时它的代价一定是最终代价」,这才是最优性的来源。** 3. 这张地图上标准 A* 为什么没比 Dijkstra 省格子? —— **因为曼哈顿距离按「每步代价 1」算,而这张图每格平均代价是 3.01。** 估计值只有真实剩余代价的三分之一左右, **队列的排序几乎没被动过,所以出队顺序跟 Dijkstra 基本一致。**要让它见效, 要么把每格代价调小,要么把估计放大(k=2),**要么换一个更贴近真实的估计函数。**搜索的规矩是「一步一步必须走通」, 可有些问题根本没有「一步一步」这回事——比如「找一组参数让这个函数最大」,参数连续,走不到底。 下一节的遗传算法就是为这种问题准备的:不走路,改去「繁殖」。
- ☐ (本节完,下一节 5.3 遗传算法)