1131 拯救大兵瑞恩(单源最短路径扩展-拆点(dp))

本文介绍了一个基于动态规划解决的迷宫营救问题,迷宫中包含门、墙和钥匙,目标是找到从起点到终点的最短路径。通过将问题转化为最短路径问题,使用二维坐标映射一维并建立边,然后运用双端队列广搜或Dijkstra算法求解。代码示例分别用Python和C++实现。
摘要由CSDN通过智能技术生成

1. 问题描述:

1944 年,特种兵麦克接到国防部的命令,要求立即赶赴太平洋上的一个孤岛,营救被敌军俘虏的大兵瑞恩。瑞恩被关押在一个迷宫里,迷宫地形复杂,但幸好麦克得到了迷宫的地形图。迷宫的外形是一个长方形,其南北方向被划分为 N 行,东西方向被划分为 M 列, 于是整个迷宫被划分为 N×M 个单元。每一个单元的位置可用一个有序数对 (单元的行号, 单元的列号) 来表示。南北或东西方向相邻的 2 个单元之间可能互通,也可能有一扇锁着的门,或者是一堵不可逾越的墙。注意: 门可以从两个方向穿过,即可以看成一条无向边。迷宫中有一些单元存放着钥匙,同一个单元可能存放 多把钥匙,并且所有的门被分成 P 类,打开同一类的门的钥匙相同,不同类门的钥匙不同。大兵瑞恩被关押在迷宫的东南角,即 (N,M) 单元里,并已经昏迷。迷宫只有一个入口,在西北角。也就是说,麦克可以直接进入 (1,1) 单元。另外,麦克从一个单元移动到另一个相邻单元的时间为 1,拿取所在单元的钥匙的时间以及用钥匙开门的时间可忽略不计。试设计一个算法,帮助麦克以最快的方式到达瑞恩所在单元,营救大兵瑞恩。

输入格式

第一行有三个整数,分别表示 N,M,P 的值。第二行是一个整数 k,表示迷宫中门和墙的总数。接下来 k 行,每行包含五个整数,Xi1,Yi1,Xi2,Yi2,Gi:当 Gi ≥ 1 时,表示 (Xi1,Yi1) 单元与 (Xi2,Yi2) 单元之间有一扇第 Gi 类的门,当 Gi = 0 时,表示 (Xi1,Yi1) 单元与 (Xi2,Yi2) 单元之间有一面不可逾越的墙。接下来一行,包含一个整数 S,表示迷宫中存放的钥匙的总数。接下来 S 行,每行包含三个整数 Xi1,Yi1,Qi,表示 (Xi1,Yi1) 单元里存在一个能开启第 Qi 类门的钥匙。

输出格式

输出麦克营救到大兵瑞恩的最短时间。如果问题无解,则输出 -1。

数据范围

|Xi1−Xi2| + |Yi1−Yi2| = 1,

0 ≤ Gi ≤ P,
1 ≤ Qi ≤ P,
1 ≤ N,M,P ≤ 10,
1 ≤ k ≤ 150

输入样例:

4 4 9
9
1 2 1 3 2
1 2 2 2 0
2 1 2 2 0
2 1 3 1 0 
2 3 3 3 0
2 4 3 4 1
3 2 3 3 0
3 3 4 3 0
4 3 4 4 0
2
2 1 2 
4 2 1

输出样例:

14
样例解释:
迷宫如下所示:

来源:https://www.acwing.com/problem/content/description/1133/  

2. 思路分析:

