leetcode2812 地牢逃生指南:论如何优雅地绕开所有小偷

🎮地牢逃生指南:论如何优雅地绕开所有小偷

你是一名勇者,代号“零”,此刻正站在一座 n × n 地牢grid的左上角 (0,0)。地牢的某些格子里藏着面目可憎的小偷grid[r][c] = 1),他们不偷装备,专偷你辛苦攒下的金币。你的任务是从 (0,0) 走到右下角的出口 (n-1, n-1)。移动规则很传统:上下左右随便走,但不能斜着飞(地牢天花板太矮,轻功施展不开)。最关键的是:你希望整条路尽可能远离小偷。毕竟离小偷越近,你的钱包就越危险。

那么问题来了:怎么衡量一条路够不够"安全"?

一条路径的安全系数,定义为整条路径上的所有格子中,离小偷距离最小的那个值。换句话说,就是你这一路上最危险的那一格距离小偷有多远。安全系数越大,说明你这一路最危险的时候也离小偷挺远,体验舒适。

你要找的,就是所有从入口到出口的路径中,最大的安全系数到底是多少。


开胃菜:如果小偷已经堵门了?

先说个扎心的情况:如果起点 (0,0) 或终点 (n-1,n-1) 本身就是小偷老巢 (grid[r] [c]=1),那你的冒险还没开始就结束了。安全系数注定是 0,因为你一出生就被包围了——就像刚进副本就发现BOSS站在复活点上——别挣扎了,直接返回 0 完事(除非你光脚不怕穿鞋的,但任务描述里没给你这个隐藏选项)。

n = len(grid)
if grid[0][0] == 1 or grid[n-1][n-1] == 1:
	return 0

侦察阶段:多源 BFS 扫描每个格子的“安全分”

排除掉上面的倒霉情况后,你决定先做个详细的地图侦察。“知己知彼,百战不殆”,这句老话在游戏里同样适用——你不会不带地图就去开荒对吧?

于是,你掏出了大杀器——多源 BFS 探测卫星。传统 BFS 是从一个点开始扩散,像往湖里扔一颗石子;多源 BFS 是同时从所有小偷的位置开始扩散,像同时在湖里扔了一堆石子,看谁的波纹先扫到某个点。

具体操作如下:

  • 初始化:把所有小偷格子同时扔进队列(像卫星同时锁定所有贼窝),它们的距离设为 0;
  • 层层扩散:每次从队列里取出一个点,向四个方向扩展。第一次扫描到某个格子时,记录的距离就是它到最近小偷的曼哈顿距离;
  • 完成扫描:O(n²) 时间后,你就得到了一张完整的"安全地图" dis[r] [c],数值越大越安全,数值为 0 的地方就是小偷的老巢

这一步就像在游戏里开了全图视野,每个格子离最近的敌人有多远一目了然。

# dis[][]: 存储每个点到最近小偷的距离
dis = [[-1] * n for _ in range(n)]  # -1表示未访问
dirs = [(-1, 0), (1, 0), (0, 1), (0, -1)]  # 四个方向

# 初始化:所有小偷位置入队
q = deque()
for i in range(n):
	for j in range(n):
		if grid[i][j] == 1:
            dis[i][j] = 0
            q.append((i, j))

# 多源BFS计算每个点到最近小偷的距离
while q:
    x, y = q.popleft()
    for dx, dy in dirs:
        nx, ny = x + dx, y + dy
        if 0 <= nx < n and 0 <= ny < n and dis[nx][ny] == -1:
            dis[nx][ny] = dis[x][y] + 1
            q.append((nx, ny))

现在你手里有了这张安全地图,接下来的问题就是:怎么用它找到那条"最安全"的路径?

找一条从左上到右下的路,让路上最小的那个数字尽量大。


解法一:二分查找

既然不知道最优的安全系数是多少,但你至少能回答一个问题:是否存在一条从入口到出口的路径,路上每个格子的安全值都 ≥ K? 如果有,那真正的安全系数至少是 K。

显然,K 越小越容易满足,K 越大越困难,这不就是二分查找最喜欢的单调性吗?于是你直接把 K 扔进二分锅里翻炒:

每次猜一个中间值 mid,用 BFS 或 DFS 检查:只走安全值 ≥ mid 的格子,能不能从起点到终点。能,说明门槛还能再高点,lo = mid + 1(贪心本性暴露);不能,就说明门槛太高了,hi = mid - 1(该降降期望了)。反复试探,最终夹出最大的可行门槛 k 。

