1. 搜索算法到底在解决什么问题
很多人第一次接触人工智能,是从机器学习、神经网络这些词开始的,觉得那才是"智能"的核心。但如果你真的翻过几本人工智能的经典教材,会发现排在最前面的章节往往不是神经网络,而是搜索算法。这不是编排上的巧合,而是因为搜索才是人工智能最底层的思维方式——把一个问题变成状态空间里的路径寻找,然后想办法高效地找到那条路。
我刚开始学这块的时候也有疑惑:搜索听起来像是搜索引擎干的事,跟人工智能有什么关系?后来做了一些路径规划、游戏AI、自动化调度的小项目才慢慢体会到,搜索算法解决的是一类非常本质的问题——在巨大的可能性空间里,找到满足约束条件的解,或者最优解。下棋时下一步走哪里、地图上从A到B怎么走最短、任务怎么排期冲突最少、数独怎么填,这些问题的底层结构是一样的。
这篇文章面向的是刚入门人工智能、正在啃搜索算法这块内容的人,也适合那些学过但没真正动手实现过、想补上实操这一环的开发者。我会从整体设计思路讲起,把盲目搜索、启发式搜索、对抗搜索这几条主线拆开,配上可以直接跑的代码和参数计算过程,最后把我自己踩过的坑整理成排查清单。核心关键词就三个:状态空间、启发式函数、搜索策略,这三个词理解了,搜索算法这章基本就通了。
搜索算法之所以是人工智能基础,是因为它提供了一套通用的建模框架。你把任何问题抽象成"初始状态、目标状态、动作集合、状态转移、路径代价"这五个要素,就能套用搜索算法去解。这个抽象能力本身就是人工智能思维的核心训练。后面学强化学习、规划系统,甚至大模型里的思维链推理,骨子里都有搜索的影子。
2. 搜索算法的整体设计与思路拆解
2.1 为什么先讲状态空间建模
搜索算法的第一步永远不是写代码,而是建模。我见过太多人一上来就想套A*,结果连状态怎么定义都没想清楚,最后写出来的东西要么状态爆炸,要么根本搜不到解。状态空间建模说白了就是把现实问题翻译成计算机能处理的形式,这个翻译质量直接决定了后面算法的上限。
一个标准的状态空间由五部分组成:初始状态、目标状态集合、可执行动作、状态转移函数、路径代价函数。拿八数码问题举例,初始状态就是那个打乱的3x3棋盘,目标状态是12345678按顺序排列,动作是空格上下左右移动,转移函数就是移动后生成新棋盘,代价函数每步算1。这五个要素定义清楚了,问题就变成了在状态图里找一条从初始到目标的路径。
这里有个关键判断:状态空间是树还是图。如果不同路径会到达同一个状态,那就是图,必须做去重,否则会重复搜索甚至死循环。八数码就是典型的图结构,同一个棋盘布局可能从不同移动顺序到达。很多人实现时忘了维护visited集合,跑小规模没问题,一上规模就卡死,这是最常见的坑之一。
2.2 盲目搜索和启发式搜索的分水岭
盲目搜索,也叫无信息搜索,代表是广度优先(BFS)和深度优先(DFS)。它们不关心目标在哪,只按固定顺序扩展节点。BFS按层扩展,保证找到最短路径(在每步代价相等时),但空间复杂度是指数级的,八数码稍微难一点就爆内存。DFS一条路走到黑,省内存但可能陷进无限深的分支,而且找到的解不一定最优。
启发式搜索的分水岭在于引入了启发式函数h(n),用来估计从当前节点到目标的代价。有了这个估计,搜索就有了方向感,不再盲目乱撞。A*算法就是启发式搜索的代表,它的评估函数是f(n) = g(n) + h(n),g是已经走过的实际代价,h是到目标的估计代价。
这里必须讲清楚一个核心概念:可采纳性。如果h(n)永远不超过从n到目标的真实最小代价,这个启发式就是可采纳的,A*保证能找到最优解。八数码里常用的两个启发式,一个是错位棋子数,一个是曼哈顿距离。错位棋子数明显低估,可采纳但不够紧;曼哈顿距离更接近真实代价,搜索效率高很多,而且同样可采纳。我实测下来,同一个八数码难题,用错位棋子数扩展的节点数往往是曼哈顿距离的好几倍。
2.3 对抗搜索为什么单独成体系
前面讲的都是单智能体搜索,环境是静态的。但下棋这类问题里,对手会主动干扰你,这就得用对抗搜索,代表是极小化极大算法和Alpha-Beta剪枝。它的思路是把博弈树展开,我方走棋取最大值,对方走棋取最小值,交替进行,最后回溯出最优走法。
对抗搜索的复杂度比普通搜索更高,因为分支因子大、深度深。Alpha-Beta剪枝是必学的优化,它能在不影响结果的前提下砍掉大量分支。理想情况下,剪枝后需要评估的节点数能从b的d次方降到b的d/2次方,相当于同样时间能多搜一倍的深度。这个提升在实战里非常明显,我做过一个五子棋AI,加了剪枝之后搜索深度从4层提到6层,棋力肉眼可见地变强。
2.4 方案选型的判断逻辑
面对一个具体问题,怎么选搜索算法?我总结了一个简单的判断流程。如果状态空间小、要求最优解、每步代价相等,用BFS。如果内存紧张、不要求最优、解可能很深,用DFS加深度限制。如果状态空间大、有合理的启发式、要求最优解,用A*。如果是双人对弈、有明确胜负,用极小化极大加Alpha-Beta。如果状态空间大到无法穷举、只求一个可行解,考虑局部搜索如爬山法、模拟退火。
这个选型逻辑不是死的,实际项目里经常组合使用。比如先用启发式快速找到一个可行解,再用局部搜索优化。关键是理解每种算法的代价和保证,别在不该用A的地方硬套A。
3. 核心细节解析与实操要点
3.1 状态表示与去重的高效实现
状态表示直接决定搜索效率。八数码用字符串或元组表示状态最方便,因为可以直接哈希,塞进set做去重。我见过有人用二维列表表示状态,结果每次去重都要遍历比较,慢得离谱。能哈希的不可变结构是首选,Python里就是tuple和str。
去重集合的选择也有讲究。小规模用set足够,大规模要考虑内存。如果状态数上千万,set的内存开销会很吓人,这时候可以用布隆过滤器做近似去重,代价是有极小概率误判,但对很多应用可以接受。我在一个滑块拼图项目里试过,状态数到两千万时set占了快2G内存,换成布隆过滤器后降到几百兆。
还有一个细节是状态的规范化。有些问题里,旋转、翻转后的状态本质等价,如果不去重会造成大量冗余搜索。比如某些拼图问题,把状态旋转后归一化再存进visited,能省掉好几倍的空间。
3.2 启发式函数的设计与调优
启发式函数是A*的灵魂,设计得好坏直接决定性能。设计原则有三条:可采纳、一致性、计算快。可采纳保证最优性,一致性(也叫单调性)保证不会重复扩展节点,计算快保证评估本身不成为瓶颈。
一致性比可采纳更强,它要求h(n) ≤ cost(n, n') + h(n'),也就是从n到n'再到目标的估计不能比直接从n估计更差。曼哈顿距离在八数码里是一致的,所以用它可以放心地不做重复检测(虽然实践中还是会做)。如果启发式只是一致性不满足但可采纳,A*仍能找到最优解,但可能重复扩展节点,效率下降。
调优启发式的常见手段是加权。把h乘以一个大于1的系数w,得到f = g + wh,这叫加权A。它能大幅提速,但不再保证最优,解的质量和速度之间要权衡。w越大搜得越快但解越差,w=1就是标准A*。我在实时性要求高的场景里常用w=1.5到2,实测速度能快两三倍,解的长度只增加百分之几。
更高级的做法是组合多个启发式。如果h1和h2都可采纳,那么max(h1, h2)也可采纳,而且更紧。八数码里可以取错位数和曼哈顿距离的最大值,虽然曼哈顿通常更大,但在某些状态下错位数反而更紧,取max能保证不漏。
3.3 优先队列的正确使用
A和BFS都依赖优先队列。Python里用heapq,但有个坑:heapq是最小堆,而A要按f值从小到大取,正好合适。但如果你把节点直接塞进去,节点之间无法比较就会报错。解决办法是塞元组(f, counter, node),counter是个递增计数器,用来在f相同时打破平局,避免比较node本身。
这个counter还有个隐藏作用:控制搜索的偏好。f相同时,counter小的先出,也就是先入先出,倾向于广度优先的行为。如果你希望f相同时优先扩展g大的(更接近目标),可以把counter设成负的g值。这个细节在解质量上有微妙影响,我做过对比,合理设置平局打破规则能减少百分之十几的扩展节点。
注意:heapq不支持修改已有元素的优先级,所以A*里如果发现到达某节点的新路径更短,不能直接更新堆里的元素,只能把新节点重新push进去,靠visited在弹出时判断是否已处理过。这是"懒惰删除"策略,会浪费一些空间但实现简单。
3.4 Alpha-Beta剪枝的实现要点
Alpha-Beta剪枝的核心是维护两个边界:alpha是我方能保证的最小值,beta是对方能保证的最大值。搜索过程中如果发现某个分支的值已经不可能影响最终决策,就剪掉。具体说,在最大化层,如果当前值已经≥beta,说明对方不会让你走到这里,剪枝;在最小化层,如果当前值≤alpha,剪枝。
实现时最容易出错的是alpha和beta的传递。每一层递归都要把当前的alpha、beta传下去,子节点返回后更新。我见过有人忘了更新,导致剪枝失效,跑出来的结果和没剪枝一样慢。还有个细节是初始值,alpha设负无穷,beta设正无穷。
剪枝效果极度依赖走法排序。如果先搜索好的走法,剪枝效率高;先搜索差的,几乎剪不掉。所以实战里都会先对走法排序,把吃子、将军这类明显好的走法排前面。我做过测试,同样的棋局,排序好的情况下扩展节点数能少一个数量级。
4. 实操过程与核心环节实现
4.1 八数码问题的完整实现
先上BFS版本,理解基础流程。状态用字符串表示,空格用'0'。
from collections import deque def bfs(start, goal="123456780"): if start == goal: return 0 visited = {start} queue = deque([(start, 0)]) while queue: state, depth = queue.popleft() idx = state.index('0') r, c = divmod(idx, 3) for dr, dc in [(-1,0),(1,0),(0,-1),(0,1)]: nr, nc = r+dr, c+dc if 0 <= nr < 3 and 0 <= nc < 3: nidx = nr*3 + nc lst = list(state) lst[idx], lst[nidx] = lst[nidx], lst[idx] nxt = ''.join(lst) if nxt == goal: return depth + 1 if nxt not in visited: visited.add(nxt) queue.append((nxt, depth+1)) return -1这段代码逻辑清晰,但只适合简单局面。八数码最难的布局BFS要扩展十几万节点,内存吃紧。下面上A*版本,用曼哈顿距离做启发式。
import heapq def manhattan(state, goal="123456780"): dist = 0 for i, ch in enumerate(state): if ch == '0': continue gi = goal.index(ch) dist += abs(i//3 - gi//3) + abs(i%3 - gi%3) return dist def astar(start, goal="123456780"): if start == goal: return 0 visited = set() counter = 0 heap = [(manhattan(start), 0, counter, start)] g_score = {start: 0} while heap: f, g, _, state = heapq.heappop(heap) if state == goal: return g if state in visited: continue visited.add(state) idx = state.index('0') r, c = divmod(idx, 3) for dr, dc in [(-1,0),(1,0),(0,-1),(0,1)]: nr, nc = r+dr, c+dc if 0 <= nr < 3 and 0 <= nc < 3: nidx = nr*3 + nc lst = list(state) lst[idx], lst[nidx] = lst[nidx], lst[idx] nxt = ''.join(lst) ng = g + 1 if nxt not in g_score or ng < g_score[nxt]: g_score[nxt] = ng counter += 1 heapq.heappush(heap, (ng + manhattan(nxt), ng, counter, nxt)) return -1关键点解释:g_score记录到达每个状态的最小实际代价,只有当新路径更短时才入堆。counter保证堆元素可比较。visited在弹出时判断,避免重复处理。这套结构是A*的标准写法,换成别的问题只需改状态转移和启发式。
4.2 参数计算:启发式到底省了多少
拿一个具体难题测一下,比如"724506831"这个布局。BFS需要扩展的节点数我实测在18万左右,A*用曼哈顿距离大概扩展2000到3000个节点,差距接近两个数量级。这不是夸张,启发式的方向感带来的剪枝效果就是这么猛。
再算一下加权A*。把h乘以1.5,扩展节点数降到1000左右,但解的长度可能从26步变成28步。如果你的应用能接受次优解,这个交换非常划算。我一般会先跑标准A*看性能,如果太慢再考虑加权。
曼哈顿距离的计算本身也有优化空间。每次状态转移只移动了一个棋子,理论上可以增量更新h值,不用全量重算。全量重算一次是O(9),增量更新是O(1),在搜索量大时差别明显。不过增量更新实现复杂,容易出bug,我建议先用全量版本跑通,性能不够再优化。
4.3 极小化极大与Alpha-Beta的落地
用一个简化的井字棋来演示对抗搜索。评估函数简单点:赢返回1,输返回-1,平局返回0。
def minimax(board, depth, is_max): winner = check_winner(board) if winner == 'X': return 1 if winner == 'O': return -1 if is_full(board): return 0 if is_max: best = -float('inf') for move in get_moves(board): board[move] = 'X' best = max(best, minimax(board, depth+1, False)) board[move] = ' ' return best else: best = float('inf') for move in get_moves(board): board[move] = 'O' best = min(best, minimax(board, depth+1, True)) board[move] = ' ' return best加上Alpha-Beta:
def alphabeta(board, depth, alpha, beta, is_max): winner = check_winner(board) if winner == 'X': return 1 if winner == 'O': return -1 if is_full(board): return 0 if is_max: best = -float('inf') for move in get_moves(board): board[move] = 'X' best = max(best, alphabeta(board, depth+1, alpha, beta, False)) board[move] = ' ' alpha = max(alpha, best) if beta <= alpha: break return best else: best = float('inf') for move in get_moves(board): board[move] = 'O' best = min(best, alphabeta(board, depth+1, alpha, beta, True)) board[move] = ' ' beta = min(beta, best) if beta <= alpha: break return best对比一下,井字棋状态少,剪枝效果不明显,但换成五子棋、象棋这种分支因子大的,剪枝能省掉百分之八九十的节点。这就是为什么所有正经的博弈AI都必带Alpha-Beta。
4.4 搜索过程的现场记录与调试
调试搜索算法最有效的手段是打印搜索树。把每次扩展的节点、f值、g值、h值打出来,看搜索是不是朝着目标方向走。如果发现f值忽大忽小、来回震荡,多半是启发式设计有问题或者优先队列用错了。
另一个技巧是统计扩展节点数和生成节点数。扩展节点是真正从堆里弹出并处理的,生成节点是入过堆的。两者比值能反映去重效率。如果生成数远大于扩展数,说明重复入堆太多,考虑优化去重或改用一致性启发式。
我还会记录搜索深度随时间的变化。正常情况下深度应该稳步增加,如果长时间卡在某个深度,可能是遇到了局部最优或者死循环。这时候检查visited和状态转移逻辑,八成有bug。
5. 常见问题与排查技巧实录
5.1 搜索算法典型问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方法 |
|---|---|---|---|
| 程序卡死无输出 | 状态空间有环未去重 | 检查visited逻辑 | 加入visited集合,弹出时判断 |
| 内存爆掉 | BFS状态爆炸 | 看状态数增长曲线 | 换A*或加深度限制 |
| 找到的解不是最优 | 启发式不可采纳 | 验证h是否高估 | 改用曼哈顿距离等可采纳启发式 |
| A*比BFS还慢 | 启发式计算太贵 | 统计h计算耗时 | 简化h或做增量更新 |
| Alpha-Beta没效果 | 走法未排序 | 打印剪枝次数 | 先搜索好走法,加排序 |
| 堆报比较错误 | 节点不可比较 | 看报错栈 | 堆元素加counter |
| 结果时好时坏 | 平局打破规则不当 | 检查counter设置 | 固定平局策略 |
5.2 我踩过的几个真实坑
第一个坑是忘了在弹出时判断visited。我早期写A*,只在入堆时判断,结果同一个状态被多次入堆,弹出时重复处理,效率大打折扣。正确做法是入堆时用g_score判断是否更优,弹出时用visited判断是否已处理。这两个判断缺一不可。
第二个坑是启发式函数写错方向。有次我把曼哈顿距离算成了欧几里得距离的平方,结果h值远大于真实代价,A*退化成贪心搜索,找到的解又长又差。教训是启发式一定要验证可采纳性,拿几个已知最优解的局面测一下,看h是否始终不超过真实步数。
第三个坑是Alpha-Beta的初始值设错。我把alpha初始化成0而不是负无穷,导致所有负值的分支被错误剪掉,AI变得只会防守不会进攻。这种bug很隐蔽,因为程序不报错,只是棋力变弱。后来我养成了习惯,对抗搜索的边界值一律用正负无穷。
第四个坑是状态表示用了可变对象。有次用list表示状态塞进set,直接报unhashable。改成tuple后解决。这个坑很基础,但新手经常犯,尤其是从其他语言转过来的。
5.3 性能优化的独家心得
搜索算法的优化,八成时间花在减少扩展节点数上,两成花在单节点处理速度上。减少节点数最有效的手段是更好的启发式,其次是双向搜索。双向搜索从起点和终点同时搜,相遇即停,理论上能把复杂度从b的d次方降到b的d/2次方。实现上要维护两个visited,判断交集。我在路径规划项目里用双向A*,速度提升非常明显。
单节点处理速度的优化,重点是避免不必要的对象创建。Python里每次list(state)和''.join(lst)都有开销,能复用就复用。还可以用整数编码状态,比如八数码用9位整数表示,位运算做转移,速度能快好几倍。不过可读性会下降,看项目需求权衡。
还有一个容易被忽视的点是启发式的缓存。如果同一个状态会被多次评估,把h值缓存起来能省重复计算。用字典做memo,命中率在搜索后期往往很高。
提示:优化之前一定要先profile,找出真正的瓶颈。我见过有人花大力气优化启发式计算,结果发现瓶颈其实在优先队列的堆操作上。用cProfile跑一下,数据说话。
6. 搜索算法的延伸与实战价值
搜索算法学完之后,你会发现它的思想渗透在人工智能的很多角落。规划系统本质上是搜索,把动作序列当成路径;强化学习里的策略搜索,是在策略空间里找最优策略;甚至大模型的推理,也可以看成在思维空间里的搜索。理解了状态、动作、代价、启发式这套框架,再看这些问题会通透很多。
实战中,搜索算法最常出现在路径规划、任务调度、游戏AI、自动化测试用例生成这些场景。路径规划用A*及其变体,任务调度用带约束的搜索,游戏AI用对抗搜索,测试用例生成用启发式搜索找覆盖路径。这些应用对搜索算法的要求各不相同,但底层框架是共通的。
如果你想继续深入,我建议往两个方向走。一个是启发式搜索的进阶,学IDA*、SMA*这些内存受限的算法,以及如何自动学习启发式函数。另一个是对抗搜索的进阶,学蒙特卡洛树搜索,这是AlphaGo的核心组件之一,把搜索和随机模拟结合起来,在超大博弈空间里也能工作。
最后分享一个我个人的学习体会:搜索算法这章,光看是学不会的,必须动手写。从BFS写起,加上A*,再加上Alpha-Beta,每写一个都拿具体问题测,看扩展节点数、看解的质量、看内存占用。写的过程中你会自然理解为什么要有启发式、为什么要剪枝、为什么状态表示这么重要。这些体感是看书看视频得不到的。等你把八数码、井字棋、迷宫路径这几个经典问题都亲手实现一遍,搜索算法这块就算真正入门了。