检查网格中是否存在有效路径 图解题解
这道题到底在问什么
- 输入
- grid=[[2,4,3],[6,5,2]]
- 输出
- true
- 输入
- grid=[[1,2,1],[1,2,1]]
- 输出
- false
先想最直接的笨办法
从队列取出 (0,0),它的编号是 2,朝上下两个方向开口。挨个看这两个方向上的邻居,判断能不能接上。(动画第 5 步)
最优解:为什么这么做
一句话答案:LeetCode 1391 检查网格中是否存在有效路径:每格是一段只朝两个方向开口的街道,用并查集把互相敞口的相邻格并成一堆,最后看左上角和右下角在不在同一堆,时间 O(m·n·α)。
从左上角能不能顺着街道走到右下角
给一个 m 行 n 列的网格 grid,每格写着 1 到 6 的编号,每个编号是一段只朝两个固定方向开口的街道:1 连左右,2 连上下,3 连左下,4 连右下,5 连左上,6 连右上。人从左上角 (0,0) 出发,只能顺着开口走,街道不能旋转,问能不能走到右下角 (m-1,n-1),能就返回 true、不能返回 false。
照着街道模拟走路,麻烦在哪
想直接照着走一遍:从起点沿开口挪到邻居,再接着挪,撞墙就退回来换方向。麻烦在于判断这两格到底连不连——不是挨着就能过,得当前格朝邻居有开口、邻居也朝回来有开口,两边都敞着才算一条能过的缝。
只问起点终点连不连,用并查集
题目只问起点和右下角连不连得上,并不要求把路线画出来。「谁和谁归到一块」用并查集来管——把连通的格子并成一堆,往后查两个格子在不在同一堆几乎一步到位。把每格看成一个点,扫一遍相邻格,互相敞口就并到一起,最后看左上角和右下角在不在同一堆。
六个编号各朝哪两边,怎么两两对接
连边的规矩只有一条:相邻两格,各自朝对方的方向都有开口。先把编号按开口方向理一遍:朝左开的是 1、3、5,朝右开的是 1、4、6,朝上开的是 2、5、6,朝下开的是 2、3、4,同一个编号会落进两组,因为它本就朝两个方向开。扫到某格只看它开口的两个方向:朝右开就查右邻居在不在朝左开那组,在就并起来;朝下开就查下邻居在不在朝上开那组。左邻、上邻不必自己查——并查集连通是双向的,它们轮到自己时会朝右、朝下回查,每格只管右、下两向,不漏也不重。每格最多两次对接,一趟扫完能连的缝都并好。
拿题面这组网格把格子并成几堆
拿题面 grid=[[2,4,3],[6,5,2]] 走一遍,格子按(行,列)记。(0,0) 是 2、上下开,上面出界,下邻 (1,0) 是 6、朝上开,接上。(0,1) 是 4、右下开:右邻 (0,2) 是 3、朝左开,下邻 (1,1) 是 5、朝上开,都并上,(0,1) (0,2) (1,1) 成一堆。(0,2) 朝下的 (1,2) 是 2、朝上开,收进这堆。再看 (1,0),它朝右的 (1,1) 朝左开,一并,(0,0) (1,0) 就和这一大堆接上,六格归一堆。最后查 (0,0) 和 (1,2) 同一堆,返回 true,起点确实通到了终点。
跑多快,单格和走不通各返回什么
外层双重循环扫 m 乘 n 个格子,每格最多两次合并,带路径压缩的查根摊还几乎是常数,时间 O(m·n·α)——α 随规模增长极慢、可当常数看;再加一个父数组记归属,空间 O(m·n)。
网格只剩一格时,左上角就是右下角,天然同堆,直接返回 true,这种起点即终点别漏判。题面例二 grid=[[1,2,1],[1,2,1]] 是另一头:起点 (0,0) 是 1、只朝左右开,左边出界,右邻 (0,1) 是 2、只朝上下开、并不朝左回敞口,(0,0) 一条能并的缝都找不到,孤零零留在那堆,终点接不进来,返回 false。这也点出最爱犯的错:当前格朝邻居开就放行、不管邻居朝没朝回来,走不通的相邻格会被误当连通。
▶ 动画逐步走查(共 26 步)——想跟着动画一帧帧对照就展开
- 3记牢这句话:相邻两格,双向敞口才连通。下面从左上角开始做 BFS,每次只把和当前格互相敞口的邻居收进队列,看这股「水」最后能不能漫到右下角。先把编号和方向对上号:1 是左右,2 是上下,3 是左下,4 是右下,5 是左上,6 是右上。
- 4起点入队,目标 (1,2)舞台是 2 行 3 列的网格,格子里是街道编号。起点 (0,0) 编号 2,先把它放进队列、标成已可达。终点是右下角 (1,2)。BFS 的规矩是:每次从队列里取一个格子,看它两个敞口方向上的邻居,谁和它互相敞口、又还没来过,就把谁收进队列。开始扩散。
- 5当前扩展 (0,0)从队列取出 (0,0),它的编号是 2,朝上下两个方向开口。挨个看这两个方向上的邻居,判断能不能接上。
- 6上边出界(0,0) 朝上有开口,可是上边已经到网格外了,没有格子,这个方向跳过。
- 7查 (1,0) 是否朝上(0,0) 朝下开口,看下边的 (1,0),它是编号 6(右上)。要连通,它得朝上回敞口才行,查一下编号 6 朝不朝上。
- 8(1,0) 已可达(1,0) 编号 6 正好朝上开口,双方互相敞口,接上了。把 (1,0) 标成已可达、放进队列,等会儿轮到它再往外扩。已确认可达 2 个格子。
- 9当前扩展 (1,0)从队列取出 (1,0),它的编号是 6,朝右上两个方向开口。挨个看这两个方向上的邻居,判断能不能接上。
- 10查 (1,1) 是否朝左(1,0) 朝右开口,看右边的 (1,1),它是编号 5(左上)。要连通,它得朝左回敞口才行,查一下编号 5 朝不朝左。
- 11(1,1) 已可达(1,1) 编号 5 正好朝左开口,双方互相敞口,接上了。把 (1,1) 标成已可达、放进队列,等会儿轮到它再往外扩。已确认可达 3 个格子。
- 12(0,0) 之前已收(1,0) 朝上边看 (0,0),它编号 2 也朝下开口,本来是连通的,但它早就在已可达里了,不用重复收,跳过。
- 13当前扩展 (1,1)从队列取出 (1,1),它的编号是 5,朝左上两个方向开口。挨个看这两个方向上的邻居,判断能不能接上。
- 14(1,0) 之前已收(1,1) 朝左边看 (1,0),它编号 6 也朝右开口,本来是连通的,但它早就在已可达里了,不用重复收,跳过。
- 15查 (0,1) 是否朝下(1,1) 朝上开口,看上边的 (0,1),它是编号 4(右下)。要连通,它得朝下回敞口才行,查一下编号 4 朝不朝下。
- 16(0,1) 已可达(0,1) 编号 4 正好朝下开口,双方互相敞口,接上了。把 (0,1) 标成已可达、放进队列,等会儿轮到它再往外扩。已确认可达 4 个格子。
- 17当前扩展 (0,1)从队列取出 (0,1),它的编号是 4,朝右下两个方向开口。挨个看这两个方向上的邻居,判断能不能接上。
- 18查 (0,2) 是否朝左(0,1) 朝右开口,看右边的 (0,2),它是编号 3(左下)。要连通,它得朝左回敞口才行,查一下编号 3 朝不朝左。
- 19(0,2) 已可达(0,2) 编号 3 正好朝左开口,双方互相敞口,接上了。把 (0,2) 标成已可达、放进队列,等会儿轮到它再往外扩。已确认可达 5 个格子。
- 20(1,1) 之前已收(0,1) 朝下边看 (1,1),它编号 5 也朝上开口,本来是连通的,但它早就在已可达里了,不用重复收,跳过。
- 21当前扩展 (0,2)从队列取出 (0,2),它的编号是 3,朝左下两个方向开口。挨个看这两个方向上的邻居,判断能不能接上。
- 22(0,1) 之前已收(0,2) 朝左边看 (0,1),它编号 4 也朝右开口,本来是连通的,但它早就在已可达里了,不用重复收,跳过。
- 23查 (1,2) 是否朝上(0,2) 朝下开口,看下边的 (1,2),它是编号 2(上下)。要连通,它得朝上回敞口才行,查一下编号 2 朝不朝上。
- 24(1,2) 已可达(1,2) 编号 2 正好朝上开口,双方互相敞口,接上了。把 (1,2) 标成已可达、放进队列,等会儿轮到它再往外扩。已确认可达 6 个格子。
- 25当前扩展 (1,2)从队列取出 (1,2),它的编号是 2,朝上下两个方向开口。挨个看这两个方向上的邻居,判断能不能接上。
- 26(0,2) 之前已收(1,2) 朝上边看 (0,2),它编号 3 也朝下开口,本来是连通的,但它早就在已可达里了,不用重复收,跳过。
- 27下边出界(1,2) 朝下有开口,可是下边已经到网格外了,没有格子,这个方向跳过。
- 28已可达 6 / 6队列空了,BFS 结束。六个格子全被收进了已可达,右下角终点 (1,2) 当然也在里面。从左上角顺着街道确实能走到右下角,存在有效路径,返回 true。回看全程,无非就是:相邻互相敞口才连通,从起点 BFS 扩散,看终点收没收进来。
⚠️ 容易写错的地方
✗ 错:只看当前格朝邻居开口就判连通
✓ 对:必须双方互相敞口才连通
相邻两格,只有一边朝对方开、另一边没朝回来,照样接不上,务必两边都验
✗ 错:把编号当方向数,以为数字越大开口越多
✓ 对:6 个编号都恰好只朝两个方向开
编号 1 到 6 只是六种「连哪两边」的代号,跟大小无关,得记住每个编号对应的那一对方向
✗ 错:默认能走到右下角就只查终点格自己
✓ 对:要从起点连通性判断,不是单看终点编号
不能只盯着终点编号单独下结论,终点也得和来向格子互相敞口才连得上,关键是从起点扩散出的连通块有没有把终点囊括进去
✗ 错:把网格当普通四连通随便走
✓ 对:只能沿街道开口方向走,且双向敞口
这题不是任意上下左右都能走,被街道编号死死限制,漏了这点会把不连通当成连通
完整代码(Python / C++ / Java)
Python
from __future__ import annotations
from typing import *
from collections import *
from functools import *
from itertools import *
from math import *
from heapq import *
from bisect import *
class Solution:
def hasValidPath(self, grid: List[List[int]]) -> bool:
m, n = len(grid), len(grid[0])
p = list(range(m * n))
def find(x):
if p[x] != x:
p[x] = find(p[x])
return p[x]
def left(i, j):
if j > 0 and grid[i][j - 1] in (1, 4, 6):
p[find(i * n + j)] = find(i * n + j - 1)
def right(i, j):
if j < n - 1 and grid[i][j + 1] in (1, 3, 5):
p[find(i * n + j)] = find(i * n + j + 1)
def up(i, j):
if i > 0 and grid[i - 1][j] in (2, 3, 4):
p[find(i * n + j)] = find((i - 1) * n + j)
def down(i, j):
if i < m - 1 and grid[i + 1][j] in (2, 5, 6):
p[find(i * n + j)] = find((i + 1) * n + j)
for i in range(m):
for j in range(n):
e = grid[i][j]
if e == 1:
left(i, j)
right(i, j)
elif e == 2:
up(i, j)
down(i, j)
elif e == 3:
left(i, j)
down(i, j)
elif e == 4:
right(i, j)
down(i, j)
elif e == 5:
left(i, j)
up(i, j)
else:
right(i, j)
up(i, j)
return find(0) == find(m * n - 1)C++
#include <algorithm>
#include <array>
#include <cctype>
#include <climits>
#include <cmath>
#include <deque>
#include <functional>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#include <queue>
#include <set>
#include <sstream>
#include <stack>
#include <string>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
using namespace std;
class Solution {
public:
vector<int> p;
bool hasValidPath(vector<vector<int>>& grid) {
int m = grid.size();
int n = grid[0].size();
p.resize(m * n);
for (int i = 0; i < p.size(); ++i) p[i] = i;
auto left = [&](int i, int j) {
if (j > 0 && (grid[i][j - 1] == 1 || grid[i][j - 1] == 4 || grid[i][j - 1] == 6)) {
p[find(i * n + j)] = find(i * n + j - 1);
}
};
auto right = [&](int i, int j) {
if (j < n - 1 && (grid[i][j + 1] == 1 || grid[i][j + 1] == 3 || grid[i][j + 1] == 5)) {
p[find(i * n + j)] = find(i * n + j + 1);
}
};
auto up = [&](int i, int j) {
if (i > 0 && (grid[i - 1][j] == 2 || grid[i - 1][j] == 3 || grid[i - 1][j] == 4)) {
p[find(i * n + j)] = find((i - 1) * n + j);
}
};
auto down = [&](int i, int j) {
if (i < m - 1 && (grid[i + 1][j] == 2 || grid[i + 1][j] == 5 || grid[i + 1][j] == 6)) {
p[find(i * n + j)] = find((i + 1) * n + j);
}
};
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
int e = grid[i][j];
if (e == 1) {
left(i, j);
right(i, j);
} else if (e == 2) {
up(i, j);
down(i, j);
} else if (e == 3) {
left(i, j);
down(i, j);
} else if (e == 4) {
right(i, j);
down(i, j);
} else if (e == 5) {
left(i, j);
up(i, j);
} else {
right(i, j);
up(i, j);
}
}
}
return find(0) == find(m * n - 1);
}
int find(int x) {
if (p[x] != x) p[x] = find(p[x]);
return p[x];
}
};Java
import java.util.*;
class Solution {
private int[] p;
private int[][] grid;
private int m;
private int n;
public boolean hasValidPath(int[][] grid) {
this.grid = grid;
m = grid.length;
n = grid[0].length;
p = new int[m * n];
for (int i = 0; i < p.length; ++i) {
p[i] = i;
}
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
int e = grid[i][j];
if (e == 1) {
left(i, j);
right(i, j);
} else if (e == 2) {
up(i, j);
down(i, j);
} else if (e == 3) {
left(i, j);
down(i, j);
} else if (e == 4) {
right(i, j);
down(i, j);
} else if (e == 5) {
left(i, j);
up(i, j);
} else {
right(i, j);
up(i, j);
}
}
}
return find(0) == find(m * n - 1);
}
private int find(int x) {
if (p[x] != x) {
p[x] = find(p[x]);
}
return p[x];
}
private void left(int i, int j) {
if (j > 0 && (grid[i][j - 1] == 1 || grid[i][j - 1] == 4 || grid[i][j - 1] == 6)) {
p[find(i * n + j)] = find(i * n + j - 1);
}
}
private void right(int i, int j) {
if (j < n - 1 && (grid[i][j + 1] == 1 || grid[i][j + 1] == 3 || grid[i][j + 1] == 5)) {
p[find(i * n + j)] = find(i * n + j + 1);
}
}
private void up(int i, int j) {
if (i > 0 && (grid[i - 1][j] == 2 || grid[i - 1][j] == 3 || grid[i - 1][j] == 4)) {
p[find(i * n + j)] = find((i - 1) * n + j);
}
}
private void down(int i, int j) {
if (i < m - 1 && (grid[i + 1][j] == 2 || grid[i + 1][j] == 5 || grid[i + 1][j] == 6)) {
p[find(i * n + j)] = find((i + 1) * n + j);
}
}
}复杂度
时间
O(m·n)
m 是行数,n 是列数,一共 m 乘 n 个格子。BFS 每个格子进出队列各一次,每次只看它两个固定方向的邻居,常数次操作;并查集写法扫每个格子做常数次合并,带路径压缩的 find 摊还几乎是常数。整体随格子总数线性增长,是 O(m·n)
空间
O(m·n)
按峰值算。BFS 要一个已可达标记(m 乘 n 个格子)加一个队列,最坏要装下接近全部格子;并查集要一个长度 m 乘 n 的父数组。两种写法峰值都是 O(m·n),不随面积平方增长
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 检查网格中是否存在有效路径 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么必须双向敞口才算连通,只验一边不行吗?+
街道是带方向的开口。想从 A 走到 B,得 A 这格朝 B 的方向有开口、B 那格也朝 A 的方向有开口,缺一边就像半截墙,人过不去。只验一边会把很多其实走不通的相邻格误判成连通,连通块被撑大,起点终点本不该连也连上了,答案就错。所以每次并两格,两边编号都得点头才算数。
这题用并查集和用 BFS 或 DFS,该怎么选?+
两种都对,复杂度都是格子总数这个量级。并查集只关心谁和谁连通、不关心具体路线,扫一遍把互相敞口的相邻格并好,最后比一下起点终点在不在同一堆就行,写起来短。BFS 或 DFS 更直观,从起点一步步扩散,能看清水漫到哪;要是后面还得问具体路线、或者边会动态增加,从起点扩散的走法更灵活。只做静态的连通判断,并查集最省心。
并查集里的路径压缩起什么作用?+
查一个格子的根时,顺手把沿途每个点的父指针直接指到根上,下次再查这些点就一步到位。反复合并、查询之后,整棵指向关系被压得很扁,单次查根的摊还代价几乎是常数。对这题来说,加上路径压缩,总时间才能稳在格子总数这个量级,不会因为并出一条长链、查根要一路往上爬而变慢。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 检查网格中是否存在有效路径 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。