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;
}