📖 第93课:太平洋大西洋水流问题

3 阅读16分钟

想系统提升编程能力、查看更完整的学习路线,欢迎访问 AI Compass:github.com/tingaicompa… 仓库持续更新刷题题解、Python 基础和 AI 实战内容,适合想高效进阶的你。

📖 第93课:太平洋大西洋水流问题

模块:图论 | 难度:Medium ⭐⭐ LeetCode 链接:leetcode.cn/problems/pa… 前置知识:第89课(岛屿数量)、第90课(腐烂的橘子) 预计学习时间:30分钟


🎯 题目描述

给定一个 m×n 的矩阵,代表一个陆地的高度图。矩阵的左边界和上边界连接太平洋,右边界和下边界连接大西洋

水流只能从高处流向低处或相同高度。找出所有既能流到太平洋又能流到大西洋的坐标。

示例:

输入:heights = [[1,2,2,3,5],[3,2,3,4,4],[2,4,5,3,1],[6,7,1,4,5],[5,1,1,2,4]]
输出:[[0,4],[1,3],[1,4],[2,2],[3,0],[3,1],[4,0]]
解释:
    太平洋 ~   ~   ~   ~   ~
       ~  1   2   2   3  (5) *
       ~  3   2  (3) (4) (4) *
       ~  2   4  (5)  3   1  *
       ~ (6) (7)  1   4   5  *
       ~ (5)  1   1   2   4  *
          *   *   *   *   * 大西洋
括号中的格子可以同时流向两个大洋

约束条件:

  • 1 <= m, n <= 200
  • 0 <= heights[i][j] <= 10^5

🧪 边界用例(面试必考)

用例类型输入期望输出考察点
单行单列[[1]][[0,0]]边界处理
全部可达[[1,1],[1,1]][[0,0],[0,1],[1,0],[1,1]]相同高度
阶梯型[[1,2],[3,4]][[1,1]]高度递增
大矩阵200×200视具体高度性能测试

💡 思路引导

生活化比喻

想象你在一座山峰上倒一杯水,水会顺着山坡流向四周的海洋。

🐌 笨办法:站在每个山顶,用水桶倒水,看看能不能流到太平洋和大西洋。这需要对每个点都做一次"全地图搜索",如果有 m×n 个点,就要搜索 m×n 次,太慢了!

🚀 聪明办法:不如换个思路——从海洋"逆流而上"!我们让水从太平洋往高处爬,标记所有能到达的格子;再让水从大西洋往高处爬,标记所有能到达的格子。最后找出同时被两次标记的格子,那就是答案!这样只需要搜索 2 次。

关键洞察

正向想"水往低处流"很难,逆向想"水往高处爬"反而简单!从边界出发逆向搜索,时间复杂度从 O(m²n²) 降到 O(mn)。


🧠 解题思维链

这一节模拟你在面试中"从零开始思考"的过程。

Step 1:理解题目 → 锁定输入输出

  • 输入:m×n 的高度矩阵 heights
  • 输出:所有既能流到太平洋又能流到大西洋的坐标列表
  • 限制:水只能从高处流向低处或相同高度(逆向看就是从低处爬向高处)

Step 2:先想笨办法(暴力法)

对每个格子 (i, j),用 DFS/BFS 看能否到达太平洋和大西洋:

  • 如果能到达左边界或上边界 → 能流向太平洋

  • 如果能到达右边界或下边界 → 能流向大西洋

  • 时间复杂度:O(m² × n²) — 每个格子 O(mn) 次搜索,共 mn 个格子

  • 瓶颈在哪:重复搜索。相邻格子的路径有大量重叠,却每次都重新搜索一遍。

Step 3:瓶颈分析 → 优化方向

  • 核心问题:从每个点出发搜索,会有大量重复计算
  • 优化思路:逆向思维——不从每个点搜索到边界,而是从边界搜索到所有能到达的点!
    • 从太平洋边界出发,标记所有"逆流而上"能到达的点
    • 从大西洋边界出发,标记所有"逆流而上"能到达的点
    • 找出同时被标记的点

Step 4:选择武器

  • 选用:逆向 DFS/BFS
  • 理由:从边界出发只需搜索 2 次(太平洋 1 次 + 大西洋 1 次),相比暴力法的 m×n 次搜索,效率提升巨大!