分析题目可以知道我们可以从位置(1,1)出发,可以往上下左右四个方向走,当我们遇到门的时候需要使用当前这种门对应的钥匙打开,如果没有对应的钥匙那么不能够通过下一个位置,如果遇到墙说明不能够通过,如果没有门或者没有墙那么可以直接过去。这道题目其实属于单源最短路径拆点的问题(也可以称为分层图),如果没有门或者墙的限制那么我们可以直接使用bfs求解从源点到终点的最短路径即可,使用一维数组dis记录到各个点的最短记录即可。但是这道题目有了墙或者门的限制那么我们应该如何求解呢?对于最短路径的拆点问题,我们可以使用dp问题的分析思路分析,最终再转化为最短路径来求解,可以发现当我们只有一维状态f[i]的时候是很难转移的,在一维状态下我们无法知道当前拥有的钥匙状态所以状态之间是无法转移的,一个很直观的想法是增加一维状态,这个是在dp问题的很常用的方法,当状态表示很难转移的时候那么需要增加对应的维数来表示,直到能够清晰表示状态之间的转移关系,因为是二维平面所以我们需要三维来d(x,y,state)来表示对应的状态(状态表示),其中state表示当前到达(x,y)位置拥有的钥匙状态为state,因为钥匙的种类p最多有10种,所以对于每一种类型的钥匙,在对应二进制数字数字的位上置为1即可,其中d(x,y,state)表示所有从起点走到(x,y)格子的路线中当前已经拥有钥匙状态为state的最小花费,state可以看成是二进制状态对应的十进制数字;怎么样进行状态计算呢?对于dp问题的状态计算主要有两种方式:① 当前状态可以由哪些状态转移过来 ② 当前状态可以更新哪些状态;对于最短路径来说比较常用的是第二种状态计算方式:使用当前的状态去更新其他的状态;分析题目可以知道对于当前的d(x,y,state)可以更新其余两种类型的状态:

  • 当前位置(x,y)有一些钥匙,那么直接将所有的钥匙拿起,因为拿走这些钥匙是不需要花费时间的,不拿白不拿,然后看是否能够更新state | key,也即d(x,y,state) 可以更新d(x,y,state | key),d(x,y,state | key)  = min (d(x,y,state | key),d(x,y,state))
  • 向上下左右四个方向走,此时又有两种情况,① 没有门或者墙 ② 有门且有匹配的钥匙,并且对应的位置为(a,b),d(a,b,state) = min(d(a,b,state), d(x,y,state) + 1) 

虽然使用dp问题的分析方式,但是无法直接使用dp问题中循环的方式进行状态的计算, dp问题可以使用循环方式解决的一个条件是所有的状态之间是存在拓扑序的,也即状态之间是不存在环的,对于这道题目来说因为空白位置的状态之间存在相互依赖的关系,也即存在环,所以我们需要将其转化为最短路径来求解,对于最短路径问题来说则不会出现状态之间的依赖(不存在环,边权都是非负的),使用最短路径来求解dp问题:可以将每一个状态看成是一个点,如果可以由当前的状态转移到下一个状态,那么我们由当前的状态向下一个状态连一条边;因为涉及到二维坐标(x,y),所以表示起来不是很方便,这里使用到的一个技巧是将二维坐标映射到一维,而且可以发现而二维坐标到一维坐标的映射是一一对应的,所以我们完全可以将二维坐标映射成一维坐标在做的时候可以直接使用一维坐标来做,两者是完全等价的;因为是最短路来求解而对于这道题目来说需要建两种边,第一种是相邻的两个位置有门,需要对应类型的钥匙才能够打开,第二种是当前位置上下左右可以到达的位置,我们建这两种边即可,然后求解从起点(1,1)到终点(n,m)的最短路径(最小花费)即可,因为边权只有0和1所以求解最短路径的最好的方法是使用双端队列广搜,其实也可以使用堆优化版的dijkstra算法,其实两者的原理是差不多的,堆优化版的dijkstra算法借助一个小根堆来解决。(这道题目思路比较容易理解但是建图的过程比较恶心)

3. 代码如下:

python:

from typing import List
import heapq


