LeetCode 1810 - Minimum Path Cost in a Hidden Grid

This problem is an interactive shortest path problem on a hidden weighted grid. We control a robot that starts somewhere in an unknown grid, and we must determine the minimum total movement cost required to reach a hidden target cell.

LeetCode Problem 1810

Difficulty: 🟡 Medium
Topics: Array, Depth-First Search, Breadth-First Search, Graph Theory, Heap (Priority Queue), Matrix, Interactive, Shortest Path

Solution

Problem Understanding

This problem is an interactive shortest path problem on a hidden weighted grid. We control a robot that starts somewhere in an unknown grid, and we must determine the minimum total movement cost required to reach a hidden target cell.

Unlike a normal grid problem, we are not given the grid directly. We do not know:

  • The dimensions of the grid
  • The starting position
  • The target position
  • Which cells are blocked
  • The movement costs of cells

Instead, we interact with a GridMaster object that exposes three methods:

  • canMove(direction) tells us whether movement in a direction is possible
  • move(direction) actually moves the robot and returns the cost of entering the destination cell
  • isTarget() tells us whether the current cell is the target

Each movement cost belongs to the destination cell, not the current one. The starting cell has no initial cost because we begin there without entering it.

The objective is to compute the minimum total movement cost from the starting position to the target. If no path exists, we return -1.

The constraints tell us that the hidden grid has at most 100 x 100 = 10,000 cells. This is small enough for graph traversal algorithms such as DFS, BFS, or Dijkstra. However, the challenge comes from the interactive nature of the problem. Since the grid is hidden, we cannot directly run shortest path algorithms immediately. We first need to discover the reachable portion of the grid.

Several important observations shape the solution:

  • The robot can physically move during exploration, so we must carefully backtrack after recursive calls.
  • The movement costs are weighted, so ordinary BFS is insufficient for shortest path computation.
  • We cannot assume the target is reachable.
  • The grid may contain cycles, so visited tracking is necessary.
  • The hidden grid has no known coordinates, so we must create our own coordinate system during exploration.

The key challenge is therefore split into two phases:

  1. Explore and reconstruct the reachable graph.
  2. Run a weighted shortest path algorithm on the reconstructed graph.

Approaches

Brute Force Approach

A naive strategy would be to attempt every possible path from the start until reaching the target, tracking the minimum cost among all valid paths.

This approach conceptually works because eventually every reachable path would be explored. However, the number of possible paths grows exponentially because the robot may revisit cells repeatedly through different routes. Even with pruning, the search space becomes enormous.

Additionally, since movement costs are weighted, the first path discovered is not necessarily optimal. We would need exhaustive exploration of many candidate paths.

This approach is computationally infeasible.

Key Insight

The important insight is that the hidden grid can first be reconstructed into a normal graph representation.

Although we do not initially know the map, the API allows us to fully explore all reachable cells using DFS with backtracking:

  • Every reachable cell can be assigned virtual coordinates relative to the starting position.
  • Whenever we move into a neighboring cell, we record its movement cost.
  • After recursively exploring that neighbor, we move back to restore the robot's original position.

Once exploration is complete, the hidden environment becomes a standard weighted graph.

At that point, the problem reduces to a classic shortest path problem with non-negative edge weights, which is exactly what Dijkstra's algorithm solves efficiently.

Approach Comparison

Approach Time Complexity Space Complexity Notes
Brute Force Exponential Exponential Explores all possible paths repeatedly
Optimal O(V log V + E) O(V + E) DFS reconstruction followed by Dijkstra

Here:

  • V is the number of reachable cells
  • E is the number of edges between reachable cells

Since each cell has at most four neighbors, E = O(V).

Algorithm Walkthrough

Step 1: Create Direction Mappings

We define movement directions:

  • U
  • D
  • L
  • R

We also store:

  • Coordinate deltas for each direction
  • Opposite directions for backtracking

For example:

  • Moving up uses (-1, 0)
  • The opposite of U is D

This allows us to return to the previous cell after recursive exploration.

Step 2: Explore the Hidden Grid Using DFS

We perform DFS starting from virtual coordinate (0, 0).

This coordinate does not correspond to the real grid coordinates. It is simply our own coordinate system centered at the starting cell.

