Question: Regex matcher with starred groups
Companies: Amazon
Difficulty: Hard
Problem statement
Implement a small regex engine for a text-processing pipeline. Write is_match(s: str, pattern: str) -> bool. It returns True only when the whole string s matches pattern.
The pattern uses these rules:
- It contains only lowercase English letters,
.,*,(, and). - A letter matches itself.
.matches exactly one lowercase letter. -
x*matches zero or more copies of the single unitxjust before the star. -
( ... )groups its contents into one unit. A*after)repeats the whole group, so(ab)*matches"","ab","abab", and so on. A group without a star matches its contents once. - Groups are never nested, and a
*never appears inside a group. Every*follows a letter, a., or a).
Constraints: 0 <= len(s) <= 1000, 0 <= len(pattern) <= 1000, and s contains only lowercase letters.
Example 1
s = "zebeb", pattern = "(ze)*(be)*"
Output: False
(ze)* can take "ze" and (be)* can take "be", but the final "b" is only part of a group, and a starred group repeats only whole copies. No split works.
Example 2
s = "rrtuvtuvw", pattern = "r*(tuv)*w"
Output: True
r* matches "rr", (tuv)* matches "tuvtuv", and w matches "w".
Reconstructed from the reported question; details may differ from what interviewers ask.
Approach
This is the classic ./* regex match with one twist: a * can follow a parenthesized group, so a starred unit can be several characters long. The trick is to see that groups add very little once the pattern is tokenized well.
Tokenize first. Walk the pattern once and produce a list of tokens (unit, star):
- A letter or
.is a unit of length 1. -
( ... )is a unit whose body is the text between the parentheses. There is no nesting, so the next)closes it. - A
*right after a unit setsstar = Trueon that unit. - A group without a star is just a run of plain characters, so expand it into one token per character. After this step only starred tokens can be longer than one character.
- An empty group
()or()*matches only the empty string, so drop it. Keeping a starred empty unit would let the recursion loop forever without consuming input.
Brute force. Recurse on (token index i, string index j). A plain token must match s[j] and move to (i+1, j+1). A starred token either matches zero times, going to (i+1, j), or matches its whole body once at j and stays on the same token at (i, j+len(body)). Without memoization this branches exponentially on inputs like a*a*a*...b.
Dynamic programming. There are only T * (n+1) states, so memoize them, or fill a table. Let dp[i][j] be true when tokens[i:] matches s[j:] exactly.
- Base case:
dp[T][j]is true only forj = n. - Plain token:
dp[i][j] = matches(unit, s, j) and dp[i+1][j+1]. - Starred token of length
L:dp[i][j] = dp[i+1][j] or (matches(unit, s, j) and dp[i][j+L]).
Why it is correct. A starred unit used k times is the same as "use it once, then the rest is still a starred unit used k-1 times". So the self-reference to dp[i][j+L] covers every repeat count. That reference always points to a larger j, because L >= 1 after empty groups are dropped. So filling j from right to left inside each row never reads a value that is not ready yet. Each row needs only the row after it, so two arrays of length n+1 are enough.
Invariant. After row i is filled, cur[j] answers whether the pattern suffix that starts at token i matches the string suffix that starts at j, for every j.
Code
from dataclasses import dataclass
@dataclass(frozen=True)
class Token:
unit: str # one char, or the body of a group, e.g. "ab." for "(ab.)"
star: bool # True if the unit repeats zero or more times
def tokenize(pattern: str) -> list[Token]:
"""Split the pattern into units; a '*' attaches to the unit before it."""
tokens: list[Token] = []
i, n = 0, len(pattern)
while i < n:
if pattern[i] == "(":
close = pattern.index(")", i) # no nesting, so the next ')' closes
unit, i = pattern[i + 1:close], close + 1
elif pattern[i] in ")*":
raise ValueError(f"unexpected {pattern[i]!r} at {i}")
else:
unit, i = pattern[i], i + 1
star = i < n and pattern[i] == "*"
if star:
i += 1
if not unit:
continue # "()" or "()*" matches only ""
if star or len(unit) == 1:
tokens.append(Token(unit, star))
else: # an unstarred group is just its letters
tokens.extend(Token(c, False) for c in unit)
return tokens
def unit_matches(unit: str, s: str, j: int) -> bool:
"""Does `unit` match s[j : j + len(unit)] exactly?"""
if j + len(unit) > len(s):
return False
return all(p == "." or p == s[j + k] for k, p in enumerate(unit))
def is_match(s: str, pattern: str) -> bool:
tokens = tokenize(pattern)
n, t = len(s), len(tokens)
# dp[i][j]: tokens[i:] match s[j:] exactly.
nxt = [False] * (n + 1)
nxt[n] = True # no tokens left matches only ""
for i in range(t - 1, -1, -1):
unit, star = tokens[i].unit, tokens[i].star
size = len(unit)
cur = [False] * (n + 1)
for j in range(n, -1, -1): # right to left: cur[j + size] is ready
here = unit_matches(unit, s, j)
if star:
cur[j] = nxt[j] or (here and cur[j + size])
else:
cur[j] = here and nxt[j + size]
nxt = cur
return nxt[0]
Complexity
Let n = len(s), m = len(pattern), and T be the token count (T <= m).
- Tokenizing:
O(m)time,O(m)space. - Matching: each state
(i, j)compares at mostlen(unit_i)characters. Summed over the tokens, that isO(n * m)time in the worst case. When every unit has length 1 this is the classicO(n * T). - Space:
O(n)for the two DP rows plusO(m)for the tokens. If the interviewer wants a reconstructed match path, keep the fullO(n * T)table.
Test cases
-
is_match("", "")returnsTrue. -
is_match("a", "")returnsFalse. -
is_match("", "a*(bc)*")returnsTrue(every unit repeats zero times). -
is_match("abcabc", "(abc)*")returnsTrue. -
is_match("abcab", "(abc)*")returnsFalse(a partial group does not count). -
is_match("xyxyz", "(xy)*z")returnsTrue, andis_match("z", "(xy)*z")returnsTrue. -
is_match("abab", "(a.)*")returnsTrue, andis_match("aba", "(a.)*")returnsFalse. -
is_match("aaab", "a*(ab)*")returnsTrue(a*takes only twoas, so a greedy match would fail). -
is_match("cat", "(ca)t")returnsTrue(an unstarred group is a plain literal). -
is_match("mississippi", "mis*(is)*s*ip*.")returnsTrue. -
is_match("ab", ".")returnsFalse(.matches exactly one letter). -
is_match("qq", ".*")returnsTrue. -
is_match("hello", "()*hello()")returnsTrue(empty groups are ignored). -
is_match("aab", "(ab)*")returnsFalse.
Follow-ups
-
Find, not full match. Return whether the pattern matches some substring of
s, or the first such span. Make the base casedp[T][j] = Truefor everyj, and try every start index. You can also add an implicit.*before the pattern. To get the span, store the end index instead of a boolean. -
More quantifiers (
+,?).x+isx x*, which the tokenizer can rewrite.x?is a token with the transitiondp[i+1][j] or (match and dp[i+1][j+L]). Bounded repeats{a,b}add a repeat counter to the state, or expand into copies. -
Alternation and nested groups (
(ab|c)*,((ab)*c)*). Fixed-length units no longer work. Compile the pattern to a Thompson NFA, with epsilon edges for*and|. Then simulate the set of active states over the string, inO(n * m)time with no backtracking. This is also the answer to "why not backtracking?": it avoids catastrophic backtracking (ReDoS). -
Many strings, one pattern. Tokenize or compile once and reuse the result for every string. For heavy reuse, turn the NFA into a DFA with subset construction, built lazily and cached, so each string runs in
O(n). -
Streaming input. The string arrives one character at a time. Run the DP forward instead: keep the set of token positions reachable after each prefix (an NFA frontier) and report a match whenever the end position is in the set. The memory stays
O(T), whatever the stream length.
Interviewer expectations
Mid-level (L4)
- Clarifies the rules: full match or substring, empty group, a
*with nothing before it, and empty inputs. - Gets the recursive "zero or one more" idea for
*, then adds memoization. - Handles starred groups correctly, including a partial final repeat that must fail.
Senior (L5)
- Separates parsing from matching, and treats unstarred groups as literals.
- Writes the bottom-up DP with the right fill order and explains why
L >= 1keeps it well founded. - States the time and space complexity precisely and reduces space to two rows.
- Tests greedy traps like
a*(ab)*without being prompted.
Staff (L6+)
- Explains why the restrictions (no nesting, no
*inside groups) make fixed-length units possible, and what changes without them. - Moves to an NFA (Thompson construction) for alternation and nesting, and discusses ReDoS and the trade-offs of a lazy DFA.
- Talks about production concerns: compiling and caching patterns, input limits, streaming, and returning match spans for the NLP use case.