浅谈 A* 算法

· · 算法·理论

浅谈 A* 算法

更好的阅读体验

概论

计算最短路,通常使用两种算法:BFS 或 Dijkstra,前者用于无权图,后者用于有权图。

两者都是计算单源多汇最短路的算法,现在我们考虑一种更特殊的情况,即单源单汇最短路。

由于有“单汇”这一特殊条件,我们可以思考是否拥有优化的空间。

于是,我们有 A* 算法,是一种启发式搜索算法,它能够计算单源单汇最短路,可看作对于 BFS 和 Dijkstra 针对“单汇”这一特性的优化。

算法流程与分析

dis[i]i 点到起点的最短距离。

与堆优化 Dijkstra 相同,我们维护一个优先队列。

开始,初始化源点 dis[start] = 0,其余点 dis 设为无穷大,并将起点入队。

之后,不断取出堆顶元素 u,遍历其邻居 v,记两者边权为 w,如果 dis[v] > dis[u] + w,就令 dis[v] > dis[u] + w,最后将 v 入队。

如果取出了汇点,则结束循环。

可以看到,其过程事实上与堆优化 Dijkstra 完全相同,从而 A* 算法也只能处理没用负权的最短路,两者区别在于优先队列的排序。

A* 算法的优先队列中根据节点的 f(x) 从小到大排序,其中 f(x) = g(x) + h(x)g(x) 表示源点到点 x 的最短路径(即 dis[x]),h(x)x 点到汇点的最短路的一个估价函数(也就是对到汇点最短路的一个估计)。

因此,A* 算法的优化其实就体现在引入了一个估价函数,每一次选择 f(x) 最小的,也就是选择估计最有可能在最短路上的点优先访问,便能提高优先访问到汇点的概率,使得搜索范围降低,带来了常数上的优化,其中 h(x) 越接近实际最短路,算法越快(但不能超过,详见性质 1)。

值得注意的是,由于在排序上多引入了一个 h(x) 函数,导致其与 Dijkstra 产生了差异:弹出堆顶的点此时的 dis 并不一定是最短的,也就是说一个点被弹出堆顶后的 dis 仍然有可能被松弛,然后再次入堆(当然,一个好的 h(x) 函数不会让一个点重复入堆,详见性质 2)。

特别地,当 h(x) = 0 时,A 退化为 Dijkstra,当 h(x) = 0 且边权为 1 时,A 退化为 BFS。

性质

1. h(x) 是低估函数是 A* 算法正确的充分条件

x 到汇点的真实最短路长度为 h'(x)

