980. Unique Paths III

HardBit ManipulationArrayBacktrackingMatrix
Leetcode Link

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:

  1. 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.

  2. 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 counter k that keeps track of the number of cells walked through.

  3. 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 comparing k against cnt + 1. The + 1 accounts for the starting cell, which is not counted yet. A return of 1 indicates a valid path is found, and 0 otherwise.

  4. Recursion and Backtracking: For each cell, the dfs function enumerates all four possible directions by taking adjacent coordinates from the dirs 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.

  5. 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).
  6. 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.

  7. 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.

  8. 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.

  9. 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 of 0. 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 Evaluator

Example 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). The k becomes 3, and then the only valid move is towards the bottom-right corner at (2,2). This cell is the endpoint, so k 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.


Discover Your Strengths and Weaknesses: Take Our 2-Minute Quiz to Tailor Your Study Plan:
Question 1 out of 10

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

Want a Structured Path to Master System Design Too? Don’t Miss This!


Load More