一、A* 算法解决什么问题?

A* 是一种 启发式搜索算法

它常用于解决:

  • 网格地图最短路径

  • 八数码、十五数码问题

  • 状态空间搜索

  • 第 K 短路

  • 迷宫寻路

  • 游戏 AI 路径规划

普通 BFS 或 Dijkstra 搜索时,会比较“盲目”地扩展节点。

而 A* 的核心思想是:

不仅考虑当前已经走了多远,还要估计从当前位置到终点还需要走多远。

因此,A* 会优先搜索那些“看起来更有希望到达终点”的状态。

astar.png

二、从 BFS / Dijkstra 引出 A*

1. BFS 的局限

BFS 适合解决边权相同的最短路问题。

例如在一个网格中,每走一步代价都是 1,那么 BFS 可以求出最短路径。

但是 BFS 的问题是:

它不知道终点在哪里,只会一层一层向外扩展。

假设起点在左上角,终点在右下角,BFS 会向所有方向扩散,很多明显不靠近终点的状态也会被搜索。

2. Dijkstra 的局限

Dijkstra 可以解决非负权图最短路问题。

它每次选择当前距离起点最近的点进行扩展。

但是 Dijkstra 也有一个问题:

它只关心从起点到当前点的距离,不关心当前点离终点还有多远。

所以在目标明确的搜索问题中,Dijkstra 也可能搜索大量无关状态。

3. A* 的改进

A* 在 Dijkstra 的基础上加入了一个“估计值”。

对于每个状态 n,A* 计算:

f(n) = g(n) + h(n)

其中:

g(n):从起点到当前状态 n 的真实代价
h(n):从当前状态 n 到终点的估计代价
f(n):经过当前状态 n 到达终点的估计总代价

A* 每次优先扩展 f(n) 最小的状态。

三、A* 的核心公式

A* 最重要的公式就是:

f(n) = g(n) + h(n)

可以把它理解成:

已经付出的代价 + 预计还要付出的代价 = 这条路线的预估总代价

例如在网格中:

起点 S 到当前点 A 已经走了 7 步
当前点 A 到终点 T 估计还需要 10 步

那么:
g(A) = 7
h(A) = 10
f(A) = 17

A* 会优先选择 f 值较小的点继续搜索。

四、启发函数 h(n)

A* 的灵魂在于启发函数 h(n)

h(n) 用来估计当前状态到终点的距离。

1. h(n) 的常见设计

在网格最短路中,常见启发函数有:

曼哈顿距离

适用于只能上下左右四个方向移动的网格。

h(x, y) = |x - targetX| + |y - targetY|

例如:

当前点:(2, 3)
终点:(6, 8)

h = |2 - 6| + |3 - 8|
  = 4 + 5
  = 9

欧几里得距离

适用于可以任意方向移动的场景。

h(x, y) = sqrt((x - targetX)^2 + (y - targetY)^2)

切比雪夫距离

适用于可以八个方向移动,并且斜向移动代价和直线移动代价相同的网格。

h(x, y) = max(|x - targetX|, |y - targetY|)

五、启发函数的关键要求

在算法竞赛中,A* 想要保证最优解,启发函数通常需要满足:

1. 不能高估真实代价

也就是说:

h(n) <= 当前点 n 到终点的真实最短距离

这叫做 可采纳性,英文叫 admissible。

简单理解:

h(n) 可以估小,但不能估大。

例如在四方向网格中,曼哈顿距离就是一个合法的启发函数。

因为从当前点到终点,至少要走横向距离加纵向距离那么多步。

2. 为什么不能高估?

假设某个状态真实到终点只需要 5 步,但你的估价函数给它估成 100。

那么 A* 可能会认为这条路很差,从而不优先搜索它,甚至错过最优解。

所以:

h(n) 估小一点没关系
h(n) 估大了可能出错

六、A* 算法流程

可以这样描述:

  1. 把起点加入优先队列。

  2. 每次取出 f = g + h 最小的状态。

  3. 如果当前状态是终点,返回答案。

  4. 否则枚举它的下一个状态。

  5. 计算新状态的 ghf

  6. 如果新状态可以被更新,就加入优先队列。

  7. 重复以上过程。

伪代码:

A*(start, target):
    创建优先队列 pq
    dist[start] = 0
    pq.push(start, f = h(start))

    while pq 不为空:
        cur = pq.poll()

        if cur 是 target:
            return dist[cur]

        for next in cur 的所有邻接状态:
            newG = dist[cur] + cost(cur, next)

            if newG < dist[next]:
                dist[next] = newG
                f = newG + h(next)
                pq.push(next, f)

    return 无解

