Skip to content

1091. Shortest Path in Binary Matrix (A Star)

Edited by Charles

Medium · Python · Graph

Problem

Read the original problem on LeetCode.

The official page contains the problem statement, examples, and constraints.

Explanation, examples & other approaches

Core idea

See the Python implementation and detailed notes below.

Original study notes follow, including intermediate snippets and earlier attempts.

Core Idea

We need to find the shortest clear path from:

(0, 0)

to:

(n - 1, n - 1)

A cell with value 0 can be visited, while a cell with value 1 is blocked.

From each cell, we can move in 8 directions:

↖  ↑  ↗
←     →
↙  ↓  ↘

Since every move has the same cost, BFS can solve this problem.

However, we can also use A* to guide the search toward the destination and potentially explore fewer unnecessary cells.

A* uses three important values:

g(n) = actual cost from the start to the current node

h(n) = estimated cost from the current node to the goal

f(n) = g(n) + h(n)

For this problem:

Start = (0, 0)
Goal  = (n - 1, n - 1)

We can visualize them as:

Start ----------------> Current ----------------> Goal
          g(n)                     h(n)

f(n) = g(n) + h(n)

Meaning of Each Value

g(n) is the actual path length we have already traveled.

h(n) estimates how far the current cell is from the destination.

f(n) estimates the total length of a path that goes through the current cell.

A* uses:

f(n)

as the priority in the min-heap.

Therefore, A* prefers nodes that have:

small actual cost
+
small estimated remaining cost

Why Use Chebyshev Distance?

The problem allows movement in 8 directions.

For a current cell:

(r, c)

and the goal:

(n - 1, n - 1)

the row difference is:

n - 1 - r

and the column difference is:

n - 1 - c

Since diagonal movement is allowed, one move can reduce both the row difference and the column difference at the same time.

Therefore, if there were no obstacles, the minimum number of moves required is:

max(rowDifference, columnDifference)

This is the Chebyshev Distance.

We implement it as:

def dist(self, point):
    return max(
        abs(self.l - 1 - point[0]),
        abs(self.l - 1 - point[1])
    )

For example:

Current = (0, 0)
Goal    = (4, 2)

row difference = 4
col difference = 2

We can move diagonally twice:

(0,0)
   ↘
   (1,1)
      ↘
      (2,2)

and then vertically twice:

(2,2)
  ↓
(3,2)
  ↓
(4,2)

Total:

4 moves

which is:

max(4, 2) = 4

Therefore:

h(n) = Chebyshev Distance

is a natural heuristic for this problem.

Why Is Chebyshev Distance a Valid Heuristic?

The heuristic should not overestimate the actual remaining shortest-path cost.

Chebyshev Distance calculates the shortest possible distance when we ignore all obstacles.

Without obstacles:

h(n) = minimum possible remaining moves

Adding obstacles can only make the actual path equal or longer:

Chebyshev Distance
        <=
Actual Remaining Distance

Therefore, the heuristic does not overestimate the actual cost.

This makes it suitable for A*.

Why Not Use Manhattan Distance?

Manhattan Distance is:

|row difference| + |column difference|

It is natural when movement is only allowed in 4 directions:

   ↑
←     →
   ↓

But this problem allows diagonal movement.

For example:

Start = (0, 0)
Goal  = (2, 2)

The actual minimum number of moves is:

(0,0)
   ↘
   (1,1)
      ↘
      (2,2)

2 moves

But Manhattan Distance gives:

2 + 2 = 4

which overestimates the true minimum cost.

Therefore:

4 directions
→ Manhattan Distance

8 directions with equal move cost
→ Chebyshev Distance

Initial Check

If either the start or the destination is blocked:

if grid[0][0] or grid[self.l - 1][self.l - 1]:
    return -1

there cannot be a valid path.

Therefore, we immediately return:

-1

Eight Possible Moves

We define all 8 directions:

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

For the current node:

(row, col)