class Solution:
    # 使用dijkstra优化版求解最短路径, 或者使用双端队列广搜也是可以的(权重只有0和1)
    def dijkstra(self, n: int, m: int, key: List[int], g: List[List[int]]):
        INF = 10 ** 10
        # p为钥匙种类的最大数目, 对应最大的二进制状态为1 << 10
        p = 10
        # 第一维表示当前在什么位置(一维坐标相当于是二维坐标), 第二维表示的钥匙状态
        # 因为是直接在一维坐标下来做所以dis最少需要声明n * m + 1个长度的列表
        dis = [[INF] * (1 << p) for i in range((n * m + 10))]
        # vis需要两维, 与dis是一样的
        vis = [[0] * (1 << p) for i in range((n * m + 10))]
        dis[1][0] = 0
        # 使用python的heapq模块来操作列表, 实现小根堆的相关操作
        q = list()
        # 元组的第一个元素肯定是存在当前的最短距离, 第二个元素是当前的一维坐标位置(可以看成是二维坐标), 第三个元素是当前的钥匙状态
        heapq.heappush(q, (0, 1, 0))
        while q:
            # x表示当前处于什么位置, y表示当前的钥匙状态
            d, x, y = heapq.heappop(q)
            # 当前状态在堆中
            if vis[x][y]: continue
            vis[x][y] = 1
            # 有钥匙的时候直接拿, 因为不拿白不拿, 不消耗任何的时间
            if key[x]:
                # 更新钥匙状态, 
                state = y | key[x]
                # 更新当前的第二维对应的状态
                if dis[x][state] > dis[x][y]:
                    dis[x][state] = dis[x][y]
                    # 有更新那么需要添加到堆中
                    heapq.heappush(q, (dis[x][state], x, state))
            # 遍历邻接点(之前在上下左右四个方向都建了可以走的边)
            for next in g[x]:
                # 有门但是没有钥匙那么跳过, 说明状态是不合法的
                if next[1] > 0 and y >> (next[1] - 1) & 1 == 0: continue
                # 更新可以到达的状态
                if dis[next[0]][y] > dis[x][y] + 1:
                    dis[next[0]][y] = dis[x][y] + 1
                    # 有更新那么需要添加到堆中
                    heapq.heappush(q, (dis[next[0]][y], next[0], y))
        # 枚举答案, 第二个表示钥匙的状态, 枚举n * m对应的所有钥匙的状态相当于是二维坐标的最后一个位置(二维坐标与一维坐标是一一映射的关系)
        res = INF
        for i in range(1 << p):
            res = min(res, dis[n * m][i])
        # 注意问题无解的时候需要返回-1
        return res if res != INF else -1
    
    # 当前位置上下左右能够直接到达的位置, 这里是创建相邻的位置直接到达的边
    def build(self, n: int, m: int, mp: List[List[int]], g: List[List[int]], dic: dict):
        pos = [[0, 1], [0, -1], [-1, 0], [1, 0]]
        for i in range(1, n + 1):
            for j in range(1, m + 1):
                for u in range(4):
                    x, y = i + pos[u][0], j + pos[u][1]
                    # 注意大于0才是合法的
                    if 0 < x <= n and 0 < y <= m:
                        # 判断在字典中是否已经存在, 如果不存在说明可以直接通过, 建一条边权为0的边即可
                        a, b = mp[i][j], mp[x][y]
                        if (a, b) not in dic:
                            # 这里不需要创建两次, 因为每一个点都会自己出发, 所以最终都会创建双向边
                            # 创建边权为0的边, 表示花费的时间为0
                            g[a].append((b, 0))

    def process(self):
        # n * m行, p为总共的钥匙种类数目(p之后都没有用到)
        n, m, p = map(int, input().split())
        # mp列表为二维坐标在一维坐标上的映射
        mp = [[0] * (m + 1) for i in range(n + 1)]
        t = 1
        # 将二维坐标映射到一维坐标后面才比较容易表示
        for i in range(1, n + 1):
            for j in range(1, m + 1):
                mp[i][j] = t
                t += 1
        k = int(input())
        # 因为二维坐标映射到一维坐标所以需要至少声明n * m + 1的节点个数. 二维坐标到一维坐标可以省掉一定的空间而且后面表示起来也很方便
        g = [list() for i in range(n * m + 10)]
        dic = dict()
        for i in range(k):
            # (x1, y2), (x2, y2)之间有门或者墙, c>=1的时候表示门
            x1, y1, x2, y2, c = map(int, input().split())
            # 使用哈希表记录是否之间有墙或者门
            a, b = mp[x1][y1], mp[x2][y2]
            # 标记一下当前的坐标已经被访问, 这样可以区分相邻的位置是否有墙或者有门或者是什么都没有可以直接通过, 有门或者是墙的时候那么标记在dic中最后剩下来的就是可以直接通过的情况
            dic[(a, b)] = 1
            dic[(b, a)] = 1
            # c大于0表示需要建一条边
            if c:
                g[a].append((b, c))
                g[b].append((a, c))
        # 使用循环来创建直接直接从一个位置到另外一个位置的边(注意是)
        self.build(n, m, mp, g, dic)
        # 读入钥匙的位置以及对应的钥匙种类·
        s = int(input())
        key = [0] * (n * m + 1)
        for i in range(s):
            x, y, q = map(int, input().split())
            # key[]是一个可以看成是二进制数字对应的十进制数字, 当前钥匙种类为q那么在对应的二进制位上标记为1, 因为同一个位置可能存在多把钥匙所以需要使用或运算, 1 << q - 1相当于是: 1 << (q - 1), 需要减去1的再进行左移运算
            key[mp[x][y]] |= 1 << q - 1
        return self.dijkstra(n, m, key, g)