七、A* 和 Dijkstra 的关系

A* 可以看成是 Dijkstra 的增强版。

Dijkstra 的排序依据是:g(n)

A* 的排序依据是:g(n) + h(n)

当:h(n) = 0 时,A* 就退化成了 Dijkstra。

所以可以这样理解:

Dijkstra:只看已经走了多远
A*:既看已经走了多远,也看离终点还有多远

八、A* 和其他搜索算法的区别

算法

适用场景

搜索依据

是否使用启发函数

BFS

边权相同的最短路

层数

Dijkstra

非负权最短路

起点到当前点的真实距离 g

贪心搜索

快速靠近目标

只看 h

A*

启发式最短路搜索

g + h

IDA*

状态空间很大、内存有限

深度限制 + 启发函数

需要强调一点:

贪心搜索只看 h(n),可能很快,但不一定最优。
A* 同时看 g(n) 和 h(n),在合适条件下可以保证最优。

九、网格最短路中的 A*

假设有一个二维网格:

0 表示可以走
1 表示障碍物

要求从左上角走到右下角,每次可以上下左右移动一步。

普通 BFS 可以做,但 A* 可以用曼哈顿距离来加速搜索。

状态设计

每个状态包含:

x:当前行
y:当前列
g:从起点到当前位置的真实距离
f:g + h,预估总代价

启发函数:

h(x, y) = Math.abs(x - targetX) + Math.abs(y - targetY)

十、代码模板:网格 A*

import java.util.*;

public class AStarGrid {

    static class Node {
        int x;
        int y;
        int g; // 起点到当前点的真实代价
        int f; // g + h

        Node(int x, int y, int g, int f) {
            this.x = x;
            this.y = y;
            this.g = g;
            this.f = f;
        }
    }

    static int[][] dirs = {
            {1, 0},
            {-1, 0},
            {0, 1},
            {0, -1}
    };

    public static int aStar(int[][] grid) {
        int m = grid.length;
        int n = grid[0].length;

        int startX = 0;
        int startY = 0;
        int targetX = m - 1;
        int targetY = n - 1;

        if (grid[startX][startY] == 1 || grid[targetX][targetY] == 1) {
            return -1;
        }

        int[][] dist = new int[m][n];
        for (int[] row : dist) {
            Arrays.fill(row, Integer.MAX_VALUE);
        }

        PriorityQueue<Node> pq = new PriorityQueue<>((a, b) -> a.f - b.f);

        dist[startX][startY] = 0;
        int startH = heuristic(startX, startY, targetX, targetY);
        pq.offer(new Node(startX, startY, 0, startH));

        while (!pq.isEmpty()) {
            Node cur = pq.poll();

            // 如果当前节点不是最优记录,跳过
            if (cur.g > dist[cur.x][cur.y]) {
                continue;
            }

            // 第一次从优先队列中弹出终点时,就是最短距离
            if (cur.x == targetX && cur.y == targetY) {
                return cur.g;
            }

            for (int[] dir : dirs) {
                int nx = cur.x + dir[0];
                int ny = cur.y + dir[1];

                if (nx < 0 || nx >= m || ny < 0 || ny >= n) {
                    continue;
                }

                if (grid[nx][ny] == 1) {
                    continue;
                }

                int newG = cur.g + 1;

                if (newG < dist[nx][ny]) {
                    dist[nx][ny] = newG;
                    int h = heuristic(nx, ny, targetX, targetY);
                    int f = newG + h;
                    pq.offer(new Node(nx, ny, newG, f));
                }
            }
        }

        return -1;
    }

    // 曼哈顿距离
    private static int heuristic(int x, int y, int targetX, int targetY) {
        return Math.abs(x - targetX) + Math.abs(y - targetY);
    }

    public static void main(String[] args) {
        int[][] grid = {
                {0, 0, 0, 0},
                {1, 1, 0, 1},
                {0, 0, 0, 0},
                {0, 1, 1, 0}
        };

        System.out.println(aStar(grid));
    }
}

十一、代码细节解释

1. 为什么用优先队列?

因为 A* 每次要取出 f = g + h 最小的状态。

所以需要使用优先队列:

PriorityQueue<Node> pq = new PriorityQueue<>((a, b) -> a.f - b.f);

2. dist 数组的作用

dist[x][y] 表示:

从起点到 (x, y) 的当前最短真实距离