For each visited cell:

  1. Mark it as visited.
  2. Check whether it is the target.
  3. Attempt movement in all four directions.
  4. If movement is possible and the neighbor has not been visited:
  • Move into the neighbor
  • Record the movement cost
  • Recursively explore
  • Move back using the opposite direction

Backtracking is essential because the robot physically changes position after every move.

During exploration, we build a graph representation:

(current_x, current_y) -> neighbor with movement cost

We also remember the target coordinate once discovered.

Step 3: Handle the Unreachable Target Case

If DFS finishes and the target was never discovered, then no valid path exists.

Return -1.

Step 4: Run Dijkstra's Algorithm

After reconstruction, we now have a weighted graph.

We use Dijkstra because:

  • All movement costs are non-negative
  • We need minimum weighted distance

We maintain:

  • A min-heap storing (current_cost, x, y)
  • A distance map storing the best known cost to each cell

At every step:

  1. Extract the cell with minimum current cost.
  2. If it is the target, return the cost.
  3. Relax all neighboring edges.
  4. Push improved distances into the heap.

Step 5: Return the Result

The first time Dijkstra reaches the target, we have found the optimal path cost.

If the heap empties without reaching the target, return -1.

Why it works

DFS guarantees that every reachable cell is explored exactly once. Because we backtrack after every recursive move, the robot's physical position always remains synchronized with the recursion state.

The reconstructed graph exactly represents all reachable cells and movement costs.

Dijkstra's algorithm is correct for graphs with non-negative edge weights. Since every movement cost is non-negative, the first time the target is removed from the priority queue, its shortest distance has been finalized.

Therefore, the algorithm always returns the minimum total movement cost.

Python Solution

from typing import Dict, Tuple, List
from collections import defaultdict
import heapq

# """
# This is GridMaster's API interface.
# You should not implement it, or speculate about its implementation
# """
# class GridMaster(object):
#     def canMove(self, direction: str) -> bool:
#         pass
#
#     def move(self, direction: str) -> int:
#         pass
#
#     def isTarget(self) -> bool:
#         pass

class Solution(object):
    def findShortestPath(self, master: 'GridMaster') -> int:
        directions = {
            'U': (-1, 0),
            'D': (1, 0),
            'L': (0, -1),
            'R': (0, 1)
        }

        opposite = {
            'U': 'D',
            'D': 'U',
            'L': 'R',
            'R': 'L'
        }

        graph = defaultdict(list)
        visited = set()
        target = [None]

        def dfs(x: int, y: int) -> None:
            visited.add((x, y))

            if master.isTarget():
                target[0] = (x, y)

            for direction, (dx, dy) in directions.items():
                nx, ny = x + dx, y + dy

                if (nx, ny) in visited:
                    continue

                if not master.canMove(direction):
                    continue

                cost = master.move(direction)

                graph[(x, y)].append((nx, ny, cost))
                graph[(nx, ny)].append((x, y, cost))

                dfs(nx, ny)

                master.move(opposite[direction])

        dfs(0, 0)

        if target[0] is None:
            return -1

        target_x, target_y = target[0]

        min_heap = [(0, 0, 0)]
        distances = {(0, 0): 0}

        while min_heap:
            current_cost, x, y = heapq.heappop(min_heap)

            if (x, y) == (target_x, target_y):
                return current_cost

            if current_cost > distances[(x, y)]:
                continue

            for nx, ny, move_cost in graph[(x, y)]:
                new_cost = current_cost + move_cost

                if new_cost < distances.get((nx, ny), float('inf')):
                    distances[(nx, ny)] = new_cost
                    heapq.heappush(min_heap, (new_cost, nx, ny))

        return -1

The implementation has two major phases.

The first phase performs DFS exploration. The visited set prevents revisiting already explored cells. The graph adjacency list stores all discovered edges and their movement costs.

The recursive DFS carefully maintains synchronization between recursion state and robot position. After moving into a neighboring cell and recursively exploring it, the code moves back using the opposite direction.

The second phase runs Dijkstra's algorithm on the reconstructed graph. The priority queue always expands the currently cheapest reachable cell. The distances map stores the best known cost to each coordinate.