if __name__ == "__main__":
    print(Solution().process())

c++:

#include <cstring>
#include <iostream>
#include <algorithm>
#include <deque>
#include <set>

#define x first
#define y second

using namespace std;

typedef pair<int, int> PII;

const int N = 11, M = 360, P = 1 << 10;

int n, m, k, p;
int h[N * N], e[M], w[M], ne[M], idx;
int g[N][N], key[N * N];
int dist[N * N][P];
bool st[N * N][P];

set<PII> edges;

void add(int a, int b, int c)
{
    e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx ++ ;
}

void build()
{
    int dx[4] = {-1, 0, 1, 0}, dy[4] = {0, 1, 0, -1};
    for (int i = 1; i <= n; i ++ )
        for (int j = 1; j <= m; j ++ )
            for (int u = 0; u < 4; u ++ )
            {
                int x = i + dx[u], y = j + dy[u];
                if (!x || x > n || !y || y > m) continue;
                int a = g[i][j], b = g[x][y];
                if (!edges.count({a, b})) add(a, b, 0);
            }
}

int bfs()
{
    memset(dist, 0x3f, sizeof dist);
    dist[1][0] = 0;

    deque<PII> q;
    q.push_back({1, 0});

    while (q.size())
    {
        PII t = q.front();
        q.pop_front();

        if (st[t.x][t.y]) continue;
        st[t.x][t.y] = true;

        if (t.x == n * m) return dist[t.x][t.y];

        if (key[t.x])
        {
            int state = t.y | key[t.x];
            if (dist[t.x][state] > dist[t.x][t.y])
            {
                dist[t.x][state] = dist[t.x][t.y];
                q.push_front({t.x, state});
            }
        }

        for (int i = h[t.x]; ~i; i = ne[i])
        {
            int j = e[i];
            if (w[i] && !(t.y >> w[i] - 1 & 1)) continue;   // 有门并且没有钥匙
            if (dist[j][t.y] > dist[t.x][t.y] + 1)
            {
                dist[j][t.y] = dist[t.x][t.y] + 1;
                q.push_back({j, t.y});
            }
        }
    }

    return -1;
}