只有当发现更短路径时,才更新:

if (newG < dist[nx][ny]) {
    dist[nx][ny] = newG;
}

3. 为什么要跳过旧状态?

优先队列中可能存在同一个点的多个版本。

例如第一次到达 (x, y) 的距离是 10,后来又找到一条距离为 7 的路径。

那么距离为 10 的旧状态还在队列里。

所以需要:

if (cur.g > dist[cur.x][cur.y]) {
    continue;
}

这和 Dijkstra 的写法类似。

十二、A* 的正确性条件

当启发函数 h(n) 不高估从 n 到终点的真实最短距离时,A* 可以保证找到最优解。

也就是:

h(n) <= realDistance(n, target)

例如四方向网格中:

h(n) = 曼哈顿距离

就是合法的。

因为不管怎么走,从 (x, y)(targetX, targetY),至少要走:

|x - targetX| + |y - targetY|

这么多步。

十三、A* 的复杂度

理论上,A* 的最坏时间复杂度仍然可能接近 Dijkstra。

可以写成:

时间复杂度:O(E log V)
空间复杂度:O(V)

其中:

V:状态数量
E:状态转移数量

但是 A* 的实际表现很依赖启发函数。

如果 h(n) 设计得好,它会大幅减少搜索状态。

如果 h(n) 设计得很差,例如永远等于 0,那么 A* 就退化成 Dijkstra。

十四、A* 的常见竞赛模型

1. 网格最短路

典型场景:给定地图、障碍物、起点、终点,求最短路径。

启发函数通常用:曼哈顿距离

适合:

只能上下左右移动
每一步代价相同

2. 八数码问题

八数码是 A* 的经典应用。

状态是一个 3×3 棋盘,例如:

1 2 3
4 0 6
7 5 8

其中 0 表示空格。

目标状态可能是:

1 2 3
4 5 6
7 8 0

常见启发函数:

所有数字当前位置到目标位置的曼哈顿距离之和

例如数字 5 当前在 (2, 1),目标位置在 (1, 1),那么它的距离是 1。

把所有数字的距离加起来,就是当前状态的估价函数。

h(state) = 每个数字到目标位置的曼哈顿距离之和

这个估价函数不会高估真实步数,因此适合作为 A* 的启发函数。

3. 第 K 短路

A* 在竞赛中还有一个非常经典的应用:求从 s 到 t 的第 K 短路

常见做法:

  1. 先在反图上从终点 t 跑一次 Dijkstra。

  2. 得到每个点到终点的最短距离 h[u]

  3. 然后从起点 s 开始做 A*。

  4. 每次按照 g[u] + h[u] 排序。

  5. 当终点第 K 次出队时,答案就是第 K 短路长度。

这里的:

g[u]:从起点 s 到当前点 u 的路径长度
h[u]:从当前点 u 到终点 t 的最短距离

因为 h[u] 是真实最短距离,所以它一定是合法估价函数。

4. IDA*

IDA* 可以看成是:迭代加深搜索 + A* 估价函数

它常用于状态空间巨大,但内存不够存大量状态的问题。

例如:

八数码
十五数码
魔板问题
一些拼图类问题

IDA* 的核心是限制:

g(n) + h(n) <= depthLimit

如果超过限制,就剪枝。

十五、A* 常见错误

错误 1:h(n) 估大了

如果启发函数高估了真实距离,A* 可能得不到最优解。

错误例子:

真实剩余距离是 5
h(n) 却返回 100

这样会让算法错误地认为这个状态很差。

错误 2:把 A* 当成一定比 BFS 快

A* 不一定总比 BFS 快。

它是否更快,取决于启发函数是否有效。

如果启发函数很弱:h(n) = 0

那它就退化成 Dijkstra。

如果边权全是 1,那么这种情况下甚至可能不如普通 BFS 简洁。

错误 3:没有处理重复状态

在状态搜索问题中,经常会出现同一个状态被多次访问。

例如八数码中:A -> B -> A

如果不判重,就可能无限搜索。

因此一般需要:

dist 数组
HashMap
HashSet

来记录状态是否访问过,或者当前状态的最优代价。

错误 4:优先队列排序只看 h

如果只按照 h(n) 排序,那就变成了贪心搜索。

贪心搜索不保证最优。

A* 必须按照:f(n) = g(n) + h(n) 排序。

错误 5:边权有负数

A* 通常用于非负边权图。

如果图中存在负边,Dijkstra 本身就不适用,A* 也不能直接套用。