算法竞赛进阶指南 搜索 0x27 A*

在探讨A*算法之前,我们先来回顾一下优先队列BFS算法。该算法维护了一个优先队列(二叉堆),不断从堆中取出“当前代价最小”的状态(堆顶)进行扩展。每个状态第一次从堆中被取出时,就得到了从初态到该状态的最小代价

如果给定一个目标状态,需要求出从初态到目标状态的最小代价,那么优先队列BFS的这个”优先策略“显然是不完善的。一个状态的当前代价最小,只能说明从起始状态到该状态的代价很小,而在未来的搜索中,从该状态到目标状态可能会花费很大的代价。另外一些状态虽然当前代价略大,但是未来到目标状态到代价可能会很小,于是从起始状态到目标状态的总代价反而更优。优先队列BFS会优先选择前者的分支,导致求出最优解的搜索量增大。

为了提高搜索效率,我们很自然地想到,可以对未来可能产生的代价进行预估。详细地讲,我们设计一个“估价函数”,以任意状态为输入,计算出从该状态到目标状态所需代价的估计值。在搜索中,仍然维护一个堆,不断从堆中取出“当前代价+未来估价”最小的状态进行扩展

为了保证第一次从堆中取出目标状态时得到的就是最优解,我们设计的估价函数需要满足一个基本准则:
在这里插入图片描述
也就是说,估价函数的估值不能大于未来实际代价,估价比实际代价更优。

如果我们设计估价函数时遵守上述准则,保证估值不大于未来实际代价,那么即使估价不太准确,导致非最优解搜索路径上的状态s先被扩展,但是随着“当前代价”的不断累加,在目标状态被取出之前的某个时刻:
1、根据s并非最优,s的“当前代价”就会大于从起始状态到目标状态的最小代价
2、对于最优解搜索路径上的状态t,因为 f ( t ) ≤ g ( t ) f(t) \leq g(t) f(t)g(t),所以t的“当前代价”加上f(t)必定小于等于t的“当前代价”加上g(t),而后者的含义就是从起始状态到目标状态的最小代价
综合以上两点,可知“t的当前代价加上f(t)“小于s的当前代价。因此,t就会被从堆中取出进行扩展,最终更新到目标状态上,产生最优解

这种带有估价函数的优先队列BFS就称为 A*算法。只要保证对于任意状态state,都有 f ( s t a t e ) ≤ g ( s t a t e ) f(state) \leq g(state) f(state)g(state),A算法就一定能在目标状态第一次从堆中被取出时得到最优解,并且在搜索过程中每个状态只需要被扩展一次(之后再被取出就可以直接忽略)。估价f(state)越准确、越接近g(state),A算法的效率就越高。如果估价始终为0,就等价于普通的优先队列BFS

1、AcWing 178. 第K短路

题意 :

  • 给定一张 N 个点(编号 1,2…N),M 条边的有向图,求从起点 S 到终点 T 的第 K 短路的长度,路径允许重复经过点或边。
  • 注意: 每条最短路中至少要包含一条边。
  • 1≤S,T≤N≤1000,
  • 0≤M≤10^4,
  • 1≤K≤1000,