证明如下: 令 $h(x)$ 为低估函数,记源点为 $s$,汇点为 $t$。 假设最后得到的 $dis[t]$ 错误,由于 $dis[t]$ 必然是路径累加而成,所以 $dis[t]$ 必然是相对正确结果大。 那么既然算法结束,t 必然已经出堆,也就是堆中不存在 $f(x)$ 比 $f(t) = g(t) + h(t) = g(t) = dis[t]$ 小(低估函数,$h(t)$ 必为 $0$)。 现在我们考虑原先 $s$ 到 $t$ 的最短路,记 A* 此时沿着这条路径走到了 $u$(已出堆),再记在最短路上 $u$ 后面点为 $v$,此时 $v$ 在堆中,那么我们有: $$f(v) = g(v) + h(v) \le g(v) + h'(v) = s\,到\,t\,最短路< dis[t] = f(t)$$ 这与“堆中不存在 $f(x)$ 比 $f(t)$ 小”的结论矛盾,故假设不成立,得证。 简单地说,就是不管错误的路径的估价多么小,能够被优先访问,但只要到达与汇点相邻的点,便会将 $f(t)$ 放入堆中,此时的 $f(t)$ 必然就是这条路径的真实长度,由于 $h(x)$ 是低估函数,所以真实沿着最短路走到的点的 $f(x)$ 必定不大于实际最短路,更加小于之前放入的 $f(t)$,使得错误的 $f(t)$ 将始终被卡在后面,不会出堆,那么也就是只有对的才会出堆。 反之,如果高估,就可能导致真实的最短路被高估得大于入堆的错误路径长度,以至于错误的会被先弹出。 ### 2. $h(x)$ 满足一致性能使得出堆的点的 $dis[i]$ 必为最小值 一致性是对于所有的边 $(u,v)$,都保证 $h(u) \le w(u, v) + h(v)$(满足三角不等式)。 只要满足一致性,那么出堆的点的 $dis[i]$ 就必为最小。 证明如下: 若 $g(u)$ 能更新 $g(v)$,则有 $g(u) + w(u,v)< g(v)$。 结合一致性 $h(u) \le w(u,v) + h(v)$,累加两式,得 $f(u) < f(v)$,则 $u$ 优先于 $v$ 出堆。 对于所有点,能够更新它的点都在它之前出堆,也就是在它之后出堆的点不能更新它,这使得其出堆时的 $dis$ 无法被松弛,即为最小。 因此我们需要注意,A* 算法与 Dijkstra 不一样的地方在于,不一定能在一个点出堆后发现其被访问过就跳过这个点,此步骤仅能在满足一致性的情况下能够使用。 ## 例题 ### 罗密欧与朱丽叶 #### 题目大意 给出一个 $n \times m$ 的迷宫,在两个位置分别有两个人,两者每秒分别都能往上下左右走一格,求他们相遇需要至少几秒钟。 #### 数据范围 $1<n,m\le 2000

解题思路

问题可以简化为求两者最短路,最后答案除以 2 即可。

由于无边权,采用 A* 的 BFS 写法。

这是一个网格图问题,对于网格图,我们通常取到终点的曼哈顿距离作为估价函数,即 h(x) = |x_{end} - x|+|y_{end} - y|

估价函数显然是低估的。

估价函数也符合一致性,走一步曼哈顿距离只会减小 1,则有 h(u) = w(u, v) + h(v),一致性成立。

故套用模板即可。

代码实现

#include<bits/stdc++.h>
#define endl "\n"
using namespace std;

const int N = 2e3 + 10;

struct node
{
    int x, y, f;
    bool operator < (const node &a) const
    {
        return f > a.f;
    }
};

int n, m, sx, sy, ex, ey;
bool b[N][N], vis[N][N];
int dis[N][N];
int dx[10] = {0, 1, 0, -1, 0};
int dy[10] = {0, 0, 1, 0, -1};

priority_queue<node> q;

int h(int x, int y)
{
    return abs(ex - x) + abs(ey - y);
}

int astar()
{
    dis[sx][sy] = 0;
    q.push((node) {sx, sy, h(sx, sy)});
    while (!q.empty())
    {
        int x = q.top().x, y = q.top().y;
        q.pop();
        if (vis[x][y])
        {
            continue;
        }
        vis[x][y] = 1;
        if (x == ex && y == ey)
        {
            return dis[x][y];
        }
        for (int i = 1; i <= 4; i++)
        {
            int nx = x + dx[i], ny = y + dy[i];
            if (nx >= 1 && nx <= n && ny >= 1 && ny <= m && b[nx][ny] && dis[nx][ny] > dis[x][y] + 1)
            {
                dis[nx][ny] = dis[x][y] + 1;
                q.push((node){nx, ny, dis[nx][ny] + h(nx, ny)});
            }
        }
    }
    return -1;
}