方法很稳妥,就是有点磨叽——每次二分都要重新扫一遍地图,就像反复翻看同一本攻略书的铁憨憨。

# 已有 dis 安全地图

def check(limit: int) -> bool:
    visit = [[False] * n for _ in range(n)]
    q = deque()
    q.append((0, 0))
    visit[0][0] = True
    while q:
        x, y = q.popleft()
        if x == n-1 and y == n-1:
            return True
        for dx, dy in dirs:
            nx, ny = x + dx, y + dy
            if (0 <= nx < n and 0 <= ny < n 
            	and not visit[nx][ny] and dis[nx][ny] >= limit):
				q.append((nx, ny))
                visit[nx][ny] = True
	return False

# 二分查找
res = 0
lo, hi = 0, min(dis[0][0], dis[n-1][n-1])
while lo <= hi:
    mid = (lo + hi) // 2
    if check(mid):
        res = mid
        lo = mid + 1
	else:
		hi = mid - 1

return res

时间复杂度:O(n² log n)。多源 BFS 预处理 O(n²),二分 log(n) 次,每次 BFS 检查又是 O(n²)。 空间复杂度:O(n²)。


解法二:潜行大师的直觉(Dijkstra 最大瓶颈路)

你合上二分的攻略,眉头一皱:每次检查都要重新 BFS,就像在迷宫里反复走已经探过的路,太死板了。能不能一次搞定?你想起江湖上的一种传说:把安全值看作“路宽”,要找一条从起点到终点“最窄处最宽”的路径。这不就是经典的最大瓶颈路径问题吗?

于是你套上 Dijkstra 的风衣(贪心 + 松弛),化身潜行大师,只不过把"累加距离"换成"维护最小值",把"找最短"换成"找最大最小值"。具体来说:

用一个最大堆维护当前所有探索到的路径,按该路径的瓶颈值(即整条路径上最危险那一步的安全系数)从大到小排序。起点的瓶颈值就是 dis[0][0]。每次从堆里弹出瓶颈值最大的路径,然后用它向四周扩展:走到邻居 (nx, ny) 时,新路径的瓶颈值是 min(当前瓶颈值, dis[nx][ny])(经典的木桶效应——一个短板拉低全家)。

如果这个新瓶颈值比已知的到达该格子的最优瓶颈值更大,就更新记录并入堆。因为你永远优先探索“当前最安全”的路径,所以第一次弹出终点时,那个瓶颈值就是全局最优——完全不用等所有路径都跑完。

这样,你只需在安全地图上跑一次 Dijkstra 变种,没有二分的重复扫描,复杂度还是 O(n² log n),但实际常数更小,优雅得像在刀尖上跳舞。

# 已有 dis 安全地图

# Dijkstra算法变种:找"最大最小值"路径,就是最大化一条路径的瓶颈(路径上最危险的那一步的安全系数)
visit = [[False] * n for _ in range(n)]
visit[0][0] = True

pq = [(-dis[0][0], 0, 0)]]  # 优先队列用负数技巧得到最大堆效果:每次弹出安全值最高的格子
max_safeness = min(dis[0][0], dis[n - 1][n - 1])  # 初始安全系数

while pq:
    val, cx, cy = heapq.heappop(pq)  # 弹出当前安全系数最高的点
    val = -val  # 转回正数
    max_safeness = min(max_safeness, val)  # 更新路径的最小安全系数
    if cx == n - 1 and cy == n - 1:
        break
	for dx, dy in dirs:
		nx, ny = cx + dx, cy + dy
		if 0 <= nx < n and 0 <= ny < n and not visit[nx][ny]:
			visit[nx][ny] = True
			heapq.heappush(pq, (-dis[nx][ny], nx, ny))  

return max_safeness

时间复杂度:O(n² log n)(堆操作)。 空间复杂度:O(n²)。


解法三:上帝视角的点灯游戏(并查集+分层优化)

前两种解法虽然都能过,但你心里总觉得差点意思。二分太磨叽,Dijkstra 虽优雅却仍要供着一座优先队列的大佛。有没有一种思路,能换个角度,彻底绕开这些麻烦?

深夜 debug 到第 42 杯咖啡时,你突然灵光炸裂:既然我想找一条路,让路上的最小安全值尽量大——那为什么不直接从最安全的格子开始,一个个"点亮",直到起点和终点连通呢?

这就像玩一款逆向的点灯游戏:地牢里所有格子一开始都是黑的(不可用),你把它们按安全值从高到低依次点亮。每点亮一个格子,就和它四周已经被点亮的邻居连成一片(并查集合并)。当起点 (0,0) 和终点 (n-1,n-1) 第一次被连进同一片区域时——恭喜!那一刻你正在点亮的格子的距离值 d,就是你能获得的最大安全系数。

