10. Regular Expression Matching - Explanation
Description
You are given an input string s consisting of lowercase english letters, and a pattern p consisting of lowercase english letters, as well as '.', and '*' characters.
Return true if the pattern matches the entire input string, otherwise return false.
'.'Matches any single character'*'Matches zero or more of the preceding element.
Example 1:
Input: s = "aa", p = ".b"
Output: falseExplanation: Regardless of which character we choose for the '.' in the pattern, we cannot match the second character in the input string.
Example 2:
Input: s = "nnn", p = "n*"
Output: trueExplanation: '*' means zero or more of the preceding element, 'n'. We choose 'n' to repeat three times.
Example 3:
Input: s = "xyz", p = ".*z"
Output: trueExplanation: The pattern ".*" means zero or more of any character, so we choose ".." to match "xy" and "z" to match "z".
Constraints:
1 <= s.length <= 201 <= p.length <= 20- Each appearance of
'*', will be preceded by a valid character or'.'.
Topics
Recommended Time & Space Complexity
You should aim for a solution as good or better than O(m * n) time and O(m * n) space, where m is the length of the string s and n is the length of the string p.
Hint 1
Try to think in terms of recursion and visualize it as a decision tree, where we explore different combinations to match the strings when encountering *. Multiple decisions are made at each step to find a valid matching path. Can you determine the possible decisions at each recursion step?
Hint 2
We recursively iterate through the strings using indices i and j for s and p, respectively. If the characters match or p[j] is '.', we increment both i and j to process the remaining strings. When the next character of string p is '*', we have two choices: skip it (treating it as zero occurrences) or match one or more characters (if s[i] matches p[j]), incrementing i accordingly.
Hint 3
If both indices go out of bounds, we return true; otherwise, we return false. If any recursive path returns true, we immediately return true. This approach is exponential. Can you think of a way to optimize it?
Hint 4
We can use memoization to cache the results of recursive calls and avoid redundant calculations. A hash map or a 2D array can be used to store these results.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Recursion - Breaking down the matching problem into smaller subproblems with base cases
- Dynamic Programming (Memoization) - Caching results to avoid redundant computations in overlapping subproblems
- Dynamic Programming (Tabulation) - Building solutions bottom-up using a 2D table
1. Recursion
Intuition
We need to check if the string s matches the pattern p, where:
.matches any single character*means "zero or more of the previous character"
A recursive approach works because at each step we decide how to match the current part of the pattern with the current part of the string.
The function dfs(i, j) represents:
"Can s[i:] be matched by p[j:]?"
There are two main cases:
- The next pattern character is NOT
*- then the current characters must match (directly or via
.), and we move both pointers forward.
- then the current characters must match (directly or via
- The next pattern character IS
*- then we have two choices:
- skip the
x*part entirely (use*as zero occurrences) - use the
x*part to match one character from the string (if it matches), and stay on the same pattern index to potentially match more
- skip the
- then we have two choices:
Algorithm
- Let
m = len(s)andn = len(p). - Define a recursive function
dfs(i, j):iis the current index insjis the current index inp
- If
jreaches the end of the pattern:- Return
trueonly ifialso reached the end of the string
- Return
- Compute whether the current characters match:
matchistrueifiis in bounds and (s[i] == p[j]orp[j] == '.')
- If the next character in the pattern is
*(i.e.,p[j + 1] == '*'):- Option 1: Skip
p[j]and*→dfs(i, j + 2) - Option 2: If
matchistrue, consume one character fromsand keep pattern atj→dfs(i + 1, j) - Return
trueif either option works
- Option 1: Skip
- Otherwise (no
*case):- If
matchistrue, move both pointers forward →dfs(i + 1, j + 1) - If not, return
false
- If
- Start with
dfs(0, 0)and return the result
class Solution:
def isMatch(self, s: str, p: str) -> bool:
m, n = len(s), len(p)
def dfs(i, j):
if j == n:
return i == m
match = i < m and (s[i] == p[j] or p[j] == ".")
if (j + 1) < n and p[j + 1] == "*":
return (dfs(i, j + 2) or # don't use *
(match and dfs(i + 1, j))) # use *
if match:
return dfs(i + 1, j + 1)
return False
return dfs(0, 0)Time & Space Complexity
- Time complexity:
- Space complexity:
Where is the length of the string and is the length of the string .
2. Dynamic Programming (Top-Down)
Intuition
We need to check if s matches pattern p, where:
.matches any single character*means "zero or more of the previous character"
A recursive solution explores all matching possibilities, especially because * can represent many choices.
However, the same (i, j) states get recomputed multiple times, making plain recursion slow.
So we use top-down dynamic programming (memoization).
We define dfs(i, j) as:
"Can the substring s[i:] match the pattern p[j:]?"
Whenever we compute the answer for a state (i, j), we store it in a cache so future calls can reuse it instantly.
Algorithm
- Let
m = len(s)andn = len(p). - Create a cache (map) to store results for states
(i, j). - Define a recursive function
dfs(i, j):iis the current index insjis the current index inp
- If
jreaches the end of the pattern:- Return
trueonly ifialso reaches the end of the string
- Return
- If
(i, j)is already in the cache:- Return the cached result
- Check if the current characters match:
matchistrueifi < mand (s[i] == p[j]orp[j] == '.')
- If the next character in the pattern is
*(p[j + 1] == '*'):- Option 1: Skip the
x*part completely →dfs(i, j + 2) - Option 2: If
matchistrue, consume one char fromsand stay onj→dfs(i + 1, j) - Store and return whether either option works
- Option 1: Skip the
- Otherwise (no
*case):- If
matchistrue, move both pointers forward →dfs(i + 1, j + 1) - Otherwise, return
false
- If
- Store the computed result in the cache before returning it
- Start with
dfs(0, 0)and return the final answer
class Solution:
def isMatch(self, s: str, p: str) -> bool:
m, n = len(s), len(p)
cache = {}
def dfs(i, j):
if j == n:
return i == m
if (i, j) in cache:
return cache[(i, j)]
match = i < m and (s[i] == p[j] or p[j] == ".")
if (j + 1) < n and p[j + 1] == "*":
cache[(i, j)] = (dfs(i, j + 2) or
(match and dfs(i + 1, j)))
return cache[(i, j)]
if match:
cache[(i, j)] = dfs(i + 1, j + 1)
return cache[(i, j)]
cache[(i, j)] = False
return False
return dfs(0, 0)Time & Space Complexity
- Time complexity:
- Space complexity:
Where is the length of the string and is the length of the string .
3. Dynamic Programming (Bottom-Up)
Intuition
We want to check whether the string s matches the pattern p, where:
.matches any single character*means "zero or more of the previous character"
Instead of recursion, we can solve this using bottom-up dynamic programming by building answers for smaller suffixes of the strings.
We define a DP state that answers:
"Can the substring s[i:] be matched by the pattern p[j:]?"
By filling a table from the end of both strings toward the beginning, we ensure that when we compute a state, all the states it depends on are already known.
Algorithm
- Create a 2D boolean DP table
dpof size(len(s) + 1) × (len(p) + 1). - Let
dp[i][j]represent whethers[i:]matchesp[j:]. - Initialize the base case:
dp[len(s)][len(p)] = truebecause two empty strings match
- Fill the table from bottom to top and right to left:
- For each position
(i, j):- First check if the current characters match:
matchistrueifi < len(s)and (s[i] == p[j]orp[j] == '.')
- First check if the current characters match:
- If the next character in the pattern is
*:- Option 1: Treat
x*as zero occurrences →dp[i][j] = dp[i][j + 2] - Option 2: If
matchistrue, consume one character fromsand stay on the same pattern →dp[i + 1][j] - Set
dp[i][j]totrueif either option works
- Option 1: Treat
- If the next character is not
*:- If
matchistrue, move both pointers forward:dp[i][j] = dp[i + 1][j + 1]
- If
- After filling the table, the final answer is stored in
dp[0][0] - Return
dp[0][0]
class Solution:
def isMatch(self, s: str, p: str) -> bool:
dp = [[False] * (len(p) + 1) for i in range(len(s) + 1)]
dp[len(s)][len(p)] = True
for i in range(len(s), -1, -1):
for j in range(len(p) - 1, -1, -1):
match = i < len(s) and (s[i] == p[j] or p[j] == ".")
if (j + 1) < len(p) and p[j + 1] == "*":
dp[i][j] = dp[i][j + 2]
if match:
dp[i][j] = dp[i + 1][j] or dp[i][j]
elif match:
dp[i][j] = dp[i + 1][j + 1]
return dp[0][0]Time & Space Complexity
- Time complexity:
- Space complexity:
Where is the length of the string and is the length of the string .
4. Dynamic Programming (Space Optimized)
Intuition
We need to determine if string s matches pattern p, where:
.matches any single character*means "zero or more of the previous character"
In the bottom-up DP solution, we used a 2D table dp[i][j] meaning:
"Does s[i:] match p[j:]?"
But each row i only depends on:
- the next row (
i + 1) - values within the current row while moving across
j
So we don't need the full 2D table. We can compress it into:
dp→ represents the row fori + 1nextDp→ represents the row fori
This keeps the same logic while using much less memory.
Algorithm
- Create a 1D boolean array
dpof sizelen(p) + 1:- it represents results for matching
s[i+1:]withp[j:]
- it represents results for matching
- Initialize the base case:
dp[len(p)] = true(empty pattern matches empty string at the very end)
- Iterate
ifromlen(s)down to0:- Create a new array
nextDpfor the current row (i) - Set
nextDp[len(p)] = (i == len(s))- empty pattern only matches if we are also at the end of the string
- Create a new array
- For each
i, iteratejfromlen(p) - 1down to0:- Compute
match:trueifi < len(s)and (s[i] == p[j]orp[j] == '.')
- If the next pattern character is
*:- Option 1: skip
x*(zero occurrences) →nextDp[j + 2] - Option 2: if
match, consume one char fromsand stay onj→dp[j] - Combine both options to set
nextDp[j]
- Option 1: skip
- Otherwise (no
*):- if
match, move both forward →nextDp[j] = dp[j + 1]
- if
- Compute
- After finishing the row, set
dp = nextDp - The final answer is
dp[0](matchings[0:]withp[0:]) - Return
dp[0]
class Solution:
def isMatch(self, s: str, p: str) -> bool:
dp = [False] * (len(p) + 1)
dp[len(p)] = True
for i in range(len(s), -1, -1):
nextDp = [False] * (len(p) + 1)
nextDp[len(p)] = (i == len(s))
for j in range(len(p) - 1, -1, -1):
match = i < len(s) and (s[i] == p[j] or p[j] == ".")
if (j + 1) < len(p) and p[j + 1] == "*":
nextDp[j] = nextDp[j + 2]
if match:
nextDp[j] |= dp[j]
elif match:
nextDp[j] = dp[j + 1]
dp = nextDp
return dp[0]Time & Space Complexity
- Time complexity:
- Space complexity:
Where is the length of the string and is the length of the string .
5. Dynamic Programming (Optimal)
Intuition
We want to check if s matches p, where:
.matches any single character*means "zero or more of the previous character"
The usual bottom-up DP state is:dp[i][j] = does s[i:] match p[j:]?
A 2D table works, and a 1D array also works if we update carefully.
This "optimal" version goes one step further: it updates the 1D array in-place without creating a second array.
The main challenge with in-place updates is that some transitions need the diagonal value:
dp[i + 1][j + 1](move both forward)
When we compress to 1D, that diagonal value would get overwritten while we move left through j.
To handle this, we keep one extra variable (dp1) that stores the previous diagonal value as we update the row.
So at each (i, j) we can still access:
dp[j]→ old value fordp[i + 1][j]dp[j + 2]→ current row value for skippingx*dp1→ old diagonaldp[i + 1][j + 1]
Algorithm
- Create a 1D boolean array
dpof sizelen(p) + 1:dp[j]represents whethers[i + 1:]matchesp[j:]for the current outer loop
- Initialize the base case:
dp[len(p)] = true(empty pattern matches empty string at the end)
- Iterate
ifromlen(s)down to0:- Save the old end value in
dp1(this will act as the diagonal whenjmoves left) - Update
dp[len(p)] = (i == len(s))since empty pattern only matches if string is also empty
- Save the old end value in
- Iterate
jfromlen(p) - 1down to0:- Compute
match:trueifi < len(s)and (s[i] == p[j]orp[j] == '.')
- If the next pattern character is
*:- Option 1: skip
x*→ usedp[j + 2] - Option 2: if
match, consume one char fromsand stay onj→ usedp[j] - Combine both to get the result for
dp[j]
- Option 1: skip
- Otherwise (no
*):- If
match, move both forward → result is the diagonaldp1
- If
- Update
dp[j]with the computed result - Shift the diagonal tracker:
- set
dp1to the olddp[j]value before it was overwritten
- set
- Compute
- After all updates,
dp[0]represents whethers[0:]matchesp[0:] - Return
dp[0]
class Solution:
def isMatch(self, s: str, p: str) -> bool:
dp = [False] * (len(p) + 1)
dp[len(p)] = True
for i in range(len(s), -1, -1):
dp1 = dp[len(p)]
dp[len(p)] = (i == len(s))
for j in range(len(p) - 1, -1, -1):
match = i < len(s) and (s[i] == p[j] or p[j] == ".")
res = False
if (j + 1) < len(p) and p[j + 1] == "*":
res = dp[j + 2]
if match:
res |= dp[j]
elif match:
res = dp1
dp[j], dp1 = res, dp[j]
return dp[0]Time & Space Complexity
- Time complexity:
- Space complexity:
Where is the length of the string and is the length of the string .
Common Pitfalls
Misunderstanding How Star Works
The * character means "zero or more of the preceding element," not "zero or more of any character." A common mistake is treating a* as matching any sequence of characters. In reality, a* only matches zero or more 'a' characters. Similarly, .* matches zero or more of any single character (because . matches any character).
Forgetting That Star Can Match Zero Occurrences
When encountering x* in the pattern, many solutions only consider the case where x matches at least one character. However, x* can also match zero characters, meaning the entire x* portion can be skipped. Both branches must be explored: skip x* entirely or consume a matching character while staying on the same pattern position.
Off-by-One Errors When Checking for Star
The pattern must be checked carefully to see if the next character is *. A common bug is checking p[j] instead of p[j+1] for the star, or failing to verify that j+1 is within bounds before accessing it. This leads to index out of bounds errors or incorrect pattern matching logic.
Not Handling Empty String or Pattern Edge Cases
The base cases require careful handling. When the pattern is exhausted (j == n), the match is valid only if the string is also exhausted (i == m). However, when the string is exhausted but the pattern is not, matching can still succeed if the remaining pattern consists entirely of x* pairs. Failing to account for patterns like a*b*c* matching an empty string is a common oversight.
Incorrect DP State Transitions
In the bottom-up DP approach, the iteration order matters. Processing from the end of both strings toward the beginning ensures that required subproblem solutions are available. A common mistake is iterating in the wrong direction or incorrectly referencing dp[i+1][j+1] when it has not been computed yet.
Sign in to join the discussion