启发式搜索——用“直觉”指导搜索方向
较难3启发式搜索:用“直觉”给搜索装上导航仪
在玩游戏或做决策时,如果时间有限、选择太多,光靠“把所有可能都试一遍”根本来不及。比如你在操场上找丢了的橡皮,总不能把整片草地每一根草都翻一遍吧?你肯定会先看看最常待的地方、刚才走过的路,这就是在用“直觉”缩小搜索范围。启发式搜索(Heuristic Search)为算法带来了类似的“直觉”——它用内置的“聪明猜测”给每个状态打分,优先探索最有希望的方向,就像在迷宫里用手电筒照向出口方向而不是乱闯。
为什么需要启发式搜索?
普通的广度优先搜索(BFS)和深度优先搜索(DFS)就像闭着眼睛在迷宫里乱摸——它们不会判断哪条路更可能通向出口,只会一个劲儿地往前或往四周平推。如果地图很大,这样会浪费大量时间。而启发式搜索会请出一个“评估员”(启发函数),对当前状态说:“嘿,右边那条路看起来更接近终点,先走右边!” 这样搜索范围大大缩小,速度飞快。
核心概念:f(n) = g(n) + h(n)
启发式搜索最著名的代表是 A* 算法(读作“A星”)。它用一条简单但强大的公式来决定下一步该走哪个状态:
f(n) = g(n) + h(n)
- g(n):从起点走到当前状态 n 已经付出的“实际代价”。比如在网格地图中每走一步代价为 1,那么 g(n) 就是已经走过的步数。
- h(n):从当前状态 n 到目标状态估计的“剩余代价”。这个估计必须又快又准,但不一定精确。比如在平面地图上可以估算“直线距离”或“曼哈顿距离”。
- f(n):总代价估计,即 g(n) + h(n)。A* 每次总是挑选当前 f 值最小的节点来扩展,也就是“最有可能最快到终点”的方向。
生活中的比喻:想象你要从教室去食堂吃饭。g(n) 是你已经走的路程(比如从座位走到走廊),h(n) 是你看着食堂的窗户估算的剩余直线距离,总 f(n) 就是你对“还得花多少时间”的猜测。你每次都会选当前看起来总时间最短的路——比如走走廊穿过操场,而不是绕到教学楼另一边。
启发函数 h(n) 的设计与“可采纳性”
h(n) 是启发式搜索的灵魂。如果 h(n) 设计得好,搜索会像装了火箭加速器;如果设计得不好,可能会走错路甚至找不到最优解。
一个重要的性质叫做 可采纳性(Admissibility):h(n) 不能高估真实剩余代价,即 h(n) ≤ 真实剩余代价。举个例子,在网格图中,如果你用“直线距离”作为 h(n),它永远不会大于实际从当前点到终点需要走的步数(因为实际路径可能要拐弯,直线距离肯定≤实际路程)。这样的 h(n) 是可采纳的;A* 使用可采纳的 h(n) 一定能找到最短路径。
如果 h(n) 高估了(比如估计需要 100 步,实际只要 30 步),A* 可能会因为觉得那条路太远而放弃,最终找到的路径不是最短的。
常见启发函数:
- 曼哈顿距离:常用于只能上下左右移动的网格。
|x1 - x2| + |y1 - y2|- 欧几里得距离:可以沿任意方向移动时使用。
sqrt((x1-x2)^2 + (y1-y2)^2)- 零启发函数:如果 h(n) 始终为 0,A* 退化为 Dijkstra 算法(或者 BFS,如果边权为 1)。
新手最容易犯的错误
- 把 h(n) 设得太高(不满足可采纳性):比如在只能上下左右的网格里用曼哈顿距离没问题,但若用了“两点间直线距离再乘以 2”就可能高估,导致算法找不到最短路径。
- 忘记更新 dist 数组(代码中的距离表):A* 依赖一个记录到每个已访问节点最小 g 值的数组,如果忘记更新,可能导致重复扩展同一个状态,效率大打折扣。
- 启发函数计算太复杂:好的启发函数既要准确又要快。如果 h(n) 计算比实际搜索还慢,那就本末倒置了。
- 没有处理“已经找到更好路径”的剪枝:在取出节点时,如果当前 g 值比记录的最小值大,说明这个节点已经被一个更好的路径访问过了,应该跳过,否则会重复工作。
完整可运行的 A* 例子:帮小 P 在迷宫里找闪闪发光的星星
下面是一个完整的 C++ 程序,在 6×6 的地图中,从起点 'S' 到终点 'E' 找一条最短路径(1 代表障碍物)。代码用注释详细解释了每一部分。
#include <iostream>
#include <vector>
#include <queue>
#include <cmath>
#include <cstring> // 用于 memset
using namespace std;
// 定义格子节点,包含坐标、已走代价g、启发式h
struct Node {
int x, y; // 坐标
int g, h; // g: 从起点到这里的实际步数, h: 启发值(曼哈顿距离)
int f() const { return g + h; } // 总代价 = 已走 + 估计剩余
// 重载小于号,用于优先队列(我们希望小顶堆,所以用 > 比较)
bool operator<(const Node& other) const {
return f() > other.f();
}
};
// 四个方向的偏移量:上下左右
int direction_x[] = {-1, 1, 0, 0};
int direction_y[] = {0, 0, -1, 1};
int main() {
// 地图,0表示空地,1表示障碍,S是起点,E是终点
vector<string> maze = {
"001000", // 第0行
"0S0100", // 第1行
"001000", // 第2行
"000001", // 第3行
"0010E0", // 第4行
"000000" // 第5行
};
int rows = 6, cols = 6;
// 找到起点S和终点E的坐标
int start_x, start_y, end_x, end_y;
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
if (maze[i][j] == 'S') {
start_x = i;
start_y = j;
}
if (maze[i][j] == 'E') {
end_x = i;
end_y = j;
}
}
}
// 距离表:记录到达每个格子所需的最少步数(初始为极大值)
vector<vector<int>> dist(rows, vector<int>(cols, 1e9));
// 优先队列(小顶堆),存放待扩展的节点
priority_queue<Node> pq;
// 计算起点的启发值:曼哈顿距离到终点
int init_h = abs(start_x - end_x) + abs(start_y - end_y);
pq.push({start_x, start_y, 0, init_h});
dist[start_x][start_y] = 0; // 起点步数为0
// 保存路径:用于最终输出(可选)
// 这里为了简洁,只输出最短步数
while (!pq.empty()) {
Node cur = pq.top(); // 取出当前f值最小的节点
pq.pop();
// 如果已经到达终点,输出步数并结束
if (cur.x == end_x && cur.y == end_y) {
cout << "找到最短路径!所需步数 = " << cur.g << endl;
return 0;
}
// 如果当前节点的g值已经比记录的最优值大,说明不是最优,跳过
if (cur.g > dist[cur.x][cur.y]) continue;
// 尝试向四个方向移动
for (int i = 0; i < 4; i++) {
int nx = cur.x + direction_x[i];
int ny = cur.y + direction_y[i];
// 检查越界和障碍(障碍用'1'表示)
if (nx < 0 || nx >= rows || ny < 0 || ny >= cols) continue;
if (maze[nx][ny] == '1') continue;
int new_g = cur.g + 1; // 新步数 = 当前步数 + 1
if (new_g < dist[nx][ny]) {
dist[nx][ny] = new_g; // 更新最短步数
int new_h = abs(nx - end_x) + abs(ny - end_y); // 曼哈顿距离
pq.push({nx, ny, new_g, new_h});
}
}
}
// 如果队列空了都没找到终点,说明无路可走
cout << "没有路径能到达终点" << endl;
return 0;
}
运行结果
编译运行上面的代码,输出应该是:
找到最短路径!所需步数 = 6
你可以改动地图验证结果,或者尝试自己画一个更复杂的迷宫。
启发式搜索还能用在哪儿?
- 游戏 AI:比如《我的世界》里的村民找路、《星际争霸》中兵营自动寻路,都离不开 A* 或其变种。
- 机器人导航:扫地机器人要从客厅到阳台,避开椅子腿,可以用 A* 规划路线。
- 拼图游戏(15-Puzzle):估计当前拼图状态与目标状态不同的数字个数作为启发函数。
- 网络路由:互联网上选择数据传输的路径时,也会用类似的“最短路径 + 估计剩余距离”算法。
相关知识点指引
如果你对启发式搜索感兴趣,接下来可以看看:
- Dijkstra 算法:A* 的爷爷,g(n) 部分就是 Dijkstra 的核心思想。
- 最佳优先搜索(Best-First Search):只考虑 h(n),不考虑 g(n),速度更快但不保证最优。
- IDA(迭代加深A)**:当内存特别小的时候用,用深度限制代替优先队列。
- 双向A*:从起点和终点同时搜索,加速效果明显。
掌握了启发式搜索,你就学会了给算法装上“直觉”——既聪明又高效,是解决复杂问题的一把利器。
例题精讲
在A*搜索算法中,保证找到最优解的充分必要条件是什么?
启发式搜索(如贪婪最佳优先)总是能够找到从起点到目标的最短路径。
以下是一个A*搜索算法的核心循环片断(伪代码),请补全计算f(n)的语句。
struct Node {
int g; // 从起点到当前节点的实际代价
int h; // 启发函数值(估计到目标代价)
int f; // 总代价
};
// ... openSet是优先队列,按f值排序
void AStar() {
Node current = openSet.top();
// 生成邻居节点
for each neighbor of current {
int tentative_g = current.g + cost(current, neighbor);
if (tentative_g < neighbor.g) {
neighbor.g = tentative_g;
neighbor.h = heuristic(neighbor); // 假设函数已定义
neighbor.f = ___; // 填空:计算f值
// 更新openSet
}
}
}关于启发式搜索中的启发函数,下列说法正确的是?
在启发式搜索中,如果启发函数h(n)始终为0,那么该搜索算法等价于广度优先搜索(BFS)。