Files
codetutor/backend/data/questions/check-if-there-is-a-valid-parentheses-string-path.yaml
2025-05-25 10:16:13 +01:00

304 lines
14 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
title: Check if There Is a Valid Parentheses String Path
slug: check-if-there-is-a-valid-parentheses-string-path
difficulty: hard
leetcode_id: 2267
leetcode_url: https://leetcode.com/problems/check-if-there-is-a-valid-parentheses-string-path/
categories:
- arrays
- dynamic-programming
patterns:
- dynamic-programming
- matrix-traversal
- dfs
description: |
A parentheses string is a **non-empty** string consisting only of `'('` and `')'`. It is **valid** if **any** of the following conditions is **true**:
- It is `()`.
- It can be written as `AB` (`A` concatenated with `B`), where `A` and `B` are valid parentheses strings.
- It can be written as `(A)`, where `A` is a valid parentheses string.
You are given an `m x n` matrix of parentheses `grid`. A **valid parentheses string path** in the grid is a path satisfying **all** of the following conditions:
- The path starts from the upper left cell `(0, 0)`.
- The path ends at the bottom-right cell `(m - 1, n - 1)`.
- The path only ever moves **down** or **right**.
- The resulting parentheses string formed by the path is **valid**.
Return `true` *if there exists a **valid parentheses string path** in the grid.* Otherwise, return `false`.
constraints: |
- `m == grid.length`
- `n == grid[i].length`
- `1 <= m, n <= 100`
- `grid[i][j]` is either `'('` or `')'`
examples:
- input: 'grid = [["(","(","("],[")","(",")"],["(","(",")"],["(","(",")"]]'
output: "true"
explanation: "Two possible valid paths: '()(())' and '((()))'. The path starts at (0,0) and ends at (m-1,n-1), moving only right or down."
- input: 'grid = [[")",")"],["(","("]]'
output: "false"
explanation: "The two possible paths form '))('' and ')((''. Neither is a valid parentheses string since they don't have matching pairs in the correct order."
explanation:
intuition: |
Think of this problem as navigating a maze where each step adds either an opening or closing parenthesis to your running string. The key insight is that you don't need to track the *entire* string you've built — you only need to track the **balance** (the count of unmatched opening parentheses).
Imagine you're walking through the grid with a counter in hand:
- When you step on `'('`, increment the counter (you have one more unmatched open paren)
- When you step on `')'`, decrement the counter (you just matched an open paren)
A valid path must satisfy two conditions:
1. The balance **never goes negative** during the walk (you can't close a parenthesis that was never opened)
2. The balance is **exactly zero** when you reach the destination (all parentheses are matched)
The path length is fixed at `m + n - 1` steps (you must move right `n-1` times and down `m-1` times). For a valid parentheses string of this length, you need exactly `(m + n - 1) / 2` opening and closing parentheses. This immediately tells us that if `m + n - 1` is odd, no valid path can exist.
We use **dynamic programming with memoisation**: at each cell `(i, j)`, we track which balance values are achievable. If we can reach the bottom-right corner with balance `0`, a valid path exists.
approach: |
We solve this using **DFS with Memoisation** or equivalently **3D Dynamic Programming**:
**Step 1: Early termination checks**
- If `(m + n - 1)` is odd, return `false` — a valid parentheses string must have even length
- If `grid[0][0] == ')'`, return `false` — path must start with `'('`
- If `grid[m-1][n-1] == '('`, return `false` — path must end with `')'`
&nbsp;
**Step 2: Define the state**
- State: `(row, col, balance)` where `balance` is the count of unmatched `'('`
- At each cell, the balance is updated based on the character: `+1` for `'('`, `-1` for `')'`
- We use a set or 3D boolean array to track visited states
&nbsp;
**Step 3: DFS with pruning**
- From each cell, try moving right `(row, col+1)` and down `(row+1, col)`
- Prune paths where:
- `balance < 0` (too many closing parentheses)
- `balance > (m - row) + (n - col) - 1` (not enough cells remaining to close all open parens)
- Use memoisation to avoid recomputing the same `(row, col, balance)` states
&nbsp;
**Step 4: Check the destination**
- Return `true` if we can reach `(m-1, n-1)` with `balance == 0`
&nbsp;
The upper bound on balance is `(m + n) / 2` since you can have at most that many unmatched opening parentheses. This bounds our state space to `O(m × n × (m + n))`.
common_pitfalls:
- title: Tracking the Full String
description: |
A naive approach might try to build and validate the actual parentheses string along each path. With up to `2^(m+n-2)` possible paths (at each non-boundary cell you have 2 choices), this leads to exponential time complexity.
Instead, recognise that you only need the **balance** (count of unmatched `'('`). The specific characters don't matter — only how many unmatched opens remain.
wrong_approach: "Building strings and checking validity at the end"
correct_approach: "Track integer balance, prune when negative"
- title: Missing the Odd Length Check
description: |
A valid parentheses string must have even length. The path length is always `m + n - 1`. If this is odd, no valid path can exist.
Checking this upfront avoids unnecessary computation.
wrong_approach: "Searching all paths even when solution is impossible"
correct_approach: "Return false immediately if (m + n - 1) is odd"
- title: Insufficient Pruning
description: |
Without pruning, the algorithm explores many hopeless paths. Key pruning rules:
1. If `balance < 0`, stop — we've closed more than we've opened
2. If `balance > remaining_steps`, stop — not enough cells left to close all opens
The second rule is often forgotten but dramatically reduces the search space.
wrong_approach: "Only pruning on negative balance"
correct_approach: "Also prune when balance exceeds remaining path length"
- title: Forgetting to Memoise
description: |
Without memoisation, the same `(row, col, balance)` state can be reached via many different paths. The algorithm degenerates to exponential time.
With memoisation, each state is processed at most once, giving polynomial time complexity.
wrong_approach: "Plain DFS without caching visited states"
correct_approach: "Use a set or 3D array to track (row, col, balance) states"
key_takeaways:
- "**State reduction**: Instead of tracking the full string, reduce the state to a single integer (balance). This is a common technique when only aggregate properties matter."
- "**Pruning is essential**: The upper bound on balance (`remaining_steps`) and the lower bound (`0`) dramatically prune the search space."
- "**3D DP on grids**: When grid problems have an additional dimension of state (here, balance), think of it as 3D DP: `dp[row][col][state]`."
- "**Early termination**: Simple checks like odd path length or wrong start/end characters can immediately rule out solutions."
time_complexity: "O(m × n × (m + n)). Each cell can have at most `(m + n) / 2` distinct balance values, and we visit each `(row, col, balance)` state at most once."
space_complexity: "O(m × n × (m + n)). We store the memoisation set containing up to `m × n × (m + n) / 2` states. The recursion stack adds O(m + n) depth."
solutions:
- approach_name: DFS with Memoisation
is_optimal: true
code: |
def hasValidPath(grid: list[list[str]]) -> bool:
m, n = len(grid), len(grid[0])
# Path length must be even for valid parentheses
if (m + n - 1) % 2 == 1:
return False
# Must start with '(' and end with ')'
if grid[0][0] == ')' or grid[m - 1][n - 1] == '(':
return False
# Memoisation: track visited (row, col, balance) states
visited = set()
def dfs(row: int, col: int, balance: int) -> bool:
# Update balance based on current cell
if grid[row][col] == '(':
balance += 1
else:
balance -= 1
# Prune: too many closing parens
if balance < 0:
return False
# Prune: not enough cells left to close all opens
remaining = (m - 1 - row) + (n - 1 - col)
if balance > remaining:
return False
# Reached destination with balanced parentheses
if row == m - 1 and col == n - 1:
return balance == 0
# Skip already visited states
state = (row, col, balance)
if state in visited:
return False
visited.add(state)
# Try moving right and down
if col + 1 < n and dfs(row, col + 1, balance):
return True
if row + 1 < m and dfs(row + 1, col, balance):
return True
return False
return dfs(0, 0, 0)
explanation: |
**Time Complexity:** O(m × n × (m + n)) — Each unique `(row, col, balance)` state is visited at most once. Balance ranges from `0` to `(m + n) / 2`.
**Space Complexity:** O(m × n × (m + n)) — The memoisation set stores up to `m × n × (m + n) / 2` states, plus O(m + n) recursion depth.
The DFS explores all paths while pruning invalid branches early. The key optimisation is recognising that balance cannot exceed the remaining path length, which bounds the state space.
- approach_name: Bottom-Up DP with Sets
is_optimal: true
code: |
def hasValidPath(grid: list[list[str]]) -> bool:
m, n = len(grid), len(grid[0])
# Path length must be even for valid parentheses
if (m + n - 1) % 2 == 1:
return False
if grid[0][0] == ')' or grid[m - 1][n - 1] == '(':
return False
# dp[i][j] = set of achievable balances at cell (i, j)
dp = [[set() for _ in range(n)] for _ in range(m)]
# Initialise starting cell
dp[0][0].add(1) # grid[0][0] is '(' so balance starts at 1
for i in range(m):
for j in range(n):
if i == 0 and j == 0:
continue
char_val = 1 if grid[i][j] == '(' else -1
# Collect balances from cells above and to the left
prev_balances = set()
if i > 0:
prev_balances |= dp[i - 1][j]
if j > 0:
prev_balances |= dp[i][j - 1]
# Compute new balances, pruning invalid ones
remaining = (m - 1 - i) + (n - 1 - j)
for bal in prev_balances:
new_bal = bal + char_val
# Prune: balance must be non-negative and achievable
if 0 <= new_bal <= remaining:
dp[i][j].add(new_bal)
# Check if we can reach destination with balance 0
return 0 in dp[m - 1][n - 1]
explanation: |
**Time Complexity:** O(m × n × (m + n)) — For each cell, we process up to `(m + n) / 2` balance values.
**Space Complexity:** O(m × n × (m + n)) — The DP table stores sets of balances for each cell.
This iterative approach builds up achievable balances cell by cell. At each cell, we combine balances from the cell above and to the left, update based on the current character, and prune impossible states.
- approach_name: Space-Optimised DP
is_optimal: false
code: |
def hasValidPath(grid: list[list[str]]) -> bool:
m, n = len(grid), len(grid[0])
if (m + n - 1) % 2 == 1:
return False
if grid[0][0] == ')' or grid[m - 1][n - 1] == '(':
return False
# Only keep current and previous row
prev_row = [set() for _ in range(n)]
curr_row = [set() for _ in range(n)]
prev_row[0].add(1) # Starting balance after '('
for i in range(m):
for j in range(n):
if i == 0 and j == 0:
curr_row[0] = prev_row[0].copy()
continue
char_val = 1 if grid[i][j] == '(' else -1
curr_row[j] = set()
# From above (previous row)
if i > 0:
for bal in prev_row[j]:
new_bal = bal + char_val
remaining = (m - 1 - i) + (n - 1 - j)
if 0 <= new_bal <= remaining:
curr_row[j].add(new_bal)
# From left (current row)
if j > 0:
for bal in curr_row[j - 1]:
new_bal = bal + char_val
remaining = (m - 1 - i) + (n - 1 - j)
if 0 <= new_bal <= remaining:
curr_row[j].add(new_bal)
prev_row, curr_row = curr_row, [set() for _ in range(n)]
return 0 in prev_row[n - 1]
explanation: |
**Time Complexity:** O(m × n × (m + n)) — Same as the full DP approach.
**Space Complexity:** O(n × (m + n)) — Only stores two rows at a time instead of the full grid.
This optimisation reduces memory usage by observing that each cell only depends on the cell above and to the left. We only need to keep track of the current row and the previous row.