2267. Check if There Is a Valid Parentheses String Path


Problem Description

The given problem presents a matrix grid filled with parentheses characters, either '(' or ')'. The task is to determine whether there exists at least one path from the top-left corner of the grid to the bottom-right corner, such that:

  • The path only moves downwards or to the right, one cell at a time.
  • The sequence of parentheses encountered along the path forms a valid parentheses string.

A parentheses string is considered valid if it meets any of the following conditions:

  1. It is an empty string or consists solely of one pair of parentheses: ().
  2. It can be divided into two consecutive parts, A and B, both of which are valid parentheses strings themselves.
  3. It can be enclosed in a single pair of parentheses, with the inside being a valid parentheses string.

In simpler terms, we must find a path where the order and number of opening '(' and closing ')' parentheses balance each other correctly as specified by the valid parentheses string definition.

Intuition

To find a solution, we use a Depth-First Search (DFS) algorithm combined with memoization to avoid redundant computations for previously visited cell states. The idea is to traverse the grid by moving right or down and tracking the balance of parentheses as follows:

  • Increment the balance t whenever an opening parenthesis '(' is encountered.
  • Decrement the balance t whenever a closing parenthesis ')' is encountered.

Since we cannot have more closing brackets before opening brackets in a valid parentheses sequence, any path where the balance becomes negative at any point is invalid and can be disregarded. While traversing, if we reach the bottom-right cell and the balance t is zero, we have found a valid path, and we can return true.

Through memoization with @cache, we store the results of subproblems so that we don't recompute the validity of paths from the same cell and with the same current balance t. By trying each possible path and checking the balance of parentheses, we can determine if a valid path exists without exploring every possible permutation exhaustively, thus saving time.

The recursive DFS function dfs(i, j, t) tries to find a path starting from cell (i, j) with a current parentheses balance t. If a valid path is found at any point, the function returns true and concludes early. Otherwise, it exhaustively searches and eventually may return false if no valid paths exist.

Learn more about Dynamic Programming patterns.

Not Sure What to Study? Take the 2-min Quiz to Find Your Missing Piece

A heap is a ...?

Solution Approach

The solution to the problem utilizes a Deep-First Search (DFS) approach that is defined in the function dfs(i, j, t), where i and j represent the current cell's row and column indices in the grid, and t is the current balance of parentheses.

Here's a step-by-step breakdown of how the solution is implemented:

  1. Initialization: The function initializes by reading the dimensions of the grid into variables m and n. The DFS function is then called starting from the top-left cell (0, 0) with an initial balance t of 0.

  2. DFS Function (dfs(i, j, t)): The core recursive function checks whether the current path is valid as it traverses the grid:

    • Update Balance: We update the balance t by incrementing for an opening parenthesis '(' and decrementing for a closing parenthesis ')'.

    • Invalid Condition: If the balance t is negative, we stop exploration down this path because more closing parentheses than opening ones have been encountered, making the path invalid.

    • Base Case - Target Cell: If the current cell is the bottom-right cell (m - 1, n - 1), we check if t is 0. A balance of zero means we have an equal number of opening and closing parentheses, indicating a valid path.

    • Recursive Exploration: For a non-terminating cell, the function branches by calling itself for the next cell to the right (i, j + 1) or the next cell down (i + 1, j), if those cells are within the bounds of the grid. This continues until the base case or invalid condition is met.

  3. Memoization: The function is decorated with the @cache decorator from Python's functools library, which memoizes the results. This means that for a given state (i, j, t), subsequent DFS calls with the same state will return the memoized result instead of recomputing. This optimizes the DFS algorithm’s efficiency and reduces the overall time complexity.

  4. Returning Result: The initial call to dfs(0, 0, 0) will eventually return true or false based on whether a valid path is found. This value is then returned by the hasValidPath method.

The solution wisely combines a DFS traversal to explore paths through the grid, a balance tracker to ensure valid parentheses structure, and memoization to avoid redundant calculations for paths already explored with a given balance. By employing these algorithms and patterns, the solution effectively returns true if there exists at least one valid parenthesis path in the grid, or false otherwise.

