Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path →
A* Pathfinding on a Grid
The pathfinder inside games and robots: A* finds the shortest route on a grid by expanding nodes in order of "cost so far + estimated cost to go". The heuristic focuses the search toward the goal instead of spreading out blindly like plain BFS. Return the shortest path length. Solve it in Python or TypeScript, with hidden tests.
The problem
Given a grid of 0 (open) and 1 (wall), a start cell and a goal cell (both [row, col]), return the length (number of steps) of the shortest path using 4-directional moves, or -1 if the goal is unreachable. Use A* with the Manhattan-distance heuristic.
Your turn — write it
Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.
Implement astar(grid, start, goal): keep a frontier of cells ordered by f = g + h, and the best g reached per cell. Pop the lowest-f cell; if it is the goal return its g; otherwise relax its open neighbours. Return -1 if the frontier empties.
Examples and constraints 4 examples
grid = [[0,0,0],[1,1,0],[0,0,0]], start = [0,0], goal = [2,0]6grid = [[0,1],[1,0]], start = [0,0], goal = [1,1]-1grid = [[0,0],[0,0]], start = [0,0], goal = [0,0]0grid = [[0,0,0,0]], start = [0,0], goal = [0,3]3- Moves are up/down/left/right only (no diagonals); each step costs 1.
- The Manhattan distance
|dr| + |dc|is an admissible heuristic (never overestimates), so A* returns the true shortest length. - Expand nodes in order of
f = g + h(cost-so-far + heuristic); returngat the goal, or-1if the frontier empties first.
Hints 4 ideas
gis the number of steps taken to reach a cell;his the Manhattan distance from that cell to the goal.- Order the frontier by
f = g + h. A priority queue is ideal; for small grids, scanning the frontier for the minimum works too. - Track the best
gfound for each cell; only push a neighbour when you reach it with a smallerg. - Because the heuristic never overestimates, the first time you pop the goal, its
gis the shortest path length.
Approach, complexity & discussion — open after you solve
Start from brute force
The obvious approach is breadth-first search from start: expand cells in rings of 1, 2, 3 … steps and return the ring number when goal appears. When every step costs 1 it is correct, because BFS reaches each cell first by a route with the fewest steps. It costs O(R·C) time and O(R·C) space for the queue and the visited set. It is not wrong, just blind: it expands every cell nearer the start than the goal is, in every direction, including the half of the map behind the start. Trying every route with DFS is far worse, because the number of simple paths grows exponentially with the grid.
The approach
A* expands cells in order of f = g + h, where g is the steps taken so far and h is the Manhattan distance to the goal. On a 4-direction grid with unit steps, one move changes Manhattan distance by exactly 1, so it never overestimates (it is admissible) and never drops faster than the cost paid (it is consistent). That gives the invariant: when a cell is popped with its current best g, that g is final, so the first time the goal is popped, its g is the answer.
In the reference, frontier is a heapq of (f, g, cell) tuples and best_g holds the cheapest g found per cell. A neighbor is pushed only when ng beats best_g. Because heapq has no decrease-key, an improved cell is pushed again, and a popped entry whose g exceeds best_g[cur] is a stale copy and is skipped. If the heap empties first, the function returns -1.
Walk through an example
On the first example the direct route down is walled off, so f climbs from 2 to 6 as the search is pushed around the wall, then holds at 6, the answer.
| step | pop (f, g, cell) | pushed | why |
|---|---|---|---|
| 1 | (2, 0, (0,0)) | (0,1) g=1, f=4 | (1,0) below is a wall |
| 2 | (4, 1, (0,1)) | (0,2) g=2, f=6 | (1,1) is a wall; f jumps to 6 |
| 3 | (6, 2, (0,2)) | (1,2) g=3, f=6 | the only open way down |
| 4 | (6, 3, (1,2)) | (2,2) g=4, f=6 | h shrinks as g grows |
| 5 | (6, 4, (2,2)) | (2,1) g=5, f=6 | heading back left |
| 6 | (6, 5, (2,1)) | (2,0) g=6, f=6 | the goal enters the heap |
| 7 | (6, 6, (2,0)) | none | goal popped, return 6 |
Complexity
With a consistent heuristic each open cell is expanded at most once and pushed at most four times, so A* costs O(R·C·log(R·C)) time for heap operations and O(R·C) space for best_g and frontier. BFS is O(R·C) time and space. A* does not beat that worst-case bound; it cuts how many cells are expanded when the heuristic points the right way.
Edge cases to test
- Start equals goal:
astar([[0,0],[0,0]], [0,0], [0,0])returns0, because the start is the first cell popped. - No route, including a goal one diagonal step away:
astar([[0,1],[1,0]], [0,0], [1,1])returns-1. The loop must end when the frontier is empty, not only when the goal is found. - The goal is a wall:
astar([[0,1]], [0,0], [0,1])returns-1, since walls are never pushed. The reference does not check the start cell, so a start on a wall still walks out (astar([[1,0]], [0,0], [0,1])returns1); if your inputs allow that, decide the rule and test it. - A non-square grid:
astar([[0,0,0,0]], [0,0], [0,3])returns3. Bounds checks must use the row countRand column countCseparately.
Common mistakes
- A non-admissible heuristic (it overestimates) — A* then returns a non-optimal path.
- Wrong heuristic for the movement model: Manhattan for 4-directional, Chebyshev or Euclidean for 8-directional.
- Not tracking
gseparately fromf, so re-relaxation uses the wrong cost.
Follow-ups an interviewer asks next
- What changes if diagonal moves are allowed?
- Add the four diagonal offsets and change the heuristic, because Manhattan now overestimates. If a diagonal costs 1, use Chebyshev distance,
max(|dr|, |dc|). If it costs √2, use octile distance,max + (√2 − 1)·min. Many games also forbid cutting a corner between two walls; that is a rule in neighbor generation, not in the heuristic. - Cells have different costs (road, mud). Does A* still work?
- Yes. Replace
ng = g + 1withg + cost(next), and keep the heuristic a lower bound by scaling Manhattan by the cheapest possible step: if a road step costs 0.5, use0.5·(|dr| + |dc|). Cells get improved more often, so the stale-entry check does more work. - Can you return the path, not just its length?
- Keep a
parentmap: wheneverbest_g[next]improves, setparent[next] = cur. When the goal pops, follow parents back to the start and reverse the list. It costs O(R·C) extra space and does not change which cells are expanded. - The map is large and many queries hit it. What next?
- Spend preprocessing to save search. Landmark heuristics (ALT) store distances to a few landmarks and use the triangle inequality for a tighter admissible bound. Hierarchical planners search coarse clusters first and refine. On uniform-cost grids, Jump Point Search skips symmetric paths without preprocessing. Bidirectional search from both ends also shrinks the explored area.
Where this shows up
A* is the workhorse of game pathfinding and robotics motion planning. The general idea — guide a search with a cheap admissible lower-bound estimate — makes it the go-to whenever Dijkstra is too slow but you can estimate distance-to-goal.
Reference solution
Reference solution — Python
import heapq
def astar(grid, start, goal):
R, C = len(grid), len(grid[0])
start, goal = tuple(start), tuple(goal)
def h(p):
return abs(p[0] - goal[0]) + abs(p[1] - goal[1]) # Manhattan
best_g = {start: 0}
frontier = [(h(start), 0, start)] # (f, g, cell)
while frontier:
f, g, cur = heapq.heappop(frontier)
if cur == goal:
return g
if g > best_g.get(cur, 1 << 30):
continue # stale entry
for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
nr, nc = cur[0] + dr, cur[1] + dc
if 0 <= nr < R and 0 <= nc < C and grid[nr][nc] == 0:
ng = g + 1
if ng < best_g.get((nr, nc), 1 << 30):
best_g[(nr, nc)] = ng
heapq.heappush(frontier, (ng + h((nr, nc)), ng, (nr, nc)))
return -1
print(astar([[0,0,0],[1,1,0],[0,0,0]], [0,0], [2,0])) # -> 6
print(astar([[0,1],[1,0]], [0,0], [1,1])) # -> -1Reference solution — TypeScript
function astar(grid: number[][], start: number[], goal: number[]): number {
const R = grid.length, C = grid[0].length;
const key = (r: number, c: number) => r * C + c;
const h = (r: number, c: number) => Math.abs(r - goal[0]) + Math.abs(c - goal[1]);
const bestG = new Map<number, number>([[key(start[0], start[1]), 0]]);
// frontier: array of [f, g, r, c]; pop the minimum f by scan (fine for grids)
const frontier: number[][] = [[h(start[0], start[1]), 0, start[0], start[1]]];
while (frontier.length) {
let bi = 0;
for (let i = 1; i < frontier.length; i++) if (frontier[i][0] < frontier[bi][0]) bi = i;
const [f, g, r, c] = frontier.splice(bi, 1)[0];
if (r === goal[0] && c === goal[1]) return g;
if (g > (bestG.get(key(r, c)) ?? Infinity)) continue;
for (const [dr, dc] of [[1,0],[-1,0],[0,1],[0,-1]]) {
const nr = r + dr, nc = c + dc;
if (nr >= 0 && nr < R && nc >= 0 && nc < C && grid[nr][nc] === 0) {
const ng = g + 1;
if (ng < (bestG.get(key(nr, nc)) ?? Infinity)) {
bestG.set(key(nr, nc), ng);
frontier.push([ng + h(nr, nc), ng, nr, nc]);
}
}
}
}
return -1;
}
console.log(astar([[0,0,0],[1,1,0],[0,0,0]], [0,0], [2,0])); // -> 6
console.log(astar([[0,1],[1,0]], [0,0], [1,1])); // -> -1Questions about A* Pathfinding on a Grid
Is A* faster than BFS on a grid?
Not in the worst case. When every move costs 1, BFS already finds the shortest path in O(R·C) time, and A* with a binary heap adds a log factor per heap operation. A* wins on how many cells it expands. It only expands cells whose estimate f = g + h is no more than the true answer, and because h is never negative, those are cells BFS would also reach before finding the goal. On an open map with a distant goal the saving is large. In a maze full of dead ends the heuristic keeps pointing into walls, and the two explore similar regions.
Why is Manhattan distance the right heuristic for a 4-direction grid?
With moves only up, down, left and right at cost 1, any route from a cell to the goal needs at least |dr| vertical moves and |dc| horizontal moves, so |dr| + |dc| is a lower bound on the remaining cost. That makes it admissible, which is what A* needs to return the shortest path. It is also consistent: one move changes it by exactly 1. Euclidean and Chebyshev distance are admissible here too, but they are smaller, so they guide the search less and usually expand more cells. Chebyshev is the natural choice once diagonal moves cost 1.
What happens if the heuristic overestimates?
A* can return a path that is not the shortest, because the true shortest route can sit in the frontier with an inflated f while the goal is popped through a longer one. Some systems accept this on purpose. Weighted A* multiplies an admissible h by a factor w greater than 1, which usually expands fewer cells, and it guarantees a path no longer than w times the optimal length. In this challenge the hidden tests check exact lengths, so the heuristic must never overestimate.
Why does the reference solution skip stale heap entries?
Python heapq has no decrease-key operation. When a cell is reached by a cheaper route, the reference pushes a new entry and leaves the old one in the heap. When the old entry is popped later, its g is larger than best_g for that cell, so the check skips it. Without the check the answer is still correct, because relaxing from an outdated cost cannot improve any neighbor, but the search does extra work re-expanding cells. The TypeScript version scans an array for the minimum instead of using a heap, and it keeps the same check.
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Challenges
- Two SumThe classic warm-up: find the two numbers that add up to a target. Brute force is O(n²) — a hash map gets you to one pass, O(n). Solve it in Python or TypeScript, right in your browser, with hidden tests and a reveal-solution button.Read →
- Valid ParenthesesThe canonical stack problem: decide whether every bracket is closed by the right type, in the right order. A stack turns nested matching into a single pass. Solve it in Python or TypeScript with hidden tests.Read →
- Fizz BuzzThe famous screening question. Print 1…n, but multiples of 3 become "Fizz", of 5 become "Buzz", and of both become "FizzBuzz". Easy — the catch is testing divisibility in the right order. Solve it in Python or TypeScript.Read →
- Number of IslandsThe canonical connected-components question. Flood-fill each unvisited patch of land with BFS or DFS, count how many floods you started — grid traversal, visited bookkeeping, and the classic diagonal trap. Solve it in Python or TypeScript.Read →
- Merge IntervalsCalendar apps and memory allocators run on this: sort by start, sweep once, grow or close the current block. Touching endpoints merge, nested intervals vanish — the classic that punishes off-by-one thinking. Solve it in Python or TypeScript.Read →
- Top-K Frequent ElementsCount, then rank — the two-step pattern behind trending topics and hot-key detection. A hash map counts; a (-count, value) sort ranks with a clean tie-break; buckets get you to O(n) if you want the flex. Solve it in Python or TypeScript.Read →