title: Swim in Rising Water
slug: swim-in-rising-water
difficulty: hard
leetcode_id: 778
leetcode_url: https://leetcode.com/problems/swim-in-rising-water/
categories:
  - graphs
  - binary-search
  - heap
patterns:
  - slug: heap
    is_optimal: true
  - slug: binary-search
    is_optimal: false
  - slug: bfs
    is_optimal: false

function_signature: "def swim_in_water(grid: list[list[int]]) -> int:"

test_cases:
  visible:
    - input: { grid: [[0, 2], [1, 3]] }
      expected: 3
    - input: { grid: [[0, 1, 2, 3, 4], [24, 23, 22, 21, 5], [12, 13, 14, 15, 16], [11, 17, 18, 19, 20], [10, 9, 8, 7, 6]] }
      expected: 16
  hidden:
    - input: { grid: [[0]] }
      expected: 0
    - input: { grid: [[3, 2], [0, 1]] }
      expected: 3
    - input: { grid: [[0, 1, 2], [5, 4, 3], [6, 7, 8]] }
      expected: 4
    - input: { grid: [[7, 5, 3], [2, 0, 6], [1, 4, 8]] }
      expected: 7

description: |
  You are given an `n x n` integer matrix `grid` where each value `grid[i][j]` represents the elevation at that point `(i, j)`.

  It starts raining, and water gradually rises over time. At time `t`, the water level is `t`, meaning **any** cell with elevation less than or equal to `t` is submerged or reachable.

  You can swim from a square to another 4-directionally adjacent square if and only if the elevation of both squares individually are at most `t`. You can swim infinite distances in zero time. Of course, you must stay within the boundaries of the grid during your swim.

  Return *the minimum time until you can reach the bottom right square* `(n - 1, n - 1)` *if you start at the top left square* `(0, 0)`.

constraints: |
  - `n == grid.length`
  - `n == grid[i].length`
  - `1 <= n <= 50`
  - `0 <= grid[i][j] < n^2`
  - Each value `grid[i][j]` is **unique**

examples:
  - input: "grid = [[0,2],[1,3]]"
    output: "3"
    explanation: "At time 0, you are at (0, 0). You cannot move anywhere because all adjacent cells have elevation > 0. At time 3, the water level allows you to swim through all cells to reach (1, 1)."
  - input: "grid = [[0,1,2,3,4],[24,23,22,21,5],[12,13,14,15,16],[11,17,18,19,20],[10,9,8,7,6]]"
    output: "16"
    explanation: "The optimal path follows the outer edge of the grid. We need to wait until time 16 so that (0, 0) and (4, 4) are connected through cells with elevations ≤ 16."

