BFS 进阶之 A*, 双向bfs

双向BFS

 在由起点状态到目标状态进行搜索时,搜索树是以指数层级递增的,所以说在一些情况下,时间复杂度会导致单向的bfs TLE掉或者 MLE掉,这时候就需要对bfs进行一些优化,双向bfs就是一种,双向bfs适用于得知起点和目标点状态时适用,使用两个队列分别从起点和目标点进行双向搜索,当两个方向的状态第一次相遇,即为最短转移路径,这样可以限制搜索树的深度一般在原搜索树的一半左右,极大的降低了所需的复杂度
可以参考一下这个图 引用自


当两种颜色相遇的时候,说明两个方向的搜索树遇到一起,这个时候就搜到了答案。

例1、 走迷宫

这个题单向bfs也是可以的,但是也可以作为双向bfs的联系,很简单,直接上代码了,注释我自认为是比较详细的

注意我们在出队列的时候,我们要选择搜索树较小的那个出队列,这样就可以把总的搜索树尽可能压小也可以这样想如果选择大的,和单向bfs就没啥不同了

#include <iostream>
#include <queue>
#include <cstdio>

using namespace std;

const int maxn = 45;
const int dir[4][2] = {
   
   {-1,0}, {0,1}, {1,0},{0,-1}};//方向

queue <pair <int,int> > q1, q2;
int dis[maxn][maxn];
int vis[maxn][maxn];//vis 1 表示 q1扩展得到,2 表示 q2扩展得到
char maps[maxn][maxn];

bool check(int x, int y, int r, int c)
{
    if (x < 0 || x >= r || y < 0 || y >= c || maps[x][y] == '#') return false;
    return true;
}

int dbfs(int r,int c)   
{
    bool flag;//记录这次是出的哪一个队列
    //将起点终点入队列,并标记
    vis[0][0] = 1,vis[r - 1][c - 1] = 2;
    dis[0][0] = dis[r - 1][c - 1] = 0;
    q1.push(make_pair(0,0)), q2.push(make_pair(r - 1,c - 1));
    while(q1.size() && q2.size()) {
        int x0,y0;
        //每一次扩展 扩展树比较小的
        if (q1.size() > q2.size()) {
            x0 = q2.front().first;
            y0 = q2.front().second;
            q2.pop();
            flag = 1;
        }
        else {
            x0 = q1.front().first;
            y0 = q1.front().second;
            q1.pop();
            flag = 0;
        }
        for(int i = 0; i < 4; i++) {
            int x1 = x0 + dir[i][0], y1 = y0 + dir[i][1];
            //判断该位置是否合法
            if (!check(x1,y1,r,c)) continue;
            //如果之前没有被扩展过就直接扩展
            if (!vis[x1][y1]) {
                dis[x1][y1] = dis[x0][y0] + 1;
                vis[x1][y1] = vis[x0][y0];
                if (flag) q2.push(make_pair(x1,y1));
                else q1.push(make_pair(x1,y1));
            }
            else {
            //两种状态相遇,这里由于起点题目要求要算一步,且由(x0,y0) -> (x1,y1)状态也要花费一步
                if(vis[x0][y0] + vis[x1][y1] == 3) return dis[x0][y0] + dis[x1][y1] + 2; 
            }
        }
    }
    return -1;
}

