Problem overview
The method ladderLength receives a beginWord, an endWord and an Array[String] called words. Changing one letter at a time, with every word along the way in words, it must find the shortest sequence from beginWord to endWord. The result is the number of words in that sequence, counting both ends, or 0 if there is none. All words have the same length and use lowercase letters only.
For "hit", "cog" and ["hot","dot","dog","lot","log","cog"], the shortest sequence is hit, hot, dot, dog, cog, so the answer is 5. If "cog" is not in the list, the answer is 0.
Approach: generate neighbors instead of scanning for them
This is a shortest-path problem on an unweighted graph where the words are the vertices, so it calls for breadth-first search. A search that scans the whole list to find the neighbors of each word costs O(N) comparisons per word. This lesson flips the work around: from a word of length L, there are only L * 26 words that differ from it in one position. Build each of those candidates, and keep the ones that are in the word list.
A set makes that membership test fast. Converting the array with toSet turns "is this candidate a real word?" from a scan of the array into an average O(1) lookup (plus the cost of hashing the string).
As in any BFS, a map from word to distance doubles as the visited set. A word gets its distance at the moment it is queued, so it can never be queued twice. Words come out of the queue in non-decreasing order of distance, so the first time endWord comes out, its stored distance is the shortest.
Step by step
- Make a set.
val wordSet = words.toSetcreates an immutableSet[String]. - Create the queue, a
scala.collection.mutable.Queue[String]. - Create
minDist, a mutableMap[String, Int]: the length of the shortest sequence frombeginWordto each word reached so far, counting both ends. - Enqueue
beginWordto start the search. - Set
minDist(beginWord)to 1, since a sequence of one word has length 1. The answer counts words, not changes. - Use a flag instead of
break. Declarevar done = falseand loop whilequeue.nonEmpty && !done. - Dequeue the front word into
word. - Test for the target. If
wordequalsendWord, setdonetotrue. Setting a flag does not skip the rest of the iteration, so the neighbor search goes in anelsebranch. - Loop over every position
iofword. - Loop over the letters
'a' to 'z'. Ranges work forCharas they do forInt, andtoincludes both ends. - Build the candidate. Strings are immutable, so
nextWordis made of the part ofwordbeforei, the letterc, and the part afteri. - Check the dictionary. Scala has no
continue, so the remaining checks are nested insideif (wordSet.contains(nextWord)). - Check that it is new. Inside it,
if (!minDist.contains(nextWord)). A word that already has a distance was reached by a sequence at least as short. This also covers the case wherecis the letter already at positioni, which rebuildsworditself. - Record the distance as
minDist(word) + 1. - Enqueue
nextWordso that its own neighbors get explored later. - Return
minDist.getOrElse(endWord, 0).
The solution in Scala
class Solution {
def ladderLength(beginWord: String, endWord: String, words: Array[String]): Int = {
val wordSet = words.toSet
val queue = scala.collection.mutable.Queue[String]()
val minDist = scala.collection.mutable.Map[String, Int]()
queue.enqueue(beginWord)
minDist(beginWord) = 1
var done = false
while (queue.nonEmpty && !done) {
val word = queue.dequeue()
if (endWord == word) {
done = true
} else {
for (i <- 0 until word.length) {
for (c <- 'a' to 'z') {
val nextWord = word.substring(0, i) + c + word.substring(i + 1)
if (wordSet.contains(nextWord)) {
if (!minDist.contains(nextWord)) {
minDist(nextWord) = minDist(word) + 1
queue.enqueue(nextWord)
}
}
}
}
}
}
minDist.getOrElse(endWord, 0)
}
}
Scala details worth a closer look:
- Adding a
Charto aString.word.substring(0, i) + c + word.substring(i + 1)works because the left operand is aString, so+appends the character's text. TwoCharvalues added to each other are different:'a' + 'b'is theInt195. substringbounds.substring(0, i)excludes indexi, andsubstring(i + 1)runs to the end of the string. Together they skip exactly the letter ati.ArrayversusSet.words.contains(x)scans the array.wordSet.contains(x)hashesxand looks it up.- The flag.
var doneis a variable because it changes. The conditionqueue.nonEmpty && !doneis checked before every iteration. getOrElse.minDist(endWord)would throwNoSuchElementExceptionfor an unreached word.getOrElse(endWord, 0)returns the default instead.- Mutable map.
minDist(nextWord) = minDist(word) + 1calls the map'supdatemethod.
Complexity
Let N be the number of words and L their length.
Time: O(N * L²), with a factor of 26. Each word is queued at most once. For each one, the loops build 26 * L candidates, and building and hashing a candidate costs O(L). The scanning version takes O(N² * L), so this version is faster when the list is long and the words are short.
Space: O(N * L). The set, the queue and the map each hold at most one string of length L per word.
Edge cases and common mistakes
endWordnot in the list. Candidates must be inwordSet, soendWordis never queued and the result is 0.beginWordnot in the list. This is fine. It is queued directly, and only its neighbors have to be in the set."hit"to"hot"with["hot"]gives 2.beginWordin the list. It already has a distance, so it is not queued again.- Counting changes. Starting
minDist(beginWord)at 0 returns one less than the required length. - Distance on dequeue. Recording the distance when a word is removed from the queue lets the same word be queued many times first.
- Adding characters. If the left side of the expression were a
Char, as inword(i) + c, Scala would add the numeric codes and produce anInt.
Testing
"hit","cog",["hot","dot","dog","lot","log","cog"]gives5."hit","cog",["hot","dot","dog","lot","log"]gives0."hit","hot",["hot"]gives2."hit","cog",["hot","dog","cog"]gives0, becausehotanddogdiffer in two positions.