🔑 模式识别提示:当题目涉及"从某点出发是否能到达多个目标",优先考虑逆向搜索(从目标反推起点)


🔑 解法一:逆向 DFS(从边界出发)

思路

从太平洋和大西洋的边界分别出发,用 DFS"逆流而上"(即从低往高走),标记所有能到达的格子。最后返回同时被两个海洋标记的格子。

图解过程

示例: heights = [[1,2,2,3,5],
                [3,2,3,4,4],
                [2,4,5,3,1],
                [6,7,1,4,5],
                [5,1,1,2,4]]

Step 1: 从太平洋边界(左边界+上边界)开始 DFS
  起点: (0,0)~(0,4) 和 (0,0)~(4,0)

  P P P P P       P=能到达太平洋的点
  P . . . .       从 (0,0) 高度1 → 可以逆流到高度≥1的邻居
  P . . . .       从 (0,1) 高度2 → 可以逆流到高度≥2的邻居
  P . . . .       ...依次标记
  P . . . .

  最终标记结果(P表示能到太平洋):
  P P P P P
  P P P P P
  P P P P .
  P P . . .
  P . . . .

Step 2: 从大西洋边界(右边界+下边界)开始 DFS
  起点: (0,4)~(4,4) 和 (4,0)~(4,4)

  最终标记结果(A表示能到大西洋):
  . . . . A
  . . . A A
  . . A . .
  A A . . A
  A . . . A

Step 3: 找出同时标记 PA 的格子
  P&A 点: (0,4), (1,3), (1,4), (2,2), (3,0), (3,1), (4,0)

Python代码

from typing import List


def pacificAtlantic(heights: List[List[int]]) -> List[List[int]]:
    """
    解法一:逆向 DFS
    思路:从太平洋和大西洋边界分别出发,逆流而上标记所有可达点
    """
    if not heights or not heights[0]:
        return []

    m, n = len(heights), len(heights[0])

    # 标记能到达太平洋和大西洋的格子
    pacific = [[False] * n for _ in range(m)]
    atlantic = [[False] * n for _ in range(m)]

    def dfs(r: int, c: int, ocean: List[List[bool]], prev_height: int):
        """
        从 (r, c) 开始 DFS,逆流而上标记所有可达点
        prev_height: 来源格子的高度,只能往≥prev_height的方向走
        """
        # 边界检查
        if r < 0 or r >= m or c < 0 or c >= n:
            return
        # 已访问过或高度不够(无法逆流)
        if ocean[r][c] or heights[r][c] < prev_height:
            return

        # 标记当前点可达
        ocean[r][c] = True

        # 向四个方向逆流而上
        for dr, dc in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
            dfs(r + dr, c + dc, ocean, heights[r][c])

    # 从太平洋边界出发(上边界和左边界)
    for i in range(m):
        dfs(i, 0, pacific, heights[i][0])  # 左边界
    for j in range(n):
        dfs(0, j, pacific, heights[0][j])  # 上边界

    # 从大西洋边界出发(下边界和右边界)
    for i in range(m):
        dfs(i, n - 1, atlantic, heights[i][n - 1])  # 右边界
    for j in range(n):
        dfs(m - 1, j, atlantic, heights[m - 1][j])  # 下边界

    # 找出同时能到达两个海洋的点
    result = []
    for i in range(m):
        for j in range(n):
            if pacific[i][j] and atlantic[i][j]:
                result.append([i, j])

    return result


# ✅ 测试
print(pacificAtlantic([[1, 2, 2, 3, 5], [3, 2, 3, 4, 4], [2, 4, 5, 3, 1], [6, 7, 1, 4, 5], [5, 1, 1, 2, 4]]))
# 期望输出:[[0,4],[1,3],[1,4],[2,2],[3,0],[3,1],[4,0]]

print(pacificAtlantic([[1]]))  # 期望输出:[[0,0]]
print(pacificAtlantic([[1, 1], [1, 1]]))  # 期望输出:[[0,0],[0,1],[1,0],[1,1]]

复杂度分析

  • 时间复杂度:O(m × n) — 每个格子最多被访问 2 次(太平洋 DFS 一次 + 大西洋 DFS 一次)
    • 具体地说:如果矩阵是 200×200,大约需要 200×200×2 = 80,000 次操作
  • 空间复杂度:O(m × n) — 两个标记矩阵 + DFS 递归栈(最坏 O(mn))

