2267. Check if There Is a Valid Parentheses String Path
Problem Description
You are given an m x n
matrix where each cell contains either '('
or ')'
. Your task is to determine if there exists a valid path from the top-left corner (0, 0)
to the bottom-right corner (m-1, n-1)
that forms a valid parentheses string.
A valid parentheses string must follow these rules:
- It can be
()
- It can be formed by concatenating two valid parentheses strings (like
AB
where bothA
andB
are valid) - It can be formed by wrapping a valid parentheses string with parentheses (like
(A)
whereA
is valid)
The path must satisfy these constraints:
- Start at cell
(0, 0)
- End at cell
(m-1, n-1)
- Only move down or right at each step
- The sequence of parentheses collected along the path forms a valid parentheses string
For example, if you traverse a path and collect "(())"
, this would be valid. But if you collect "(()"
or ")("
, these would be invalid.
Return true
if such a valid path exists, otherwise return false
.
The solution uses depth-first search with memoization. It tracks the balance of parentheses (count of '('
minus count of ')'
) as it explores paths. The key insight is that at any point, if the balance becomes negative or exceeds the remaining cells needed to reach the destination, the path cannot be valid. The path is valid only if it reaches the bottom-right corner with a balance of exactly 0.
Intuition
To form a valid parentheses string, we need equal numbers of '('
and ')'
, with '('
never being outnumbered by ')'
at any point when reading left to right. This gives us our first insight: the total path length m + n - 1
must be even (since we need pairs of parentheses).
The key observation is tracking the "balance" - the difference between open and close parentheses seen so far. As we traverse the path:
- When we see
'('
, the balance increases by 1 - When we see
')'
, the balance decreases by 1 - The balance must never go negative (that would mean more
')'
than'('
at some point) - The balance must equal 0 when we reach the destination
We can use DFS to explore all possible paths from (0, 0)
to (m-1, n-1)
. At each cell (i, j)
, we have at most two choices: go right or go down. We keep track of the current balance k
as we explore.
An important optimization comes from realizing that if our current balance k
exceeds the number of remaining cells to the destination (m - i + n - j
), we can't possibly close all open parentheses even if all remaining cells were ')'
. This allows us to prune invalid paths early.
Similarly, we can immediately reject paths where:
- The starting cell
(0, 0)
contains')'
(would start with negative balance) - The ending cell
(m-1, n-1)
contains'('
(can't end with balance 0)
The memoization with @cache
is crucial because many different paths might reach the same cell with the same balance, and we don't want to recompute the result for each one.
Learn more about Dynamic Programming patterns.
Solution Approach
The implementation uses Depth-First Search (DFS) with memoization to explore all valid paths while avoiding redundant computations.
Initial Validation: First, we check three conditions that would make a valid path impossible:
- If
(m + n - 1) % 2 == 1
: The path length is odd, so we can't have matching parentheses - If
grid[0][0] == ")"
: Starting with a close parenthesis gives negative balance - If
grid[m-1][n-1] == "("
: Ending with an open parenthesis prevents reaching balance 0
DFS Function Design:
The core function dfs(i, j, k)
determines if there's a valid path from position (i, j)
with current balance k
:
-
Update Balance: First, we update the balance based on the current cell:
d = 1 if grid[i][j] == "(" else -1 k += d
-
Pruning Conditions: We check if the current state is invalid:
- If
k < 0
: More close parentheses than open ones (invalid string) - If
k > m - i + n - j
: Balance exceeds remaining cells, impossible to close all parentheses
- If
-
Base Case: When we reach
(m-1, n-1)
, returnTrue
only ifk == 0
(balanced parentheses) -
Recursive Exploration: We try moving right and down:
for a, b in pairwise((0, 1, 0)): x, y = i + a, j + b
This iterates through
(0, 1)
for moving right and(1, 0)
for moving down. -
Boundary Check and Recursion: For each valid next position within bounds, we recursively check if it leads to a valid path.
Memoization with @cache:
The @cache
decorator stores results for each unique (i, j, k)
combination. This is crucial because multiple paths might reach the same cell with the same balance, and we avoid recomputing the result.
The solution starts by calling dfs(0, 0, 0)
with initial position (0, 0)
and balance 0
(before processing the first cell). The algorithm efficiently explores all possible paths while pruning invalid branches early and reusing computed results through memoization.
Ready to land your dream job?
Unlock your dream job with a 5-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's walk through a small example with this grid:
[['(', '(', ')'], [')', '(', ')']]
Initial Checks:
- Grid dimensions: m=2, n=3
- Path length: m + n - 1 = 4 (even ✓)
- grid[0][0] = '(' (valid start ✓)
- grid[1][2] = ')' (valid end ✓)
DFS Exploration starting from dfs(0, 0, 0):
-
At (0,0): Cell contains '(', balance becomes 0+1=1
- Balance 1 is valid (not negative)
- Balance 1 ≤ remaining cells (4), so continue
- Try two paths: right to (0,1) or down to (1,0)
-
Path 1: (0,0) → (0,1) with balance 1
- At (0,1): Cell contains '(', balance becomes 1+1=2
- Balance 2 ≤ remaining cells (3), valid
- Try: right to (0,2) or down to (1,1)
-
Path 1a: (0,0) → (0,1) → (0,2) with balance 2
- At (0,2): Cell contains ')', balance becomes 2-1=1
- Balance 1 ≤ remaining cells (2), valid
- Only option: down to (1,2)
- At (1,2): Cell contains ')', balance becomes 1-1=0
- Reached destination with balance 0! ✓
- Path "(())" is valid!
-
Path 1b: (0,0) → (0,1) → (1,1) with balance 2
- At (1,1): Cell contains '(', balance becomes 2+1=3
- Balance 3 > remaining cells (2)
- Cannot close all parentheses, prune this path ✗
-
Path 2: (0,0) → (1,0) with balance 1
- At (1,0): Cell contains ')', balance becomes 1-1=0
- Balance 0 ≤ remaining cells (3), valid
- Only option: right to (1,1)
- At (1,1): Cell contains '(', balance becomes 0+1=1
- Only option: right to (1,2)
- At (1,2): Cell contains ')', balance becomes 1-1=0
- Reached destination with balance 0! ✓
- Path "()()" is valid!
Result: The function returns True
because we found valid paths. The memoization would cache results like dfs(1,1,2) = False
and dfs(1,2,1) = True
to avoid recomputation if these states were reached again through different paths.
Solution Implementation
1class Solution:
2 def hasValidPath(self, grid: List[List[str]]) -> bool:
3 from functools import cache
4 from typing import List
5
6 @cache
7 def dfs(row: int, col: int, open_count: int) -> bool:
8 """
9 Depth-first search to find valid parentheses path.
10
11 Args:
12 row: Current row position
13 col: Current column position
14 open_count: Number of unmatched opening parentheses
15
16 Returns:
17 True if a valid path exists from current position to end
18 """
19 # Update open parentheses count based on current cell
20 delta = 1 if grid[row][col] == "(" else -1
21 open_count += delta
22
23 # Pruning: invalid if negative count or impossible to balance
24 if open_count < 0:
25 return False
26
27 # Maximum possible closing parentheses from current position to end
28 remaining_steps = (rows - row - 1) + (cols - col - 1)
29 if open_count > remaining_steps:
30 return False
31
32 # Check if we reached the destination
33 if row == rows - 1 and col == cols - 1:
34 return open_count == 0
35
36 # Try moving right and down
37 directions = [(0, 1), (1, 0)] # right, down
38 for dx, dy in directions:
39 next_row, next_col = row + dx, col + dy
40 # Check bounds and recursively explore
41 if 0 <= next_row < rows and 0 <= next_col < cols:
42 if dfs(next_row, next_col, open_count):
43 return True
44
45 return False
46
47 rows, cols = len(grid), len(grid[0])
48
49 # Early termination checks
50 # Path length must be even for balanced parentheses
51 if (rows + cols - 1) % 2 == 1:
52 return False
53
54 # Start must be '(' and end must be ')'
55 if grid[0][0] == ")" or grid[rows - 1][cols - 1] == "(":
56 return False
57
58 # Start DFS from top-left corner with 0 open parentheses
59 return dfs(0, 0, 0)
60
1class Solution {
2 // Grid dimensions
3 private int rows, cols;
4 // The input grid containing parentheses
5 private char[][] grid;
6 // 3D visited array: [row][col][balance]
7 // Tracks if we've visited position (row, col) with a specific balance value
8 private boolean[][][] visited;
9
10 /**
11 * Determines if there's a valid path from top-left to bottom-right
12 * where parentheses are balanced.
13 *
14 * @param grid 2D array containing '(' and ')' characters
15 * @return true if a valid balanced path exists, false otherwise
16 */
17 public boolean hasValidPath(char[][] grid) {
18 rows = grid.length;
19 cols = grid[0].length;
20
21 // Early termination checks:
22 // 1. Path length must be even for balanced parentheses
23 // 2. Must start with '(' and end with ')'
24 if ((rows + cols - 1) % 2 == 1 ||
25 grid[0][0] == ')' ||
26 grid[rows - 1][cols - 1] == '(') {
27 return false;
28 }
29
30 this.grid = grid;
31 // Initialize visited array with maximum possible balance value
32 visited = new boolean[rows][cols][rows + cols];
33
34 // Start DFS from top-left corner with initial balance 0
35 return dfs(0, 0, 0);
36 }
37
38 /**
39 * Performs depth-first search to find a valid balanced path.
40 *
41 * @param row Current row position
42 * @param col Current column position
43 * @param balance Current balance of parentheses (incremented for '(', decremented for ')')
44 * @return true if a valid path exists from current position, false otherwise
45 */
46 private boolean dfs(int row, int col, int balance) {
47 // Check if this state has been visited before
48 if (visited[row][col][balance]) {
49 return false;
50 }
51
52 // Mark current state as visited
53 visited[row][col][balance] = true;
54
55 // Update balance based on current cell's parenthesis
56 balance += grid[row][col] == '(' ? 1 : -1;
57
58 // Pruning conditions:
59 // 1. Balance cannot be negative (more closing than opening parentheses)
60 // 2. Balance cannot exceed remaining cells (impossible to balance)
61 if (balance < 0 || balance > rows - row + cols - col) {
62 return false;
63 }
64
65 // Check if we reached the destination
66 if (row == rows - 1 && col == cols - 1) {
67 // Valid path only if parentheses are balanced
68 return balance == 0;
69 }
70
71 // Direction vectors for moving right and down
72 // dirs[0]=1, dirs[1]=0 represents moving down (row+1, col+0)
73 // dirs[1]=0, dirs[2]=1 represents moving right (row+0, col+1)
74 final int[] dirs = {1, 0, 1};
75
76 // Try both possible moves: down and right
77 for (int d = 0; d < 2; d++) {
78 int nextRow = row + dirs[d];
79 int nextCol = col + dirs[d + 1];
80
81 // Check if next position is within bounds and recursively explore
82 if (nextRow >= 0 && nextRow < rows &&
83 nextCol >= 0 && nextCol < cols &&
84 dfs(nextRow, nextCol, balance)) {
85 return true;
86 }
87 }
88
89 // No valid path found from current position
90 return false;
91 }
92}
93
1class Solution {
2public:
3 bool hasValidPath(vector<vector<char>>& grid) {
4 int rows = grid.size();
5 int cols = grid[0].size();
6
7 // Early termination: path length must be even for balanced parentheses
8 // Also check if start is ')' or end is '(' which would make balancing impossible
9 if ((rows + cols - 1) % 2 != 0 ||
10 grid[0][0] == ')' ||
11 grid[rows - 1][cols - 1] == '(') {
12 return false;
13 }
14
15 // DP memoization: visited[row][col][balance]
16 // balance represents the current count of unmatched '(' parentheses
17 bool visited[rows][cols][rows + cols];
18 memset(visited, false, sizeof(visited));
19
20 // Direction vectors for moving right and down
21 int directions[3] = {1, 0, 1}; // {row_delta, col_delta} pairs
22
23 // DFS with memoization to find valid path
24 auto dfs = [&](this auto&& dfs, int row, int col, int balance) -> bool {
25 // Check if this state has been visited
26 if (visited[row][col][balance]) {
27 return false;
28 }
29 visited[row][col][balance] = true;
30
31 // Update balance based on current cell
32 balance += (grid[row][col] == '(') ? 1 : -1;
33
34 // Pruning: balance should never be negative (more ')' than '(')
35 // Also, balance shouldn't exceed remaining cells (impossible to balance)
36 int remaining_cells = (rows - row - 1) + (cols - col - 1);
37 if (balance < 0 || balance > remaining_cells + 1) {
38 return false;
39 }
40
41 // Check if we reached the destination with balanced parentheses
42 if (row == rows - 1 && col == cols - 1) {
43 return balance == 0;
44 }
45
46 // Try moving in two directions: right and down
47 for (int dir = 0; dir < 2; ++dir) {
48 int next_row = row + directions[dir];
49 int next_col = col + directions[dir + 1];
50
51 // Check boundaries and recursively search
52 if (next_row >= 0 && next_row < rows &&
53 next_col >= 0 && next_col < cols &&
54 dfs(next_row, next_col, balance)) {
55 return true;
56 }
57 }
58
59 return false;
60 };
61
62 // Start DFS from top-left corner with initial balance of 0
63 return dfs(0, 0, 0);
64 }
65};
66
1/**
2 * Determines if there exists a valid path from top-left to bottom-right
3 * in a grid containing only '(' and ')' characters, where the path forms
4 * a valid parentheses string.
5 *
6 * @param grid - 2D array of strings containing only '(' or ')'
7 * @returns true if a valid parentheses path exists, false otherwise
8 */
9function hasValidPath(grid: string[][]): boolean {
10 const rows: number = grid.length;
11 const cols: number = grid[0].length;
12
13 // Early termination checks:
14 // 1. Path length must be even for balanced parentheses
15 // 2. Must start with '(' and end with ')'
16 if ((rows + cols - 1) % 2 !== 0 ||
17 grid[0][0] === ')' ||
18 grid[rows - 1][cols - 1] === '(') {
19 return false;
20 }
21
22 // 3D visited array: [row][col][balance]
23 // Tracks if we've visited position (row, col) with a specific balance count
24 const visited: boolean[][][] = Array.from({ length: rows }, () =>
25 Array.from({ length: cols }, () =>
26 Array(rows + cols).fill(false)
27 )
28 );
29
30 // Direction vectors for moving right and down
31 const directions: number[] = [1, 0, 1];
32
33 /**
34 * Depth-first search to find a valid parentheses path
35 *
36 * @param row - Current row position
37 * @param col - Current column position
38 * @param balance - Current balance of parentheses (count of unmatched '(')
39 * @returns true if a valid path exists from current position
40 */
41 const dfs = (row: number, col: number, balance: number): boolean => {
42 // Check if this state has been visited
43 if (visited[row][col][balance]) {
44 return false;
45 }
46
47 // Mark current state as visited
48 visited[row][col][balance] = true;
49
50 // Update balance based on current cell
51 // '(' increases balance, ')' decreases it
52 balance += grid[row][col] === '(' ? 1 : -1;
53
54 // Pruning conditions:
55 // 1. Balance cannot be negative (more ')' than '(')
56 // 2. Balance cannot exceed remaining cells (impossible to balance)
57 const remainingCells: number = (rows - row - 1) + (cols - col - 1);
58 if (balance < 0 || balance > remainingCells) {
59 return false;
60 }
61
62 // Check if we reached the destination
63 if (row === rows - 1 && col === cols - 1) {
64 return balance === 0; // Valid only if perfectly balanced
65 }
66
67 // Try moving in both possible directions (right and down)
68 for (let dirIndex = 0; dirIndex < 2; dirIndex++) {
69 const nextRow: number = row + directions[dirIndex];
70 const nextCol: number = col + directions[dirIndex + 1];
71
72 // Check bounds and recursively explore
73 if (nextRow >= 0 && nextRow < rows &&
74 nextCol >= 0 && nextCol < cols &&
75 dfs(nextRow, nextCol, balance)) {
76 return true;
77 }
78 }
79
80 return false;
81 };
82
83 // Start DFS from top-left corner with initial balance of 0
84 return dfs(0, 0, 0);
85}
86
Time and Space Complexity
Time Complexity: O(m × n × (m + n))
The time complexity is determined by the memoized DFS function. The dfs
function has three parameters: i
(row position from 0 to m-1), j
(column position from 0 to n-1), and k
(balance of parentheses). The key insight is that k
represents the current balance of opening and closing parentheses, which can range from 0 to at most m + n - 1
(the maximum path length from top-left to bottom-right). Due to the @cache
decorator, each unique combination of (i, j, k)
is computed only once. There are m
possible values for i
, n
possible values for j
, and at most m + n
possible values for k
. Therefore, the total number of unique states is O(m × n × (m + n))
.
Space Complexity: O(m × n × (m + n))
The space complexity comes from two sources:
- The memoization cache stores results for all possible states
(i, j, k)
, which requiresO(m × n × (m + n))
space - The recursion stack depth in the worst case can go up to
O(m + n)
(the length of the longest path)
Since the cache dominates the space usage, the overall space complexity is O(m × n × (m + n))
.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Incorrect Balance Calculation Timing
A common mistake is updating the balance after checking pruning conditions or at the wrong point in the recursion. The balance must be updated immediately upon entering a cell, before any validity checks.
Incorrect approach:
def dfs(row, col, open_count):
# Checking conditions before updating balance - WRONG!
if open_count < 0:
return False
# Update balance after checks
delta = 1 if grid[row][col] == "(" else -1
open_count += delta
Correct approach:
def dfs(row, col, open_count):
# Update balance first
delta = 1 if grid[row][col] == "(" else -1
open_count += delta
# Then check validity
if open_count < 0:
return False
2. Off-by-One Error in Remaining Steps Calculation
Many implementations incorrectly calculate the remaining steps to reach the destination, forgetting that we're already at position (row, col)
.
Incorrect calculation:
# This counts total remaining cells including current position remaining_steps = (rows - row) + (cols - col)
Correct calculation:
# Excludes current position, counts only moves needed remaining_steps = (rows - row - 1) + (cols - col - 1)
3. Missing Early Termination Checks
Failing to check if a valid path is even possible before starting DFS leads to unnecessary computation.
Incomplete validation:
# Only checking path length if (rows + cols - 1) % 2 == 1: return False # Missing checks for start and end cells
Complete validation:
# Check all three conditions if (rows + cols - 1) % 2 == 1: return False if grid[0][0] == ")" or grid[rows - 1][cols - 1] == "(": return False
4. Incorrect Memoization Key
Using the wrong combination of parameters as the memoization key can lead to incorrect results or missed optimization opportunities.
Wrong memoization:
@cache
def dfs(row, col): # Missing balance parameter!
balance = calculate_balance_somehow() # Can't track properly
Correct memoization:
@cache
def dfs(row, col, open_count): # All state variables included
# Process with complete state information
5. Balance vs. Open Count Confusion
Some implementations confuse "balance" (which can be negative) with "open count" (unmatched opening parentheses). While both approaches work, mixing them leads to errors.
Consistent open count approach:
# open_count represents unmatched '(' parentheses delta = 1 if grid[row][col] == "(" else -1 open_count += delta if open_count < 0: # More ')' than '(' encountered return False
Solution to Avoid These Pitfalls:
- Always update the balance/count immediately upon entering a cell
- Carefully calculate remaining steps excluding the current position
- Implement all necessary pre-validation checks before starting DFS
- Include all state variables (row, col, balance) in the memoization decorator
- Be consistent with your chosen representation (balance or open count) throughout the implementation
- Test edge cases: single cell grid, all same parenthesis type, alternating patterns
Which algorithm is best for finding the shortest distance between two points in an unweighted graph?
Recommended Readings
What is Dynamic Programming Prerequisite DFS problems dfs_intro Backtracking problems backtracking Memoization problems memoization_intro Pruning problems backtracking_pruning Dynamic programming is an algorithmic optimization technique that breaks down a complicated problem into smaller overlapping sub problems in a recursive manner and uses solutions to the sub problems to construct a solution
Coding Interview 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
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 assets algo monster recursion jpg You first call Ben and ask
Want a Structured Path to Master System Design Too? Don’t Miss This!