int main()
{
    ios :: sync_with_stdio(0);
    cin.tie(0), cout.tie(0);
    cin >> n >> m;
    for (int i = 1; i <= n; i++)
    {
        for (int j = 1; j <= m; j++)
        {
            char ch;
            cin >> ch;
            if (ch == 'o')
            {
                b[i][j] = 1;
            }
            else if (ch == '#')
            {
                b[i][j] = 0;
            }
            else if (ch == 'R')
            {
                b[i][j] = 1;
                sx = i, sy = j;
            }
            else
            {
                b[i][j] = 1;
                ex = i, ey = j;
            }
            dis[i][j] = 1e9;
        }
    }
    int ans = astar();
    if (ans == -1)
    {
        cout << "forever" << endl;
    }
    else
    {
        cout << fixed << setprecision(1) << 1.0 * ans / 2 << endl;
    }
    return 0;
}

无人驾驶

题目大意

给出一个 n\times m 的迷宫,空地上有 1\sim 9 的数字表示走入这一格的费用,并且还有 p 次撞破墙走入该格子的机会,会产生 1 的额外费用,求从 (a, b)(n, m) 的最小代价。

数据范围

a \le n \le 200$,$b \le m \le 200$,$p \le 40

解题思路

由于有边权,采用 A* 的 Dijkstra 写法。

对于估价函数,我们仍然采用到终点的曼哈顿距离,证明已经给出。

这里还有 p 次撞墙的机会,考虑使用拆点和分层图解决,将一个点 (x, y) 拆成若干个点 (x, y, p),其中 p 表示还有几次撞墙的机会。

撞墙实际上就是从 (x, y, p) 走到 (nx, ny, p - 1),边权为 1,其他情况照做即可。

答案即为 \min_{0 \le x \le p}(n, m, x)

代码

#include <bits/stdc++.h>
#define endl "\n"
using namespace std;

const int N = 210, M = 50;

struct node
{
    int x, y, p, f;
    bool operator < (const node &a) const
    {
        return f > a.f;
    }
};

int n, m, sx, sy, sp;
int a[N][N];
int dis[N][N][M];
bool vis[N][N][M];
int dx[10] = {0, 1, 0, -1, 0};
int dy[10] = {0, 0, 1, 0, -1};

priority_queue<node> q;

int h(int x, int y)
{
    return n - x + m - y;
}

void astar()
{
    dis[sx][sy][sp] = a[sx][sy];
    q.push((node) {sx, sy, sp, dis[sx][sy][sp] + h(sx, sy)});
    while (!q.empty())
    {
        int x = q.top().x, y = q.top().y, p = q.top().p;
        q.pop();
        if (vis[x][y][p])
        {
            continue;
        }
        vis[x][y][p] = 1;
        if (x == n && y == m)
        {
            return;
        }
        for (int i = 1; i <= 4; i++)
        {
            int nx = x + dx[i], ny = y + dy[i], np = p, w = a[nx][ny];
            if (a[nx][ny] == -1)
            {
                if (!np)
                {
                    continue;
                }
                np--;
                w = 1;
            }
            if (1 <= nx && nx <= n && 1 <= ny && ny <= m && !vis[nx][ny][np] && dis[nx][ny][np] > dis[x][y][p] + w)
            {
                dis[nx][ny][np] = dis[x][y][p] + w;
                q.push((node) {nx, ny, np, dis[nx][ny][np] + h(nx, ny)});
            }
        }
    }
}

int main()
{
    ios :: sync_with_stdio(0);
    cin.tie(0), cout.tie(0);
    cin >> n >> m >> sx >> sy >> sp;
    for (int i = 1; i <= n; i++)
    {
        string s;
        cin >> s;
        s = " " + s;
        for (int j = 1; j <= m; j++)
        {
            if (s[j] == '#')
            {
                a[i][j] = -1;
            }
            else
            {
                a[i][j] = s[j] - '0';
            }
            fill(dis[i][j], dis[i][j] + sp + 1, INT_MAX);
        }
    }
    astar();
    int ans = *min_element(dis[n][m], dis[n][m] + sp + 1);
    if (ans == INT_MAX)
    {
        cout << "mission impossible" << endl;
    }
    else
    {
        cout << ans << endl;
    }
    return 0;
}