Start learning free

LESSON 345 OF 348 · GRAPHS

Word Ladder in Kotlin: Bidirectional BFS with Level Expansion

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 33
    Ctrl + Enter to check

    KOTLIN · GRAPHS

    Word Ladder in Kotlin, explained

    Problem overview

    The function ladderLength receives a beginWord, an endWord and a list words. It returns the number of words in the shortest chain from beginWord to endWord, where each step changes one letter and every word after the first is in the list. If no chain exists, it returns 0.

    For beginWord = "hit", endWord = "cog" and ["hot","dot","dog","lot","log","cog"], the chain hit -> hot -> dot -> dog -> cog has 5 words, so the answer is 5.

    Approach: search from both ends

    A plain breadth-first search (BFS) from beginWord spreads out like a growing circle. If every word has about b neighbours and the chain has d steps, the circle holds roughly b^d words by the time it reaches the target. Two searches that start at opposite ends and stop when they meet only need to grow to depth about d / 2 each, and 2 · b^(d/2) is far smaller than b^d when the graph branches a lot. That is the idea of bidirectional BFS.

    The neighbour lookup is the one from the previous lesson: each word is grouped under its wildcard patterns ("hot" gives "*ot", "h*t" and "ho*"), and two words are neighbours when they share a pattern.

    Two details make the bidirectional search correct:

    • Expand whole levels. If each side advanced one word at a time, the searches could meet through a longer chain while a shorter one was still waiting in a queue. Expanding a complete level before checking again means a shorter chain would have produced a meeting in an earlier call.
    • Expand the smaller side. Growing the side whose queue is shorter keeps both circles small.

    Step by step

    1. Reject a missing target. If words does not contain endWord, return 0 at once. The end search starts at endWord and looks up its patterns in a map built from the list, so without this check it could find a chain that ends at a word the list doesn't have.
    2. Build the pattern map. getStarWord(word, i) replaces the letter at i with *. buildGraph(words) loops over every word and position and adds the word to the list stored under its pattern, using getOrPut, then returns the map.
    3. Create two queues and two distance maps: beginQueue and endQueue, and beginMinDist and endMinDist. Each map gives the length of the shortest chain from that side's start to every word it has reached, counting both ends, and its keys are that side's visited set.
    4. Seed both sides. Add beginWord to beginQueue and endWord to endQueue with addLast, and store 1 for each in its own map.
    5. Declare var result = 0 to receive the outcome of each expansion.
    6. Loop while both queues have words. When one side runs dry, its circle can't grow and the two searches can never meet.
    7. Write processLevel. It takes the pattern map, the queue and distances of the side being expanded, and the other side's distances, which it only reads. repeat(queue.size) runs its block once for each word currently in the queue. The size is read once, so words added meanwhile wait for the next call.
    8. Dequeue and look at the neighbours. For each word, loop over its positions, compute starWord and loop over the words in that bucket.
    9. Detect the meeting. If the other side's map contains nextWord, return minDist[word]!! + otherMinDist[nextWord]!!. The first term counts the words from this side's start to word. The second counts the words from nextWord to the other end. They are different words, so the sum is the length of the whole chain.
    10. Otherwise grow the side. If minDist has no entry for nextWord, store minDist[word]!! + 1 and addLast it to the queue for the next level.
    11. End the level. After the repeat block, return 0, meaning no meeting happened.
    12. Choose a side in the main loop. If beginQueue.size <= endQueue.size, expand the begin side; otherwise expand the end side with the roles swapped. If result is positive, return it.
    13. Return 0 after the loop: one side ran out before the two met.

    The solution in Kotlin

    class Solution {
        fun getStarWord(word: String, i: Int): String {
            return word.substring(0, i) + "*" + word.substring(i + 1)
        }
    
        fun buildGraph(words: List<String>): MutableMap<String, MutableList<String>> {
            val graph = HashMap<String, MutableList<String>>()
            for (word in words) {
                for (i in word.indices) {
                    val starWord = getStarWord(word, i)
                    graph.getOrPut(starWord) { ArrayList() }.add(word)
                }
            }
            return graph
        }
    
        fun processLevel(graph: Map<String, MutableList<String>>, queue: ArrayDeque<String>, minDist: MutableMap<String, Int>, otherMinDist: Map<String, Int>): Int {
            repeat(queue.size) {
                val word = queue.removeFirst()
                for (i in word.indices) {
                    val starWord = getStarWord(word, i)
                    for (nextWord in graph.getOrDefault(starWord, ArrayList())) {
                        if (otherMinDist.containsKey(nextWord)) {
                            return minDist[word]!! + otherMinDist[nextWord]!!
                        }
                        if (!minDist.containsKey(nextWord)) {
                            minDist[nextWord] = minDist[word]!! + 1
                            queue.addLast(nextWord)
                        }
                    }
                }
            }
            return 0
        }
    
        fun ladderLength(beginWord: String, endWord: String, words: List<String>): Int {
            if (!words.contains(endWord)) {
                return 0
            }
            val graph = buildGraph(words)
            val beginQueue = ArrayDeque<String>()
            val endQueue = ArrayDeque<String>()
            val beginMinDist = HashMap<String, Int>()
            val endMinDist = HashMap<String, Int>()
            beginQueue.addLast(beginWord)
            endQueue.addLast(endWord)
            beginMinDist[beginWord] = 1
            endMinDist[endWord] = 1
            var result = 0
            while (beginQueue.isNotEmpty() && endQueue.isNotEmpty()) {
                if (beginQueue.size <= endQueue.size) {
                    result = processLevel(graph, beginQueue, beginMinDist, endMinDist)
                } else {
                    result = processLevel(graph, endQueue, endMinDist, beginMinDist)
                }
                if (result > 0) {
                    return result
                }
            }
            return 0
        }
    }
    

    The Kotlin details worth a closer look:

    • return inside repeat. repeat is an inline function, so the block is not a separate function: a return in it returns from processLevel itself. That is what lets the meeting check exit with the answer from deep inside three loops.
    • Read-only versus mutable parameters. minDist is a MutableMap because processLevel writes to it. otherMinDist and graph are read-only Map types, which promises the function won't change them. A HashMap can be passed for either.
    • queue.size is read once. repeat(queue.size) evaluates its argument before the first run, so words added by addLast inside the block do not extend the loop.
    • Nullable lookups. Reading a map gives Int?. Both minDist[word]!! and otherMinDist[nextWord]!! are safe: the first word was queued with an entry, and the second was just found with containsKey.
    • var result and assignment in both branches. The if/else assigns the same variable, which is then tested after it.
    • addLast and removeFirst work as the back and front of the queue. getOrPut and getOrDefault behave as in the previous lesson.

    Complexity

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

    Time. Building the pattern map costs O(N · L²). In the worst case, the two searches together still touch every word once, with O(L²) work per word for the patterns, so the worst-case bound is the same as for a single BFS. The gain is in practice: on a graph with many neighbours per word, the two circles meet after exploring far fewer words than one circle would.

    Space: O(N · L²). The pattern map holds N × L patterns of length L. The queues and distance maps hold at most N words each.

    Edge cases and common mistakes

    • endWord not in the list. The first line returns 0. For "hit", "cog" and ["hot","dot","dog","lot","log"] that is the correct answer.
    • beginWord not in the list. This is allowed, but it needs care. The end side can never step onto beginWord, because the pattern map is built from the list only. Only the begin side's first expansion links beginWord to the other words. That is why a tie goes to the begin side, with <=. With <, "hot", "dot" and ["dot"] gives 0, because the end side expands first, finds nothing and runs out. With <= the answer is 2.
    • Expanding one word at a time. A meeting found mid-level may not be the shortest chain. Expanding whole levels avoids that.
    • Adding the two distances wrongly. Each distance already counts its own start word, and the two words at the meeting are distinct, so the sum needs no + 1 or - 1. For the first example it is 3 + 2 = 5.
    • Not stopping when a side is empty. If the loop ran while only one queue had words, it would call processLevel on an empty queue and never end.
    • Confusing 0 with an answer. A real chain has at least two words, so a 0 from processLevel can only mean "no meeting yet".

    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.
    • "hot", "dog", ["hot","dog"] gives 0.
    Keep going with KotlinAbout 340 step-by-step lessons, from your first line of code to coding interviews. Free to start.
    Start learning free