explanation:
  intuition: |
    Imagine you're standing on a terrain map where each cell has a different elevation. Rain is falling steadily, and the water level rises by 1 unit each second. At time `t`, any cell with elevation ≤ `t` becomes a "lake" you can swim through.

    The key insight is that this is a **path optimisation problem** where we want to minimise the **maximum elevation** along our path from start to end. We don't care about the sum of elevations or the number of steps — we only care about the single highest point we must traverse.

    Think of it like this: if you find a path where the highest elevation is `16`, then at time `t = 16`, every cell on that path is underwater and you can swim the entire route. The answer is the **minimum possible maximum elevation** across all valid paths.

    This problem can be solved in multiple ways:
    - **Min-Heap (Dijkstra-like)**: Greedily expand to the lowest-elevation neighbour, tracking the maximum elevation seen
    - **Binary Search + BFS/DFS**: Binary search on the answer `t`, and check if a path exists using only cells with elevation ≤ `t`
    - **Union-Find**: Sort cells by elevation and union them until start and end are connected

  approach: |
    We'll use a **Min-Heap (Priority Queue)** approach, similar to Dijkstra's algorithm but optimised for this problem:

    **Step 1: Initialise the data structures**

    - `heap`: A min-heap storing `(elevation, row, col)` tuples, starting with `(grid[0][0], 0, 0)`
    - `visited`: A set to track cells we've already processed
    - `result`: Track the maximum elevation encountered so far (initialised to `grid[0][0]`)

    &nbsp;

    **Step 2: Process cells in order of elevation**

    - Pop the cell with the **smallest elevation** from the heap
    - Update `result` to be the maximum of current result and this cell's elevation
    - If we've reached `(n-1, n-1)`, return `result` — this is our answer

    &nbsp;

    **Step 3: Explore neighbours**

    - For each of the 4 adjacent cells (up, down, left, right):
    - If the neighbour is within bounds and not visited, add it to the heap
    - Mark it as visited to avoid reprocessing

    &nbsp;

    **Step 4: Return the result**

    - When we pop the destination cell from the heap, `result` contains the minimum time needed

    &nbsp;

    The min-heap ensures we always expand the lowest-elevation unvisited cell first. This greedy strategy guarantees that when we reach the destination, we've found the path with the minimum maximum elevation.

  common_pitfalls:
    - title: Confusing This With Shortest Path
      description: |
        This is NOT a standard shortest path problem where you sum edge weights. Here, the cost of a path is the **maximum** elevation along it, not the sum.

        Using standard BFS (which finds shortest path by number of edges) will give wrong answers. For example, a path with 10 cells of elevation 5 is better than a path with 2 cells where one has elevation 20.
      wrong_approach: "Standard BFS counting steps"
      correct_approach: "Min-heap tracking maximum elevation"

    - title: Not Handling the Starting Cell
      description: |
        The starting cell `(0, 0)` has an elevation too! If `grid[0][0] = 5`, you cannot start swimming until time `t = 5`.

        Always initialise your result/answer with `grid[0][0]`, not `0`.
      wrong_approach: "Initialising answer to 0"
      correct_approach: "Initialising answer to grid[0][0]"

    - title: Revisiting Cells
      description: |
        Without proper visited tracking, you might add the same cell to the heap multiple times, leading to TLE or incorrect results.

        Mark cells as visited **when adding to the heap**, not when popping. This prevents duplicate entries.
      wrong_approach: "Marking visited only when popping from heap"
      correct_approach: "Marking visited immediately when adding to heap"

    - title: Binary Search Without Proper Bounds
      description: |
        If using binary search, the search space is `[max(grid[0][0], grid[n-1][n-1]), n^2 - 1]`. The lower bound must include both the start and end cell elevations since we must traverse both.

        Using `[0, n^2 - 1]` works but is less efficient.

  key_takeaways:
    - "**Minimax path problem**: When optimising the maximum (or minimum) value along a path, consider Dijkstra-like approaches with a heap"
    - "**Multiple valid approaches**: This problem can be solved with heap, binary search + BFS, or Union-Find — each offers different insights"
    - "**Greedy expansion**: The min-heap ensures we always process the most promising cell first, similar to Dijkstra's algorithm"
    - "**Related problems**: Path With Minimum Effort (LC 1631), Cheapest Flights Within K Stops — similar minimax/path optimisation patterns"

  time_complexity: "O(n^2 log n). Each of the n^2 cells is added to the heap at most once, and heap operations are O(log n^2) = O(log n)."
  space_complexity: "O(n^2). The heap and visited set can each hold up to n^2 elements."

