Start learning free

LESSON 370 OF 415 · GRAPHS

Word Ladder in Swift: Bidirectional BFS with inout Queues

Practice O(N * L2) Read the full explanation

Given two words (beginWord and endWord), and a list of words (words), 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",
words = ["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"
words = ["hot","dot","dog","lot","log"]

Output: 0

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

    SWIFT · GRAPHS

    Word Ladder in Swift, explained

    Problem overview

    The method ladderLength receives beginWord, endWord and a list words. Each move changes one letter and must land on a word from the list. It returns the number of words in the shortest sequence from beginWord to endWord, counting both ends, or 0 when there is none. All words have the same length and are lowercase.

    For "hit", "cog" and ["hot","dot","dog","lot","log","cog"] the shortest sequence is hit, hot, dot, dog, cog, so the answer is 5. Without "cog" in the list the answer is 0.

    Approach: search from both ends and meet in the middle

    A normal breadth-first search (BFS) from beginWord explores outward in all directions until it hits endWord. The number of words at each distance usually grows quickly, so the last levels are the expensive ones. Running a second BFS from endWord at the same time, and stopping when the two searches reach a common word, means each side only has to go about half the distance. Two small searches are cheaper than one large one.

    The neighbors come from the pattern graph of the previous lesson: words that become the same string after one letter is replaced with * are neighbors. The graph is undirected, so the same graph serves both searches.

    Each side keeps its own queue and its own minDist, which maps a word to the length of the shortest sequence from that side's start to the word, with both ends counted. A word has an entry exactly when that side has visited it. When one side steps onto a word the other side has already visited, the searches have met, and the two distances add up to the answer.

    The searches expand one whole level at a time, and the side with fewer words in its queue goes first, since it is cheaper to grow.

    Step by step

    1. Return 0 early if words doesn't contain endWord: nothing can end there, and the second search starts from it.
    2. Write buildGraph, which takes the words and returns a [String: [String]] from pattern to matching words. For each word and each position it asks getStarWord for the pattern and appends the word to that pattern's list.
    3. Write getStarWord. It copies the word into a character array, sets position i to "*" and builds a string back from the array.
    4. Set up both sides. In ladderLength, build the graph, then create beginQueue and endQueue with their starting words and beginMinDist and endMinDist with the start words at distance 1.
    5. Declare result as a variable initialized to 0 to hold what each level expansion returns.
    6. Loop while neither queue is empty. If one side runs out of words, the searches can't meet.
    7. Write processLevel, which expands one level of one side. It takes the graph, that side's queue and distances, and the other side's distances. The queue and distances are inout, because the function must change the caller's copies.
    8. Take exactly the current level. Loop queue.count times, removing the front word each time. The range is computed once, so words appended during this call wait for the next call.
    9. Check each neighbor. For each position, look at the words in the pattern's bucket. If the other side has a distance for nextWord, the searches have met: return this side's distance to word plus that distance.
    10. Otherwise extend this side. If nextWord is new to this side, give it minDist[word]! + 1 and append it to the queue.
    11. Return 0 if the level ended without a meeting. A real answer is at least 2, so 0 can't be confused with one.
    12. Choose a side. In the loop, expand the begin side when beginQueue.count <= endQueue.count, and the end side otherwise. If result is positive, return it.
    13. Return 0 after the loop.

    The solution in Swift

    class Solution {
        func getStarWord(_ word: String, _ i: Int) -> String {
            var chars = Array(word)
            chars[i] = "*"
            return String(chars)
        }
    
        func buildGraph(_ words: [String]) -> [String: [String]] {
            var graph = [String: [String]]()
            for word in words {
                let n = word.count
                for i in 0..<n {
                    let starWord = getStarWord(word, i)
                    graph[starWord, default: []].append(word)
                }
            }
            return graph
        }
    
        func processLevel(_ graph: [String: [String]], _ queue: inout [String], _ minDist: inout [String: Int], _ otherMinDist: [String: Int]) -> Int {
            for _ in 0..<queue.count {
                let word = queue.removeFirst()
                let n = word.count
                for i in 0..<n {
                    let starWord = getStarWord(word, i)
                    for nextWord in graph[starWord, default: []] {
                        if let other = otherMinDist[nextWord] {
                            return minDist[word]! + other
                        }
                        if minDist[nextWord] == nil {
                            minDist[nextWord] = minDist[word]! + 1
                            queue.append(nextWord)
                        }
                    }
                }
            }
            return 0
        }
    
        func ladderLength(_ beginWord: String, _ endWord: String, _ words: [String]) -> Int {
            if !words.contains(endWord) {
                return 0
            }
            let graph = buildGraph(words)
            var beginQueue = [String]()
            var endQueue = [String]()
            var beginMinDist = [String: Int]()
            var endMinDist = [String: Int]()
            beginQueue.append(beginWord)
            endQueue.append(endWord)
            beginMinDist[beginWord] = 1
            endMinDist[endWord] = 1
            var result = 0
            while !beginQueue.isEmpty && !endQueue.isEmpty {
                if beginQueue.count <= endQueue.count {
                    result = processLevel(graph, &beginQueue, &beginMinDist, endMinDist)
                } else {
                    result = processLevel(graph, &endQueue, &endMinDist, beginMinDist)
                }
                if result > 0 {
                    return result
                }
            }
            return 0
        }
    }
    

    The Swift details worth knowing:

    • inout parameters. Arrays and dictionaries are value types, so a plain parameter is a read-only copy. Marking queue and minDist as inout lets processLevel change the caller's variables, and the call site must write &beginQueue to show it. graph and otherMinDist are only read, so they are passed normally, and Swift avoids copying them unless they are changed.
    • if let. if let other = otherMinDist[nextWord] runs its body only when the lookup is not nil, with the unwrapped distance bound to other.
    • Distance arithmetic. Each distance counts its own start word. word and nextWord are different words, so minDist[word]! + other counts every word of the whole sequence exactly once.
    • for _ in 0..<queue.count. The underscore discards the loop value. The range is built once from the queue's size at that moment, even though the loop body removes and appends elements.
    • Arrays as queues. removeFirst() shifts the rest of the array, so a dequeue costs time proportional to the queue length.

    Complexity

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

    Time: O(N · L²) in the worst case. Building the graph takes O(N · L²), because each of the N · L patterns costs O(L) to create. Each side visits every word at most once, and a visit builds L patterns and scans buckets of at most 26 words. So the worst case matches the one-sided search. The gain is practical: on graphs where the number of words per level grows quickly, each side stops near the middle and visits far fewer words.

    Space: O(N · L²). The graph stores up to N · L pattern strings of length L. The queues and distance tables add O(N · L).

    Edge cases and common mistakes

    • Start word not in the list. This is the reason for the <=. With "hot", "dot" and ["dot"], both queues hold one word at the start. Using < would send the end side first. It would find nothing, since "hot" is not in the graph, would run out of words, and the method would return 0. With <= the begin side goes first and the answer is 2.
    • Target missing. The early check returns 0 before any graph is built.
    • Expanding one word at a time instead of whole levels. The searches could meet through a longer sequence while a shorter one is still waiting in a queue.
    • Forgetting inout or &. A parameter without inout is a constant, so changing queue or minDist inside the function is a compile error. Passing an inout argument without & is an error too.
    • Adding 1 twice. The returned total is two distances added together. Adding another 1 would double-count the meeting point.

    Testing

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