优缺点

  • ✅ 逆向思维巧妙,从 O(m²n²) 优化到 O(mn)
  • ✅ 代码清晰,易于理解
  • ❌ 使用两个额外矩阵,空间开销较大(可优化为集合)

🏆 解法二:逆向 BFS(从边界出发,最优解)

优化思路

DFS 递归深度可能达到 O(mn),在超大矩阵上可能栈溢出。使用 BFS 迭代版本更稳定,且逻辑更清晰。

💡 关键想法:BFS 用队列代替递归栈,避免栈溢出,且更适合"逐层扩散"的场景。

图解过程

BFS 从边界出发的扩散过程(以太平洋为例):

初始队列: [(0,0), (0,1), ..., (0,4), (1,0), ..., (4,0)]
           ↓ 逐层扩散
第 1 轮: 处理所有边界点,将可达的邻居加入队列
第 2 轮: 处理新加入的点,继续向更高处扩散
...
直到队列为空(所有可达点都标记完毕)

Python代码

from typing import List
from collections import deque


def pacificAtlanticBFS(heights: List[List[int]]) -> List[List[int]]:
    """
    解法二:逆向 BFS(最优解)
    思路:用 BFS 从边界逆流而上,避免 DFS 栈溢出
    """
    if not heights or not heights[0]:
        return []

    m, n = len(heights), len(heights[0])

    # 用集合记录可达点(比矩阵更节省空间)
    pacific = set()
    atlantic = set()

    def bfs(queue: deque, visited: set):
        """BFS 逆流而上,标记所有可达点"""
        while queue:
            r, c = queue.popleft()
            visited.add((r, c))

            # 向四个方向扩散(只能往高度≥当前高度的方向走)
            for dr, dc in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
                nr, nc = r + dr, c + dc
                # 检查边界、是否访问过、高度是否足够
                if (0 <= nr < m and 0 <= nc < n and
                    (nr, nc) not in visited and
                    heights[nr][nc] >= heights[r][c]):
                    queue.append((nr, nc))
                    visited.add((nr, nc))  # 提前标记,避免重复加入队列

    # 初始化太平洋边界队列
    pacific_queue = deque()
    for i in range(m):
        pacific_queue.append((i, 0))  # 左边界
    for j in range(n):
        pacific_queue.append((0, j))  # 上边界

    # 初始化大西洋边界队列
    atlantic_queue = deque()
    for i in range(m):
        atlantic_queue.append((i, n - 1))  # 右边界
    for j in range(n):
        atlantic_queue.append((m - 1, j))  # 下边界

    # 从两个海洋边界分别 BFS
    bfs(pacific_queue, pacific)
    bfs(atlantic_queue, atlantic)

    # 返回交集(同时能到达两个海洋的点)
    return [[r, c] for r, c in pacific & atlantic]


# ✅ 测试
print(pacificAtlanticBFS([[1, 2, 2, 3, 5], [3, 2, 3, 4, 4], [2, 4, 5, 3, 1], [6, 7, 1, 4, 5], [5, 1, 1, 2, 4]]))
# 期望输出:[[0,4],[1,3],[1,4],[2,2],[3,0],[3,1],[4,0]]

print(pacificAtlanticBFS([[1]]))  # 期望输出:[[0,0]]

复杂度分析

  • 时间复杂度:O(m × n) — 每个格子最多入队一次,出队一次
  • 空间复杂度:O(m × n) — 两个集合 + 队列(队列最大 O(mn))

🐍 Pythonic 写法

利用 Python 的集合运算简化代码:

# 简化版:用集合交集一行搞定
def pacificAtlanticPythonic(heights: List[List[int]]) -> List[List[int]]:
    if not heights:
        return []
    m, n = len(heights), len(heights[0])

    def dfs_reach(starts):
        """从 starts 出发,返回所有可达点的集合"""
        visited = set(starts)
        stack = list(starts)
        while stack:
            r, c = stack.pop()
            for dr, dc in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
                nr, nc = r + dr, c + dc
                if (0 <= nr < m and 0 <= nc < n and
                    (nr, nc) not in visited and
                    heights[nr][nc] >= heights[r][c]):
                    visited.add((nr, nc))
                    stack.append((nr, nc))
        return visited

    # 边界起点
    pacific = [(0, j) for j in range(n)] + [(i, 0) for i in range(1, m)]
    atlantic = [(m - 1, j) for j in range(n)] + [(i, n - 1) for i in range(m - 1)]

    # 集合交集一行搞定
    return [list(p) for p in dfs_reach(pacific) & dfs_reach(atlantic)]

