The iterative dynamic programming (DP) approach is the default implementation for computing Levenshtein distance. It uses a bottom-up tabulation method with space optimization, achieving $\mathcal{O}(mn)$ time complexity with only $\mathcal{O}(\min(m,n))$ space.
This is the Wagner-Fischer algorithm with memory optimization.
The classic DP recurrence relation, for $1 \le i \le m$ and $1 \le j \le n$:
dp[i][j] = \min \begin{cases}
dp[i-1][j] + 1 & \text{deletion} \\
dp[i][j-1] + 1 & \text{insertion} \\
dp[i-1][j-1] + \mathrm{cost} & \text{substitution}
\end{cases}
where $\mathrm{cost} = 0$ when the $i$-th source character equals the $j$-th target character, and $1$ otherwise.
Base cases:
dp[0][j] = j$ (insert $j$ characters)dp[i][0] = i$ (delete $i$ characters)Instead of storing the full $m \times n$ matrix, we only keep two rows:
prev_row: Previous row (i-1)curr_row: Current row (i)pub fn standard_distance_impl(source: &str, target: &str) -> usize {
let source_chars: SmallVec<[char; 32]> = source.chars().collect();
let target_chars: SmallVec<[char; 32]> = target.chars().collect();
let m = source_chars.len();
let n = target_chars.len();
// Handle edge cases
if m == 0 {
return n;
}
if n == 0 {
return m;
}
// Use space-optimized version (two rows instead of full matrix)
let mut prev_row = vec![0; n + 1];
let mut curr_row = vec![0; n + 1];
// Initialize first row: [0, 1, 2, ..., n]
for (j, item) in prev_row.iter_mut().enumerate().take(n + 1) {
*item = j;
}
// Fill the matrix row by row
for i in 1..=m {
curr_row[0] = i; // First column: deletion cost
for j in 1..=n {
let cost = if source_chars[i - 1] == target_chars[j - 1] {
0 // Characters match
} else {
1 // Substitution required
};
curr_row[j] = (prev_row[j] + 1) // deletion
.min(curr_row[j - 1] + 1) // insertion
.min(prev_row[j - 1] + cost); // substitution
}
// Swap rows: curr becomes prev for next iteration
std::mem::swap(&mut prev_row, &mut curr_row);
}
prev_row[n] // Final answer
}
Source: src/distance/mod.rs:244-288
"" s i t t i n g
"" 0 1 2 3 4 5 6 7
k 1 1 2 3 4 5 6 7
i 2 2 1 2 3 4 5 6
t 3 3 2 1 2 3 4 5
t 4 4 3 2 1 2 3 4
e 5 5 4 3 2 2 3 4
n 6 6 5 4 3 3 2 3
Memory: O(m×n) = 8×8 = 64 cells
Only store two rows at a time:
Iteration i=1 (processing 'k'):
prev_row: [0, 1, 2, 3, 4, 5, 6, 7] (initialization)
curr_row: [1, 1, 2, 3, 4, 5, 6, 7] (computed)
Iteration i=2 (processing 'i'):
prev_row: [1, 1, 2, 3, 4, 5, 6, 7] (swapped from curr)
curr_row: [2, 2, 1, 2, 3, 4, 5, 6] (computed)
...
Memory: O(2n) = 2×8 = 16 cells (4× reduction)
Extends standard DP to support transposition (swapping adjacent characters) as a single operation. Because the recurrence only looks back two rows and does not maintain a last-occurrence table, it computes OSA (restricted Damerau), not unrestricted Damerau–Levenshtein distance.
dp[i][j] = \min \begin{cases}
dp[i-1][j] + 1 & \text{deletion} \\
dp[i][j-1] + 1 & \text{insertion} \\
dp[i-1][j-1] + \mathrm{cost} & \text{substitution} \\
dp[i-2][j-2] + 1 & \text{transposition (if characters match diagonally)}
\end{cases}
Transposition condition:
Requires three rows instead of two:
pub fn transposition_distance(source: &str, target: &str) -> usize {
let source_chars: SmallVec<[char; 32]> = source.chars().collect();
let target_chars: SmallVec<[char; 32]> = target.chars().collect();
let m = source_chars.len();
let n = target_chars.len();
if m == 0 {
return n;
}
if n == 0 {
return m;
}
// Need three rows for transposition
let mut two_ago = vec![0; n + 1];
let mut prev_row = vec![0; n + 1];
let mut curr_row = vec![0; n + 1];
// Initialize first row
for (j, item) in prev_row.iter_mut().enumerate().take(n + 1) {
*item = j;
}
// Fill the matrix
for i in 1..=m {
curr_row[0] = i;
for j in 1..=n {
let cost = if source_chars[i - 1] == target_chars[j - 1] {
0
} else {
1
};
curr_row[j] = (prev_row[j] + 1) // deletion
.min(curr_row[j - 1] + 1) // insertion
.min(prev_row[j - 1] + cost); // substitution
// Check for transposition
if i > 1
&& j > 1
&& source_chars[i - 1] == target_chars[j - 2]
&& source_chars[i - 2] == target_chars[j - 1]
{
curr_row[j] = curr_row[j].min(two_ago[j - 2] + 1);
}
}
// Rotate rows: curr → prev → two_ago
std::mem::swap(&mut two_ago, &mut prev_row);
std::mem::swap(&mut prev_row, &mut curr_row);
}
prev_row[n]
}
Source: src/distance/mod.rs:304-359
| Operation | Complexity | Explanation |
|---|---|---|
| Standard distance | $\mathcal{O}(mn)$ | Fill $m \times n$ DP table |
| Transposition distance | $\mathcal{O}(mn)$ | Same, with extra transposition check |
| Character comparison | $\mathcal{O}(1)$ | Direct equality check |
| Min of 3 values | $\mathcal{O}(1)$ | Constant-time comparison |
Total: $\mathcal{O}(mn)$ for strings of length $m$ and $n$
| Implementation | Space | Explanation |
|---|---|---|
| Full matrix | $\mathcal{O}(mn)$ | Store entire DP table |
| Two-row optimization | $\mathcal{O}(n)$ | Only prev_row + curr_row |
| Three-row (transposition) | $\mathcal{O}(n)$ | two_ago + prev_row + curr_row |
| Character vectors | $\mathcal{O}(m + n)$ | SmallVec for source/target |
Total:
\mathcal{O}(m + n + 2n) = \mathcal{O}(m + 3n)$\mathcal{O}(m + n + 3n) = \mathcal{O}(m + 4n)$For $m \approx n$: $\mathcal{O}(n)$ space
Source: k i t t e n (m=6)
Target: s i t t i n g (n=7)
Initialize:
prev_row: [0, 1, 2, 3, 4, 5, 6, 7]
i=1, source[0]='k':
j=1: target[0]='s', cost=1, min(1+1, 0+1, 0+1) = 1
j=2: target[1]='i', cost=1, min(2+1, 1+1, 1+1) = 2
j=3: target[2]='t', cost=1, min(3+1, 2+1, 2+1) = 3
j=4: target[3]='t', cost=1, min(4+1, 3+1, 3+1) = 4
j=5: target[4]='i', cost=1, min(5+1, 4+1, 4+1) = 5
j=6: target[5]='n', cost=1, min(6+1, 5+1, 5+1) = 6
j=7: target[6]='g', cost=1, min(7+1, 6+1, 6+1) = 7
curr_row: [1, 1, 2, 3, 4, 5, 6, 7]
i=2, source[1]='i':
prev_row: [1, 1, 2, 3, 4, 5, 6, 7] (swapped)
j=1: target[0]='s', cost=1, min(1+1, 1+1, 1+1) = 2
j=2: target[1]='i', cost=0, min(2+1, 2+1, 1+0) = 1 ← Match!
j=3: target[2]='t', cost=1, min(3+1, 1+1, 2+1) = 2
j=4: target[3]='t', cost=1, min(4+1, 2+1, 3+1) = 3
j=5: target[4]='i', cost=0, min(5+1, 3+1, 4+0) = 4
j=6: target[5]='n', cost=1, min(6+1, 4+1, 5+1) = 5
j=7: target[6]='g', cost=1, min(7+1, 5+1, 6+1) = 6
curr_row: [2, 2, 1, 2, 3, 4, 5, 6]
i=3, source[2]='t':
prev_row: [2, 2, 1, 2, 3, 4, 5, 6]
j=1: 's', cost=1, min(2+1, 2+1, 2+1) = 3
j=2: 'i', cost=1, min(1+1, 3+1, 2+1) = 2
j=3: 't', cost=0, min(2+1, 2+1, 1+0) = 1 ← Match!
j=4: 't', cost=0, min(3+1, 1+1, 2+0) = 2
j=5: 'i', cost=1, min(4+1, 2+1, 3+1) = 3
j=6: 'n', cost=1, min(5+1, 3+1, 4+1) = 4
j=7: 'g', cost=1, min(6+1, 4+1, 5+1) = 5
curr_row: [3, 3, 2, 1, 2, 3, 4, 5]
i=4, source[3]='t':
prev_row: [3, 3, 2, 1, 2, 3, 4, 5]
j=1: 's', cost=1, min(3+1, 3+1, 3+1) = 4
j=2: 'i', cost=1, min(2+1, 4+1, 3+1) = 3
j=3: 't', cost=0, min(1+1, 3+1, 2+0) = 2
j=4: 't', cost=0, min(2+1, 2+1, 1+0) = 1 ← Match!
j=5: 'i', cost=1, min(3+1, 1+1, 2+1) = 2
j=6: 'n', cost=1, min(4+1, 2+1, 3+1) = 3
j=7: 'g', cost=1, min(5+1, 3+1, 4+1) = 4
curr_row: [4, 4, 3, 2, 1, 2, 3, 4]
i=5, source[4]='e':
prev_row: [4, 4, 3, 2, 1, 2, 3, 4]
j=1: 's', cost=1, min(4+1, 4+1, 4+1) = 5
j=2: 'i', cost=1, min(3+1, 5+1, 4+1) = 4
j=3: 't', cost=1, min(2+1, 4+1, 3+1) = 3
j=4: 't', cost=1, min(1+1, 3+1, 2+1) = 2
j=5: 'i', cost=1, min(2+1, 2+1, 1+1) = 2
j=6: 'n', cost=1, min(3+1, 2+1, 2+1) = 3
j=7: 'g', cost=1, min(4+1, 3+1, 3+1) = 4
curr_row: [5, 5, 4, 3, 2, 2, 3, 4]
i=6, source[5]='n':
prev_row: [5, 5, 4, 3, 2, 2, 3, 4]
j=1: 's', cost=1, min(5+1, 5+1, 5+1) = 6
j=2: 'i', cost=1, min(4+1, 6+1, 5+1) = 5
j=3: 't', cost=1, min(3+1, 5+1, 4+1) = 4
j=4: 't', cost=1, min(2+1, 4+1, 3+1) = 3
j=5: 'i', cost=1, min(2+1, 3+1, 2+1) = 3
j=6: 'n', cost=0, min(3+1, 3+1, 2+0) = 2 ← Match!
j=7: 'g', cost=1, min(4+1, 2+1, 3+1) = 3
curr_row: [6, 6, 5, 4, 3, 3, 2, 3]
Final answer: prev_row[7] = 3
Edits: k→s (sub), e→i (sub), +g (insert)
Uses SmallVec<[char; 32]> for efficient character storage:
let source_chars: SmallVec<[char; 32]> = source.chars().collect();
let target_chars: SmallVec<[char; 32]> = target.chars().collect();
Benefits:
\le 32$ chars)Uses std::mem::swap for efficient row rotation without copying:
std::mem::swap(&mut prev_row, &mut curr_row);
Cost: $\mathcal{O}(1)$ pointer swap, not $\mathcal{O}(n)$ vector copy
if m == 0 {
return n; // Insert all characters from target
}
if n == 0 {
return m; // Delete all characters from source
}
Measured on Intel Core i7-9750H (2.6 GHz):
| String Length | Time (µs) | Operations | Ops/Second |
|---|---|---|---|
| 5 × 5 | 0.12 | 25 | 208K cells/µs |
| 10 × 10 | 0.42 | 100 | 238K cells/µs |
| 50 × 50 | 11.2 | 2,500 | 223K cells/µs |
| 100 × 100 | 44.5 | 10,000 | 225K cells/µs |
| 500 × 500 | 1,112 | 250,000 | 225K cells/µs |
| 1000 × 1000 | 4,448 | 1,000,000 | 225K cells/µs |
Observation: Consistent ~225K cells/µs throughput across string lengths (good cache behavior).
| String Length | Full Matrix | Two-Row | Savings |
|---|---|---|---|
| 100 × 100 | 40 KB | 800 bytes | 50× |
| 500 × 500 | 1 MB | 4 KB | 250× |
| 1000 × 1000 | 4 MB | 8 KB | 500× |
\mathcal{O}(mn)$ always, no best/worst case variance\mathcal{O}(n)$ space vs $\mathcal{O}(mn)$ for full matrix| Aspect | Iterative DP | Recursive + Memo |
|---|---|---|
| Time (first query) | $\mathcal{O}(mn)$ | $\mathcal{O}(mn)$ |
| Time (repeated query) | $\mathcal{O}(mn)$ | $\mathcal{O}(1)$ (cache hit) |
| Space | $\mathcal{O}(n)$ | $\mathcal{O}(\text{depth} + \text{cache})$ |
| Common prefix optimization | ✗ | ✓ |
| Early exit optimization | ✗ | ✓ |
| Stack overflow risk | ✗ | ✓ (mitigated) |
| Best for | One-off queries | Repeated queries |
\mathcal{O}(n)$ spacesrc/distance/mod.rs:244-359Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |