23. Fuzzy-Match a KB Title
Problem
When exact or embedding search over the knowledge base comes back empty for a raw customer query, a typo-tolerant fallback can help: compute the edit distance between the query and each candidate KB article title, and treat low-distance titles as plausible matches worth surfacing anyway.
Given two strings a and b, return the Levenshtein distance between
them — the minimum number of single-character insertions, deletions, or substitutions needed to
turn a into b.
Source: src/23_fuzzy_match_kb_title.py
def edit_distance(a: str, b: str) -> int:
...
>>> edit_distance("kitten", "sitting")
3
>>> edit_distance("horse", "ros")
3
>>> edit_distance("abc", "abc")
0
Step-by-Step Approach
- Define
dp[i][j]as the edit distance between the firsticharacters ofaand the firstjcharacters ofb. Build a(len(a)+1) x (len(b)+1)table. - Base cases: turning an empty prefix of
ainto aj-length prefix ofbtakesjinsertions, sodp[0][j] = j. Symmetricallydp[i][0] = i(all deletions). - For each
ifrom 1 tolen(a)andjfrom 1 tolen(b): ifa[i-1] == b[j-1], the last characters already match, so no extra edit is needed:dp[i][j] = dp[i-1][j-1]. - Otherwise the last characters differ, so take the best of three options and add 1: delete
from
a(dp[i-1][j]), insert intoa(dp[i][j-1]), or substitute (dp[i-1][j-1]). - Fill the table row by row (or column by column) in increasing order of
iandj, since each cell only depends on cells with smaller or equal indices. - The answer is
dp[len(a)][len(b)], the bottom-right corner.
The key insight is that the edit distance between two prefixes reduces to a choice among the edit distances of three smaller prefix pairs, which is exactly what makes it amenable to bottom-up DP rather than exponential brute-force recursion.
Reference solution
def edit_distance(a: str, b: str) -> int:
# classic bottom-up DP table, dp[i][j] = distance between a[:i] and b[:j]; O(len(a)*len(b))
m, n = len(a), len(b)
# fresh row list per iteration, not aliased
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
# base case: i deletions to turn a[:i] into empty
dp[i][0] = i
for j in range(n + 1):
# base case: j insertions to turn empty into b[:j]
dp[0][j] = j
for i in range(1, m + 1):
for j in range(1, n + 1):
# a[i-1]/b[j-1]: current chars for prefixes of length i/j
if a[i - 1] == b[j - 1]:
dp[i][j] = dp[i - 1][j - 1]
else:
# delete/insert/substitute
dp[i][j] = 1 + min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
return dp[m][n]
Key Functions & Tricks
[[0] * (n + 1) for _ in range(m + 1)]— fresh row lists per iteration, avoids row aliasing.dp[i][0] = i/dp[0][j] = j— base cases: all deletions / all insertions.a[i - 1] == b[j - 1]— off-by-one: row/coli/jmeans length-i/jprefix.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])— cheapest of delete/insert/substitute, O(1) per cell.
How to Recognize This Pattern
Signal words: "minimum number of operations to transform one string into another,"
"similarity/distance between two sequences," "typo tolerance," "fuzzy match." Any time the
problem compares two sequences and the optimal way to align a prefix pair depends only on
three smaller prefix pairs (drop one from a, drop one from b, or drop one from both), that's
a 2D edit-distance-style DP. Common variations: allow only a subset of operations (e.g.
insert/delete but no substitute, which is closer to longest-common-subsequence), or weight
operations differently. The space can be optimized to O(min(len(a), len(b))) by keeping only
the previous row, since each row only depends on the row above it. A common pitfall is mixing
up the base-case direction — an empty a against a j-length
b needs j insertions, not 0.