【图论】最小步数(双向广搜与A*算法)

算法提高课笔记

目录

  • 最小步数
    • 魔板
      • 题意
      • 思路
      • 代码
  • 双向广搜
    • 字串变换
      • 题意
      • 思路
      • 代码
  • A*
    • 八数码
      • 题意
      • 思路
      • 代码(+详细注释)
    • 第K短路
      • 题意
      • 思路
      • 代码( + 详细注释)

最小步数

魔板

原题链接

Rubik 先生在发明了风靡全球的魔方之后,又发明了它的二维版本——魔板。

这是一张有 8 个大小相同的格子的魔板:

1 2 3 4
8 7 6 5

我们知道魔板的每一个方格都有一种颜色。

这 8 种颜色用前 8 个正整数来表示。

可以用颜色的序列来表示一种魔板状态,规定从魔板的左上角开始,沿顺时针方向依次取出整数,构成一个颜色序列。

对于上图的魔板状态,我们用序列 (1,2,3,4,5,6,7,8) 来表示,这是基本状态。

这里提供三种基本操作,分别用大写字母 A,B,C 来表示(可以通过这些操作改变魔板的状态):

A:交换上下两行;
B:将最右边的一列插入到最左边;
C:魔板中央对的4个数作顺时针旋转。

下面是对基本状态进行操作的示范:

A:

8 7 6 5
1 2 3 4

B:

4 1 2 3
5 8 7 6

C:

1 7 2 4
8 6 3 5

对于每种可能的状态,这三种基本操作都可以使用。

你要编程计算用最少的基本操作完成基本状态到特殊状态的转换,输出基本操作序列。

注意:数据保证一定有解。

输入格式

输入仅一行,包括 8 个整数,用空格分开,表示目标状态。

输出格式

输出文件的第一行包括一个整数,表示最短操作序列的长度。

如果操作序列的长度大于0,则在第二行输出字典序最小的操作序列。

数据范围

输入数据中的所有数字均为 1 到 8 之间的整数。

输入样例

2 6 8 4 5 7 3 1

输出样例

7
BCABCCB

题意

给出一个原始状态,问怎么经过最少步骤转换成特殊状态,以及转换步骤是什么

思路

思路比较简单但是代码实现有难度,依然是把原始状态先入队,然后每次更新队头序列可以进行的操作
每个操作直接打表更简单

代码

#include 

using namespace std;

char a[10];
string endd;
map<string, int> dist; // 存储每一个状态到最终状态的距离
map<string, pair<char, string> > pre; // 存储从后一个状态到前一个状态的具体操作

string get(string t, int op) // 模拟每个操作
{
    string k;
    if (op == 0) k = {t[4], t[5], t[6], t[7], t[0], t[1], t[2], t[3]};
    if (op == 1) k = {t[3], t[0], t[1], t[2], t[7], t[4], t[5], t[6]};
    if (op == 2) k = {t[0], t[5], t[1], t[3], t[4], t[6], t[2], t[7]};
    return k;
}

int bfs()
{
    string s = "12348765";
    queue<string> q;
    dist[s] = 0;
    q.push(s);

    while (q.size())
    {
        auto t = q.front();
        q.pop();

        if (t == endd) return dist[t];

        for (int i = 0; i < 3; i ++ ) // 更新每个操作后的新序列
        {
            string s = get(t, i);
            if (!dist.count(s))
            {
                dist[s] = dist[t] + 1;
                pre[s] = {'A' + i, t};
                q.push(s);
            }
        }
    }
}

int main()
{
    string start = "12348765", ans;
    for (int i = 1; i <= 8; i ++ ) cin >> a[i];
    reverse(a + 5, a + 9);
    for (int i = 1; i <= 8; i ++ ) endd.push_back(a[i]);

    cout << bfs() << '\n';

    while (endd != start)
    {
        ans += pre[endd].first;
        endd = pre[endd].second;
    }
    reverse(ans.begin(), ans.end());
    cout << ans;
}

双向广搜

因为BFS越到后面,每一层可以扩展的情况越多(会成指数型增长),为了防止TLE / MLE,可以用双向广搜对其进行优化

从两端开始搜索,到中间相遇,使得搜索次数降低,避免了中间大部分不必要的搜索环节

字串变换

原题链接

题意

一个字符串想转变成另一个字符串,现在给出若干个变换规则(某个字串可以转换成另一个字串),问最少的步骤数目是多少

思路

从开始和结尾两个字符串同时往中间搜索,搜索过程中相遇就直接输出,具体看代码注释

代码

#include 

using namespace std;

const int N = 6;

int n;
string A, B;
string a[N], b[N];