⚠️ 面试建议:先写清晰版本展示思路,再提 Pythonic 写法展示语言功底。 面试官更看重你的思考过程,而非代码行数。


📊 解法对比

维度解法一:逆向 DFS🏆 解法二:逆向 BFS(最优)
时间复杂度O(m×n)O(m×n) ← 时间相同
空间复杂度O(m×n)O(m×n) ← 空间相同
代码难度简单简单
稳定性⭐⭐(大矩阵可能栈溢出)⭐⭐⭐ ← BFS 更稳定
面试推荐⭐⭐⭐⭐⭐ ← 首选
适用场景常规矩阵大规模矩阵、生产环境

为什么 BFS 是最优解:

  • 时间和空间复杂度与 DFS 相同,都是 O(mn)
  • BFS 迭代版本避免了递归栈溢出,在 200×200 等大矩阵上更稳定
  • BFS 逻辑更贴合"逐层扩散"的直觉,代码更易维护

面试建议:

  1. 先用 30 秒说明暴力法(从每个点搜索,O(m²n²))
  2. 立即提出逆向优化(从边界搜索,O(mn))
  3. 首选 🏆 BFS 实现,强调其稳定性优势
  4. 如果面试官追问递归版本,再补充 DFS 写法

🎤 面试现场

模拟面试中的完整对话流程,帮你练习"边想边说"。

面试官:请你解决一下这道题。

:(审题 30 秒)好的,这道题要求找出所有既能流到太平洋又能流到大西洋的格子。我的第一个想法是对每个格子做 DFS,看能否到达两个海洋,时间复杂度是 O(m²n²)。

不过这样会有大量重复搜索。我们可以逆向思维:从太平洋和大西洋的边界分别出发,用 BFS"逆流而上"标记所有可达的格子,最后找出同时被两个海洋标记的点。这样只需要搜索 2 次,时间复杂度优化到 O(mn)。

面试官:很好,请写一下代码。

:(边写边说)我用两个集合 pacific 和 atlantic 记录可达点,从边界初始化队列,BFS 扩散时只往高度≥当前高度的方向走,最后返回两个集合的交集。

面试官:测试一下?

:用示例 [[1,2,2,3,5],...] 走一遍。从太平洋边界(上和左)出发,能标记大部分左上区域;从大西洋边界(下和右)出发,能标记大部分右下区域。两者交集正好是题目要求的 7 个点。再测一个边界情况 [[1]],单格子既在太平洋边界又在大西洋边界,返回 [[0,0]] 正确。

高频追问

追问应答策略
"还有更优解吗?"时间已经是 O(mn) 最优(至少要遍历所有格子),空间也很难继续优化。可以讨论是否有并行优化的可能。
"为什么用 BFS 而不是 DFS?"BFS 迭代版本避免递归栈溢出,在大矩阵(200×200)上更稳定。DFS 递归版本代码更简洁,但可能栈溢出。
"如果有 3 个或 4 个海洋?"同样思路:从每个海洋边界分别 BFS,最后求所有集合的交集。时间复杂度仍是 O(k×mn),k 为海洋数量。
"能否原地修改矩阵节省空间?"可以用负数或特殊值标记访问过的点,但会破坏原数据,一般不推荐。实际项目中维护独立的 visited 集合更安全。

🎓 知识点总结

Python技巧卡片 🐍

# 技巧1:集合交集 — 找两个集合的公共元素
set1 = {(0, 0), (0, 1), (1, 0)}
set2 = {(0, 1), (1, 0), (1, 1)}
common = set1 & set2  # 交集: {(0, 1), (1, 0)}

# 技巧2:提前标记避免重复入队 — BFS 优化
visited.add((nr, nc))  # 入队时立即标记
queue.append((nr, nc))
# 而不是出队时才标记,否则同一个点可能被重复加入队列

# 技巧3:列表推导式 + 集合运算
result = [[r, c] for r, c in pacific & atlantic]

💡 底层原理(选读)

为什么逆向搜索更高效?

  • 正向思维:从每个点出发搜索到边界,需要 m×n 次完整搜索,复杂度 O(m²n²)
  • 逆向思维:从边界出发"逆流而上",只需 2 次搜索(太平洋 + 大西洋),复杂度 O(mn)