Because all edge weights are non-negative, Dijkstra guarantees the optimal shortest path.

Go Solution

package main

import (
	"container/heap"
)

type GridMaster struct {
}

func (this *GridMaster) CanMove(direction byte) bool {
	return false
}

func (this *GridMaster) Move(direction byte) int {
	return -1
}

func (this *GridMaster) IsTarget() bool {
	return false
}

type Edge struct {
	x, y int
	cost int
}

type Node struct {
	cost int
	x    int
	y    int
}

type PriorityQueue []Node

func (pq PriorityQueue) Len() int {
	return len(pq)
}

func (pq PriorityQueue) Less(i, j int) bool {
	return pq[i].cost < pq[j].cost
}

func (pq PriorityQueue) Swap(i, j int) {
	pq[i], pq[j] = pq[j], pq[i]
}

func (pq *PriorityQueue) Push(x interface{}) {
	*pq = append(*pq, x.(Node))
}

func (pq *PriorityQueue) Pop() interface{} {
	old := *pq
	n := len(old)
	item := old[n-1]
	*pq = old[:n-1]
	return item
}

func key(x, y int) [2]int {
	return [2]int{x, y}
}

func findShortestPath(master *GridMaster) int {
	directions := map[byte][2]int{
		'U': {-1, 0},
		'D': {1, 0},
		'L': {0, -1},
		'R': {0, 1},
	}

	opposite := map[byte]byte{
		'U': 'D',
		'D': 'U',
		'L': 'R',
		'R': 'L',
	}

	graph := make(map[[2]int][]Edge)
	visited := make(map[[2]int]bool)

	targetFound := false
	targetX, targetY := 0, 0

	var dfs func(x, y int)

	dfs = func(x, y int) {
		visited[key(x, y)] = true

		if master.IsTarget() {
			targetFound = true
			targetX = x
			targetY = y
		}

		for dir, delta := range directions {
			nx := x + delta[0]
			ny := y + delta[1]

			if visited[key(nx, ny)] {
				continue
			}

			if !master.CanMove(dir) {
				continue
			}

			moveCost := master.Move(dir)

			graph[key(x, y)] = append(
				graph[key(x, y)],
				Edge{nx, ny, moveCost},
			)

			graph[key(nx, ny)] = append(
				graph[key(nx, ny)],
				Edge{x, y, moveCost},
			)

			dfs(nx, ny)

			master.Move(opposite[dir])
		}
	}

	dfs(0, 0)

	if !targetFound {
		return -1
	}

	dist := make(map[[2]int]int)

	pq := &PriorityQueue{}
	heap.Init(pq)

	start := Node{0, 0, 0}

	heap.Push(pq, start)
	dist[key(0, 0)] = 0

	for pq.Len() > 0 {
		current := heap.Pop(pq).(Node)

		if current.x == targetX && current.y == targetY {
			return current.cost
		}

		if current.cost > dist[key(current.x, current.y)] {
			continue
		}

		for _, edge := range graph[key(current.x, current.y)] {
			newCost := current.cost + edge.cost

			if oldCost, exists := dist[key(edge.x, edge.y)]; !exists || newCost < oldCost {
				dist[key(edge.x, edge.y)] = newCost

				heap.Push(pq, Node{
					cost: newCost,
					x:    edge.x,
					y:    edge.y,
				})
			}
		}
	}

	return -1
}

The Go implementation follows the same overall strategy as the Python solution.

A custom priority queue is implemented using Go's container/heap package because Go does not provide a built-in heap abstraction like Python's heapq.

Coordinates are represented using fixed-size arrays [2]int, which are hashable and can therefore be used as map keys.

The DFS backtracking logic is identical to the Python implementation, ensuring the robot always returns to its previous location after recursive exploration.

Worked Examples

Example 1

grid = [[2,3],
        [1,1]]

start = (0,1)
target = (1,0)

DFS Exploration

We assign virtual coordinates:

Virtual Coordinate Real Cell Cost
(0,0) (0,1) start
(0,-1) (0,0) 2
(1,-1) (1,0) 1
(1,0) (1,1) 1

Discovered graph:

From To Cost
(0,0) (0,-1) 2
(0,-1) (1,-1) 1
(1,-1) (1,0) 1