int extend(queue<string>& q, unordered_map<string, int>& da, unordered_map<string, int>& db, string a[N], string b[N]) // 要处理da 从a->b
{
    int d = da[q.front()];
    while (q.size() && da[q.front()] == d)
    {
        auto t = q.front();
        q.pop();

        for (int i = 0; i < n; i ++ ) // 遍历每个规则
            for (int j = 0; j < t.size(); j ++ ) // 遍历每个元素作为开头
                if (t.substr(j, a[i].size()) == a[i])
                {
                    string r = t.substr(0, j) + b[i] + t.substr(j + a[i].size()); // 记录新字符串
                    if (db.count(r)) return da[t] + db[r] + 1; // 在另一个里找到了,直接输出
                    if (da.count(r)) continue; // 在自己里找到了,说明重复了直接开始下一次循环
                    da[r] = da[t] + 1; // 更新距离
                    q.push(r); // 新字符串入队
                }
    }
    return 11;
}

int bfs()
{
    if (A == B) return 0;
    queue<string> qa, qb; // qa qb两个队列同时搜索
    unordered_map<string, int> da, db; // 不同字符串到起始字符串的步骤数

    qa.push(A), qb.push(B); // 队头入队
    da[A] = db[B] = 0; // 初始化

    int step = 0;
    while (qa.size() && qb.size())
    {
        int t;
        // 每次对队列长的那边进行操作
        if (qa.size() < qb.size()) t = extend(qa, da, db, a, b);
        else t = extend(qb, db, da, b, a);

        if (t <= 10) return t;
        if ( ++ step == 10) return -1;
    }
    return -1;
}

int main()
{
    cin >> A >> B;
    while (cin >> a[n] >> b[n]) n ++ ;

    int t = bfs();
    if (t == -1) cout << "NO ANSWER!\n";
    else cout << t << '\n';
}

A*

A*与双向广搜解决的问题类似,利用估价函数,在非常少的步骤内从一个状态到达另一个状态

可以解决任何边权的问题,只要没有负权回路

步骤:

  1. 把BFS中的队列换成优先队列(小根堆),队列中存的是从起点到当前点的真实距离(不一定是最短距离)和从当前点到终点的估计距离(算法的尽头是玄学),优先队列排序的依据是存储的两个距离相加,也就是从起点到终点的估计距离
  2. 取出优先队列队头(也就是估计距离最短的点)
  3. for循环 t 的所有邻边,能更新的话就更新并将邻边入队
  4. 当终点第一次出队时break