the next node is:

nextNode = (
    curNode[0] + move[0],
    curNode[1] + move[1]
)

Define minDist

We use:

minDist = [
    [float('inf')] * self.l
    for _ in range(self.l)
]

where:

minDist[r][c]

represents:

The minimum actual path length g found so far from (0, 0) to (r, c).

It stores g, not f.

This distinction is very important.

minDist → g
dist()  → h
heap priority → f = g + h

Why Does the Starting Distance Equal 1?

We initialize:

minDist[0][0] = 1

because the problem defines path length as the number of cells in the path, not the number of edges.

For example:

(0,0) → (1,1) → (2,2)

contains:

3 cells

so the path length is:

3

Therefore, at the starting cell:

g(start) = 1

rather than 0.

Initialize the Min-Heap

The heap stores:

(f, g, node)

In the code:

minHeap = [
    (1 + self.dist((0, 0)), 1, (0, 0))
]

At the starting node:

g = 1

h = self.dist((0, 0))

f = g + h

Therefore:

(
    1 + self.dist((0, 0)),   # f
    1,                        # g
    (0, 0)                    # node
)

Pop the Most Promising Node

We pop:

curDist, steps, curNode = heapq.heappop(minHeap)

In this code:

curDist = f = g + h
steps   = g
curNode = current cell

The variable name curDist here represents the A* priority f.

The actual path length is:

steps

This distinction is important.

Skip Stale States

A node may be pushed into the heap multiple times.

For example, suppose we first find:

g(A) = 8

and push it into the heap.

Later, we find a better path:

g(A) = 5

and push it again.

The heap now contains both states:

(..., 5, A)
(..., 8, A)

But:

minDist[A] = 5

When the old state with g = 8 is eventually popped, we skip it:

if steps > minDist[curNode[0]][curNode[1]]:
    continue

Notice that we compare:

g vs minDist

not:

f vs minDist

because minDist stores actual distances g.

Why Return steps?

When we reach:

if curNode == (self.l - 1, self.l - 1):

we return:

return steps

because steps is:

g(goal)

which is the actual shortest path length from the start to the goal.

Remember:

g = actual cost
h = estimated remaining cost
f = g + h = search priority

The problem asks for the actual shortest path length, so conceptually the answer is g.

At the goal:

h(goal) = 0

so:

f(goal)
= g(goal) + h(goal)
= g(goal)

Thus f and g happen to have the same value at the goal, but returning g is conceptually clearer.

Relaxation

For every valid neighbor:

newSteps = steps + 1

This calculates the new actual distance:

newG = currentG + 1

because moving to a neighboring cell costs one additional step.

If:

newSteps < minDist[nextNode[0]][nextNode[1]]

we have found a better path to nextNode.

Therefore:

minDist[nextNode[0]][nextNode[1]] = newSteps

Calculate the New A* Priority

After obtaining the new actual distance:

newG = newSteps

we calculate:

newLen = newSteps + self.dist(nextNode)

which means:

newF = newG + newH

where:

newG = actual distance from start to nextNode

newH = estimated distance from nextNode to goal

newF = estimated total path cost

Then:

heapq.heappush(
    minHeap,
    (newLen, newSteps, nextNode)
)

Again, the heap stores:

(f, g, node)

The Most Important Part of A*

The core process can be summarized as:

Current Node
     |
     | actual movement cost = 1
     ↓
Next Node
     |
     ├── newG = currentG + 1
     |
     ├── newH = heuristic(nextNode)
     |
     └── newF = newG + newH
                    |
                    ↓
               Push to Heap

In code:

newSteps = steps + 1

if newSteps < minDist[nextNode[0]][nextNode[1]]:
    minDist[nextNode[0]][nextNode[1]] = newSteps

    newLen = newSteps + self.dist(nextNode)

    heapq.heappush(
        minHeap,
        (newLen, newSteps, nextNode)
    )

A* vs Dijkstra

Dijkstra uses:

