Fuzzy matching
Check whether the letters of a short query appear in order inside a longer name, and score how good the match looks.
O(n)Space O(1)4 practice problemsStep-through pictureThe idea
In a command palette you can type gco and see Git: Commit at the top. The letters g, c, o don't sit next to each other in the name, but they appear in the right order. A query whose letters appear in order inside a name, with gaps allowed, is a subsequence of it. That is the core test of fuzzy matching.
Many names match a short query, so each match also gets a score. Matching letters earn points. Letters right after the previous match (consecutive) earn a bonus, and letters at the start of a word (after a space, -, _, or /) earn a bigger bonus, because people tend to type the first letters of words. Results are sorted by score.
The simplest version walks the name once, left to right, with an index qi into the query. Each time the current name letter equals query[qi], it counts as a match and qi moves on. If qi reaches the end of the query, it matched. This greedy walk takes the first possible letter each time, which is fast and usually good enough.
An everyday example
Spotting your initials on a list of names
Someone tells you a street name contains the letters M, S, T in that order. You scan each street name left to right and tick off M, then look for S after it, then T after that. "Main Street" passes. "Master Plan" passes too, but it feels like a weaker guess. Main Street's letters start words, so you rank it higher.
| In the example | In the code |
|---|---|
| The letters you were told | query, lowercased to q |
| The next letter you're looking for | q[qi] |
| Ticking off a letter | score += 1 and qi++ |
| A letter that starts a word | i === 0 or the previous character is a space, -, _, or / |
| Two ticked letters side by side | previous === i - 1, a consecutive bonus |
From the slow way to fuzzy matching
- Start with the obvious filter: keep the names where
name.includes(query). Typinggtpto findGo to Pathfinds nothing, because the lettersg,t,pare not next to each other. People type the first letters of words, so a plain substring search is too strict. - Relax the test: the query's letters must appear in the name in the same order, with gaps allowed. That is a subsequence test, and it needs one pass with two indexes:
iover the name andqiover the query. Whentext[i]equalsq[qi], moveqion. Taking the earliest matching letter each time never hurts, because it leaves the most name for the letters still to find. - Now many names match a short query, so they need a rank. Ask what makes a match feel right. Letters that start words feel right, because the user typed initials. Letters right next to each other feel right, because the user typed part of a word. Scattered letters in the middle of words feel like an accident.
- Turn those feelings into points during the same pass:
1for each matched letter,2more when it's right after the previous match, and3more when it starts a word. Return-1if some query letter was never found, then sort by score with a fixed tie-breaker. That one pass with bonuses is fuzzy matching.
How it works
- Lowercase both strings so
Gmatchesg:qandtext. - Set
score = 0,qi = 0(the next query letter to find), andprevious = -2(where the last match was;-2means none yet). - Walk
iovertext. Stop early if every query letter is found. - If
text[i]isn'tq[qi], move on. - If it is, add
1. Add2more if it is right after the previous match, and3more if it starts a word. Then setprevious = iandqi++. - After the walk, return
scoreifqi === q.length, or-1if some query letter was never found.
Watch it run
This picture runs the code below on a real input. Press Step to move one step at a time, or Play to watch. The highlighted line is the one that just ran. Change the input to see other cases.
See every step at onceThe same run, laid out side by side
The code
Point at a note to light up the lines it explains.
function fuzzyScore(query, candidate) {const q = query.toLowerCase();const text = candidate.toLowerCase();let score = 0;let qi = 0;let previous = -2;for (let i = 0; i < text.length && qi < q.length; i++) {if (text[i] !== q[qi]) continue;score += 1;if (previous === i - 1) score += 2;if (i === 0 || " -_/".includes(text[i - 1])) score += 3;previous = i;qi++;}return qi === q.length ? score : -1;}
How fast it is
| Time | O(n) |
|---|---|
| Extra memory | O(1) |
Each character of the name is looked at once, and only a few counters are stored. The lowercase copies are O(n) if you count them.
- Best case
O(n)The name starts with the query, so the loop stops after
q.lengthletters. Lowercasing both strings still reads the whole name once. - Average case
O(n)A typical name: the loop walks part or all of it, one step per character.
- Worst case
O(n)The last query letter is missing or is the last letter of the name, so the loop reads all
ncharacters. Rankingknames addsO(k log k)for the sort.
Common mistakes
- Using
includesorindexOfon the whole query. That only finds letters that are next to each other, which isn't fuzzy. - Forgetting to lowercase. Users don't type capitals in a command palette.
- Sorting results without a tie-breaker. Equal scores then appear in a different order from one keystroke to the next.
- Assuming greedy gives the best possible score. It gives a valid match; for the best one, compare every placement with dynamic programming.
When to use it
Look for these clues in a problem:
- The user types a few letters and expects matching items even when the letters aren't next to each other.
- The question mentions a command palette, quick open, file finder, or search-as-you-type.
- You must check whether one string's letters appear in order inside another.
- Results must be ranked by how well they match, with clear tie-breaking rules.
Check yourself
Answer each one in your head first, then open it.
Why does taking the earliest matching letter never miss a match?
Any placement that uses a later letter can be changed to use the earlier one instead, and that leaves at least as much of the name for the remaining letters. So if any placement exists, the greedy one finds one too.
What is the score of gtp against Go to Path?
12. Each of g, t, and p starts a word, so each earns 1 + 3 = 4. None of them touch each other, so there's no side-by-side bonus.
Why does previous start at -2 instead of -1?
At i = 0, the check is previous === i - 1, which is -1. Starting at -1 would give the very first letter a side-by-side bonus it didn't earn.
Why can the greedy score be lower than the best possible score?
Greedy takes the first matching letter, which may not be at a word start, while a later copy of that letter is. For ops in Open Project Settings, greedy scores 11 but the best placement scores 12.
Practice problems
Stuck on one? Tuto is in the playground too, and gives you hints, never the answer.
0 of 4 solved. Solve one to earn the bronze medal, half for silver, and all of them for gold.
Start practicingGo further
You don't need these to start practicing. Open one when you want more.
Why it works
The reasoning that makes it always right
For the yes-or-no answer, the loop keeps this rule: after looking at text[0] to text[i], the first qi query letters are matched in order inside that part, and no placement could fit more query letters into it. Taking the earliest possible spot for each letter keeps the rule, because any other placement uses a later spot and leaves less room for the rest. This is called greedy staying ahead.
So if the loop ends with qi < q.length, no placement of all the letters exists, and -1 is right. If qi === q.length, the greedy placement is a real placement, so the name matches. The loop always ends because i grows by one each time and stops at text.length.
The score is a different story. It scores the greedy placement, which isn't always the best one. For ops against Open Project Settings, greedy takes the p at index 1, right after the o, and scores 11. Taking the p that starts Project instead would score 12, because a word start is worth more than touching the previous letter. Finding the best placement needs dynamic programming over every placement.
Going deeper
3 more ideas
Why greedy finds every match
If the query's letters appear in order anywhere in the name, taking the earliest possible spot for each letter never hurts: it leaves the most room for the letters after it. So the greedy walk says "matched" exactly when the query is a subsequence. The score is another story: an earlier letter can miss a word-start bonus that a later one would get. Tools that want the best score use dynamic programming over all placements.
How fast it is
One pass over each name, so scoring one name is O(n) for a name of length n. Ranking k names is O(total length) plus sorting, O(k log k). That is quick enough to rerun on every keystroke for thousands of commands.
Making the order of results predictable
Two names often get the same score. Decide the tie-breaker up front, such as the shorter name first, then alphabetical. Without one, results can jump around between keystrokes, which feels broken to users.
Work it by hand
Every step in a table, with why
Score the query gopa against Go to Path. Two word starts and two runs of side-by-side letters both earn bonuses, and the loop stops before the end of the name.
query = "gopa", candidate = "Go to Path"
| Step | i | text[i] | q[qi] | score | What happens | Why |
|---|---|---|---|---|---|---|
| 1 | 0 | g | g | 4 | match: 1 + word start 3 | i === 0, so it starts a word. previous is -2, so there is no side-by-side bonus. Now qi = 1, previous = 0. |
| 2 | 1 | o | o | 7 | match: 1 + next to previous 2 | previous === i - 1, so the letters touch. The letter before is g, not a separator, so no word start. |
| 3 | 2 | | p | 7 | skip | A space is not p. Gaps are allowed, so just move on. |
| 4 | 3 | t | p | 7 | skip | Not p either. |
| 5 | 4 | o | p | 7 | skip | Still looking for p. |
| 6 | 5 | | p | 7 | skip | Another space. It doesn't match, but it makes the next letter a word start. |
| 7 | 6 | p | p | 11 | match: 1 + word start 3 | The letter before is a space. The previous match was at index 1, so no side-by-side bonus. |
| 8 | 7 | a | a | 14 | match: 1 + next to previous 2 | Right after the p. Now qi = 4 = q.length, so the loop condition fails before index 8. |
It returns 14. The loop ended early because every query letter was found; the last two letters of path were never looked at.
Compared with other ways
When to pick something else
| Approach | Time | Extra memory | Choose it when |
|---|---|---|---|
| Fuzzy matching | O(n) | O(1) | A command palette or quick finder that reruns on every keystroke over thousands of names. One pass per name is fast enough. |
Substring search with includes | O(n · m) | O(1) | Users type whole words or exact parts, like a search box over article titles. That bound is the worst case of a simple search; engines are often faster. |
| Best-placement dynamic programming | O(n · m) | O(n · m) | The ranking must be as good as possible, and names are short enough that a small table per name is cheap. |
| Edit distance for typos | O(n · m) | O(m) | Users misspell, like setings for settings. Fuzzy matching can't fix a wrong or extra letter; edit distance counts them. |
Extra memory: O(1) for the loop: score, qi, previous, and i. The two lowercase copies take O(n) if you count them.
How the steps grow
- Fuzzy matching
O(n) - Substring search with
includes, Best-placement dynamic programming, Edit distance for typosO(n · m)
| Approach | n = 10 | n = 1,000 | n = 1,000,000 |
|---|---|---|---|
| Fuzzy matching | 10 | 1,000 | 1 million |
Substring search with includes | 100 | 1 million | 1 trillion |
| Best-placement dynamic programming | 100 | 1 million | 1 trillion |
| Edit distance for typos | 100 | 1 million | 1 trillion |
Inputs that need care
5 tricky inputs
- An empty query:
fuzzyScore("", "Anything") qi < q.lengthis false at once, so the loop never runs and it returns0. Every name matches with the same score. Most palettes show all items in their normal order in this case.- Letters out of order:
fuzzyScore("ab", "ba") bis skipped while looking fora, theaat index 1 matches, and then the name ends withqi = 1. It returns-1.- A repeated query letter:
fuzzyScore("ee", "Dev") - Each query letter needs its own letter in the name.
Devhas only onee, so it returns-1.Deletehas three, sofuzzyScore("ee", "Delete")returns2. - camelCase names:
fuzzyScore("gun", "getUserName") - Only a space,
-,_, or/counts as a word break, and the text is lowercased first. SoUandNearn no word-start bonus and the score is only6. Check for a lowercase letter followed by a capital before lowercasing to reward camelCase. - Accents: the user types
cafeforCafé toLowerCasekeeps the accent, soedoesn't matché. Normalize both strings withnormalize("NFD")and strip the accent marks if they should match.
Variations and follow-ups
Twists interviewers add
Best placement with dynamic programming
Fill a table where each cell is the best score for matching the first qi query letters with the last one placed at name index i. Each cell looks at the best earlier cell, plus the bonuses. It costs O(n * m) for a query of length m but always finds the highest score. Tools like fzf with its default algorithm score this way.
Returning the matched positions
Push i into a list every time a letter matches. The UI uses that list to bold the matched letters in each result, which helps people see why an item matched.
Filtering faster as the user types
Adding a letter to the query can only remove matches: if gopa matches a name, so does gop. So on each new keystroke, filter the previous matches instead of the full list. This makes long lists feel instant.
File-path bonuses
File finders add rules such as "matches in the file name beat matches in the folder path" and "a match right after / starts a word". The loop is the same; only the bonus rules change.
In an interview
Spot it, explain it, answer follow-ups
- Spot it from "command palette", "quick open", "letters in order", "subsequence", or "rank the results". If letters can be wrong, not just missing, it's edit distance instead.
- Explain it in two layers: first the two-index subsequence test and why the earliest letter is safe, then the bonuses as a separate scoring rule. Say out loud that greedy finds every match but not always the best score.
- Test by hand: an empty query, a query longer than the name, letters out of order, and mixed upper and lower case. Then score one name and check each bonus.
- Expect the follow-up "two names have the same score; which comes first?". Pick a fixed rule, such as shorter name first and then alphabetical, so results don't jump between keystrokes.
Where frontend code uses it
The same idea in real UI code
- VS Code's Quick Open and command palette, and similar finders in other editors, match file and command names this way, rewarding word starts and consecutive letters.
- Command menus in web apps (often opened with
Ctrl+KorCmd+K) filter and rank actions on every keystroke with a fuzzy score. - Terminal tools like
fzfuse the same idea with more detailed bonuses for word boundaries and consecutive matches.