Start learning free

LESSON 367 OF 414 · GRAPHS

Word Ladder in Rust: BFS Trying All 26 Letters per Position

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

    RUST · GRAPHS

    Word Ladder in Rust, explained

    Problem overview

    The function ladder_length takes a begin_word, an end_word and a vector words. It returns the number of words in the shortest chain from begin_word to end_word, counting both ends, where each step changes exactly one letter and every word after the first must be in words. If there is no such chain, it returns 0.

    For begin_word = "hit", end_word = "cog" and words = ["hot", "dot", "dog", "lot", "log", "cog"], the chain hit -> hot -> dot -> dog -> cog has five words, so the answer is 5. Without "cog" in the list, the answer is 0. All words have the same length and contain only lowercase letters, and begin_word itself doesn't need to be in the list.

    Approach: generate the neighbors instead of searching for them

    Words are nodes of a graph, and two words are connected when they differ in one position. The shortest chain is the shortest path from begin_word, so breadth-first search (BFS) fits: it reaches every word at distance 1 before any at distance 2, so the first time a word is reached, the route is a shortest one.

    The simplest BFS finds the neighbors of a word by comparing it with every word in the list, which costs a pass over the whole list per word. This lesson flips the work around. A word with L letters has only L * 25 possible one-letter variations: for each position, 25 other letters. Instead of scanning the list, build each variation and ask a hash set whether it is a real word. When the list is long, 25 lookups per position are far cheaper than comparing against every entry.

    The same two pieces of state as in any BFS remain. The queue holds words waiting to be expanded, and the map min_dist stores the length of the shortest chain found to each word, with begin_word at 1. A word with an entry in min_dist has been reached, so the map also works as the visited set.

    Step by step

    1. Put the words in a HashSet. words.into_iter().collect() moves the strings into a set, so "is this candidate a real word?" takes constant time on average. The type annotation tells collect which collection to build.
    2. Create the queue and min_dist. A VecDeque<String> gives constant-time push_back and pop_front, and the map records distances.
    3. Seed the search. Push a clone of begin_word onto the queue, then move begin_word into min_dist with distance 1.
    4. Pop words until the queue is empty with while let Some(word) = queue.pop_front().
    5. Break at the target. Words come out in order of distance, so when end_word is popped, its stored distance is the shortest.
    6. Collect the word's characters into a Vec<char>. A Rust String can't be indexed by position, so the characters go into a vector that can.
    7. Visit every position i and every letter c from b'a' to b'z'. Skip the letter that is already at that position, since it would rebuild word.
    8. Build the candidate. Clone chars, overwrite position i with c and collect the characters into a String. The clone leaves chars untouched for the next letter.
    9. Filter the candidate. continue if it isn't in word_set, and again if min_dist already has it.
    10. Record and enqueue. The distance is min_dist[&word] + 1. The map gets a clone of the candidate, and the queue gets the candidate itself.
    11. Return the result from the map, or 0 if end_word was never reached.

    The solution in Rust

    use std::collections::{HashMap, HashSet, VecDeque};
    
    impl Solution {
        fn ladder_length(begin_word: String, end_word: String, words: Vec<String>) -> i32 {
            let word_set: HashSet<String> = words.into_iter().collect();
            let mut queue: VecDeque<String> = VecDeque::new();
            let mut min_dist: HashMap<String, i32> = HashMap::new();
            queue.push_back(begin_word.clone());
            min_dist.insert(begin_word, 1);
            while let Some(word) = queue.pop_front() {
                if end_word == word {
                    break;
                }
                let chars: Vec<char> = word.chars().collect();
                for i in 0..chars.len() {
                    for c in b'a'..=b'z' {
                        let c = c as char;
                        if c == chars[i] {
                            continue;
                        }
                        let mut next_chars = chars.clone();
                        next_chars[i] = c;
                        let next_word: String = next_chars.into_iter().collect();
                        if !word_set.contains(&next_word) {
                            continue;
                        }
                        if min_dist.contains_key(&next_word) {
                            continue;
                        }
                        let dist = min_dist[&word] + 1;
                        min_dist.insert(next_word.clone(), dist);
                        queue.push_back(next_word);
                    }
                }
            }
            *min_dist.get(&end_word).unwrap_or(&0)
        }
    }
    

    Rust details worth a closer look:

    • into_iter() consumes the vector. The function owns words and never needs it again, so moving the strings into the set avoids copying them.
    • Bytes versus characters. b'a'..=b'z' is a range of u8 values, and ..= includes the end, so all 26 letters are visited. The line let c = c as char; shadows the byte with a char, which is the type stored in chars. Casting a u8 to char with as is always valid.
    • Why a Vec<char>. Strings are UTF-8, and a character may take several bytes, so Rust doesn't offer word[i]. Here the words are lowercase ASCII, and collecting the characters is the simplest way to change one of them.
    • Borrowing for lookups. contains and contains_key take references, so the code passes &next_word. That leaves next_word available to be moved into the queue at the end.
    • Clone, then move. The map needs an owned key, so it gets next_word.clone(). The queue then takes next_word itself, which is its last use. Reversing the order would be a compile error, because the value would already be moved.
    • The final expression. min_dist.get(&end_word) returns an Option<&i32>, unwrap_or(&0) supplies a default reference, and * reads the number.

    Complexity

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

    Time: O(N · L² · 26), which is O(N · L²) for a fixed alphabet. Each word is queued at most once. Expanding it tries L positions with 25 letters each, and every candidate costs O(L) to build and hash. Compare that with the version that scans the list: it costs O(N · L) per expanded word, so the letter-by-letter version wins when N is much larger than 26 · L, and loses when the list is tiny and the words are long.

    Space: O(N · L). The set, the map and the queue each hold up to N words of length L.

    Edge cases and common mistakes

    • end_word not in the list. No candidate can equal it, so it never enters the map and the result is 0.
    • begin_word not in the list. The search still starts from it. With begin_word = "hot", end_word = "dot" and words = ["dot"] the answer is 2.
    • Forgetting the visited check. Without the min_dist test, two words that are neighbors of each other would be queued again and again.
    • Checking only the set. Being in word_set says a candidate is a real word, not that it is new. Both tests are needed.
    • Using b'a'..b'z'. Without the =, the letter z is never tried and any chain that needs it is missed.
    • Distance base. The seed distance is 1, because a chain with only begin_word has one word. Starting at 0 makes every answer one too small.
    • Letters outside a to z. The loop only tries lowercase letters. That matches the lesson's guarantee but would miss words with other characters.

    Testing

    • begin_word = "hit", end_word = "cog" and words = ["hot", "dot", "dog", "lot", "log", "cog"] give 5.
    • The same call without "cog" in the list gives 0.
    • begin_word = "hot", end_word = "dot" and words = ["hot", "dot"] give 2.
    • begin_word = "ab", end_word = "cd" and words = ["ad", "cd"] give 3.
    Keep going with RustAbout 340 step-by-step lessons, from your first line of code to coding interviews. Free to start.
    Start learning free