LeetCode 417. 太平洋大西洋水流问题
题目描述
题意分析
给一个高度矩阵,左边界和上边界之外是太平洋,右边界和下边界之外是大西洋。雨水从任意格子出发,只能流向高度不高于自己的四邻格子。要找出所有既能流到太平洋、又能流到大西洋的格子坐标。
「不高于」这三个字要抠清楚:允许平流,也就是等高的相邻格子之间可以互相流动。如果错写成严格递减,大片高原地带会被漏掉。
边界格子天然贴着海洋:第一行和第一列上的格子已经在太平洋岸边,最后一行和最后一列上的格子已经在大西洋岸边。四个角同时贴着两片海洋——左上角与右下角各自紧邻两洋,因此这两个角必然在答案里。
输出是坐标列表,顺序不限,但每个坐标只能出现一次。这提示实现时要有明确的去重机制。
规模上矩阵是两百乘两百量级,允许对每个格子做常数次访问,但不允许对每个格子各跑一次完整搜索。
解法:边界反向 BFS
核心思路
按题意正着做,就是对每个格子发起一次搜索,看水能不能顺流到达两片海洋。这样每个格子都要遍历一遍与它相连的下坡区域,最坏情况下总代价是格子数的平方,而且相邻格子的搜索路径大量重复。
突破口是把水流方向反过来。「水能从 A 流到海」等价于「从海出发逆流而上能走到 A」。逆流的规则也随之翻转:正向要求下一格高度不高于当前格,反向就要求下一格高度不低于当前格。
这一反转的价值在于起点合并:正向有 $m \cdot n$ 个不同的起点,反向只有两组固定起点——太平洋沿岸的一整行加一整列,大西洋沿岸的一整行加一整列。于是只需要两次多源遍历,每次把各自海洋能逆流到达的格子全部标记出来。
维持的不变量是:某个格子在太平洋标记矩阵中为真,当且仅当水能从它流入太平洋;大西洋同理。两次遍历互不干扰,各用一套访问标记。最后扫一遍矩阵,两个标记同时为真的格子就是答案,天然去重、天然按行优先有序。
遍历方式用广度优先还是深度优先都行,因为这里只关心可达性、不关心距离。选队列版是为了避免在两百乘两百的矩阵上递归过深。
解题步骤
- 准备两个与矩阵同形的布尔标记数组和两个队列,分别对应两片海洋。两套标记必须完全独立,共用一套就无法区分「能流到哪一片」。
- 把第一列和第一行的所有格子放进太平洋队列,把最后一列和最后一行的所有格子放进大西洋队列。入队时立刻打标记,并且先检查是否已标记——左上角会被行循环和列循环各碰一次,重复入队虽不影响正确性但会浪费队列空间。
- 对两个队列各跑一次遍历。取出一个格子后枚举四个方向,依次检查:是否越界、是否已访问、高度是否低于当前格。三个条件任一成立就跳过。
- 高度判断的方向是本题的核心:只有当邻居不低于当前格时才继续扩展,因为水正是从那个更高(或等高)的邻居流向当前格的。写成大于会漏掉等高平流的情况。
- 访问标记在入队时打,不是出队时打。边界上有大量起点,出队时才标记会让同一格子被多个方向重复推入,队列规模成倍膨胀。
- 两次遍历都结束后,按行优先扫描整个矩阵,收集两个标记都为真的坐标。扫描顺序天然保证了每个坐标只输出一次。
以
heights = [[1,2,3],[8,9,4],[7,6,5]]走一遍:这是一个从左上角向内盘旋上升的矩阵。先做太平洋方向。起点是第一行 (0,0)、(0,1)、(0,2) 和第一列 (1,0)、(2,0),共五个格子(左上角只入队一次)。从 (0,0) 高度 1 出发,邻居 (1,0) 高度 8 和 (0,1) 高度 2 都已在起点集合中。从 (0,1) 高度 2 出发,下方 (1,1) 高度 9 不低于 2,标记入队。从 (0,2) 高度 3 出发,下方 (1,2) 高度 4 不低于 3,标记入队。从 (1,0) 高度 8 出发,下方 (2,0) 高度 7 低于 8,跳过(它本就是起点)。从 (2,0) 高度 7 出发,右方 (2,1) 高度 6 低于 7,跳过。从 (1,1) 高度 9 出发,四周的 6 和 4 都更低,无新增。从 (1,2) 高度 4 出发,下方 (2,2) 高度 5 不低于 4,标记入队。从 (2,2) 高度 5 出发,左方 (2,1) 高度 6 不低于 5,标记入队。至此九个格子全部被太平洋标记。
再做大西洋方向。起点是最后一列 (0,2)、(1,2)、(2,2) 和最后一行 (2,0)、(2,1),共五个。从 (0,2) 高度 3 出发,左方 (0,1) 高度 2 低于 3,跳过。从 (1,2) 高度 4 出发,左方 (1,1) 高度 9 不低于 4,标记入队。从 (2,0) 高度 7 出发,上方 (1,0) 高度 8 不低于 7,标记入队。从 (2,1) 高度 6 出发,上方 (1,1) 已标记。从 (1,1) 高度 9 出发,上方 (0,1) 高度 2 和左方 (1,0) 高度 8 都更低,跳过。从 (1,0) 高度 8 出发,上方 (0,0) 高度 1 更低,跳过。大西洋最终标记了七个格子,缺 (0,0) 和 (0,1)。
两套标记求交集,按行优先扫描得到
[[0,2], [1,0], [1,1], [1,2], [2,0], [2,1], [2,2]],共七个。验证 (0,1):它高度为 2,向右只能到高度 3 的 (0,2)(更高,流不过去),向下只能到高度 9 的 (1,1)(更高),唯一能流的方向是向左到 (0,0) 再入太平洋,确实到不了大西洋,被正确排除。
代码实现
class Solution {
// 反向移动时,只有相邻格子高度大于等于当前格子,才说明水可以从那个相邻格子流回当前海洋。
public List<List<Integer>> pacificAtlantic(int[][] heights) {
int m = heights.length;
int n = heights[0].length;
boolean[][] pacific = new boolean[m][n];
boolean[][] atlantic = new boolean[m][n];
Queue<int[]> pacificQueue = new ArrayDeque<>();
Queue<int[]> atlanticQueue = new ArrayDeque<>();
for (int i = 0; i < m; i++) {
offer(pacificQueue, pacific, i, 0);
offer(atlanticQueue, atlantic, i, n - 1);
}
for (int j = 0; j < n; j++) {
offer(pacificQueue, pacific, 0, j);
offer(atlanticQueue, atlantic, m - 1, j);
}
bfs(heights, pacificQueue, pacific);
bfs(heights, atlanticQueue, atlantic);
List<List<Integer>> res = new ArrayList<>();
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (pacific[i][j] && atlantic[i][j]) {
List<Integer> cell = new ArrayList<>();
cell.add(i);
cell.add(j);
res.add(cell);
}
}
}
return res;
}
private void offer(Queue<int[]> queue, boolean[][] visited, int row, int col) {
if (visited[row][col]) {
return;
}
visited[row][col] = true;
queue.offer(new int[] {row, col});
}
private void bfs(int[][] heights, Queue<int[]> queue, boolean[][] visited) {
int m = heights.length;
int n = heights[0].length;
int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
while (!queue.isEmpty()) {
int[] cur = queue.poll();
for (int[] dir : dirs) {
int nextRow = cur[0] + dir[0];
int nextCol = cur[1] + dir[1];
if (nextRow < 0 || nextRow >= m || nextCol < 0 || nextCol >= n) {
continue;
}
if (visited[nextRow][nextCol]) {
continue;
}
if (heights[nextRow][nextCol] < heights[cur[0]][cur[1]]) {
continue;
}
visited[nextRow][nextCol] = true;
queue.offer(new int[] {nextRow, nextCol});
}
}
}
}
func pacificAtlantic(heights [][]int) [][]int {
// 反向移动时,只有相邻格子高度大于等于当前格子,才说明水可以从那个相邻格子流回当前海洋。
m, n := len(heights), len(heights[0])
pacific := make([][]bool, m)
atlantic := make([][]bool, m)
for i := 0; i < m; i++ {
pacific[i] = make([]bool, n)
atlantic[i] = make([]bool, n)
}
pacificQueue := make([][2]int, 0)
atlanticQueue := make([][2]int, 0)
push := func(queue *[][2]int, visited [][]bool, row, col int) {
if visited[row][col] {
return
}
visited[row][col] = true
*queue = append(*queue, [2]int{row, col})
}
for i := 0; i < m; i++ {
push(&pacificQueue, pacific, i, 0)
push(&atlanticQueue, atlantic, i, n-1)
}
for j := 0; j < n; j++ {
push(&pacificQueue, pacific, 0, j)
push(&atlanticQueue, atlantic, m-1, j)
}
dirs := [][2]int{{1, 0}, {-1, 0}, {0, 1}, {0, -1}}
bfs := func(queue [][2]int, visited [][]bool) {
for head := 0; head < len(queue); head++ {
cur := queue[head]
for _, dir := range dirs {
nextRow := cur[0] + dir[0]
nextCol := cur[1] + dir[1]
if nextRow < 0 || nextRow >= m || nextCol < 0 || nextCol >= n {
continue
}
if visited[nextRow][nextCol] {
continue
}
if heights[nextRow][nextCol] < heights[cur[0]][cur[1]] {
continue
}
visited[nextRow][nextCol] = true
queue = append(queue, [2]int{nextRow, nextCol})
}
}
}
bfs(pacificQueue, pacific)
bfs(atlanticQueue, atlantic)
res := make([][]int, 0)
for i := 0; i < m; i++ {
for j := 0; j < n; j++ {
if pacific[i][j] && atlantic[i][j] {
res = append(res, []int{i, j})
}
}
}
return res
}
复杂度分析
- 时间复杂度:$O(m \cdot n)$,两次遍历各自让每个格子最多入队一次、出队后检查四个方向,最后的收集扫描也是一遍全表,三部分都是格子数的常数倍。
- 空间复杂度:$O(m \cdot n)$,两个布尔标记矩阵各占一份网格大小,队列最坏时容纳全部格子,输出列表在极端情况下同样是这个量级。
关键点总结
- 起点太多时反转搜索方向。「每个点能否到达目标集合」翻译成「从目标集合出发能到达哪些点」,把 $m \cdot n$ 次搜索压成常数次多源遍历,这是网格题最高频的降复杂度手法。
- 方向反转的同时,转移条件也必须取反。正向的「不高于」对应反向的「不低于」,这一步漏掉会得出完全相反的结果。
- 需要区分「属于哪一类」时就用多套独立标记,最后求交集。合用一套标记只能回答「能否到达任意一片海」,回答不了「是否两片都能到」。
- 等号的归属要从题意里读,不能凭直觉。本题允许等高平流,所以两个方向的判断都必须包含等号。
- 面试视角:先讲正向做法为什么慢,再讲反转带来的起点合并,这个推导过程本身就是考点。写完可以补一句「深度优先版本代码更短,但两百乘两百的矩阵递归深度可达四万,队列版更稳妥」,体现工程判断。
易错点总结
- 错误写法:反向遍历时仍用「邻居高度不高于当前格」的条件。
heights = [[1,2,3],[8,9,4],[7,6,5]]→ 从边界出发只能走下坡,标记结果与真实可达关系恰好相反,输出几乎全错。- 错误写法:把不等号写成严格的「邻居高度大于当前格」。存在等高平原的矩阵,如
[[1,1],[1,1]]→ 平流被禁止,中间格子无法被标记,正确答案应包含全部四个格子。- 错误写法:两片海洋共用一个访问标记矩阵。任意用例 → 只能得出「能流到某片海」的结论,无法判断是否两片都能到,输出会包含大量不合格的坐标。
- 错误写法:太平洋起点只放第一行,忘了第一列(或大西洋只放最后一列,忘了最后一行)。
heights = [[1,2,3],[8,9,4],[7,6,5]]→ (1,0)、(2,0) 这些左边界格子拿不到太平洋标记,答案漏项。- 错误写法:边界入队时不检查是否已标记。矩阵的四个角 → 会被行循环和列循环各推一次,队列里出现重复元素;若同时又把标记推迟到出队,重复会进一步放大。
- 错误写法:把访问标记放在出队时打。边界起点很多的矩阵 → 同一个格子被相邻的多个起点重复推入队列,队列规模成倍膨胀。
- 错误写法:越界检查放在读取邻居高度之后。第 0 行的任意格子向上扩展 → 先访问
heights[-1][j]直接抛出越界异常。- 错误写法:在遍历过程中就把满足条件的坐标加入结果列表。任意用例 → 一个格子可能先被太平洋标记、后被大西洋标记,收集时机不对会漏收或重复收,必须等两次遍历都结束后统一扫描。
- 错误写法:假设矩阵是方阵,用同一个边长校验行列下标。
heights = [[1, 2, 3]]这类单行矩阵 → 列方向的合法下标被误判越界,右侧格子拿不到标记。- 错误写法:认为单行或单列矩阵需要特判。
heights = [[1, 2]]→ 实际上每个格子都同时贴着两片海洋,通用流程会自然把它们全部标记,额外的特判反而容易写错。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 130. 被围绕的区域 | 中等 | 同为从边界反向标记,标记的是「不该被翻转」的安全区 |
| 1020. 飞地的数量 | 中等 | 从边界扩散后统计未被触及的格子数量 |
| 1254. 统计封闭岛屿的数目 | 中等 | 需要在连通块遍历中判断该块是否触碰过边界 |
| 200. 岛屿数量 | 中等 | 基础的连通块计数,转移条件不含高度比较 |
| 695. 岛屿的最大面积 | 中等 | 在连通块遍历中顺带累计规模并取最大值 |
| 934. 最短的桥 | 中等 | 先用一次遍历圈出一座岛,再以整座岛为多源向外扩散 |