CC++ & Algorithm

启发式搜索——用“直觉”指导搜索方向

较难3
语言版本:C++
概述:启发式搜索给每个状态一个“聪明猜测”的分数,优先探索最有希望的方向,像迷宫中用手电筒照向出口方向。

启发式搜索:用“直觉”给搜索装上导航仪

在玩游戏或做决策时,如果时间有限、选择太多,光靠“把所有可能都试一遍”根本来不及。比如你在操场上找丢了的橡皮,总不能把整片草地每一根草都翻一遍吧?你肯定会先看看最常待的地方、刚才走过的路,这就是在用“直觉”缩小搜索范围。启发式搜索(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)。

新手最容易犯的错误

  1. 把 h(n) 设得太高(不满足可采纳性):比如在只能上下左右的网格里用曼哈顿距离没问题,但若用了“两点间直线距离再乘以 2”就可能高估,导致算法找不到最短路径。
  2. 忘记更新 dist 数组(代码中的距离表):A* 依赖一个记录到每个已访问节点最小 g 值的数组,如果忘记更新,可能导致重复扩展同一个状态,效率大打折扣。
  3. 启发函数计算太复杂:好的启发函数既要准确又要快。如果 h(n) 计算比实际搜索还慢,那就本末倒置了。
  4. 没有处理“已经找到更好路径”的剪枝:在取出节点时,如果当前 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*:从起点和终点同时搜索,加速效果明显。

掌握了启发式搜索,你就学会了给算法装上“直觉”——既聪明又高效,是解决复杂问题的一把利器。

例题精讲

1单选题

在A*搜索算法中,保证找到最优解的充分必要条件是什么?

A启发函数h(n)等于从n到目标点的实际代价
B启发函数h(n)是可采纳的(admissible),即h(n) <= h*(n)
C启发函数h(n)是单调的(consistent)
D搜索空间是有限的
2判断题

启发式搜索(如贪婪最佳优先)总是能够找到从起点到目标的最短路径。

3填空题
以下是一个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
        }
    }
}
4单选题

关于启发式搜索中的启发函数,下列说法正确的是?

A启发函数值越大,搜索速度越快
B启发函数必须满足单调性才能用于A*算法
C如果启发函数总是等于0,则A*退化为Dijkstra算法
D启发函数可以任意选取,不影响搜索结果正确性
5判断题

在启发式搜索中,如果启发函数h(n)始终为0,那么该搜索算法等价于广度优先搜索(BFS)。