priority = g

It only considers the actual distance traveled so far.

A* uses:

priority = g + h

It considers both:

distance already traveled
+
estimated distance remaining

Therefore:

Dijkstra:

Start → expands outward based on g

A*:

Start → searches more toward the Goal
        based on g + h

The heuristic gives A* a sense of direction.

There are three related strategies:

Algorithm Priority
Dijkstra g
Greedy Best-First Search h
A* g + h

Dijkstra

priority = g

Only considers how much cost has already been paid.

priority = h

Only considers how close a node appears to be to the destination.

This may aggressively move toward the goal but can choose a bad route because it ignores the cost already paid.

A*

priority = g + h

Balances both:

actual cost so far
+
estimated remaining cost

This is why A* can be both goal-directed and still find the shortest path when using an appropriate heuristic.

Complexity

There are at most:

n²

cells.

Each cell has at most:

8

neighbors.

Heap operations take:

O(log(n²)) = O(log n)

Therefore, a standard upper-bound analysis is:

Time: O(n² log n)

The distance matrix and heap may contain up to O(n²) states:

Space: O(n²)

Remember the three values in A*

g = actual distance from Start to Current

h = estimated distance from Current to Goal

f = g + h

In this implementation:

steps        → g

self.dist()  → h

curDist      → f

minDist[][]  → best known g

The heap is ordered by f

heapq.heappush(
    minHeap,
    (f, g, node)
)

A* uses f to decide which node should be explored first.

Relaxation must use g

When moving to a neighbor:

newSteps = steps + 1

which means:

newG = currentG + edgeCost

Do not use:

newG = currentF + edgeCost

because f already contains the heuristic.

minDist stores g, not f

minDist[r][c]
=
minimum actual path length found so far
from the start to (r, c)

Therefore, the stale-state check is:

if steps > minDist[row][col]:
    continue

For 8-direction movement, use Chebyshev Distance

h = max(rowDifference, colDifference)

because diagonal movement can reduce both differences simultaneously.

A useful rule to remember:

4-direction grid
→ Manhattan Distance

8-direction grid with equal move cost
→ Chebyshev Distance

The central idea of A*

Dijkstra:
priority = g

A*:
priority = g + h

The heuristic h helps A* focus the search toward the destination instead of expanding equally in every direction.

Study record
Field Value
Attempts 1
Mastery ❚
The Latest Attempt September 3, 2026

Python solution

import heapq

class Solution:
    def shortestPathBinaryMatrix(self, grid: List[List[int]]) -> int:
        self.l = len(grid)

        if grid[0][0] or grid[self.l - 1][self.l - 1]:
            return -1

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

        minHeap = [
            (1 + self.dist((0, 0)), 1, (0, 0))
        ]

        minDist = [
            [float('inf')] * self.l
            for _ in range(self.l)
        ]
        minDist[0][0] = 1

        while minHeap:
            curDist, steps, curNode = heapq.heappop(minHeap)

            if steps > minDist[curNode[0]][curNode[1]]:
                continue

            if curNode == (self.l - 1, self.l - 1):
                return steps

            for move in moves:
                nextNode = (
                    curNode[0] + move[0],
                    curNode[1] + move[1]
                )

                if (
                    0 <= nextNode[0] < self.l
                    and 0 <= nextNode[1] < self.l
                    and not grid[nextNode[0]][nextNode[1]]
                ):
                    newSteps = steps + 1

                    if newSteps < minDist[nextNode[0]][nextNode[1]]:
                        minDist[nextNode[0]][nextNode[1]] = newSteps

                        newLen = newSteps + self.dist(nextNode)

                        heapq.heappush(
                            minHeap,
                            (newLen, newSteps, nextNode)
                        )

        return -1

    def dist(self, point):
        return max(
            abs(self.l - 1 - point[0]),
            abs(self.l - 1 - point[1])
        )

Back to Day 55 Graph XIII · All Algorithm notes