思路 :

  • 一个比较直接的想法是使用优先队列BFS进行求解。优先队列(堆)中保存一些二元组(x, dist),其中x为节点编号,dist表示从S沿着某条路径到x的距离
  • 起初,堆中只有(S, 0)。我们不断从堆中取出dist值最小的二元组(x, dist),然后沿着从x出发的每条边(x, y)进行扩展,把新的二元组(y, dist + length(x, y))插入到堆中(无论堆中是否已经存在一个节点编号为y的二元组)
  • 上一节我们已经讲到,在优先队列BFS中,某个状态第一次从堆中被取出时,就得到了从初态到它的最小代价。读者用数学归纳法很容易得到一个推论:对于任意正整数i和任意节点x,当第i次从堆中取出包含节点x的二元组时,对应的dist值就是从S到x的第i短路。所以,当扩展到的节点y已经被取出K次时,就没有必要再插入堆中了。最后当节点T第K次被取出时,就得到了S到T的第K短路。
  • 使用优先队列BFS在最坏情况下的复杂度 O ( K ∗ ( N + M ) ∗ l o g ( N + M ) ) O(K*(N+M)*log(N+M)) O(K(N+M)log(N+M))。这道题目给定了起点和终点,求长度最短(代价最小)的路径,可以考虑使用A*算法提高搜索效率
  • 根据估价函数的设计准则,在第K短路中从x到T的估计距离f(x)应该不大于第K短路中从x到T的实际距离g(x)。于是,我们可以把估价函数f(x)定为从x到T的最短路长度,这样不但能保证 f ( x ) ≤ g ( x ) f(x) \leq g(x) f(x)g(x),还能顺应g(x)的实际变化趋势。最终我们得到了以下A*算法:
    1、预处理出各个节点x到终点T的最短路长度f(x)——这等价于从反向图以T的起点求解单源最短路径问题,可以在 O ( ( N + M ) ∗ l o g ( N + M ) ) O((N+M)*log(N+M)) O((N+M)log(N+M))的时间内完成
    2、建立一个二叉堆,存储一些二元组(x, dist + f(x)),其中x为节点编号,dist表示从S到x当前走过的距离。起初堆中只有(S, 0 + f(0))
    3、从二叉堆中取出dist + f(x)值最小的二元组(x, dist + f(x)),然后沿着从x出发的每条边(x, y)进行扩展。如果节点y被取出的次数尚未达到K,就把新的二元组(y, dist + length(x, y) + f(y))插入堆中
    4、重复第2~3步,直至第K次取出包含终点T的二元组,此时二元组中的dist值就是从S到T的第K短路
  • A算法的复杂度上界与优先队列BFS相同。不过,因为估价函数的作用,图中很多节点访问次数都远小于K,上述A算法已经能够比较快速地求出结果。
#include <iostream>
#include <queue>
#include <cstring>
using namespace std;
typedef pair<int, int> PII;
typedef pair<int, PII> PIII;
const int N = 1e3 + 10, M = 2e4 + 10; // 虽然有向图,但是还要存反向边

int n, m;
// 有向图+其反向图:只需要多一个rh数组即可,然后把边开两倍大,其他包括idx都共用即可
// add函数再传入一个h数组作为参数来区分
int h[N], rh[N], e[M], ne[M], w[M], idx;
int S, T, K;
bool st[N];
int dist[N], cnt[N];

void add(int h[], int a, int b, int c) {
    e[idx] = b; w[idx] = c; ne[idx] = h[a]; h[a] = idx ++ ;
}
void dijkstra() {
//    memset(st, 0, sizeof st);
    memset(dist, 0x3f, sizeof dist);
    priority_queue<PII, vector<PII>, greater<PII>> heap;
    heap.push({0, T});
    dist[T] = 0;
    
    while (heap.size()) {
        PII t = heap.top(); heap.pop();
        
        int ver = t.second, distance = t.first;
        if (st[ver]) continue;
        st[ver] = true;
        
        for (int i = rh[ver]; ~i; i = ne[i]) {
            int j = e[i];
            if (dist[j] > dist[ver] + w[i]) {
                dist[j] = dist[ver] + w[i];
                heap.push({dist[j], j});
            }
        }
    }
}
int astar() {
    priority_queue<PIII, vector<PIII>, greater<PIII>> heap;
    heap.push({0 + dist[S], {0, S}});
    
    while (heap.size()) {
        PIII t = heap.top(); heap.pop();
        
        int ver = t.second.second, distance = t.second.first;
        ++ cnt[ver];
        if (cnt[T] == K) return distance;
        
        for (int i = h[ver]; ~i; i = ne[i]) {
            int j = e[i];
            if (cnt[j] < K) {
                heap.push({distance + w[i] + dist[j], {distance + w[i], j}});
            }
        }
    }
    return -1;
}

int main() {
    memset(h, -1, sizeof h);
    memset(rh, -1, sizeof rh);
    scanf("%d%d", &n, &m);
    for (int i = 0, a, b, c; i < m; ++ i) {
        scanf("%d%d%d", &a, &b, &c);
        add(h, a, b, c);
        add(rh, b, a, c);
    }
    scanf("%d%d%d", &S, &T, &K);
    if (S == T) ++ K; // 如果S等于T,找第2短路
    
    dijkstra();
    printf("%d", astar());
}

2、AcWing 179. 八数码

题意 :

  • 求出八数码的最少移动步数,并给出一种方案
  • 输入占一行,将 3×3 的初始网格描绘出来。
  • 输出占一行,包含一个字符串,表示得到正确排列的完整行动记录。