这是一个经典的多源问题转单源问题的优化思路:

  • 多源 → 单源:将所有边界点作为一个"超级源点",一次 BFS 就能标记所有可达点
  • 类似问题:多源最短路、多起点 BFS 等

算法模式卡片 📐

  • 模式名称:逆向 DFS/BFS(从目标反推起点)
  • 适用条件:
    • 需要判断多个起点能否到达某个/某些目标
    • 正向搜索代价过高(需要多次完整搜索)
    • 目标点数量较少或集中(如边界)
  • 识别关键词:"能否从 A 到达 B"、"多个起点"、"边界"、"水流/传播"
  • 模板代码:
# 逆向 BFS 通用模板
def reverse_bfs(grid, targets):
    """
    从目标集合 targets 反向 BFS,标记所有能到达的点
    """
    visited = set(targets)
    queue = deque(targets)

    while queue:
        x, y = queue.popleft()
        for nx, ny in get_neighbors(x, y):
            if (nx, ny) not in visited and can_reach(nx, ny, x, y):
                visited.add((nx, ny))
                queue.append((nx, ny))

    return visited

易错点 ⚠️

  1. 忘记提前标记导致重复入队

    • 错误写法:出队时才标记 visited.add((r,c))
    • 正确写法:入队时立即标记,避免同一点多次入队导致超时
  2. 边界初始化遗漏

    • 错误:只加左边界和上边界,漏掉角点
    • 正确:左边界 range(m) + 上边界 range(n) 会自动包含 (0,0)
  3. 逆流条件写错

    • 错误:heights[nr][nc] > heights[r][c](只能往更高处走)
    • 正确:heights[nr][nc] >= heights[r][c](相同高度也能走)

🏗️ 工程实战(选读)

这个算法思想在真实项目中的应用,让你知道"学了有什么用"。

  • 场景1:地形分析系统

    • 气象部门预测洪水覆盖范围:从河流边界逆向扩散,标记所有低于警戒水位的区域
  • 场景2:网络可达性分析

    • 云服务商分析网络拓扑:从边界路由器反向探测,找出所有能访问公网的内网节点

🏋️ 举一反三

完成本课后,试试这些同类题目来巩固知识:

题目难度相关知识点提示
LeetCode 130. 被围绕的区域Medium边界 DFS/BFS从边界的 'O' 出发标记,剩下的 'O' 就是被围绕的
LeetCode 542. 01矩阵Medium多源 BFS从所有 0 出发 BFS,更新到最近 0 的距离
LeetCode 1162. 地图分析Medium多源 BFS从所有陆地出发 BFS,找最远的海洋

📝 课后小测

试试这道变体题,不要看答案,自己先想 5 分钟!

题目:给定一个矩阵,代表不同高度的陆地。现在下雨了,水从任意一点都可能溢出到四周相邻的更低处。找出所有"汇水点"——水流到这个点后无法再流向更低处(即该点高度 ≤ 四周所有相邻点)。

💡 提示(实在想不出来再点开)

汇水点就是"局部最低点"。遍历矩阵,检查每个点是否 ≤ 四周所有邻居即可。

✅ 参考答案
def find_sink_points(heights: List[List[int]]) -> List[List[int]]:
    """找出所有汇水点(局部最低点)"""
    m, n = len(heights), len(heights[0])
    result = []

    for i in range(m):
        for j in range(n):
            is_sink = True
            # 检查四个方向的邻居
            for di, dj in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
                ni, nj = i + di, j + dj
                if 0 <= ni < m and 0 <= nj < n:
                    # 如果有任何邻居更低,当前点就不是汇水点
                    if heights[ni][nj] < heights[i][j]:
                        is_sink = False
                        break

            if is_sink:
                result.append([i, j])

    return result

# 测试
print(find_sink_points([[3, 2, 1], [2, 1, 0], [1, 0, 1]]))
# 期望输出:[[1,2]] (高度为0的点是最低点)

核心思路:遍历每个点,检查其是否小于等于所有邻居。时间 O(mn),空间 O(1)。


如果这篇内容对你有帮助,推荐收藏 AI Compass:github.com/tingaicompa… 更多系统化题解、编程基础和 AI 学习资料都在这里,后续复习和拓展会更省时间。