Discover Your Strengths and Weaknesses: Take Our 2-Minute Quiz to Tailor Your Study Plan:

Which of the tree traversal order can be used to obtain elements in a binary search tree in sorted order?

Example Walkthrough

Let's illustrate the solution approach with an example. Consider the following grid:

1grid = [
2  "(()",
3  "())",
4  "())"
5]

We want to find at least one path from the top-left corner to the bottom-right corner, where the sequence of parentheses forms a valid string. Here is how we might apply the solution approach outlined previously:

  1. Initialization: We start at the top-left corner of the grid at cell (0, 0) and an initial balance t of 0.

  2. Recursive DFS Exploration:

    • Start at (0, 0) with character '(' which increments the balance t to 1.
    • Move right to (0, 1) encountering another '(', now balance is 2.
    • Next, at (0, 2), we encounter ')', and balance decrements to 1.
    • Now we can only go down to (1, 2). Here we find ')' again, so balance decrements to 0.
    • From (1, 2), we can only move down to (2, 2). The character here is ')', which would decrement the balance to -1.

    At this point, the path goes invalid because the balance t becomes negative, so this path is abandoned.

  3. Backtracking: We backtrack to the last valid cell (1, 2) with balance 0 and attempt another path:

    • Move up one cell to (0, 2) and then left to (0, 1), looping back to a previously visited state, but this time with an invalid balance due to the path we've taken. This path is abandoned.
  4. Exploring Alternative Paths:

    • Start over at (0, 0) this time going downwards.
    • Move down to (1, 0) encountering ')', which decrements the balance to 0.
    • Next, we move right to (1, 1) and get '(', now balance is 1.
    • Moving right again to (1, 2), we find ')', so balance goes to 0.
    • Now, go down to (2, 2). The balance is valid at 0, but we have found that this cell will lead to an invalid path from our previous exploration.

The algorithm continues exploring alternative paths by stepping through the grid, updating balances, backtracking upon invalid paths, and attempting new pathways until it eventually finds a valid path or exhausts all possibilities.

  1. Memoization: Throughout this process, the algorithm has been storing the resulting state of each cell and current balance in memory, ensuring that any repeat visits to the same state do not require re-computation. This significantly reduces the amount of work needed.

  2. Returning Result: It turns out there's no valid path from the top-left to the bottom-right cell in our example, so the hasValidPath call would return false.

This example illustrates the recursive and backtrack nature of the DFS approach with memoization by trying different paths and checking the balance of parentheses until a valid or no path is found.

Solution Implementation

