327 lines
14 KiB
YAML
327 lines
14 KiB
YAML
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:
|
||
- slug: dynamic-programming
|
||
is_optimal: false
|
||
- slug: matrix-traversal
|
||
is_optimal: true
|
||
- slug: dfs
|
||
is_optimal: false
|
||
|
||
function_signature: "def has_valid_path(grid: list[list[str]]) -> bool:"
|
||
|
||
test_cases:
|
||
visible:
|
||
- input: { grid: [["(", "(", "("], [")", "(", ")"], ["(", "(", ")"], ["(", "(", ")"]] }
|
||
expected: true
|
||
- input: { grid: [[")", ")"], ["(", "("]] }
|
||
expected: false
|
||
hidden:
|
||
- input: { grid: [["(", ")"]] }
|
||
expected: true
|
||
- input: { grid: [["("]] }
|
||
expected: false
|
||
- input: { grid: [[")"]] }
|
||
expected: false
|
||
- input: { grid: [["(", "("], [")", ")"]] }
|
||
expected: true
|
||
- input: { grid: [["(", ")"], ["(", ")"]] }
|
||
expected: false
|
||
|
||
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 `')'`
|
||
|
||
|
||
|
||
**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
|
||
|
||
|
||
|
||
**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
|
||
|
||
|
||
|
||
**Step 4: Check the destination**
|
||
|
||
- Return `true` if we can reach `(m-1, n-1)` with `balance == 0`
|
||
|
||
|
||
|
||
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.
|