Shortest path through a gridLESSON 2.25 · 25 OF 43 IN CHAPTER
PART A / Coding problems and trade-offs
Step 51 of 252
LESSON 2.25 · 25 OF 43 IN CHAPTERTry it, then open the solution

Shortest path through a grid

THE PROBLEM

“Our warehouse viewer marks open cells and shelves. A picker moves one cell north, south, east, or west, with each move costing one step. Return the shortest route between two positions without changing the displayed map. What happens if a position is blocked or no route exists?”

Write this:

def shortest_grid_path(grid, start, goal):
    ...

Constructed practice question. Prerequisite: BFS. A cell (row,column) is a graph vertex; legal adjacent moves are edges. The grid supplies neighbors implicitly, so no separate adjacency list is necessary.

Contract Required behavior
Input Nonempty rectangular 0/1 grid; two in-bounds integer coordinates
Output Endpoint-inclusive shortest coordinate list; any shortest route accepted
Boundaries 0 is open; 1 is wall; blocked/unreachable endpoints return []
Failure Empty/ragged grid, other cell values, or out-of-bounds endpoints raise ValueError
Scope No diagonal moves, weighted terrain, moving walls, or input mutation
Optional refresher · the underlying tool

A grid cell is a coordinate (row, column). Move only to legal four-neighbors and mark a cell when enqueueing it so two paths do not each queue it:

neighbors = [(r-1,c), (r+1,c), (r,c-1), (r,c+1)]
from collections import deque
queue = deque([(start, 0)])  # coordinate, distance in moves

On [[0,0],[1,0]], start (0,0), goal (1,1), the shortest route takes 2 moves through (0,1); diagonal shortcuts do not count.

A design choice worth saying aloud

Store coordinates (row, col) in the queue and a separate visited_cells set; store distance with a queued coordinate or process one BFS level at a time. Mark on enqueue. Check bounds, walls, and whether start/goal are passable before exploring, or a blocked start can accidentally yield a valid path.

What the interviewer expects

The interviewer gives you the scenario and the contract above. Explain what a successful call returns, walk through one example below, and name what your state means before choosing a data structure.

Done means: Endpoint-inclusive shortest coordinate list; any shortest route accepted.

Now predict each output before looking at the reference; invalid input should leave any existing state unchanged unless the contract says otherwise.

Test-case scenarios to settle before coding

01 · Representative

Input / starting state
[[0,1,0],[0,1,0],[0,0,0]], (0,0) to (0,2)
Expected result
[(0,0),(1,0),(2,0),(2,1),(2,2),(1,2),(0,2)]

What it is testing: Six moves around the wall.

02 · Same endpoint

Input / starting state
open start equals goal
Expected result
one-coordinate path

What it is testing: Distance zero still includes the point.

03 · Blocked endpoint

Input / starting state
start or goal cell is 1
Expected result
[]

What it is testing: Blocked is valid input but unsolvable.

04 · Unreachable

Input / starting state
[[0,1,0]], (0,0) to (0,2)
Expected result
[]

What it is testing: Wall separates the only route.

05 · Tie

Input / starting state
two equal shortest routes
Expected result
either valid shortest route

What it is testing: Do not overfit an unspecified tie.

06 · Invalid/atomic

Input / starting state
empty/ragged grid or bad coordinate
Expected result
ValueError; grid unchanged

What it is testing: Validate shape and bounds.

For each case, show which branch or state change produces that result.

For rows [[0,1,0],[0,1,0],[0,0,0]], start (0,0) and goal (0,2) require six moves around the shelf. The path is [(0,0),(1,0),(2,0),(2,1),(2,2),(1,2),(0,2)]. In [[0,1,0]] the same endpoints are unreachable. Ask whether distance alone would suffice: route output requires retaining reconstruction information.

Diagram: Test-case scenarios to settle before coding

Before reading further, hand-label each open cell with its shortest distance from the start. Then implement a path-returning solution and test start equals goal.

Solution, distance layers, and follow-ups

Enumerating every simple route and keeping the shortest is a correct baseline but can explore exponentially many routes on an open grid. Greedily moving toward the goal fails immediately on the pictured shelf: the first required move increases Manhattan distance. DFS's first path is also not necessarily shortest.

Use a FIFO queue and a parent map. Insert the start with no parent. When visiting a cell, generate the four neighbors, discard outside/wall/discovered positions, record the parent, and enqueue. On reaching the goal, follow parent links backward and reverse. Never mark the input grid, because the caller still displays it.

cells leave the queue in nondecreasing distance, and each saved parent is one step closer to the start. First discovery is shortest because no later parent can have a smaller distance. Mark on enqueue to bound the frontier; otherwise two neighboring cells can enqueue the same position repeatedly.

Grid row Column 0 distance Column 1 Column 2 distance
0 0 wall 6
1 1 wall 5
2 2 3 4

For R rows and C columns, validation/search take O(RC) time. Parent map and queue use O(RC) auxiliary space in the worst case; the returned route uses O(P) for P cells. Copying a full path into every queue entry would add avoidable path-length cost. Parent pointers retain exactly the information reconstruction needs.

Follow-up 1 — allow diagonal moves. Predict the new shortest distance. If corner cutting is allowed, the pictured route drops to four moves through the bottom middle cell. If diagonals may not pass between a shelf and a boundary, neighbor generation must check the adjacent orthogonal cells too. Define this policy before changing the algorithm.

Diagram: Test-case scenarios to settle before coding

Follow-up 2 — terrain costs vary. FIFO layers now measure moves, not cost. Use Dijkstra for nonnegative costs and define whether cost belongs to entering a cell or traversing an edge. A* may reduce exploration with an admissible heuristic; the correctness claim must name that heuristic and the movement rules.

Senior depth is connecting neighbor policy, the shortestness proof, and tests for mutation/unreachability. Lead depth adds map-version consistency for live requests.

Reference: solution.py (download file, source below); tests verify a forced detour, open-grid distance, unchanged input, blocked endpoints, and malformed maps.

solution.py · solution.py
"""Four-direction unit-cost BFS; input is never changed."""
from collections import deque


def shortest_grid_path(grid, start, goal):
    if not grid or not grid[0] or any(len(row) != len(grid[0]) for row in grid):
        raise ValueError("nonempty rectangular grid required")
    rows, cols = len(grid), len(grid[0])
    if any(cell not in (0, 1) for row in grid for cell in row):
        raise ValueError("cells must be 0 (open) or 1 (wall)")
    for point in (start, goal):
        if (len(point) != 2 or any(not isinstance(x, int) for x in point)
                or not 0 <= point[0] < rows or not 0 <= point[1] < cols):
            raise ValueError("endpoint outside grid")
    start, goal = tuple(start), tuple(goal)
    if grid[start[0]][start[1]] or grid[goal[0]][goal[1]]:
        return []
    queue = deque([start])
    parent = {start: None}
    while queue:
        cell = queue.popleft()
        if cell == goal:
            path = [cell]
            while parent[path[-1]] is not None:
                path.append(parent[path[-1]])
            return path[::-1]
        r, c = cell
        for nr, nc in ((r + 1, c), (r, c + 1), (r - 1, c), (r, c - 1)):
            neighbor = (nr, nc)
            if (0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 0
                    and neighbor not in parent):
                parent[neighbor] = cell
                queue.append(neighbor)
    return []
python -m unittest discover -s curriculum/01-code/02-data-structures-algorithms/problems/24-grid-shortest-path -p 'test_*.py'