Start learning free

LESSON 343 OF 347 · GRAPHS

Word Ladder in C#: BFS with a Wildcard Pattern Graph

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

    C# · GRAPHS

    Word Ladder in C#, explained

    Problem overview

    LadderLength(beginWord, endWord, words) returns how many words are on the shortest chain from beginWord to endWord, where each step changes one letter and each word stepped onto is in words. The count includes both ends. If no chain exists, it returns 0. All words have the same length and contain lowercase letters only.

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

    Approach: index words by wildcard pattern

    Breadth-first search finds the shortest chain because it reaches words in order of distance. The question is how to find a word's neighbors. Trying 26 letters at every position works, but most candidates are not words at all.

    This lesson avoids that waste with a precomputed index. 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 letter when they share a pattern. For example, hot and dot both produce *ot. So a dictionary from pattern to the list of words with that pattern is a ready-made neighbor lookup.

    The search then asks, for each dequeued word, "which words share each of my patterns?" instead of "which of my 26 * L candidates exist?". The shortest-path logic is the same as in the plain BFS version of this problem; only the neighbor finding changes.

    Step by step

    1. Create graph, a Dictionary<string, List<string>> from pattern to the words that match it.
    2. Loop over the words and over every position i of each word. Each word contributes one pattern per position.
    3. Write GetStarWord(word, i). It returns the part of word before i, then "*", then the part after i. Two Substring calls build it: Substring(0, i) takes the first i characters (its second argument is a length, not an end index), and Substring(i + 1) runs to the end.
    4. Fill the index. Compute starWord for each word and position. If graph has no key for it yet, store a new empty list; then add the word to that list. The target-typed new() works because the compiler knows the dictionary's value type is List<string>.
    5. Set up the BFS after both loops: a Queue<string>, and a Dictionary<string, int> named minDist that maps each reached word to its chain length (counting both ends). Having an entry means "already visited".
    6. Seed it. Enqueue beginWord and set minDist[beginWord] = 1. A chain containing only the start has one word.
    7. Loop while queue.Count > 0. Dequeue a word. If it equals endWord, break: words come out of the queue in order of distance, so the stored distance is the shortest.
    8. For each position of the dequeued word, compute its pattern again with GetStarWord. Every word stored under that pattern differs from it at that position only.
    9. Visit the neighbors. Loop over graph.GetValueOrDefault(starWord, new()). The empty default means a pattern that isn't in the index gives a loop that runs zero times, instead of throwing KeyNotFoundException.
    10. Skip visited words. If minDist has no entry for nextWord, set its distance to minDist[word] + 1 and enqueue it. The word itself matches its own pattern, but it already has an entry, so it is skipped.
    11. Return minDist.GetValueOrDefault(endWord, 0).

    The solution in C#

    class Solution {
        public string GetStarWord(string word, int i) {
            return word.Substring(0, i) + "*" + word.Substring(i + 1);
        }
    
        public int LadderLength(string beginWord, string endWord, List<string> words) {
            Dictionary<string, List<string>> graph = new Dictionary<string, List<string>>();
            foreach (string word in words) {
                for (int i = 0; i < word.Length; ++i) {
                    string starWord = GetStarWord(word, i);
                    if (!graph.ContainsKey(starWord)) {
                        graph[starWord] = new();
                    }
                    graph[starWord].Add(word);
                }
            }
            Queue<string> queue = new Queue<string>();
            Dictionary<string, int> minDist = new Dictionary<string, int>();
            queue.Enqueue(beginWord);
            minDist[beginWord] = 1;
            while (queue.Count > 0) {
                string word = queue.Dequeue();
                if (endWord == word) {
                    break;
                }
                for (int i = 0; i < word.Length; ++i) {
                    string starWord = GetStarWord(word, i);
                    foreach (string nextWord in graph.GetValueOrDefault(starWord, new())) {
                        if (!minDist.ContainsKey(nextWord)) {
                            minDist[nextWord] = minDist[word] + 1;
                            queue.Enqueue(nextWord);
                        }
                    }
                }
            }
            return minDist.GetValueOrDefault(endWord, 0);
        }
    }

    C# details worth knowing

    • The variable starWord is declared in two different loop bodies. That is fine because each loop body is its own scope. The loop variable word in the first foreach is also fine next to the later string word = queue.Dequeue();, since the first one's scope has ended by then.
    • graph[starWord] = new(); is the target-typed new expression, available since C# 9. Writing new List<string>() means the same thing.
    • The second argument of GetValueOrDefault is evaluated every time the call runs, so the loop allocates a small empty list per pattern lookup. That is harmless here.
    • Strings are immutable. GetStarWord builds new strings; it never changes word.

    Complexity

    Let N be the number of words and L their length. Building the index creates N * L patterns, each costing O(L) to build and hash, so that takes O(N * L^2). The index stores N * L patterns of length L, so space is O(N * L^2).

    In the search, each word is dequeued once and looks up L patterns. A bucket holds words that differ only at one position, and a list without duplicates can have at most 26 of those, so scanning a bucket is a constant amount of work. The search therefore costs O(N * L^2) as well, mostly for building the patterns. Compared with trying 26 letters per position, the work per step is smaller in practice, because only real neighbors are looked at; the cost is the extra memory for the index.

    Edge cases and common mistakes

    • Missing pattern. A word that isn't in words, such as beginWord, may produce patterns that were never stored. Use GetValueOrDefault (or TryGetValue), not the indexer.
    • beginWord not in the list. It is not in the index either, but its patterns still find the words it connects to. The start doesn't have to be a list member.
    • endWord not in the list. It never gets a distance, so the final line returns 0.
    • Forgetting that a word shares its own pattern. Without the visited check, the BFS would enqueue each word again from its own bucket.
    • Starting the distance at 0. The answer counts words, so the start is 1.

    Testing

    hit -> cog, words hot dot dog lot log cog   ->  5
    hit -> cog, words hot dot dog lot log       ->  0
    hot -> dot, words dot                       ->  2
    

    The last case has a start word that is not in the list; the answer is 2 because hot and dot share the pattern *ot.

    Keep going with C#About 340 step-by-step lessons, from your first line of code to coding interviews. Free to start.
    Start learning free