(和Dijkstra非常像,可以看为Dijkstra中所有从当前点到终点的估计距离都取0((但本质上没什么关系

必须要保证当前点到终点的估计距离一定要小于等于真实距离,并且要保证有解(否则效率很低)

A*只能保证终点距离起点是最优的,不能保证其他点第一次出队时也是最优的,除了终点外,每个点不一定只扩展一次

八数码

原题链接

在一个 3×3 的网格中,1∼8 这 8 个数字和一个 X 恰好不重不漏地分布在这 3×3 的网格中。

例如:

1 2 3
X 4 6
7 5 8

在游戏过程中,可以把 X 与其上、下、左、右四个方向之一的数字交换(如果存在)。

我们的目的是通过交换,使得网格变为如下排列(称为正确排列):

1 2 3
4 5 6
7 8 X

例如,示例中图形就可以通过让 X 先后与右、下、右三个方向的数字交换成功得到正确排列。

交换过程如下:

1 2 3   1 2 3   1 2 3   1 2 3
X 4 6   4 X 6   4 5 6   4 5 6
7 5 8   7 5 8   7 X 8   7 8 X

把 X 与上下左右方向数字交换的行动记录为 u、d、l、r。

现在,给你一个初始网格,请你通过最少的移动次数,得到正确排列。

输入格式

输入占一行,将 3×3 的初始网格描绘出来。

例如,如果初始网格如下所示:

1 2 3 
x 4 6 
7 5 8 

则输入为:1 2 3 x 4 6 7 5 8

输出格式

输出占一行,包含一个字符串,表示得到正确排列的完整行动记录。

如果答案不唯一,输出任意一种合法方案即可。

如果不存在解决方案,则输出 unsolvable

输入样例

2  3  4  1  5  x  7  6  8 

输出样例

ullddrurdllurdruldr

题意

每次可以修改一个数字上下左右移动,输出一个从给定状态转化到标准状态的最少步骤

思路

首先,怎么判断是否有解?

我们将九宫格中的数字转换成字符串,每次将数字左右移动时,字符串中的逆序对数量不变,上下移动时,逆序对数量总是改变2,所以奇偶性不变
我们知道最终要求的状态的逆序对是偶数,所以看初始状态的逆序对数量,是偶数就一定有解,是奇数就一定无解

然后是最关键的问题:怎么设计估计函数?

注意到每次移动,最好的情况是将移动的数字向目标位置移动一格,因此估价函数可以取当前的每一个数字和最终目标位置之间的曼哈顿距离之和(这样可以保证估计值一定小于等于真实值)

代码(+详细注释)

#include 

using namespace std;

typedef pair<int, string> PIS;
#define ft first
#define sd second

int f(string state) // 估价函数(曼哈顿距离)
{
    int res = 0;
    for (int i = 0; i < state.size(); i ++ )
        if (state[i] != 'x')
        {
            int t = state[i] - '1'; // 转换成数字
            res += abs(i / 3 - t / 3) + abs(i % 3 - t % 3); // 计算曼哈顿距离
        }
    return res;
}

string bfs(string start)
{
    string end = "12345678x";
    char op[] = "urdl";
    int dx[4] = {-1, 0, 1, 0}, dy[4] = {0, 1, 0, -1};

    unordered_map<string, int> dist; // 记录每个状态距离初始状态的步数
    unordered_map<string, pair<char, string> > prev; // 前一个字符串是由后一个字符串经过什么操作得来的
    priority_queue<PIS, vector<PIS>, greater<PIS> > heap; // 第一个参数是估计距离 第二个参数是当前状态

    dist[start] = 0; // 初始化
    heap.push({f(start), start});

    while (heap.size())
    {

        auto t = heap.top(); // 取出队头元素
        heap.pop();

        string state = t.sd; // 当前状态
        if (state == end) break;

        int x, y; // xy表示空位的坐标
        for (int i = 0; i < state.size(); 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; // 将state在每个操作前恢复至初始状态
            swap(state[x * 3 + y], state[a * 3 + b]); // 修改操作后字符窜
            if (!dist.count(state) || dist[state] > dist[source] + 1) // 没更新过或者有更优解
            {
                dist[state] = dist[source] + 1; // 更新距离
                prev[state] = {op[i], source}; // 记录操作
                heap.push({dist[state] + f(state), state}); // 存进优先队列中
            }
        }
    }

    string res; // 记录操作字符串
    while (end != start)
    {
        res += prev[end].ft;
        end = prev[end].sd;
    }
    reverse(res.begin(), res.end());
    return res;
}

int main()
{
    string start, seq;
    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; j < 8; j ++ )
            if (seq[i] > seq[j]) cnt ++ ;

    if (cnt & 1) cout << "unsolvable\n";
    else cout << bfs(start) << '\n';
}

第K短路

原题链接

给定一张 N 个点(编号 1,2…N),M 条边的有向图,求从起点 S 到终点 T 的第 K 短路的长度,路径允许重复经过点或边。

注意: 每条最短路中至少要包含一条边。

输入格式

第一行包含两个整数 N 和 M。

接下来 M 行,每行包含三个整数 A,B 和 L,表示点 A 与点 B 之间存在有向边,且边长为 L。

最后一行包含三个整数 S,T 和 K,分别表示起点 S,终点 T 和第 K 短路。

输出格式

输出占一行,包含一个整数,表示第 K 短路的长度,如果第 K 短路不存在,则输出 −1。

数据范围

1 ≤ S , T ≤ N ≤ 1000,
0 ≤ M ≤ 104,
1 ≤ K ≤ 1000,
1 ≤ L ≤ 100

输入样例

2 2
1 2 5
2 1 4
1 2 2

输出样例

14

题意

找到起点到终点第k短的路径

思路

这一题和之前不同的地方在于,之前只有需要更新距离的时候才将点存进队列,现在不管是否需要更新距离,都要存进队列

估价函数:从起点到终点的最短路(用Dijkstra求)(这样就可以保证估计距离小于等于真实距离)

之前求最短路的时候我们认为终点第一次从队列中弹出时是最小值,在这里第k次从队列中弹出时是第k小值(因为每个点最多只能走不超过K次)(可以证明)

代码( + 详细注释)

#include 

using namespace std;

const int N = 1010, M = 200010;

typedef pair<int, int> PII;
typedef pair<int, PII> PIII;
#define ft first 
#define sd second 

int n, m, S, T, K;
int h[N], rh[N], e[M], w[M], ne[M], idx;
int dist[N], cnt[N];
bool st[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()
{
    priority_queue<PII, vector<PII>, greater<PII> > heap; // 第二个参数是点 第一个参数是第二个参数到终点的距离
    heap.push({0, T}); // 终点入队

    memset(dist, 0x3f, sizeof dist);
    dist[T] = 0; // 初始化

    while (heap.size())
    {
        auto t = heap.top();
        heap.pop();

        int ver = t.sd; // 记录当前遍历的点
        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; // 第一个参数是估计距离 第二个参数中 ft:到起始点的距离 sd:当前点
    heap.push({dist[S], {0, S}});

    while (heap.size())
    {
        auto t = heap.top();
        heap.pop();

        int ver = t.sd.sd, distance = t.sd.ft; // distance是到起点的真实距离
        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) // 没遍历k次以上的就入队
                heap.push({distance + w[i] + dist[j], {distance + w[i], j}});
        }
    }
    return -1;
}

int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0); cout.tie(0);

    cin >> n >> m;
    memset(h, -1, sizeof h);
    memset(rh, -1, sizeof rh);

    for (int i = 0; i < m; i ++ )
    {
        int a, b, c;
        cin >> a >> b >> c;
        add(h, a, b, c); // 存正向边
        add(rh, b, a, c); // 存反向边(因为dijkstra里需要计算所有点到终点的最短路
    }
    cin >> S >> T >> K;
    if (S == T) K ++ ;

    dijkstra();

    cout << astar() << '\n';
}

你可能感兴趣的:(图论,图论,算法)