Start learning free

LESSON 335 OF 338 · GRAPHS

Word Ladder in Go: BFS with Wildcard Pattern Buckets

Practice O(N * L2) Read the full explanation

Given two words (beginWord and endWord), and a dictionary's word list, find the length of shortest transformation sequence from beginWord to endWord, such that:

  1. Only one letter can be changed at a time.
  2. Each transformed word must exist in the word list.

Note:

  • Return 0 if there is no such transformation sequence.
  • All words have the same length.
  • All words contain only lowercase alphabetic characters.
  • You may assume no duplicates in the word list.
  • You may assume beginWord and endWord are non-empty and are not the same.

Example 1:

Input:
beginWord = "hit",
endWord = "cog",
wordList = ["hot","dot","dog","lot","log","cog"]

Output: 5

Explanation: As one shortest transformation is "hit" -> "hot" -> "dot" -> "dog" -> "cog",
return its length 5.

Example 2:

Input:
beginWord = "hit"
endWord = "cog"
wordList = ["hot","dot","dog","lot","log"]

Output: 0

Explanation: The endWord "cog" is not in wordList, therefore no possible transformation.
STEP 1 OF 19
    Ctrl + Enter to check

    GO · GRAPHS

    Word Ladder in Go, explained

    Problem overview

    The function ladderLength receives beginWord, endWord and a slice words. Each move changes exactly one letter, and every word you land on must be in words. It returns the number of words in the shortest chain from beginWord to endWord, counting both, or 0 if there is no chain.

    For "hit" to "cog" with ["hot","dot","dog","lot","log","cog"] the answer is 5, through hit, hot, dot, dog, cog. If "cog" is not in the list, the answer is 0.

    Approach: group words by wildcard pattern

    Breadth-first search (BFS) finds the shortest path, but it needs a quick way to list a word's neighbours. Comparing against the whole list is slow, and trying all 26 letters at every position builds many candidates that mostly aren't words at all.

    This lesson uses a different observation. Replace one letter of a word with * and you get a pattern: hot gives *ot, h*t and ho*. Two words differ in exactly one position when they share a pattern at that position. So hot, dot and lot all appear under *ot, and that bucket is precisely the set of words one letter away from each of them in the first position.

    The plan has two phases. First, one pass over the list builds a map from each pattern to the words that match it. Then BFS runs as usual, but to find the neighbours of a word it builds that word's patterns and reads the matching buckets. No candidate that isn't in the list is ever generated.

    Step by step

    1. Declare graph as a map[string][]string, from pattern to words, created with make.
    2. Loop over the words, and for each word loop over every position i.
    3. Build the pattern with a helper, getStarWord(word, i). It is written at the end, and Go lets code call a function declared further down the file.
    4. Add the word to its bucket: graph[starWord] = append(graph[starWord], word). No check for a missing key is needed. A missing key reads as a nil slice, and append accepts a nil slice and returns a new one.
    5. Set up the BFS as in any shortest-path search: a queue holding beginWord, and a map minDistance with beginWord at 1. The map also records which words are already reached.
    6. Loop while the queue has words, take the front word and re-slice the queue.
    7. Stop at the target. If the word is endWord, return its distance. Words leave the queue in order of distance, so the first arrival is the shortest.
    8. For each position of the word, build its pattern with the same helper.
    9. Walk the bucket graph[starWord]. Each word in it differs from the current word only at that position. If the pattern isn't in the map, the lookup gives a nil slice and the loop runs zero times.
    10. Visit each unseen neighbour once: if it has no entry in minDistance, give it the distance minDistance[word] + 1 and append it to the queue.
    11. Return 0 after the loop because the queue emptied without reaching the target.
    12. Write getStarWord. It returns the part of word before index i, then "*", then the part after index i.

    The solution in Go

    func ladderLength(beginWord, endWord string, words []string) int {
        graph := make(map[string][]string)
        for _, word := range words {
            for i := 0; i < len(word); i++ {
                starWord := getStarWord(word, i)
                graph[starWord] = append(graph[starWord], word)
            }
        }
        queue := []string{beginWord}
        minDistance := map[string]int{beginWord: 1}
        for len(queue) > 0 {
            word := queue[0]
            queue = queue[1:]
            if word == endWord {
                return minDistance[word]
            }
            for i := 0; i < len(word); i++ {
                starWord := getStarWord(word, i)
                for _, nextWord := range graph[starWord] {
                    if _, exists := minDistance[nextWord]; !exists {
                        minDistance[nextWord] = minDistance[word] + 1
                        queue = append(queue, nextWord)
                    }
                }
            }
        }
        return 0
    }
    
    func getStarWord(word string, i int) string {
        return word[:i] + "*" + word[i+1:]
    }
    

    The Go details worth knowing:

    • Slicing strings. word[:i] is the substring before index i and word[i+1:] is the substring after it. Index i itself is left out of both, which is how the letter disappears. For "hot", getStarWord("hot", 1) is "h*t". Slicing is by byte, which suits lowercase English letters.
    • Building with +. Strings are immutable, so each + creates a new string. That is fine for three short pieces.
    • A nil slice is a usable empty slice. len, range and append all work on it. This is what lets the code skip the "is the key there?" check twice, in the graph-building loop and in the search.
    • The pattern buckets include the word itself. hot is in all three of its own buckets. When the search reads them, it meets hot, but minDistance already has an entry for it (it was just dequeued), so it is skipped.
    • The start word needn't be in the list. "hit" adds nothing to the graph, but the search still builds its patterns *it, h*t and hi* and reads their buckets. Only h*t holds a word, hot.
    • Two scopes for starWord. The name is declared in the first loop and again in the search. Each loop body is its own scope, so there is no clash.

    Complexity

    Let N be the number of words and L their length.

    Time to build the graph: O(N * L²). There are N * L pattern insertions, and each one builds a string of length L and hashes it.

    Time for the search. Every word is dequeued at most once. For each one the code builds L patterns, at O(L) each, and walks the matching buckets. When the buckets are small, this is about O(N * L²) in total, the same as the build. A bucket is walked once for every dequeued word that matches its pattern, so a bucket with k words costs up to k² in the worst case, when most words share a pattern. In ordinary word lists the buckets stay small.

    Space: O(N * L²). Each word is stored in L buckets, and each of the N * L pattern keys is a string of length L. The word strings in the buckets share memory with the list, so the keys dominate.

    Edge cases and common mistakes

    • Target missing from the list. It never appears in any bucket, so it's never reached, and the result is 0.
    • Same pattern, different positions. Patterns from different positions can't collide: *ot and h*t differ in where the star is, so they are different keys.
    • Counting the chain length wrongly. "hit" to "hot" with ["hot"] returns 2, since both words count.
    • Skipping the visited check. Words are in several buckets and neighbours point back to each other, so without the minDistance check the queue would grow without end.
    • Ignoring the assignment of append. Writing append(graph[starWord], word) on its own line doesn't compile, because the result is unused. The result has to be stored back into the map.

    Testing

    • "hit", "cog", ["hot","dot","dog","lot","log","cog"] gives 5.
    • "hit", "cog", ["hot","dot","dog","lot","log"] gives 0.
    • "hit", "hot", ["hot"] gives 2.
    Keep going with GoAbout 340 step-by-step lessons, from your first line of code to coding interviews. Free to start.
    Start learning free