int main()
{
    int r,c;
    cin >> r >> c;
    for(int i = 0; i < r; i++) cin >> maps[i];
    int ans = dbfs(r,c);
    cout << ans << endl;
    return 0;

例2、八数码

 很经典的一道搜索题目,待会再A* 里还会引用到这个题,这个题的的做法有很多,这里先说一下双向bfs
  首先记录状态的话,采用了把九宫格拉扁成字符串进行记录,关于判重的话,可以采用康托展开
但是这里选择map更方便一些,但是相较于会慢一些,不过使用unordered_map的话,查询是O(1)的,所以效率会比map提升一倍 (使用map耗时204ms,而后者为97ms)
 要注意,要先利用八数码的一个性质把没有解的情况去除,不然会有没有解的样例导致TLE,不论A* 还是双向BFS(别的可能也会T但是没测试过…)

#include <iostream>
#include <algorithm>
#include <queue>
#include <map>
#include <unordered_map>

using namespace std;

const int dir[4][2] = {
   
   {-1,0}, {0,1}, {1,0}, {0,-1}};

string start;

inline bool check(int x,int y)
{
    if(x < 0 || x >=3 || y < 0 || y >= 3) return false;
    return true;
}

int dbfs()
{
    bool flag;
    string end = "12345678x";
    unordered_map <string,int> vis;//1为正向队列,2为逆向队列
    unordered_map <string,int> dis;
    queue <string> q1,q2;
    vis[start] = 1, vis[end] = 2;
    dis[start] = 0, dis[start] = 0;
    q1.push(start), q2.push(end);
    while (q1.size() && q2.size()) {
        string cur;
        if (q1.size() > q2.size()) {
            cur = q2.front();
            q2.pop();
            flag = true;
        }
        else {
            cur = q1.front();
            q1.pop();
            flag = false;
        }
        //找'x'位置
        int x,y;
        for (int i = 0; i < 9; i++) {
            if (cur[i] == 'x') {
                x = i / 3, y = i % 3;
                break;
            }
        }
        for (int i = 0; i < 4; i++) {
            string nex = cur;
            int xx = x + dir[i][0], yy = y + dir[i][1];
            if (!check(xx,yy)) continue;
            swap(nex[3 * x + y], nex[3 * xx + yy]);
            if (!vis.count(nex)) {
                dis[nex] = dis[cur] + 1;
                vis[nex] = vis[cur];
                if(flag) q2.push(nex);
                else q1.push(nex);
            }
            else {
              if(vis[nex] + vis[cur] == 3) return dis[nex] + dis[cur] + 1;
            }
        }
    }
    return -1;
}

int main()
{
    string t, h;
    int cnt = 0;
    for (int i = 0; i < 9; i++) {
        cin >> h;
        start += h;
        if (h != "x") t += h;
    }
    for (int i = 0; i < 8; i++) {
        for (int j = 0; j <= i; j++) {
            if (t[j] > t[i]) cnt++;
        }
    }
    //cout << start + " " << t << endl;
    if(cnt % 2) cout << -1 << endl;
    else cout << dbfs() << endl;
    return 0;
}

A* 算法

简要概述

 简要概述一下的话,就是带有启发式函数的bfs,那么什么是启发式函数呢?即为,在进行bfs搜索时,我不在是盲目的搜索每一层,而是具有选择性的去进行扩展,比如在求两点最短距离时,我每一次选择距离目标点更近的点进行扩展,而不是把所有点扩展一遍

具体做法

 我们首先要定义一个估价函数 f(x) ,即为 经过某点x到目标点的代价其函数表示如下:
 f(x) = dis(x) + h(x); dis(x) 表示起点到点x的实际代价,h(x)为 x点到目标点的估计代价,且g(x)为x点到目标点的实际代价,首先我们要保证当h(x) <= g(x)时,A*是一定有解的,那么我们可以采用贪心的思想,每一次选择离目标点(状态)最近的一个点(状态)进行扩展,所以我们要按照f(x)从小到大进行排序(一般是利用优先队列进行),每一次选择一个代价最小的点进行扩展,也符合了启发式搜索的特性,我每一次向着更优的方向去扩展,最终得到最优解.

关于算法的数学理论部分

(这一部分数学理论可以记得不是很清楚了,如有错误欢迎指正):

  1. A算法的效率是取决于启发式函数h(x)的,如果h(x)为0的话,那么就说明,我们的搜索是盲目的,一点提示信息都是没有的,其实就是俗称的bfs,也就是当h(x)越接近g(x),搜索效率就越高,当h(x) 恰好等于g(x)时,就可以相当于Dijkstra算法,而且可以保证当h(x) <= g(x)时,是一定可以得到最优解的,关于数学证明 可以去找一下专门的证明文章 其实我也不会 当h(x) > g(x)时,A的搜索效率极高,但是不一定保证会有正确答案,所以当设计启发式函数时要注意尽量让 h(x) <= g(x)
  2. 并不是我每一次出堆的点(状态)距离起点(初始状态)的距离都是最优的,一般情况下,只能证明当终点(目标状态)第一次从堆中弹出时,可以保证这是最优解 证明如下

证明:
先说明两个引理,没有这两个引理会导致下面的证明出现漏洞
结论1:
 i通过i->…->k->…->j这条最短路径p到达j,k是这条路径上的一个中间点,那么i->…k也是i到k的最短路径。
结论2:
 对于最短路径上一点vj前面的所有点,但凡vj进入open,这些点要么都已经进入closed,要么至少有一个就在open中
引用自A* 算法证明
有兴趣的同学可以去看一下
还有一个结论3:
 目标点的fn=gn+0,如果有路径到达目标点,那么所有能到达目标点的路径都在open表里面,而且A*算法必然能找到最优的那条路径(后半句对于证明不影响)

下面是证明过程

反证法:
假设终点第一次出堆时不是最小值,那么意味着dist[end] > dist优
那么说明堆中存在一个最优路径中的某个点(起码起点在路径上),记该点为u,
dist优 = dist[u] + g(u) >= dist[u] + f(u)
-> dist[end] > dist优 >= dist[u] + f(u),说明优先队列在扩展到出队点的过程中存在一个比出堆元素更小的值,这就矛盾了。
所以说终点第一次出堆时就是最优的。

但是当h(x)具有一致性时不要问我一致性是什么,吃了数学不好的亏,是可以保证每一次出队都是最优解的

  1. 要注意的是 与BFS和Dijkstra算法相比,A算法是不会进行判重的,因为每一次出队的状态并不是保证最优的(除目标点外),可能需要对一个已经入过队的点进行多次修正以保证可以得到最优解,所以是不能对点进行判重的,当然,对于已经证明f(x)具有一致性的话,是可以对于每一次出队的点进行判重来提高效率,因为以及可以保证每一次出队的点都是最优,不会再有别的点对它进行修正,但是对于一致性的证明是有一些困难的,如果要保险一些的话,尽量还是选择不对其进行判重,但是在竞赛使用的时候,会卡你不判重所导致的时间额外消耗或者会卡你无解的情况TLE,所以这时候就需要直接默认具有一致性去判重,不需要太过在意是否能证明一致性 不这样过不了啊大人。在一般情况下,h(x)都是可采纳的,但是不满足一致性,如果对于没有解的情况进行A搜索的话,依旧会到达所有点(状态) 并且因为是优先队列的关系每一次出队的时间复杂度还是O(nlogn),会导致时间复杂度巨增,所以在进行求解前要把无解的情况做一个剪枝处理

应用的环境:
1、有解(无解时,仍然会把所有空间搜索,会比一般的bfs慢,因为优先队列的操作是logn的)
2、边权非负,如果是负数,那么终点的估值有可能是负无穷,终点可能会直接出堆

性质:
除了终点以外的其他点无法在出堆或者如堆的时候确定距离,只能保证终点出堆时是最优的可以。

  1. 对于h(x)的一些常用的启发式函数,通常使用欧拉距离和欧几里德距离进行计算

例1、Knight Moves

一道简单的bfs题,但是也可以用A*去做,思路很简单主要是练习下代码的实现,这里之间上代码了

#include <iostream>
#include <cstdio>
#include <queue>
#include <algorithm>                                                                                              
#include <cstring>
#include <string>
#include <cmath>

using namespace std;

typedef long long ll;
typedef pair <int,int> P;

const int inf = 0x7fffffff;
const int maxn= 10 + 5;
const int mod = 1e9 + 7;
const int dir8[8][2] = {
   
   {-1,0},{1,0},{0,-1},{0,1},{-1,-1},{-1,1},{1,-1},{1,1}};
const int dir4[4][2] = {
   
   {-1,0},{1,0},{0,-1},{0,1}};
const int dirr[8][2] = {
   
   {1, 2}, {1, -2}, {-1, 2}, {-1, -2}, {2, 1}, {2, -1}, {-2, 1}, {-2, -1}} ;


//存储起点终点
P s,t;

//h(x) 为欧拉距离
float gx[maxn][maxn];
int step[maxn][maxn];
bool vis[maxn][maxn];

inline bool check(P x) 
{
    if(x.first < 1 || x.first > 8 || x.second < 1 || x.second > 8) 
        return false;
    return true;
}

inline float caldis(P x,P y)
{
    return sqrt((x.first - y.first) * (x.first - y.first) + (x.second - y.second) * (x.second - y.second)); 
}

inline void init()
{
    memset(vis,false,sizeof vis);
    memset(gx,0,sizeof gx);
    for(int i = 0; i <= maxn; i++) {
        for(int j = 0; j <= maxn; j++) 
            step[i][j] = inf;
    }
}

int Solve()
{
    init();
    priority_queue < pair <float,P> > que;
    step[s.first][s.second] = 0;
    que.push(make_pair(-caldis(s,t), s));
    //每次选择一个f最小的开始扩展
    while(!que.empty()) {
        P x = que.top().second;
        //cout << "first: " << que.top().first << endl;
        que.pop();
        if(x == t)
            return step[x.first][x.second];
        for(int i = 0; i < 8; i++) {
            P u;
            u.first = x.first + dirr[i][0] , u.second = x.second + dirr[i][1];
            if(!check(u))
                continue;
            if(!vis[x.first][x.second] || step[u.first][u.second] > step[x.first][x.second] + 1) {
                vis[x.first][x.second] = true;
                step[u.first][u.second] = step[x.first][x.second] + 1;
                gx[u.first][u.second] = gx[x.first][x.second] + sqrt(5);
                que.push(make_pair( -gx[u.first][u.second] - caldis(u,t),u));
            }
        }
    }
    return 0;
}

int main()
{    
    char s1[3],s2[3];
    while(~scanf("%s%s",s1,s2)) {
        s.first = s1[0] - 'a' + 1,s.second = s1[1] - '0';
        t.first = s2[0] - 'a' + 1,t.second = s2[1] - '0';
        int ans = Solve();
        printf("To get from %s to %s takes %d knight moves.\n",s1,s2,ans);
    }
    return 0;
}

例2、八数码

这里采用的是欧几里德距离进行估价函数的设计,这里相较于上面八数码的改动就是要求输出路径,注意路径并不唯一,并每次更新修正的时候都记录下前一个状态和转移方向,当走到目标状态时回溯即可,要注意利用组合数学去把无解的情况删除,A*在处理无解情况时是及其臃肿的

代码

#include <iostream>
#include <cstdio>
#include <queue>
#include <map>
#include <algorithm>
#include <cstring>
#include <string>
#include <cmath>

using namespace std;

typedef long long ll;
typedef pair <int,int> P;

const int inf = 0x7fffffff;
const int maxn= 10 + 5;
const int mod = 1e9 + 7;
const int dir8[8][2] = {
   
   {-1,0},{1,0},{0,-1},{0,1},{-1,-1},{-1,1},{1,-1},{1,1}};
const int dir4[4][2] = {
   
   {-1,0},{1,0},{0,-1},{0,1}};
const char path[4] = {'u','d','l','r'};
string toal = "12345678x";

struct node 
{
    int f;//估计函数
    string cur;//当前数据状态
    bool operator < (const node &a) const {
        return f > a.f;
    }
};

//获取函数H值 以曼哈顿距离为H函数值
int getH(string s)
{
    int h = 0;
    for(int i = 0; i < 9; i++) {
        //求曼哈顿距离
        if(s[i] != 'x') {
            //当做01234567x  这样坐标是(t / 3, t % 3) 
            int t = s[i] - '1';
            //当前字符的坐标(i / 3, i % 3) 正确的位置(t / 3, t % 3)
            h = h + abs(i / 3 - t / 3) + abs(i % 3 - t % 3);
        } 
    }
}

inline bool check(int x,int y)
{
    if(x < 0 || x >= 3 || y < 0 || y >= 3) return false;
    return true;
}

void print(string start);

map <string, pair <int,string> > pre;//用来记录路径

void Astar(string start)
{
    string last, next;
    map <string,int>  g;//用来记录该状态的g函数值
    map <string,bool> vis;//用来判重
    priority_queue <node> q;
    g[start] = 0;
    q.push( node{ getH(start),start} );
    int gx;
    while(!q.empty()) {
        node temp = q.top();
        q.pop();
        last = temp.cur;
        gx = g[last];
        // if(vis[last]) continue;
        // vis[last] = true;
        if(last == toal) {
			print(start);
			return ;
		}
        int x,y;
        //查找x的位置
        for(int i = 0; i < 9; i++) {
            if(last[i] == 'x') {
                x = i / 3;
                y = i % 3; 
                break;
            }
        }
        //开始对状态进行拓展
        for(int i = 0; i < 4; i++) {
            int fx = x + dir4[i][0],fy = y + dir4[i][1];
            if(!check(fx,fy))//是否超出了矩阵的范围 
                continue;
            next = last;
            swap(next[fx * 3 + fy], next[x * 3 + y]);
            //没有进入过open列表或者可以被优化
            if(!vis.count(next) || g[next] > gx + 1) {
                vis[next] = true;
                g[next] = gx + 1;
                q.push(node{ getH(next) + g[next], next});
                //记录路径保留上一状态
                pre[next] = make_pair(i,last);
            }   
        }
    }
}

void print(string start)
{
    string finall = "";
    while(toal != start) {
        finall += path[pre[toal].first];
        toal = pre[toal].second;
    }
    reverse(finall.begin(),finall.end());
    cout << finall << endl;
}

int main()
{
    string start = "",t,q;
    for(int i = 0; i < 9; i++) {
        cin >> t;
        start += t;
        if(t != "x") q +=t;
    }
    int rev = 0;
    for(int i = 0; i < 8; i++) {
        for(int j = i; j < 8; j++) {
            if(q[j] > q[i])
                rev++;
        }
    }
    if(rev % 2) cout << "unsolvable" << endl;
    else Astar(start);
    return 0;
}

例 3、第k短路

这类题就很明显的体现出卡判重和剪枝的情况

关于双向BFS和A*的一些小缺陷

这两种算法在题目要求输出一些特殊情况的路径时会及其复杂,就比如说HDU 3567 要求输出字典序最小的最短路径,说实话,输出最短路径很好解决,但是涉及到字典序最小的时候,就会发现这两种算法是极其难以控制路径的组成,A*基本我还不知道如何求的字典序最小,双向BFS的做法我个人认为很复杂,并且我仿写的一个代码对拍了几百组数据也没发现有啥问题,心态发生了些微妙的变化,况且我认为这种题目有点阴间并且少见 是我又菜做题又少的原因 所以对于一般要求输出最短路径这两种算法是可以应付的,但是对于HDU3567一类的题,就会比较头痛了,这也是这两个算法我认为有些缺憾的地方 李在赣神魔!!

猜你喜欢

转载自blog.csdn.net/CUCUC1/article/details/112714835
BFS