1from typing import List
2from functools import lru_cache
3
4class Solution:
5    def has_valid_path(self, grid: List[List[str]]) -> bool:
6        # Cache the results of the recursive function to avoid recomputation.
7        @lru_cache(maxsize=None)
8        def dfs(row: int, col: int, balance: int) -> bool:
9            # Increment the balance if an opening parenthesis is encountered
10            if grid[row][col] == '(':
11                balance += 1
12            else:  # Decrement the balance for a closing parenthesis
13                balance -= 1
14          
15            # A negative balance means there are more closing than opening parens - invalid
16            if balance < 0:
17                return False
18          
19            # Reached the bottom-right corner. Check if the path is valid.
20            if row == height - 1 and col == width - 1:
21                return balance == 0
22
23            # Attempt to move down or right
24            for next_row, next_col in [(row + 1, col), (row, col + 1)]:
25                # Check if the next move is within the grid and whether it leads to a solution
26                if next_row < height and next_col < width and dfs(next_row, next_col, balance):
27                    return True
28          
29            # None of the paths led to a solution
30            return False
31
32        # Get grid dimensions
33        height, width = len(grid), len(grid[0])
34      
35        # Start the depth-first search from the top-left corner with a balance of zero
36        return dfs(0, 0, 0)
37
38# Example usage:
39# sol = Solution()
40# result = sol.has_valid_path([['(', ')'], ['(', ')']])
41# print(result)  # Output should be True if the path is valid
42
1class Solution {
2    private boolean[][][] visited; // A 3D array to keep track of visited positions with a particular balance t
3    private char[][] grid; // The input grid containing parentheses
4    private int rows; // Number of rows in the grid
5    private int columns; // Number of columns in the grid
6
7    // Method to determine if there's a valid path from top-left to bottom-right
8    public boolean hasValidPath(char[][] grid) {
9        rows = grid.length;
10        columns = grid[0].length;
11        this.grid = grid;
12        visited = new boolean[rows][columns][rows + columns]; // Initialize the visited array
13        return dfs(0, 0, 0); // Start depth-first search from the top-left corner with balance 0
14    }
15
16    // Depth-first search method to find a valid path
17    private boolean dfs(int row, int col, int balance) {
18        // If this position with the current balance is already visited, return false to avoid loops
19        if (visited[row][col][balance]) {
20            return false;
21        }
22        // Mark the current position and balance as visited
23        visited[row][col][balance] = true;
24      
25        // Update the balance: increment for '(', decrement for ')'
26        balance += grid[row][col] == '(' ? 1 : -1;
27      
28        // If balance is negative, we have more ')' than '(', so return false
29        if (balance < 0) {
30            return false;
31        }
32      
33        // If we've reached the bottom-right corner, check for correct balance
34        if (row == rows - 1 && col == columns - 1) {
35            return balance == 0;
36        }
37      
38        // Directions to move right or down
39        int[] directionRow = {0, 1};
40        int[] directionCol = {1, 0};
41      
42        // Loop through possible directions
43        for (int k = 0; k < 2; ++k) {
44            int newRow = row + directionRow[k];
45            int newCol = col + directionCol[k];
46          
47            // Check if we can move to the new position and if dfs returns true, then path is valid
48            if (newRow < rows && newCol < columns && dfs(newRow, newCol, balance)) {
49                return true;
50            }
51        }
52      
53        // If no path is found, return false
54        return false;
55    }
56}
57
1// Visited array to keep track of the states that have been visited
2bool visited[100][100][200];
3
4// Directions array represents the movement: right (0,1) and down (1,0)
5int directions[2][2] = {{0, 1}, {1, 0}};
6
7class Solution {
8public:
9    // Function to determine if there exists a valid path in the grid
10    bool hasValidPath(vector<vector<char>>& grid) {
11        // Initialize visited array to track visited states
12        memset(visited, 0, sizeof(visited));
13        // Start the Depth-First Search from the top-left corner with 0 opened parentheses
14        return dfs(0, 0, 0, grid);
15    }
16
17    // Helper function to perform DFS
18    bool dfs(int row, int col, int balance, vector<vector<char>>& grid) {
19        // If current state has already been visited, we can return false as it would be a cycle
20        if (visited[row][col][balance]) return false;
21        // Mark current state as visited
22        visited[row][col][balance] = true;
23
24        // Adjust balance based on whether current char is an opening or closing parenthesis
25        balance += grid[row][col] == '(' ? 1 : -1;
26
27        // If balance is negative, the path is invalid
28        if (balance < 0) return false;
29
30        // Get grid dimensions
31        int rows = grid.size(), cols = grid[0].size();
32
33        // If we have reached the bottom-right corner, check if balance is 0 (valid path)
34        if (row == rows - 1 && col == cols - 1) {
35            return balance == 0;
36        }
37
38        // Explore the neighboring right and down cells
39        for (int k = 0; k < 2; ++k) {
40            int newRow = row + directions[k][0];
41            int newCol = col + directions[k][1];
42
43            // Check bounds and continue DFS if the next cell is within the grid
44            if (newRow < rows && newCol < cols && dfs(newRow, newCol, balance, grid)) {
45                return true;
46            }
47        }
48
49        // If no path from this cell leads to a valid path, return false
50        return false;
51    }
52};
53
1interface Coordinate {
2    x: number;
3    y: number;
4}
5
6// The visited record keeps track of which states have been visited: [row][column][balance]
7const visited: boolean[][][] = Array(100).fill(null).map(() => 
8    Array(100).fill(null).map(() => Array(200).fill(false)));
9
10// Directions array represents the movement: right (0,1) and down (1,0)
11const directions: Coordinate[] = [{x: 0, y: 1}, {x: 1, y: 0}];
12
13// Function to determine if there exists a valid path in the grid
14function hasValidPath(grid: char[][]): boolean {
15    // Initialize visited array to track visited states
16    resetVisited();
17    // Start the Depth-First Search from the top-left corner with 0 opened parentheses
18    return dfs(0, 0, 0, grid);
19}
20
21// Helper function to perform DFS
22function dfs(row: number, col: number, balance: number, grid: char[][]): boolean {
23    // If current state has already been visited, return false to avoid cycles
24    if (visited[row][col][balance]) return false;
25
26    // Mark current state as visited
27    visited[row][col][balance] = true;
28
29    // Adjust balance based on whether current char is an opening or closing parenthesis
30    balance += grid[row][col] === '(' ? 1 : -1;
31
32    // If balance is negative, the path is invalid
33    if (balance < 0) return false;
34
35    // Get grid dimensions
36    const rows = grid.length;
37    const cols = grid[0].length;
38
39    // If we have reached the bottom-right corner, check if balance is 0 (valid path)
40    if (row === rows - 1 && col === cols - 1) {
41        return balance === 0;
42    }
43
44    // Explore the neighboring right and down cells
45    for (let k = 0; k < directions.length; ++k) {
46        const newRow = row + directions[k].x;
47        const newCol = col + directions[k].y;
48
49        // Check bounds and continue DFS if the next cell is within the grid
50        if (newRow < rows && newCol < cols && dfs(newRow, newCol, balance, grid)) {
51            return true;
52        }
53    }
54
55    // If no path from this cell leads to a valid path, return false
56    return false;
57}
58
59// Resets the visited array to track new states for a fresh call to hasValidPath
60function resetVisited(): void {
61    for (let i = 0; i < visited.length; i++) {
62        for (let j = 0; j < visited[i].length; j++) {
63            visited[i][j].fill(false);
64        }
65    }
66}
67
68// Assuming the global type char[][] is equivalent to the character grid structure being used
69type char = '(' | ')';
70
Not Sure What to Study? Take the 2-min Quiz

