Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path →
Mini Regex Matcher (. and *)
Implement regular-expression matching with "." (any single char) and "*" (zero-or-more of the preceding element) — the classic hard interview problem. The whole subtlety is that "*" can match nothing or many, so you must explore both. Solve it with DP in Python or TypeScript, with hidden tests.
The problem
Implement is_match(s, p): does the pattern p match the entire string s? Two special characters: . matches any single character, and * matches zero or more of the element immediately before it. The match must cover all of s, not just a prefix.
Your turn — write it
Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.
Implement is_match(s, p) with DP. Let dp[i][j] mean "does s[i:] match p[j:]?" Fill it from the end. Handle a * at p[j+1] by either skipping the pair (zero matches) or consuming one char of s (if it matches) and staying on the same pattern element.
Examples and constraints 5 examples
s = 'aa', p = 'a'Falses = 'aa', p = 'a*'Trues = 'ab', p = '.*'Trues = 'aab', p = 'c*a*b'Trues = '', p = 'a*'True- A
*always follows a valid element (a letter or.) — it never appears first. - The match is full-string: the pattern must consume all of
s. - Because
*can match zero or many, you must try both branches — a recursion with memoization or a 2-D DP does it in O(m·n).
Hints 4 ideas
- Work backwards:
dp[i][j]= doess[i:]matchp[j:]? The base casedp[len(s)][len(p)] = True. - Let
first = i < len(s) and (p[j] == s[i] or p[j] == ".")— does the current char match here? - If the next pattern char is
*:dp[i][j] = dp[i][j+2](use zero) or(first and dp[i+1][j])(use one, stay). - Otherwise:
dp[i][j] = first and dp[i+1][j+1]. The answer isdp[0][0].
Approach, complexity & discussion — open after you solve
Start from brute force
The obvious approach is to recurse on the two choices directly. match(i, j) returns i == len(s) once the pattern is used up; at a starred element it tries match(i, j + 2) (zero copies) and, if the character matches, match(i + 1, j) (one more copy); otherwise it needs first and match(i + 1, j + 1). It is correct, because it tries every way a star can split the string. But it solves the same (i, j) pairs again and again, so time is exponential in the worst case: on s = 'a' * 16 and p = 'a*' * 8 + 'b' it makes 1,817,045 calls where the table has 306 cells. Space is O(len(s) + len(p)) for the recursion stack.
The approach
The insight is that every state of the match is a pair of positions: how much of s is left and how much of p is left. There are only (ls + 1)(lp + 1) such pairs, so solve each once. dp[i][j] answers “does s[i:] match p[j:]?”, and every cell depends only on cells below it or to its right, so the reference fills the table from the end, starting from dp[ls][lp] = True (empty matches empty).
The outer loop starts at i = ls, not ls - 1, because the empty-suffix row is where starred elements match nothing. For each cell, first says whether p[j] matches s[i] (a literal or ., and only while i < ls). If p[j + 1] is *, the cell is dp[i][j + 2] (zero copies: skip the pair) or first and dp[i + 1][j] (one copy, staying on the same element so it can take more). Otherwise it is first and dp[i + 1][j + 1]. The answer is dp[0][0].
Walk through an example
The second example, s = 'aa' and p = 'a*', filled in the reference's order (i from 2 down to 0, j from 1 down to 0), because the first example never reaches the star branch.
| step | cell | s[i:] vs p[j:] | branch taken | value |
|---|---|---|---|---|
| 1 | dp[2][1] | '' vs '*' | plain: first is False (no characters left); this column is never read | False |
| 2 | dp[2][0] | '' vs 'a*' | star: zero copies, dp[2][2] is the base case | True |
| 3 | dp[1][1] | 'a' vs '*' | plain: '*' is not 'a' | False |
| 4 | dp[1][0] | 'a' vs 'a*' | star: zero copies dp[1][2] is False; one copy, first and dp[2][0], is True | True |
| 5 | dp[0][1] | 'aa' vs '*' | plain: no match | False |
| 6 | dp[0][0] | 'aa' vs 'a*' | star: zero copies dp[0][2] is False; one copy, first and dp[1][0], is True | True |
| return | dp[0][0] | whole string vs whole pattern | — | True |
Complexity
DP: O(ls · lp) time, because each of the (ls + 1)(lp + 1) cells is filled once with constant work, and O(ls · lp) space for the table; keeping only rows i and i + 1 cuts space to O(lp). Brute-force recursion: exponential time in the worst case, because it re-solves the same (i, j) pairs, and O(ls + lp) stack space.
Edge cases to test
s = '',p = '': True, from the base cell alone.s = '',p = 'a*b*.*': True. Every pair takes zero copies, which only works because the loop fills the rowi = ls.s = 'aaa',p = 'a*a': True. The star has to leave oneafor the final literal; a greedy matcher that letsa*take all three returns False.s = 'ab',p = '.*c': False..*can take any prefix, but nothing supplies thec.s = 'mississippi',p = 'mis*is*p*.': False. Aftermi,ss,i,ss, onlyp*and one.remain forippi.s = 'a',p = 'ab*': True. A trailing starred element matches nothing.
Common mistakes
- Handling
*greedily, lettinga*take everyaand never give one back:is_match('aaa', 'a*a')andis_match('ab', '.*b')both come out False instead of True. - Letting
.match past the end of the string. - No memoization, so overlapping subproblems explode exponentially.
- Starting the outer loop at
ls - 1. The empty-suffix row stays False, soa*can never match nothing andis_match('aa', 'a*')returns False. - Writing
dp[i + 1][j] and firstinstead offirst and dp[i + 1][j]. Ati = lsthe short-circuit is what stops the read of rowls + 1, which does not exist:is_match('', 'a*b')then raises IndexError in Python and TypeError in TypeScript.
Follow-ups an interviewer asks next
- Add
+(one or more) and?(zero or one). - Both fit the same table. For
x+at j, the cell isfirst and (dp[i + 1][j] or dp[i + 1][j + 2]): consume one copy, then stay for more or move past the pair. Forx?it isdp[i][j + 2] or (first and dp[i + 1][j + 2]): skip it, or consume exactly one and move on. The recurrence changes; the O(ls · lp) bound does not. - Wildcard matching instead:
?is one character and*is any sequence. - Now the star stands alone instead of repeating the element before it. For a star at j,
dp[i][j] = dp[i][j + 1] or (i < ls and dp[i + 1][j]): match nothing, or swallow one character and stay on the star.?behaves like the dot here. Same two-index table, same O(ls · lp) time and space. - Can you do it in O(lp) memory?
- Yes. Row i reads only itself and row i + 1, so keep two arrays of length lp + 1 and swap them after each i. Fill each row from j = lp − 1 down to 0, because a star cell reads
dp[i][j + 2]from the same row, and set the last cell to True only in the first row filled (i = ls), the base case. - How do production regex engines avoid exponential time?
- Engines such as RE2 and Rust's regex crate compile the pattern to an automaton and track every live state at once (Thompson's construction), which runs in time linear in the input for a fixed pattern. Backtracking engines, including Python's
re, can take exponential time on patterns like(a*)*b, which is the ReDoS risk. The price of the automaton approach is giving up backreferences.
Where this shows up
This tiny matcher is the conceptual core of regular expressions. Real engines compile to an NFA/DFA for speed, but the “* means try both branches” recursion is the same idea — and it explains catastrophic backtracking, the pathological slowdown behind real-world ReDoS bugs.
Reference solution
Reference solution — Python
def is_match(s, p):
ls, lp = len(s), len(p)
# dp[i][j] = does s[i:] match p[j:] ?
dp = [[False] * (lp + 1) for _ in range(ls + 1)]
dp[ls][lp] = True # empty vs empty
for i in range(ls, -1, -1):
for j in range(lp - 1, -1, -1):
first = i < ls and (p[j] == s[i] or p[j] == ".")
if j + 1 < lp and p[j + 1] == "*":
dp[i][j] = dp[i][j + 2] or (first and dp[i + 1][j]) # zero, or one+stay
else:
dp[i][j] = first and dp[i + 1][j + 1] # consume one
return dp[0][0]
print(is_match("aa", "a")) # -> False
print(is_match("aa", "a*")) # -> True
print(is_match("aab", "c*a*b")) # -> TrueReference solution — TypeScript
function isMatch(s: string, p: string): boolean {
const ls = s.length, lp = p.length;
// dp[i][j] = does s[i:] match p[j:] ?
const dp: boolean[][] = Array.from({ length: ls + 1 }, () => new Array(lp + 1).fill(false));
dp[ls][lp] = true;
for (let i = ls; i >= 0; i--) {
for (let j = lp - 1; j >= 0; j--) {
const first = i < ls && (p[j] === s[i] || p[j] === ".");
if (j + 1 < lp && p[j + 1] === "*") {
dp[i][j] = dp[i][j + 2] || (first && dp[i + 1][j]); // zero, or one+stay
} else {
dp[i][j] = first && dp[i + 1][j + 1]; // consume one
}
}
}
return dp[0][0];
}
console.log(isMatch("aa", "a")); // -> false
console.log(isMatch("aa", "a*")); // -> true
console.log(isMatch("aab", "c*a*b")); // -> trueQuestions about Mini Regex Matcher (. and *)
Why does the regex DP loop start at i = len(s) instead of len(s) - 1?
Because the row i = len(s) asks whether a pattern suffix matches the empty string, and starred elements can. In the reference, dp[2][0] for s = 'aa' and p = 'a*' is True only because its zero-copies branch reads dp[2][2], the base case. Skip that row and every cell in it stays False, so 'a*' can never match nothing, and is_match('aa', 'a*') returns False even though the answer is True. The column j = len(p) needs no loop of its own: an empty pattern matches only the empty string, which is the single True base cell.
What is the time complexity of regular expression matching with dot and star?
With the table, O(n times m), where n is the length of the string and m the length of the pattern: there are (n + 1)(m + 1) cells and each takes constant work. Space is the same, or O(m) if you keep only two rows. Plain recursion that tries both branches of every star without remembering results is exponential in the worst case, because it solves the same pair of positions many times. Memoizing that recursion on the pair (i, j) gives the same O(n times m) bound as the bottom-up table.
Why can the star not just match as many characters as possible?
Because the rest of the pattern may need some of them. Matching 'aaa' against 'a*a' greedily lets the star take all three a's and leaves nothing for the final literal a, so a greedy matcher answers False when the correct answer is True. The same happens with '.*b' against 'ab'. A correct matcher must be able to give characters back, either by trying both branches at every star, which is what the table does, or by backtracking. Backtracking engines do exactly that, and it is where their worst-case slowdowns come from.
How is regular expression matching different from wildcard matching?
In this problem the star repeats the element before it, so 'a*' means zero or more a's and '.*' means any sequence. In wildcard matching, the kind used for file globs, the star stands on its own and matches any sequence, and a question mark matches one character. Both are solved with the same two-index table in O(n times m), but the star case differs: here it skips or repeats a pair of pattern characters, while in wildcards a single star either matches nothing or swallows one more character of the string.
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Challenges
- Two SumThe classic warm-up: find the two numbers that add up to a target. Brute force is O(n²) — a hash map gets you to one pass, O(n). Solve it in Python or TypeScript, right in your browser, with hidden tests and a reveal-solution button.Read →
- Valid ParenthesesThe canonical stack problem: decide whether every bracket is closed by the right type, in the right order. A stack turns nested matching into a single pass. Solve it in Python or TypeScript with hidden tests.Read →
- Fizz BuzzThe famous screening question. Print 1…n, but multiples of 3 become "Fizz", of 5 become "Buzz", and of both become "FizzBuzz". Easy — the catch is testing divisibility in the right order. Solve it in Python or TypeScript.Read →
- Number of IslandsThe canonical connected-components question. Flood-fill each unvisited patch of land with BFS or DFS, count how many floods you started — grid traversal, visited bookkeeping, and the classic diagonal trap. Solve it in Python or TypeScript.Read →
- Merge IntervalsCalendar apps and memory allocators run on this: sort by start, sweep once, grow or close the current block. Touching endpoints merge, nested intervals vanish — the classic that punishes off-by-one thinking. Solve it in Python or TypeScript.Read →
- Top-K Frequent ElementsCount, then rank — the two-step pattern behind trending topics and hot-key detection. A hash map counts; a (-count, value) sort ranks with a clean tie-break; buckets get you to O(n) if you want the flex. Solve it in Python or TypeScript.Read →