Problem overview
The function minDistance(_ word1: String, _ word2: String) -> Int returns the fewest operations that turn word1 into word2. Three operations are allowed, and each counts as one: insert a character anywhere, delete a character, or replace a character with another one.
For word1 = "horse" and word2 = "ros" the answer is 3: replace h with r to get rorse, delete the second r to get rose, then delete e. For "intention" and "execution" the answer is 5, and for "" and "abc" it is 3, because the empty word needs three inserts. Both words hold lowercase letters a to z, up to 500 each, and either can be empty.
Approach: a table of prefix distances
Trying every sequence of operations is far too slow. Instead, solve smaller versions of the problem and reuse their answers. Let dp[i][j] be the fewest operations that turn the first i characters of word1 into the first j characters of word2. If m and n are the lengths of the words, the table has (m + 1) × (n + 1) entries, and the answer is dp[m][n].
Row 0 and column 0 stand for an empty prefix. Turning i characters into nothing takes i deletes, so dp[i][0] = i. Turning nothing into j characters takes j inserts, so dp[0][j] = j.
For the other entries, look at the last characters of the two prefixes, word1[i - 1] and word2[j - 1]:
- They are equal. They need no operation, so
dp[i][j] = dp[i - 1][j - 1]. Spending an operation on either of them is never better. - They differ. One operation fixes the end, and
dp[i][j]is 1 plus the smallest of three neighbours.dp[i - 1][j - 1]is a replace: both prefixes become one character shorter.dp[i - 1][j]is a delete of the last character ofword1's prefix.dp[i][j - 1]is an insert of the character that matches the last one inword2's prefix, which then is one character shorter.
Filling the table row by row, left to right, guarantees that the three neighbours (above, left and diagonally up-left) are filled before they are needed. For "horse" and "ros" the finished table is:
0 1 2 3
1 1 2 3
2 2 1 2
3 2 2 2
4 3 3 2
5 4 4 3
Row i belongs to the first i characters of horse, and column j to the first j characters of ros. The bottom right entry, 3, is the answer.
Step by step
- Copy each word's bytes into an array with
Array(word.utf8), stored inletconstantschars1andchars2. A SwiftStringcan't be indexed by a number, and an array can. The words contain onlyatoz, one byte each, so comparing bytes compares letters. - Store the lengths
chars1.countandchars2.countinmandn. They come up in the table size and in all the loops. - Create
dpas avar:m + 1rows, each an array ofn + 1zeros. The extra row and column are for the empty prefixes. - Loop
iover0...mto fill column 0. - Set
dp[i][0]toi:ideletes empty the prefix. - Loop
jover0...nto fill row 0. - Set
dp[0][j]toj:jinserts build the prefix. The entrydp[0][0]is written twice, both times with 0. - Loop
iover1..<m + 1for the remaining rows. Row 0 is done, so it starts at 1. - Loop
jover1..<n + 1inside it, for the same reason: column 0 is done. - Equal last characters: compare
chars1[i - 1]withchars2[j - 1]. The firsticharacters end at indexi - 1, because the array counts from 0 while the table counts characters. If they match, copydp[i - 1][j - 1]. - Different last characters: set
dp[i][j]to 1 plusminof the replace, delete and insert neighbours. This is the key line. - Return
dp[m][n], which covers both words completely.
The solution in Swift
class Solution {
func minDistance(_ word1: String, _ word2: String) -> Int {
let chars1 = Array(word1.utf8)
let chars2 = Array(word2.utf8)
let m = chars1.count
let n = chars2.count
var dp = [[Int]](repeating: [Int](repeating: 0, count: n + 1), count: m + 1)
for i in 0...m {
dp[i][0] = i
}
for j in 0...n {
dp[0][j] = j
}
for i in 1..<m + 1 {
for j in 1..<n + 1 {
if chars1[i - 1] == chars2[j - 1] {
dp[i][j] = dp[i - 1][j - 1]
} else {
dp[i][j] = 1 + min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1])
}
}
}
return dp[m][n]
}
}
The Swift details worth a closer look:
- Bytes instead of characters.
word.utf8is a view of the string's UTF-8 bytes, andArray(...)turns it into a[UInt8]. For the lowercase letters in this lesson, one byte is one letter. Outside that range this would break:"héllo"has 5 characters but 6 bytes, so the byte-based function reports 2 operations between"héllo"and"hello", not 1. UsingArray(word)would give an array ofCharactervalues, which also supports==. 1..<m + 1and not1...m. A closed range can't end before it starts. With an emptyword1,mis 0, and1...mwould stop the program with a run-time error. The half-open1..<1is just empty, so the loop is skipped and the function returnsdp[0][n]. The ranges0...mand0...nare fine, because they start at 0 and the lengths are never negative.- The table is made of copies.
[Int](repeating: 0, count: n + 1)is a row, and[[Int]](repeating: row, count: m + 1)repeats it. Arrays are value types, so each row is an independent copy, and writing todp[i][j]changes only that row. minwith three arguments. Swift's globalminaccepts any number of values, so no nesting is needed.- Operator precedence. In
1..<m + 1the addition happens first, so the range ends beforem + 1, which makesmthe last row.
Complexity
Time: O(m · n). The table has (m + 1) × (n + 1) entries, and each takes a constant amount of work.
Space: O(m · n). The whole table is kept. Each row only reads itself and the row above it, so two rows of n + 1 numbers would be enough and would bring the space down to O(n). The table is easier to follow, so the lesson keeps all of it.
Edge cases and common mistakes
- Empty words.
minDistance("", "abc")is 3,minDistance("abc", "")is 3 andminDistance("", "")is 0. The initialised first row and column handle all three. - Off-by-one indexing. The table counts characters, and the arrays count from 0. The characters compared at
dp[i][j]arechars1[i - 1]andchars2[j - 1]. Usingchars1[i]goes out of bounds on the last row and stops the program. - Forgetting the base row and column. With zeros there, every distance comes out too small, because turning a prefix into an empty word would look free.
- Taking the minimum of only two neighbours. Leaving out the replace case gives
minDistance("a", "b")as 2 instead of 1. - Counting the 1 when the characters match. Equal last characters add nothing. Adding 1 anyway would turn
minDistance("abc", "abc")into a nonzero answer. - Greedy thinking. Two operations are not enough for
"horse"to"ros", and no quick rule such as comparing lengths finds the right count, which is why the table compares every prefix pair.
Testing
"horse"to"ros"gives3."intention"to"execution"gives5.""to"abc"gives3, and"abc"to""also gives3.""to""gives0, and"abc"to"abc"gives0."ab"to"ba"gives2."a"to"b"gives1."kitten"to"sitting"gives3.