思路 :

  • 先进行可行性判定。我们在0x05节中已经提到过,把除空格之外的所有数字排成一个序列,求出该序列的逆序对数。如果初态和终态的逆序对数奇偶性相同,那么这两个状态互相可达,否则一定不可达。而终态12345678的逆序对数是0也就是偶数。
  • (该结论的必要性很容易证明:空格左右移动时,写成的序列显然不变;空格向上(下)移动时,相当于某个数与它前(后)边的3-1个数交换了位置,因为2是偶数,所以逆序对数的变化也只能是偶数)(+2,-2,+0(+0(+1-1)的情况见下图))
    (原先两个逆序对 -> -2
    原先没有逆序对 -> +2
    原先一个逆序对一个不是逆序对 -> +0)

在这里插入图片描述

  • 若问题优解,我们就采用A*算法搜索一种移动步数最少的方案。经过观察可以发现,每次移动只能把一个数字与空格交换位置,这样至多把一个数字向它在目标状态中的位置移近一步。即使每一步移动都是有意义的,从任何一个状态到目标状态的移动步数也不可能小于所有数字当前位置与目标位置的曼哈顿距离之和

  • 于是,对于任意状态state,我们可以把估价函数设计为所有数字在state中的位置与目标状态end中的位置的曼哈顿距离之和,即:
    f ( s t a t e ) = ∑ n u m = 1 9 ( ∣ s t a t e x n u m − e n d x n u m ∣ + ∣ s t a t e y n u m − e n d y n u m ∣ ) f(state)= \sum^9_{num=1}(|state_{x_{num}}-end_{x_{num}}|+|state_{y_{num}}-end_{y_{num}}|) f(state)=num=19(statexnumendxnum+stateynumendynum)

  • 其中, s t a t e x n u m state_{x_{num}} statexnum表示在状态state下数字num的行号, s t a t e y n u m state_{y_{num}} stateynum为列号。

  • 我们不断从堆中取出“从初态到当前状态state已经移动的步数+f(state)“最小的状态进行扩展,当终态第一次被从堆中取出时,就得到了答案

  • 在A*算法中,为了保证效率,每个状态只需要在第一次被取出时扩展一次。本题中的状态时一个八数码,并非一个简单的节点编号,所以需要使用Hash来记录每个八数码是否已经被取出并扩展过一次。我们可以选择取模配合开散列处理冲突,或STL map等Hash方法。

  • 注意因为使用map来记录更新最小值,因此在判断大小之前要先判断dist.count(state) == 0

#include <iostream>
#include <cstring>
#include <queue>
#include <algorithm>
#include <unordered_map>
using namespace std;
typedef pair<int, string> PIS;

string start, ed = "12345678x", seq;

int f(string state) {
    int res = 0;
    for (int i = 0; i < 9; ++ i) {
        if (state[i] != 'x') {
            int now = state[i] - '1';
            res += abs(i / 3 - now / 3) + abs(i % 3 - now % 3);
        }
    }
    return res;
}
void bfs() {
    unordered_map<string, int> dist;
//    map<string, char> pre;
    unordered_map<string, pair<char, string>> pre;
    priority_queue<PIS, vector<PIS>, greater<PIS>> heap;
    
    int dx[] = {1, 0, -1, 0}, dy[] = {0, 1, 0, -1};
    char ops[5] = "drul";
    
    heap.push({0 + f(start), start});
    dist[start] = 0;
    
    while (heap.size()) {
        PIS t = heap.top(); heap.pop();
        
        string state = t.second;
        if (state == ed) break;
        
        int x, y;
        for (int i = 0; i < 9; ++ i)
            if (state[i] == 'x') {
                x = i / 3;
                y = i % 3;
                break;
            }
        
        string source = state;
        for (int i = 0; i < 4; ++ i) {
            int a = x + dx[i], b = y + dy[i];
            if (a < 0 || a >= 3 || b < 0 || b >= 3) continue;
            state = source;
            swap(state[a * 3 + b], state[x * 3 + y]);
            if (dist.count(state) == 0 || dist[state] > dist[source] + 1) {
                dist[state] = dist[source] + 1;
                heap.push({dist[state] + f(state), state});
                pre[state] = {ops[i], source};
            }
        }
    }
    
    string res = "";
    while (ed != start) {
        res += pre[ed].first;
        ed = pre[ed].second;
    }
    reverse(res.begin(), res.end());
    printf("%s", res.c_str());
}

int main() {
    char c;
    while (cin >> c) {
        start += c;
        if (c != 'x') seq += c;
    }
    int cnt = 0;
    for (int i = 0; i < 8; ++ i)
        for (int j = i + 1; j < 8; ++ j)
            if (seq[i] > seq[j])
                ++ cnt;
    if (cnt % 2) puts("unsolvable");
    else bfs();
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值