980. Unique Paths III
Problem Description
The problem presents a grid that consists of integer values. This grid has different types of cells:
- The starting square, marked with a '1', exists uniquely in the grid.
- The ending square, marked with a '2', also exists uniquely in the grid.
- Empty squares, designated by '0', are cells we can walk through.
- Obstacle squares, marked as '-1', are cells we cannot pass through.
The grid reflects a two-dimensional space where the player could move in four directions: up, down, left, or right. The task is to determine how many unique paths can be taken from the starting square to the ending square, such that every non-obstacle square is visited exactly once throughout the journey. It's a pathfinding problem where the added challenge is that it requires traversing each accessible cell on the grid precisely one time.
Flowchart Walkthrough
To analyze LeetCode problem 980, Unique Paths III, let's use the algorithm flowchart available here. Here is the step-by-step walkthrough:
Is it a graph?
- Yes: Even though it's a grid problem, it can be treated as a graph where each cell is a vertex and each adjacent cell is connected by an edge.
Need to solve for kth smallest/largest?
- No: The problem is not about finding the kth smallest or largest element, but about finding all unique paths.
Involves Linked Lists?
- No: This problem does not use linked lists.
Does the problem have small constraints?
- Yes: The grid size is limited, implying the complete search over possible paths is feasible.
Brute force / Backtracking?
- Yes: Given the structure and requirements of the problem, where we need to explore every possible path that visits all non-obstacle cells exactly once, a brute force or backtracking approach is applicable.
Conclusion: The flowchart suggests using a backtracking approach to explore all possible routes that walk over every required cell exactly once, ensuring compliance with the problem’s constraints.
Intuition
To understand the solution, we should first recognize that this problem can be approached through backtracking, which is a common technique used to find all (or some) solutions to problems that incrementally build candidates to the solutions. At each step, the solution attempts to move forward and, if not successful or the sequence violates the conditions, it will 'backtrack' to try different possible paths.
This problem essentially boils down to generating all possible movements over the grid, starting from the designated 'start' cell and moving through all reachable non-obstacle cells until the 'end' cell is reached, all while keeping track of the path length. The unique constraint is that the number of cells visited must equal the total number of non-obstacle cells exactly.
The given solution uses a recursive depth-first search (DFS) function, dfs
, that explores all potential paths from the current cell. The parameters (i, j, k)
passed to the dfs
function represent the current cell's coordinates and the number of cells visited thus far, respectively. If the end cell is reached (grid[i][j] == 2
), the function checks if the count k
is equal to the number of non-obstacle cells cnt
plus one (to include the starting cell itself). If it matches, it increments the solution count, as a valid path has been found.
To constrain the path to valid moves and prevent revisiting cells, an auxiliary set, vis
, keeps track of visited coordinates. The condition for continuing down a path is that the next cell must be on the grid, unvisited, and not an obstacle. If these conditions are met, the cell is marked as visited and the dfs
process continues for that next cell. After all paths from that cell have been explored, it's marked as unvisited for further iterations of adjacent cells.
This approach incrementally explores all possible paths while keeping track of the number of paths fulfilling the condition of visiting each non-obstacle cell exactly once. The search continues until no further steps are possible and returns the total count of such unique paths as the solution.
Learn more about Backtracking patterns.
Solution Approach
The implementation uses a backtracking approach embedded in the dfs
(depth-first search) function. The essential parts include recursion, backtracking, state tracking, and a means to tally successful path counts.
Here's a step-by-step breakdown of the algorithm and the patterns used in the solution:
-
Initialization: The solution starts with identifying the starting point from the grid and counting the number of zeroes (empty cells). The count of empty cells is critical as it will be used later to verify if a path is valid.
-
Depth-First Search (DFS): The
dfs
function is recursive and is responsible for exploring all possible paths from a given cell. It receives three parameters: the current cell's coordinates(i, j)
and a counterk
that keeps track of the number of cells walked through. -
Base Case: When the end cell is reached (denoted as
2
in the grid), the function checks whether all non-obstacle cells are visited. It does so by comparingk
againstcnt + 1
. The+ 1
accounts for the starting cell, which is not counted yet. A return of1
indicates a valid path is found, and0
otherwise. -
Recursion and Backtracking: For each cell, the
dfs
function enumerates all four possible directions by taking adjacent coordinates from thedirs
list. The list suffix(-1, 0, 1, 0, -1)
is a clever way to iterate over four combinations that represent the up, down, left, and right moves. -
Coordinate Checking: Before making a recursive
dfs
call to the adjacent cell, the function ensures that the cell is valid — it needs to:- Be within the bounds of the grid.
- Not be an obstacle (
grid[x][y] != -1
). - Not have been visited already (not in
vis
set).
-
Marking Visited Cells: Cells that meet the conditions are then added to the
vis
set, which tracks the visited state of each cell. This prevents the algorithm from revisiting cells, which ensures that it only considers paths that visit each cell exactly once. -
Recursive Call and Unmarking: The recursive call is then made, and upon returning, the cell is removed from the
vis
set. This "unmarking" step is the essence of backtracking - it undoes the mark before the function backtracks, allowing subsequent calls to consider the cell in their paths. -
Aggregating Results: Each depth-first traversal returns a count of valid paths from that cell. These are then summed up and returned to aggregate the total number of valid paths.
-
Returning the Result: Finally, the top-level call to the
dfs
function kicks off the backtracking process from the starting cell and provides the initial path count of0
. The final result is the total number of unique paths found, which is returned to the caller.
The solution effectively uses backtracking to explore the solution space exhaustively while employing a visitation set to adhere to strict path constraints. The recursive nature of dfs
allows for elegant exploration of all paths, and the check against total cells cements the correct count of full-coverage paths to be returned.
Ready to land your dream job?
Unlock your dream job with a 2-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's consider a simple example with a 3x3 grid that demonstrates how the solution works.
[ [1, 0, 0], [-1, 0, 0], [0, 0, 2] ]
In this grid:
- '1' is the starting square
- '2' is the ending square
- '0' represents empty squares we can walk through
- '-1' is an obstacle square we cannot pass
Step 1: Initialization
We identify the starting square is at position (0,0), and there are five empty squares ('0').
Step 2: Depth-First Search (DFS)
Starting at (0,0), we begin our DFS traversal. Since we start on the starting square, our path length k
is initially 0
.
Step 3: Recursion and Backtracking
At each cell, we attempt to move in every direction: up, right, down, and left. Since we're in the top-left corner, only the right and down moves are possible. Here's how each move is considered:
- Moving right: We reach the cell at (0,1). Since it is valid, we increment
k
to 1 and continue the DFS from here. - Moving down: We can't move down from the starting position because there's an obstacle at (1,0).
Step 4: Recursive Calls
Now at (0,1), we again explore adjacent cells.
- Moving right: We reach (0,2). The
k
is incremented to 2. From here, only one move down is valid, taking us to (1,2). Thek
becomes 3, and then the only valid move is towards the bottom-right corner at (2,2). This cell is the endpoint, sok
is now 4. - Moving down: The only valid move from (0,1) is (2,0), since (1,0) is blocked. From (2,0), we can move right twice to eventually reach (2,2), again incrementing 'k' each time.
Step 5: Check for Valid Path
Upon reaching the end cell at (2,2), we check if all non-obstacle cells were visited. Since the path length k
needs to be equal to the number of non-obstacle cells (cnt + 1
), which here is 5 + 1 = 6 for a valid path, but we only have k
as 4, this means that our path is not valid.
Thus, this example would conclude with all paths ending with a path length less than the required length, resulting in 0 valid paths.
In a case where the grid allows for more valid movements, the DFS would continue exploring, backtracking as it goes along, until it can either no longer make a move or reaches the end with the correct path length. The total number of times it successfully reaches the end with the correct path length will be summed up and returned as the solution.
Solution Implementation
1from typing import List
2
3class Solution:
4 def uniquePathsIII(self, grid: List[List[int]]) -> int:
5 # Depth-first search algorithm to explore the paths.
6 def dfs(x: int, y: int, steps: int) -> int:
7 # If the end has been reached and all squares visited, return 1.
8 if grid[x][y] == 2:
9 return int(steps == empty_squares + 1)
10 path_count = 0
11 # Explore all four directions.
12 for dx, dy in directions:
13 next_x, next_y = x + dx, y + dy
14 # Check boundaries and if the cell is not visited and not an obstacle.
15 if 0 <= next_x < rows and 0 <= next_y < columns and (next_x, next_y) not in visited and grid[next_x][next_y] != -1:
16 visited.add((next_x, next_y))
17 path_count += dfs(next_x, next_y, steps + 1)
18 visited.remove((next_x, next_y))
19 return path_count
20
21 # Size of the grid.
22 rows, columns = len(grid), len(grid[0])
23 # Find the starting position.
24 start_x, start_y = next((i, j) for i in range(rows) for j in range(columns) if grid[i][j] == 1)
25 # Directions offset for exploring (up, right, down, left).
26 directions = [(-1, 0), (0, 1), (1, 0), (0, -1)]
27 # Count the number of empty squares that need to be traversed.
28 empty_squares = sum(row.count(0) for row in grid)
29 # Set of visited cells to avoid revisiting.
30 visited = {(start_x, start_y)}
31 # Start DFS from the starting position without any steps taken yet.
32 return dfs(start_x, start_y, 0)
33
1class Solution {
2 // Dimensions of the grid.
3 private int rowCount;
4 private int columnCount;
5
6 // Count of non-obstacle cells that need to be visited.
7 private int emptyCellCount;
8
9 // The grid itself.
10 private int[][] grid;
11
12 // Visitation state of each cell.
13 private boolean[][] visited;
14
15 public int uniquePathsIII(int[][] grid) {
16 rowCount = grid.length;
17 columnCount = grid[0].length;
18 this.grid = grid;
19
20 // Starting point coordinates.
21 int startX = 0, startY = 0;
22
23 // Initialize count of empty cells and find the starting cell.
24 for (int i = 0; i < rowCount; ++i) {
25 for (int j = 0; j < columnCount; ++j) {
26 if (grid[i][j] == 0) { // Empty cell.
27 emptyCellCount++;
28 } else if (grid[i][j] == 1) { // Start cell.
29 startX = i;
30 startY = j;
31 }
32 }
33 }
34
35 // Initialize visited matrix for tracking visited cells.
36 visited = new boolean[rowCount][columnCount];
37 visited[startX][startY] = true;
38
39 // Start the DFS from the starting cell.
40 return dfs(startX, startY, 0);
41 }
42
43 // Depth-first search (DFS) to explore all unique paths.
44 private int dfs(int x, int y, int visitedCellsCount) {
45 // Base case: if it's the end cell and all non-obstacle cells have been visited.
46 if (grid[x][y] == 2) {
47 return visitedCellsCount == emptyCellCount + 1 ? 1 : 0;
48 }
49 int pathCount = 0;
50 // Directions array representing up, right, down, left (x, y) changes.
51 int[] directions = {-1, 0, 1, 0, -1};
52
53 // Explore all four directions.
54 for (int d = 0; d < 4; ++d) {
55 int nextX = x + directions[d];
56 int nextY = y + directions[d + 1];
57
58 // Check if next cell is within the grid, not visited, and not an obstacle.
59 if (nextX >= 0 && nextX < rowCount && nextY >= 0 && nextY < columnCount &&
60 !visited[nextX][nextY] && grid[nextX][nextY] != -1) {
61 visited[nextX][nextY] = true;
62
63 // Continue DFS from the next cell.
64 pathCount += dfs(nextX, nextY, visitedCellsCount + 1);
65
66 // Backtrack: unvisit the cell.
67 visited[nextX][nextY] = false;
68 }
69 }
70 return pathCount;
71 }
72}
73
1class Solution {
2public:
3 int uniquePathsIII(vector<vector<int>>& grid) {
4 int rowCount = grid.size();
5 int colCount = grid[0].size();
6 int zeroCount = 0; // Count of non-obstacle squares
7
8 // Counting the number of zero squares in the grid
9 for (auto& row : grid) {
10 for (auto& cell : row) {
11 zeroCount += cell == 0;
12 }
13 }
14
15 // Directions array for the DFS moves (up, right, down, left)
16 int directions[5] = {-1, 0, 1, 0, -1};
17 // Visited matrix to mark visited cells during DFS
18 bool visited[rowCount][colCount];
19 memset(visited, false, sizeof visited);
20
21 // DFS function to search for unique paths
22 function<int(int, int, int)> depthFirstSearch = [&](int row, int col, int pathCount) -> int {
23 // Reaching the endpoint (grid value 2) with all zeros covered
24 if (grid[row][col] == 2) {
25 return pathCount == zeroCount + 1 ? 1 : 0;
26 }
27 int pathCountTotal = 0;
28
29 // Explore all possible directions from the current cell
30 for (int i = 0; i < 4; ++i) {
31 int nextRow = row + directions[i], nextCol = col + directions[i + 1];
32
33 // Check if the next move is within the grid bounds, not visited and not an obstacle
34 if (nextRow >= 0 && nextRow < rowCount && nextCol >= 0 && nextCol < colCount
35 && !visited[nextRow][nextCol] && grid[nextRow][nextCol] != -1) {
36 visited[nextRow][nextCol] = true;
37 pathCountTotal += depthFirstSearch(nextRow, nextCol, pathCount + 1);
38 visited[nextRow][nextCol] = false; // Backtrack
39 }
40 }
41
42 return pathCountTotal;
43 };
44
45 // Find the starting cell (grid value 1) and initiate DFS from there
46 for (int row = 0; row < rowCount; ++row) {
47 for (int col = 0; col < colCount; ++col) {
48 if (grid[row][col] == 1) {
49 visited[row][col] = true;
50 return depthFirstSearch(row, col, 0);
51 }
52 }
53 }
54
55 return 0; // No starting point found, return 0 paths
56 }
57};
58
1// Function to calculate the number of unique paths on a grid, where 1 represents
2// the starting point, 2 represents the ending point, and -1 represents obstacles.
3function uniquePathsIII(grid: number[][]): number {
4 const rows = grid.length;
5 const cols = grid[0].length;
6 let startRow = 0;
7 let startCol = 0;
8 let emptyCells = 0;
9
10 // Count the number of empty cells and find the starting point
11 for (let i = 0; i < rows; ++i) {
12 for (let j = 0; j < cols; ++j) {
13 if (grid[i][j] === 0) {
14 emptyCells++;
15 } else if (grid[i][j] === 1) {
16 startRow = i;
17 startCol = j;
18 }
19 }
20 }
21
22 // Visited cells' tracking array
23 const visited = Array.from({ length: rows }, () => Array(cols).fill(false));
24 visited[startRow][startCol] = true;
25
26 // Possible moves (up, right, down, left)
27 const directions = [-1, 0, 1, 0, -1];
28
29 // Depth-first search to explore all unique paths
30 const dfs = (row: number, col: number, steps: number): number => {
31 // If the ending point is reached and all empty cells are visited
32 if (grid[row][col] === 2) {
33 return steps === emptyCells + 1 ? 1 : 0;
34 }
35
36 let paths = 0;
37
38 // Explore the four possible directions
39 for (let d = 0; d < 4; ++d) {
40 const nextRow = row + directions[d];
41 const nextCol = col + directions[d + 1];
42
43 // Check if the next move is valid (inside grid, not visited, no obstacle)
44 if (nextRow >= 0 && nextRow < rows && nextCol >= 0 && nextCol < cols &&
45 !visited[nextRow][nextCol] && grid[nextRow][nextCol] !== -1) {
46 visited[nextRow][nextCol] = true;
47 paths += dfs(nextRow, nextCol, steps + 1);
48 visited[nextRow][nextCol] = false; // Backtrack
49 }
50 }
51 return paths;
52 };
53
54 // Begin DFS from the starting point with 0 steps
55 return dfs(startRow, startCol, 0);
56}
57
Time and Space Complexity
The time complexity of the given code can be analyzed by understanding the number of recursive calls made by the dfs
function. Since in the worst-case scenario each cell that is not a starting point (1
), an ending point (2
), or an obstacle (-1
) needs to be visited, and from each cell, we can move to 3 other unvisited cells (excluding the one we just came from), the maximum number of moves will be 3^(cnt)
, where cnt
is the number of cells that are not obstacles (value 0
). Since the cnt
includes all the empty squares except the starting and ending points, and each move involves a recursive step, the total time complexity can indeed be as high as O(3^(m * n))
when cnt
tends towards m * n
.
However, note that this is a very rough upper bound. The actual time complexity is usually less because the dfs
function includes checks that prevent revisiting cells and moving into cells with obstacles, and it stops exploring further when the destination is reached. The true complexity also depends on the specific layout of the grid, so O(3^(m * n))
should be viewed as the upper limit for how the complexity scales in the number of calls the dfs
function might make in the worst-case scenario.
The space complexity is determined by the size of the call stack in the recursion (which is based on the maximum depth of the recursion tree) and the additional space used to keep track of visited cells (the vis
set). The depth of the recursion tree is limited by the number of empty squares cnt
, so the maximum depth could be O(m * n)
in the worst case when every cell is empty. The vis
set will at most contain cnt
elements if every empty cell is visited exactly once. Therefore, the space complexity is indeed O(m * n)
in the worst-case scenario due to the call stack and the vis
set.
Learn more about how to find time and space complexity quickly using problem constraints.
What's the output of running the following function using input [30, 20, 10, 100, 33, 12]
?
1def fun(arr: List[int]) -> List[int]:
2 import heapq
3 heapq.heapify(arr)
4 res = []
5 for i in range(3):
6 res.append(heapq.heappop(arr))
7 return res
8
1public static int[] fun(int[] arr) {
2 int[] res = new int[3];
3 PriorityQueue<Integer> heap = new PriorityQueue<>();
4 for (int i = 0; i < arr.length; i++) {
5 heap.add(arr[i]);
6 }
7 for (int i = 0; i < 3; i++) {
8 res[i] = heap.poll();
9 }
10 return res;
11}
12
1class HeapItem {
2 constructor(item, priority = item) {
3 this.item = item;
4 this.priority = priority;
5 }
6}
7
8class MinHeap {
9 constructor() {
10 this.heap = [];
11 }
12
13 push(node) {
14 // insert the new node at the end of the heap array
15 this.heap.push(node);
16 // find the correct position for the new node
17 this.bubble_up();
18 }
19
20 bubble_up() {
21 let index = this.heap.length - 1;
22
23 while (index > 0) {
24 const element = this.heap[index];
25 const parentIndex = Math.floor((index - 1) / 2);
26 const parent = this.heap[parentIndex];
27
28 if (parent.priority <= element.priority) break;
29 // if the parent is bigger than the child then swap the parent and child
30 this.heap[index] = parent;
31 this.heap[parentIndex] = element;
32 index = parentIndex;
33 }
34 }
35
36 pop() {
37 const min = this.heap[0];
38 this.heap[0] = this.heap[this.size() - 1];
39 this.heap.pop();
40 this.bubble_down();
41 return min;
42 }
43
44 bubble_down() {
45 let index = 0;
46 let min = index;
47 const n = this.heap.length;
48
49 while (index < n) {
50 const left = 2 * index + 1;
51 const right = left + 1;
52
53 if (left < n && this.heap[left].priority < this.heap[min].priority) {
54 min = left;
55 }
56 if (right < n && this.heap[right].priority < this.heap[min].priority) {
57 min = right;
58 }
59 if (min === index) break;
60 [this.heap[min], this.heap[index]] = [this.heap[index], this.heap[min]];
61 index = min;
62 }
63 }
64
65 peek() {
66 return this.heap[0];
67 }
68
69 size() {
70 return this.heap.length;
71 }
72}
73
74function fun(arr) {
75 const heap = new MinHeap();
76 for (const x of arr) {
77 heap.push(new HeapItem(x));
78 }
79 const res = [];
80 for (let i = 0; i < 3; i++) {
81 res.push(heap.pop().item);
82 }
83 return res;
84}
85
Recommended Readings
Backtracking Template Prereq DFS with States problems dfs_with_states Combinatorial search problems Combinatorial search problems involve finding groupings and assignments of objects that satisfy certain conditions Finding all permutations combinations subsets and solving Sudoku are classic combinatorial problems The time complexity of combinatorial problems often grows rapidly with the size of
LeetCode Patterns Your Personal Dijkstra's Algorithm to Landing Your Dream Job The goal of AlgoMonster is to help you get a job in the shortest amount of time possible in a data driven way We compiled datasets of tech interview problems and broke them down by patterns This way we
Recursion Recursion is one of the most important concepts in computer science Simply speaking recursion is the process of a function calling itself Using a real life analogy imagine a scenario where you invite your friends to lunch https algomonster s3 us east 2 amazonaws com recursion jpg You first
Want a Structured Path to Master System Design Too? Don’t Miss This!