int main()
{
    cin >> n >> m >> p >> k;

    for (int i = 1, t = 1; i <= n; i ++ )
        for (int j = 1; j <= m; j ++ )
            g[i][j] = t ++ ;

    memset(h, -1, sizeof h);
    while (k -- )
    {
        int x1, y1, x2, y2, c;
        cin >> x1 >> y1 >> x2 >> y2 >> c;
        int a = g[x1][y1], b = g[x2][y2];

        edges.insert({a, b}), edges.insert({b, a});
        if (c) add(a, b, c), add(b, a, c);
    }

    build();

    int s;
    cin >> s;
    while (s -- )
    {
        int x, y, c;
        cin >> x >> y >> c;
        key[g[x][y]] |= 1 << c - 1;
    }

    cout << bfs() << endl;

    return 0;
}
### 回答1: Dijkstra算法是一种用于解决单源最短路径问题的算法。它的基本思想是从起点开始,逐步扩展到其他节点,每次选择当前距离起点最近的节点,并更新与该节点相邻的节点的距离。通过这种方式,可以找到起点到其他节点的最短路径。Dijkstra算法的时间复杂度为O(n^2),但是可以通过使用堆优化来将其优化到O(nlogn)。 ### 回答2: Dijkstra算法是一种解决单源最短路径问题的贪心算法,其思想是利用“松弛”操作来不断更新当前点到源点的最短距离,但前提是所有边的权重非负。如果有负权边,则需要使用Bellman-Ford算法。 首先,我们需要定义一个数组dis数组,用于存储源点s到各个点的最短距离。dis[s]初始为0,其他点初始为无限大。接着,我们需要维护一个集合S,表示已经求出最短路径的点的集合。将源点s加入集合S中。 对于每个未加入S的点v,我们通过选择其它点到源点s的最短路径中的一个点u,然后将dis[v]更新为dis[u] + w(u,v),其中w(u,v)表示边(u,v)的权重。具体地,这个操作称为“松弛”操作。 在松弛操作中,我们需要比较dis[u] + w(u,v)和dis[v]的大小,如果前者更小,则更新dis[v]的值为dis[u] + w(u,v)。 重复执行以上操作,直到所有的点都加入到集合S中。最后dis数组中存储的就是源点s到所有点的最短距离。 Dijkstra算法可以用堆优化,时间复杂度为O(mlogn),其中n表示图中的点数,m表示边数。Dijkstra算法也可以应用于稠密图,时间复杂度为O(n^2)。 总之,Dijkstra算法是一种经典的求解单源最短路径问题的算法,其实现简单,效率高,被广泛应用于路由算法和图像处理等领域。 ### 回答3: Dijkstra算法是一种在加权有向图中寻找从源节点到其他节点的最短路径的贪心算法。该算法基于其它路径加权节点的已知最短路径去更新更长路径的信息直到找到从源节点到目标节点的最短路径。在整个计算过程中,Dijkstra算法需要维护一个待处理节点集合和一个距离源节点的最短路径数组。 算法的具体实现如下: 1. 初始化源节点及其距离为0,其他节点的距离为无穷大。 2. 将源节点加入到待处理节点集合中。 3. 对于源节点的所有相邻节点,更新它们距离源节点的最短路径。如果当前路径小于之前已知的最短路径,则更新最短路径数组。 4. 遍历待处理节点集合中除源节点外的节点,选择距离最近的节点作为当前节点,并将它从待处理机集合中移除。 5. 对于当前节点的所有相邻节点,更新它们距离源节点的最短路径。如果当前路径小于之前已知的最短路径,则更新最短路径数组。 6. 重复步骤4和5,直到待处理节点集合为空或者目标节点已经被遍历。 Dijkstra算法的时间复杂度为O(n^2),其中n为节点数,由于它是贪心算法,只能处理非负权重的图,否则可能会陷入死循环。但是,Dijkstra算法单源最短路径问题的最优解,因此在处理小规模的图时效果很好。在处理大规模图时,需要使用其他高效的算法,如A*算法、Bellman-Ford算法等。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值