为什么?因为你按从大到小的顺序点亮格子,当前这一轮点亮的 d 就是已点亮区域里安全值最小的那一档。起点和终点刚好在这一步连通,说明恰好存在一条全程安全值 ≥ d 的路径,而 d+1 的时候它们还不连通。这不就是最优解嘛——无需二分,无需优先队列,一个排序加一个并查集就搞定了。

你兴奋地搓搓小手,准备重新跑一遍多源 BFS ,在算出每个格子到最近小偷的距离时,把所有格子塞进一个 cells 列表里,然后按距离逆序遍历(实际上BFS 天然保证了列表里的格子是按距离递增排列的,逆序就是从大到小,连 sort 都省了),再用一个带 rank 的 UnionFind 类来维护连通性——每点亮一个格子,就和四周已经被点亮的邻居合并,然后检查起点和终点是否在同一个集合里。稳如老狗。

等等。

你你盯着刚跑完的多源 BFS 发了一会儿呆,忽然醒悟:BFS 本身就是按距离一层一层扩散的啊! 小偷在距离 0 的层,然后距离 1 的层,距离 2 的层……这分明就是天然的分组!早知如此,在 BFS 的时候顺手把同一距离的格子收集进 groups[d] 里,岂不比事后再排序聪明一百倍?

于是你立即改写 BFS,分层收集格子。等 BFS 结束,你手里不仅有一份安全地图,还多了一份从 0 到最大距离的分层花名册,直接从最大距离倒序遍历,一层一层点亮,连排序的 O(n² log n) 都省成了 O(n²),常数小到令人发指。

既然分层已天然就绪,你索性把并查集也精简到底:什么 UnionFind 类、花里胡哨的封装统统不要。就一个 fa 数组(list 存父节点),外加一个带路径压缩的 find 函数,一行 fa[x] = find(fa[x]) 搞定一切。合并时直接 fa[find(a)] = find(b),简单粗暴,却能在近乎 O(1) 的均摊时间内把两个格子所在的区域焊在一起。

先 BFS 分层建图,再逆序点灯 + 极简并查集判定连通,一气呵成。

def maximumSafenessFactor(grid):
    n = len(grid)
    if grid[0][0] == 1 or grid[n-1][n-1] == 1:
        return 0

    # 多源BFS,同时按距离分层 
    dis = [[-1] * n for _ in range(n)]
	q = []
	# 初始化:所有小偷位置入队
	for i in range(n):
        for j in range(n):
            if grid[i][j] == 1:
                q.append((i, j))
                dis[i][j] = 0

	groups = [q]  # groups[d]存储所有距离为d的点(分层记录)
	while q:
        tmp = q  # 当前层的所有点
        q = []
        for i, j in tmp:
            for x, y in (i + 1, j), (i - 1, j), (i, j + 1), (i, j - 1):
                if 0 <= x < n and 0 <= y < n and dis[x][y] < 0:
                    dis[x][y] = len(groups)   # 新一层的距离
                    q.append((x, y))
		groups.append(q)  # 将当前层加入groups

    # 极简并查集 
    fa = list(range(n * n))
    
    def find(x: int) -> int:
        if fa[x] != x:
            fa[x] = find(fa[x])
        return fa[x]

    # 从最大距离倒序遍历,逐层点亮 
    for d in range(len(groups) - 2, 0, -1):
        # 激活所有距离为d的点
        for i, j in groups[d]:
            # 与四周距离 ≥ d 的已点亮邻居合并
            for x, y in (i + 1, j), (i - 1, j), (i, j + 1), (i, j - 1):
				if 0 <= x < n and 0 <= y < n and dis[x][y] >= d:
					fa[find(x * n + y)] = find(i * n + j)
		# 检查起点和终点是否连通
		if find(0) == find(n * n - 1):  
			return d
            
    return 0

时间复杂度:O(n²),多源 BFS 分层 O(n²),倒序点灯并查集近似 O(n²)。 空间复杂度:O(n²)。


尾声

三种解法,三种不同的快乐:二分是铁憨憨的稳妥,Dijkstra 是贪心轻功的优雅,并查集分层是逆向思维的通透。

无论你选哪条路,底层都是同一张由多源 BFS 绘制的“安全地图”。它告诉你每个格子离最近的坏蛋有多远,而你要做的,不过是在这张图上用自己的方式写一个“勇者不寄”的故事。

知识共享许可协议
本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可。