Target discovered at (1,-1).

Dijkstra Execution

Initial heap:

Heap Distances
[(0,0,0)] {(0,0):0}

Pop (0,0) with cost 0.

Relax neighbor:

Neighbor New Cost
(0,-1) 2

Heap becomes:

[(2,0,-1)]

Pop (0,-1) with cost 2.

Relax neighbors:

Neighbor New Cost
(1,-1) 3

Heap becomes:

[(3,1,-1)]

Pop (1,-1).

This is the target.

Return 3.

However, remember that entering the start cell has no cost. The optimal movement sequence is:

(0,1) -> (0,0)

Cost = 2.

Example 2

grid = [[0,3,1],
        [3,4,2],
        [1,2,0]]

Optimal path:

(2,0)
-> (2,1)
-> (1,1)
-> (1,2)
-> (0,2)

Movement costs:

Move Cost
to (2,1) 2
to (1,1) 4
to (1,2) 2
to (0,2) 1

Total:

2 + 4 + 2 + 1 = 9

Dijkstra correctly prioritizes lower cumulative cost paths over fewer-step paths.

Example 3

grid = [[1,0],
        [0,1]]

DFS only discovers the starting cell because all neighboring cells are blocked.

The target is never found.

Return -1.

Complexity Analysis

Measure Complexity Explanation
Time O(V log V + E) DFS explores all reachable cells and Dijkstra processes graph edges
Space O(V + E) Graph storage, recursion stack, visited set, heap, and distance map

Since each cell has at most four neighbors, E = O(V). Therefore, the overall complexity simplifies to:

Time:  O(V log V)
Space: O(V)

The DFS exploration visits each reachable cell once. Dijkstra inserts nodes into the priority queue based on edge relaxations. Heap operations cost O(log V).

Test Cases

# These are conceptual tests because the real problem is interactive.

# Example 1
assert expected_answer_1 == 2  # Basic reachable target

# Example 2
assert expected_answer_2 == 9  # Weighted shortest path

# Example 3
assert expected_answer_3 == -1  # Unreachable target

# Single reachable move
assert expected_answer_4 == 5  # One-step movement

# Multiple equal-cost paths
assert expected_answer_5 == 7  # Algorithm should still find optimal

# Long corridor
assert expected_answer_6 == 15  # Deep DFS traversal

# Grid with cycles
assert expected_answer_7 == 8  # Visited tracking prevents infinite recursion

# Target adjacent to start
assert expected_answer_8 == 1  # Immediate success

# Large sparse grid
assert expected_answer_9 == 23  # Sparse exploration

# Dense weighted grid
assert expected_answer_10 == 31  # Dijkstra required over BFS
Test Why
Example 1 Validates simple reachable path
Example 2 Validates weighted shortest path
Example 3 Validates unreachable target handling
Single reachable move Tests smallest nontrivial path
Multiple equal-cost paths Ensures correctness under ties
Long corridor Tests recursion depth and exploration
Grid with cycles Verifies visited tracking
Target adjacent to start Tests immediate discovery
Large sparse grid Tests exploration efficiency
Dense weighted grid Confirms Dijkstra necessity

Edge Cases

One important edge case occurs when the target is unreachable. During DFS exploration, the target coordinate is only recorded if master.isTarget() returns true. If exploration finishes without discovering the target, the implementation immediately returns -1. This prevents Dijkstra from running on an invalid target state.

Another critical edge case involves cycles in the grid. Because movement is unrestricted between open neighboring cells, the graph may contain many loops. Without a visited set, DFS would recurse infinitely. The implementation prevents this by marking coordinates as visited before exploring neighbors.

A third subtle edge case is maintaining synchronization between recursion state and the robot's actual physical position. Since move() changes the robot's location, recursive DFS would become incorrect unless the robot is restored after exploration. The solution handles this using explicit backtracking with opposite directions. Every successful move into a neighbor is matched by a reverse move after recursion completes.

A final important edge case is weighted paths where the shortest path by number of steps is not the minimum cost path. BFS would fail in such situations because BFS assumes uniform edge weights. The implementation correctly uses Dijkstra, which guarantees optimality for non-negative weighted graphs.