How many ways can you arrange the three letters A, B and C?

Time and Space Complexity

The given Python code defines a method hasValidPath which determines if there is a valid path from the upper left corner to the lower right corner in a grid, following certain rules related to parentheses balance. The algorithm uses Depth First Search (DFS) with memoization to explore possible paths.

Time Complexity:

The time complexity of the DFS is potentially O(2^(m+n)) without memoization, since in the worst case, each cell has two choices (to go right or down) and could be visited multiple times. However, with memoization, each unique state is computed only once. A state is defined by the current position (i, j) and the current balance t of parentheses. Since i can take m values, j can take n values, and t can range from -n to m (since the path is invalid if the balance is outside this range), the number of unique states is O(m * n * (m+n)).

Therefore, t can have maximum m + n + 1 valid different values (from 0 to m + n), so the time complexity is O(m * n * (m+n)).

Space Complexity:

For space complexity, we have:

  1. The recursive stack space, which in the worst-case scenario can grow as deep as m + n (the length of the longest path from (0, 0) to (m-1, n-1)). This contributes O(m + n) to the space complexity.
  2. The cache that memoizes the states to avoid re-computing the same state. This cache could be at most m * n * (m+n) in size, as analyzed before, which makes it O(m * n * (m+n)).

Thus, the space complexity is primarily determined by the size of the memoization cache and the recursive call stack. The resulting total space complexity of the algorithm is O(m * n * (m+n)).

Learn more about how to find time and space complexity quickly using problem constraints.

Fast Track Your Learning with Our Quick Skills Quiz:

Consider the classic dynamic programming of fibonacci numbers, what is the recurrence relation?


Recommended Readings


Got a question? Ask the Teaching Assistant anything you don't understand.

Still not clear? Ask in the Forum,  Discord or Submit the part you don't understand to our editors.

←
↑TA đŸ‘šâ€đŸ«