solutions:
  - approach_name: Min-Heap (Dijkstra-like)
    is_optimal: true
    code: |
      import heapq

      def swim_in_water(grid: list[list[int]]) -> int:
          n = len(grid)
          # Heap stores (elevation, row, col)
          heap = [(grid[0][0], 0, 0)]
          visited = {(0, 0)}
          # Track the maximum elevation we've had to traverse
          result = grid[0][0]
          # 4 directions: up, down, left, right
          directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]

          while heap:
              # Get the cell with the smallest elevation
              elevation, row, col = heapq.heappop(heap)
              # Update our answer with the maximum elevation seen
              result = max(result, elevation)

              # If we've reached the destination, return the answer
              if row == n - 1 and col == n - 1:
                  return result

              # Explore all 4 neighbours
              for dr, dc in directions:
                  new_row, new_col = row + dr, col + dc
                  # Check bounds and if not visited
                  if 0 <= new_row < n and 0 <= new_col < n and (new_row, new_col) not in visited:
                      visited.add((new_row, new_col))
                      heapq.heappush(heap, (grid[new_row][new_col], new_row, new_col))

          return result  # Should never reach here for valid input
    explanation: |
      **Time Complexity:** O(n^2 log n) — Each cell is pushed/popped from the heap once, with O(log n^2) per operation.

      **Space Complexity:** O(n^2) — For the heap and visited set.

      This approach is a modified Dijkstra's algorithm. Instead of tracking cumulative distances, we track the maximum elevation encountered. The min-heap ensures we always expand the lowest-elevation frontier cell, guaranteeing optimality.

  - approach_name: Binary Search + BFS
    is_optimal: false
    code: |
      from collections import deque

      def swim_in_water(grid: list[list[int]]) -> int:
          n = len(grid)

          def can_reach(threshold: int) -> bool:
              """Check if we can reach (n-1, n-1) using only cells with elevation <= threshold."""
              if grid[0][0] > threshold:
                  return False

              queue = deque([(0, 0)])
              visited = {(0, 0)}
              directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]

              while queue:
                  row, col = queue.popleft()
                  if row == n - 1 and col == n - 1:
                      return True

                  for dr, dc in directions:
                      new_row, new_col = row + dr, col + dc
                      if (0 <= new_row < n and 0 <= new_col < n and
                          (new_row, new_col) not in visited and
                          grid[new_row][new_col] <= threshold):
                          visited.add((new_row, new_col))
                          queue.append((new_row, new_col))

              return False

          # Binary search on the answer
          left = max(grid[0][0], grid[n-1][n-1])
          right = n * n - 1

          while left < right:
              mid = (left + right) // 2
              if can_reach(mid):
                  right = mid  # Try a smaller threshold
              else:
                  left = mid + 1  # Need a larger threshold

          return left
    explanation: |
      **Time Complexity:** O(n^2 log(n^2)) = O(n^2 log n) — Binary search has O(log n^2) iterations, each running BFS in O(n^2).

      **Space Complexity:** O(n^2) — For the BFS queue and visited set.

      This approach reframes the problem: "Given a water level `t`, can we reach the destination?" We binary search on `t` to find the minimum value where the answer is "yes". The BFS checks connectivity using only cells with elevation ≤ `t`.

  - approach_name: Union-Find
    is_optimal: false
    code: |
      def swim_in_water(grid: list[list[int]]) -> int:
          n = len(grid)

          # Union-Find data structure
          parent = list(range(n * n))
          rank = [0] * (n * n)

          def find(x: int) -> int:
              if parent[x] != x:
                  parent[x] = find(parent[x])  # Path compression
              return parent[x]

          def union(x: int, y: int) -> None:
              px, py = find(x), find(y)
              if px == py:
                  return
              # Union by rank
              if rank[px] < rank[py]:
                  px, py = py, px
              parent[py] = px
              if rank[px] == rank[py]:
                  rank[px] += 1

          # Map elevation to position
          elevation_to_pos = {}
          for r in range(n):
              for c in range(n):
                  elevation_to_pos[grid[r][c]] = (r, c)

          directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]

          # Process cells in order of elevation
          for t in range(n * n):
              r, c = elevation_to_pos[t]
              # Union with any adjacent cell that has elevation <= t
              for dr, dc in directions:
                  nr, nc = r + dr, c + dc
                  if 0 <= nr < n and 0 <= nc < n and grid[nr][nc] <= t:
                      union(r * n + c, nr * n + nc)

              # Check if start and end are connected
              if find(0) == find(n * n - 1):
                  return t

          return n * n - 1  # Should never reach here for valid input
    explanation: |
      **Time Complexity:** O(n^2 α(n^2)) ≈ O(n^2) — We process each cell once, with near-constant time Union-Find operations.

      **Space Complexity:** O(n^2) — For the parent and rank arrays, plus the elevation map.

      This approach processes cells in increasing order of elevation. At each step, we union the current cell with any adjacent cells that are already "underwater" (elevation ≤ current time). We stop when the start and end cells become connected. This is theoretically the fastest approach due to the near-constant time Union-Find operations.
