The input grid is a non-empty list of strings, all the same length. Each character is one cell: . is open, # is a wall, S is the start and E is the end. There is exactly one S and one E, and both are open cells. Each move steps to one of the four side neighbours (no diagonals) and never leaves the grid.
S..#
.#..
...E
In this grid the shortest path takes 5 moves.
Write shortest_path_length(grid) -> int that returns the fewest moves from S to E that never enter a # cell, or -1 when E cannot be reached.
shortest_path_length(["S..#", ".#..", "...E"]) # 5
shortest_path_length(["S#E"]) # -1
5
print(shortest_path_length(["S..#", ".#..", "...E"]))
Find the fewest moves between two cells of a grid with walls, return the path itself, then handle the two twists candidates report most: a battery that charging cells refill, and a grid with no edges.
Also known as: Shortest path in a binary matrix, Maze shortest path, Shortest path in an infinite grid
BFS shortest path (grids and state graphs) shows up in 12.6% of the 1,447 coding interview reports we studied, at 12 companies. Find the fewest steps across a grid or between states, often with a twist: several movement types, obstacles that change, or a state that has to carry extra information.
Reported at: OpenAI, Snowflake, Databricks, Robinhood, Amazon, Cursor, Zip, Citadel, Waymo, Roblox, Perplexity, xAI, Anthropic.
This is an original practice problem written by AceOffer to train the pattern. It is not a question any company asked.
Follow-ups candidates reported on questions with this pattern, in our words. Parts marked “from reports” below are built on them.
The input grid is a non-empty list of strings, all the same length. Each character is one cell: . is open, # is a wall, S is the start and E is the end. There is exactly one S and one E, and both are open cells. Each move steps to one of the four side neighbours (no diagonals) and never leaves the grid.
S..#
.#..
...E
In this grid the shortest path takes 5 moves.
Write shortest_path_length(grid) -> int that returns the fewest moves from S to E that never enter a # cell, or -1 when E cannot be reached.
shortest_path_length(["S..#", ".#..", "...E"]) # 5
shortest_path_length(["S#E"]) # -1
Write shortest_path(grid) -> list[tuple[int, int]] that returns the cells of a shortest path as (row, col) tuples, starting with the S cell and ending with the E cell. Its length is shortest_path_length(grid) + 1. Return [] when there is no path.
Tie-break: when several shortest paths exist, return the one that is smallest when the lists are compared with Python's < (first differing cell decides, and cells compare by row, then column).
shortest_path(["S..", "...", "..E"])
# [(0, 0), (0, 1), (0, 2), (1, 2), (2, 2)]
Grid cells may now also be C, a charging cell (open, like .). You get an integer capacity >= 0.
S with capacity units of charge. Every move uses 1 unit, and a move is only allowed when you have at least 1 unit left.C cell, the charge goes back to capacity. S, E and . do not change it.Write shortest_path_with_battery(grid, capacity) -> int returning the fewest moves from S to E under these rules, or -1 if E cannot be reached.
shortest_path_with_battery(["S.C.E"], 2) # 4 (recharge on C after 2 moves)
shortest_path_with_battery(["S...E"], 3) # -1 (needs 4 units)
Now the grid is infinite. Points are (x, y) integer tuples, a move changes x or y by 1, and blocked is a set of points you cannot enter. Everything not in blocked is open.
Write shortest_path_unbounded(start, end, blocked) -> int returning the fewest moves from start to end, or -1 if no path exists (for example when end is walled in, or when start or end is itself blocked). start == end returns 0. The function must always finish, even when there is no path.
shortest_path_unbounded((0, 0), (3, 0), set()) # 3
shortest_path_unbounded((0, 0), (5, 5), {(4, 5), (6, 5), (5, 4), (5, 6)}) # -1
Open this page on a laptop or desktop to write and run your code against the tests in the browser.
Spoilers: try each part before reading its walkthrough.
Treat each open cell as a node with up to four edges to its side neighbours. Every edge has the same cost, so breadth-first search is enough: it expands cells in rings of increasing distance from S, which means the first time a cell is reached, that distance is already the minimum. The reference keeps a dict steps from cell to distance and a frontier list for the current ring; building the next ring from the current one avoids a deque entirely. A cell enters steps the moment it is discovered, so it is never queued twice. The answer is steps.get(E, -1). Each cell is processed once and checks four neighbours, so time and memory are O(R x C) for an R by C grid.
Knowing the length is not enough to rebuild the route under a fixed tie-break. The clean trick is to run the same BFS from E instead, labelling every reachable cell with its distance to the end. From S, any neighbour whose label is exactly one smaller lies on some shortest path. Python compares lists of tuples cell by cell, so to get the smallest path you only need the smallest valid next cell at each step. The reference lists neighbours in the order up, left, right, down, which is already ascending (row, col) order, and takes the first that qualifies. That greedy walk can never strand you, because a cell with label d always has a neighbour labelled d - 1. Cost stays O(R x C).
With a battery, arriving at a cell is not the whole story: getting there with more charge left opens moves that a weaker arrival cannot make. So the search node becomes (cell, charge). From a node with charge >= 1, each open neighbour costs one unit, except that landing on C resets the charge to capacity. A node with 0 charge is a dead end and is simply not expanded. The visited set holds the full pair, so the same cell can be queued once per distinct charge level, which is what lets a detour through a charger beat a direct path that runs dry. BFS still applies because every move costs one. There are at most R x C x (capacity + 1) states, which bounds both time and memory.
On an endless plane BFS has no natural stopping point, so an enclosed end would loop forever. The fix is a finite search window. Take the smallest box containing start, end and every blocked point, and grow it by one cell on each side. Outside the original box there are no obstacles, and the added ring is entirely open, so any route that wanders further out can be pressed back onto that ring without getting longer. That means BFS restricted to the grown box finds the true shortest distance, and it finishes because the box is finite. The reference checks bounds and blocked for each neighbour and returns -1 when the frontier empties. Cost is proportional to the area of the box.
All moves cost one, so breadth-first search reaches each cell first along a shortest route. Add a cell to the visited set when it is put on the queue.
Run the search from E to label every cell with its distance to E. Then from S, keep stepping to the smallest neighbour (row, then column) whose label is one lower.
Position alone is no longer enough: reaching a cell with 3 units left is better than with 1. Search over (cell, charge left) and keep that pair in the visited set.
An open plane makes BFS run forever when there is no path. Limit the search to the bounding box of start, end and every blocked point, grown by one cell on each side.
Parts 1 and 2 visit each cell at most once: O(R x C) time and space for an R by C grid. Part 3 searches (cell, charge) pairs, O(R x C x (capacity + 1)). Part 4 is bounded by the box around the B blocked points plus start and end, O(W x H) for that box; a box that is huge but mostly empty is where an A* search with a Manhattan-distance estimate would help.
The company guides collect the questions candidates report, with every part, the follow-ups interviewers added, and how often each one comes up.
Browse the interview guides