DSA sheet · Graphs · BFS pattern

Word Ladder

This is a hard problem, but the teacher promises it will feel like a medium one by the end, and she's right. The trick is to see that the words are nodes of a hidden graph. Two words are joined by an edge when they differ in exactly one letter. Once you see that, "the shortest chain of words" is just BFS level counting, the same thing we did in BFS Traversal. She solves it with BFS first, then shows a small optimisation: never change the letter you just changed.

Every problem below follows the same order:
① the question in simple words → ② what the constraints tell us → ③ intuition → ④ building the logic from examples → ⑤ approach steps → ⑥ code → ⑦ code line by line → ⑧ dry run → ⑨ complexity & remember

Part 0 · Before starting

What is a graph?

A graph is a set of nodes (dots, also called vertices) joined by edges (lines). An edge says "these two things are directly connected".

In Word Ladder the edges are undirected (if "hot" can become "dot" by one change, "dot" can become "hot" back) and unweighted (every change costs 1 step).

How graphs are usually given, and the adjacency list

Most graph problems hand you the edges in one of three ways:

formlooks likemeaning
edge list[[0,1],[1,2]]each pair is one edge
adjacency matrixm[i][j] == 11 means i and j are joined
adjacency listadj[0] = [1]for each node, the list of its neighbours

We usually turn the input into an adjacency list, because BFS and DFS keep asking "who are the neighbours of this node?".

building an adjacency list from an edge list (general pattern)
n = 3
edges = [[0, 1], [1, 2]]
adj = [[] for _ in range(n)]
for a, b in edges:
    adj[a].append(b)     # undirected: add both ways
    adj[b].append(a)
print(adj)               # [[1], [0, 2], [1]]

A graph that nobody gives you: the implicit graph

Word Ladder gives no edges at all. You only get words. The edges are hidden in a rule: two words are neighbours when they have the same length and differ in exactly one position. A graph like this is called implicit: we don't build the adjacency list up front, we find each word's neighbours on the fly, while BFS runs.

How do we find the neighbours of a word? Change one position at a time to each of the 26 letters and ask "is this new word in the dictionary?". That's the heart of this problem.

Visited: why we need it

Graphs can have loops: hot → dot → hot → dot … If we don't remember which nodes we've already seen, BFS goes round forever and counts the same word many times. So every graph walk keeps a visited record. With numbered nodes it's a list of booleans visited[i]. Here the nodes are strings, so we can't index a list with them. We need a set or a dictionary. The teacher uses a neat trick that we'll see in Part A: delete the word from the dictionary set once it's used.

Connected components: groups of nodes that can reach each other. If the end word lies in a different group from the begin word, BFS never reaches it, and the answer is 0.

BFS level by level, and why it gives the shortest path

BFS (Breadth First Search) uses a queue (collections.deque: add at the back with append, take from the front with popleft). It visits the start, then all nodes 1 step away, then all nodes 2 steps away, and so on. To know which level we're on, we take size = len(queue) at the start of each round. Exactly those size nodes form the current level.

BFS level by level (recall from BFS Traversal)
from collections import deque
adj = [[1, 2], [0, 3], [0], [1]]   # a tiny example graph
start = 0
queue = deque([start])
visited = {start}
level = 0
while queue:
    size = len(queue)              # nodes on this level
    for _ in range(size):
        node = queue.popleft()
        for nb in adj[node]:
            if nb not in visited:
                visited.add(nb)
                queue.append(nb)
    level += 1                     # finished one whole level
print(level)                       # 3 levels: {0}, {1, 2}, {3}

Because BFS finishes every node at distance 1 before touching distance 2, the first time it reaches a node, it has used the fewest possible steps. In an unweighted graph, BFS layers = shortest path. That's exactly what "shortest transformation sequence" asks for.

Doubt: why not DFS?
→ DFS runs down one long chain first. It may reach "cog" through a 7-word chain before it ever tries the 5-word chain, so the first answer DFS finds is not guaranteed to be the shortest. To get the shortest with DFS you'd have to try every chain and keep the minimum, which is far slower. BFS gives the shortest one directly.

DFS (Depth First Search) goes deep along one path using recursion or an explicit stack, then backtracks. We don't need it on this page.

Part A · BFS, trying every letter at every position

LeetCode 127 · Hard

1The question in simple words

You get a beginWord, an endWord and a dictionary wordList. You want to turn the begin word into the end word, step by step:

Return the number of words in the shortest such chain, counting both the begin word and the end word. If there is no way to reach the end word, return 0.

Example 1: begin = "hit", end = "cog", list = ["hot","dot","dog","lot","log","cog"]. One shortest chain is hit → hot → dot → dog → cog: 5 words, so the answer is 5.

Example 2: the same, but "cog" is not in the list. Then the answer is 0, because the last word of the chain must come from the list.

Here's the hidden graph for Example 1. An edge joins two words that differ in one letter:

                (dot)——(dog)
               /  |       |  \
   (hit)——(hot)   |       |   (cog)
               \  |       |  /
                (lot)——(log)

hot–dot and hot–lot differ in the first letter. dot–lot differ in the first letter too. dot–dog and lot–log differ in the last letter. dog–cog and log–cog differ in the first letter. dog–log differ in the first letter.

2What the constraints tell us

3Intuition: how to think about it

Think of the word list as a bag of allowed words. You stand on "hit". You look at one letter at a time, try all 26 letters in its place, and check the bag each time. Every word that's in the bag is one step away. Those words are your first ring. From each of them you do the same to get the second ring, and so on.

The ring number where "cog" first shows up is the length of the shortest chain. Growing rings like this is exactly BFS, where every level = one more letter changed.

4Building the logic from her example

Step 1: from "hit", try every letter at every position

So the only neighbour of "hit" is hot.

Doubt 1: we found "hot". Why not stop trying letters at that position?
→ Because there could be more matches at the same position. If "hut" were also in the list, it would be another neighbour, and maybe the shortest chain goes through "hut". We don't know until we've tried all 26 letters, so we always go all the way to z.

Step 2: from "hot", do the same

So the next level is dot, lot.

How do we mark "already used" for strings?

We can't make a visited list of booleans, because a list is indexed by numbers and our nodes are words. We could keep a separate dictionary or set of seen words. The teacher's simpler idea:

The set is also the visited markerPut the word list into a set (fast "is it there?" checks). When a word is found and added to the queue, remove it from the set. Next time anyone makes that word again, it's no longer in the set, so it's treated as "not allowed", which is exactly what "already visited" should mean.
Doubt 2: "hit" was never in the list. Do we need to mark it?
→ In her example no: "hit" isn't in the set, so making it again is simply a miss. But the begin word may also appear in the list. Then the first time we try "the same letter back" at a position, we rebuild "hit", find it in the set, and push it again. That doesn't change the answer (BFS already found the shortest), but it wastes work. So in my code I also remove the begin word from the set at the start: word_set.discard(beginWord). discard doesn't complain if the word isn't there.

Step 3 onwards

Doubt 3: in her walkthrough she says "log" is reached from "dog". In the real run, who finds "log" first?
→ The queue holds dot, lot in that order. "dot" is popped first and finds "dog". Then "lot" is popped and finds "log". So in the actual code "log" comes from "lot", and later "dog" only finds "cog". It doesn't matter for the answer: dog and log are both on the same level (level 4), and cog is on level 5 either way.

Counting: what is a "level" here?

Each level is one more changed letter: hit (1) → hot (2) → dot (3) → dog (4) → cog (5). The answer is the level number where we pop the end word. Since we count words, not changes, the begin word itself is already word number 1.

Her bug on screen: level must start at 1She first set level = 0, and the answer came out one too small (4 instead of 5). The begin word also counts as a word in the chain, so the count must start at 1. She fixed it on screen.

Rule: if the end word isn't in the list → return 0 straight away

We only ever move to words that are in the set. If "cog" isn't in the set, no step can ever produce it as an allowed word. BFS would run through everything and find nothing. So we check this first and return 0 immediately (Example 2).

Detail: put the original letter back

We change the word inside a list of characters, because Python strings can't be edited in place. While trying position 0 of "hit", the list goes hit → ait → … → zit. When the inner loop ends, the list still says "zit"!

Now we want to try position 1 of the real word "hit", not of "zit". So before changing position 0 we save the letter (original = 'h'), and after trying all 26 letters we put it back: chars[i] = original.

Doubt 4: what goes wrong if we forget to put the letter back?
→ Position 1 would then be tried on "zit" (zat, zbt, …, zot) instead of "hit". We'd miss "hot" completely and might find words that are really 2 changes away from "hit", which would break the level count.
Doubt 5: is there a more "Python" way?
→ Yes: nxt = word[:i] + c + word[i+1:] builds a fresh string every time, so there's nothing to restore. Both are fine. I kept the list-and-restore version because it's the teacher's logic and it shows why the restore line exists.

Where do we check for the end word?

Right after popping a word: if word == endWord: return level. The word was put in the queue at this level, so level is its chain length.

(You could also check at the moment you push it and return level + 1. That stops one level earlier. Both give the same answer.)

5Approach steps

  1. Put wordList into a set. If endWord isn't in it, return 0.
  2. Remove the begin word from the set (if it's there), put it in a queue, and set level = 1.
  3. While the queue isn't empty: take size = len(queue) and pop exactly that many words.
  4. For each popped word: if it's the end word, return level.
  5. Otherwise, for every position i: save the letter, try all 26 letters, and whenever the new word is in the set, push it and remove it from the set. Then put the letter back.
  6. After the whole level is done, level += 1.
  7. If the queue empties without meeting the end word, return 0.

6Code (Python)

Word Ladder with BFS
from collections import deque
from string import ascii_lowercase

class Solution:
    def ladderLength(self, beginWord, endWord, wordList):
        word_set = set(wordList)              # allowed words, also our "not visited yet"
        if endWord not in word_set:           # can never land on the end word
            return 0
        word_set.discard(beginWord)           # never come back to the start

        queue = deque([beginWord])
        level = 1                             # beginWord is word number 1

        while queue:
            size = len(queue)                 # words on this level
            for _ in range(size):
                word = queue.popleft()
                if word == endWord:
                    return level

                chars = list(word)
                for i in range(len(chars)):
                    original = chars[i]       # remember the real letter
                    for c in ascii_lowercase: # try a..z at position i
                        chars[i] = c
                        nxt = "".join(chars)
                        if nxt in word_set:
                            queue.append(nxt)
                            word_set.remove(nxt)   # mark visited
                    chars[i] = original       # put it back before the next position
            level += 1

        return 0

7Code line by line

linewhat it means
word_set = set(wordList)A set answers "is this word allowed?" in about O(L) time (hashing the word). A list would need to scan all 5000 words.
if endWord not in word_set: return 0We can only step onto words in the set, so a missing end word is unreachable.
word_set.discard(beginWord)My small addition (Doubt 2): stops the begin word from being pushed again if it's also in the list.
queue = deque([beginWord]) level = 1BFS starts from the begin word, which already counts as one word of the chain.
size = len(queue)Freeze how many words belong to the current level, so that words pushed during this round wait for the next round.
if word == endWord: return levelBFS reaches each word at its smallest level first, so this is the shortest chain.
chars = list(word)A list of letters that we can change in place.
original = chars[i]Save the letter before we overwrite it 26 times.
for c in ascii_lowercase: chars[i] = c nxt = "".join(chars)Build every word that differs at position i (it also rebuilds the word itself when c equals the original letter, but that word is no longer in the set).
if nxt in word_set: queue.append(nxt) word_set.remove(nxt)Allowed and not used yet → it's a neighbour on the next level. Removing it is our "visited" mark.
chars[i] = originalUndo the change ("zit" → "hit") before moving to the next position.
level += 1One whole level is done, so the next words are one change further away.
return 0The queue ran out: the end word is in a different connected group.

8Dry run

Example 1: begin "hit", end "cog". At the start, set = {hot, dot, dog, lot, log, cog}.

steplevelword poppedneighbours found (in set)queue AFTERset AFTER
01––[hit]hot dot dog lot log cog
11hitpos 1: hot[hot]dot dog lot log cog
level 1 done → level = 2
22hotpos 0: dot, lot[dot, lot]dog log cog
level 2 done → level = 3
33dotpos 0: hot, lot already gone · pos 2: dog[lot, dog]log cog
43lotpos 2: log[dog, log]cog
level 3 done → level = 4
54dogpos 0: cog (log already gone)[log, cog](empty)
64lognothing left in the set[cog](empty)
level 4 done → level = 5
75cogit's the end word → return 5[](empty)
level 1hit
level 2hot
level 3dotlot
level 4doglog
level 5cogend word popped → answer 5
after level 2 (* = visited)
        (dot*)——(dog)
       /                \
(hit*)—(hot*)          (cog)
       \                /
        (lot*)——(log)
after level 4
        (dot*)——(dog*)
       /                \
(hit*)—(hot*)          (cog*)
       \                /
        (lot*)——(log*)

Example 2 (no "cog" in the list): the very first check sees that "cog" isn't in the set → return 0, with no BFS at all.

9Complexity & remember

Remember Word LadderWords = nodes, one-letter difference = edge. BFS from the begin word, level starts at 1. For each popped word try every position × a–z, push the hits and remove them from the set (that's visited). Put the letter back. End word missing from the list → 0.

Part B · Small optimisation: skip the index you just changed

1The question

The same problem as Part A. We only want to cut some wasted work.

2What the constraints tell us

The same as Part A. L ≤ 10, so skipping one position out of L saves at most about 10% of the tries. The teacher says this up front: the gain is small, and the big-O stays the same.

3Intuition: why re-trying that position is wasted work

We reached "hot" from "hit" by changing position 1 (i → o). While we were processing "hit", we already tried every letter at position 1: hat, hbt, …, hot, …, hzt. Any of those that were in the list got pushed and removed then.

Now, at "hot", trying position 1 again makes hat, hbt, …, hzt, the exact same 26 words. They've all been checked already. So when we pop "hot", we can skip position 1 and only try positions 0 and 2.

4Building the logic

Doubt 1: is skipping really safe? Could a new word hide at that position?
→ It's safe. Say the parent P became the word W by changing position i. W and P agree everywhere except position i. Any word we'd make by changing position i of W also agrees with P everywhere except position i. So it's a word we already made when we processed P (or it's P itself). At that moment it was either pushed and removed, or it wasn't in the list at all. Either way it isn't in the set now, so trying it again can't find anything new.
Doubt 2: why didn't she make this the main solution?
→ It only removes one position out of L for each word. Time goes from about N·L·26·L to N·(L−1)·26·L, which is still O(N·L²·26). On LeetCode the run time and memory came out about the same. It's a nice "I noticed the waste" point to mention in an interview, but not a different complexity.

5Approach steps (only what changes)

  1. The queue starts with (beginWord, -1).
  2. Pop (word, skip). If word is the end word, return level.
  3. For every position i, except i == skip: try a–z, and push (nxt, i) for each hit.
  4. Everything else (set as visited, restoring the letter, levels starting at 1) stays the same.

6Code (Python)

Word Ladder with BFS, skipping the changed index
from collections import deque
from string import ascii_lowercase

class Solution:
    def ladderLength(self, beginWord, endWord, wordList):
        word_set = set(wordList)
        if endWord not in word_set:
            return 0
        word_set.discard(beginWord)

        queue = deque([(beginWord, -1)])      # (word, index we must not change)
        level = 1

        while queue:
            for _ in range(len(queue)):
                word, skip = queue.popleft()
                if word == endWord:
                    return level

                chars = list(word)
                for i in range(len(chars)):
                    if i == skip:             # already fully tried at the parent
                        continue
                    original = chars[i]
                    for c in ascii_lowercase:
                        chars[i] = c
                        nxt = "".join(chars)
                        if nxt in word_set:
                            queue.append((nxt, i))   # remember which index made it
                            word_set.remove(nxt)
                    chars[i] = original
            level += 1

        return 0

7Code line by line (only the new lines)

linewhat it means
queue = deque([(beginWord, -1)])The start word has no "changed index", so −1 means "skip nothing".
for _ in range(len(queue)):The same as taking size first. range reads the length once, at the start of the loop.
word, skip = queue.popleft()Unpack the pair.
if i == skip: continueThat position was already fully tried at the parent word, so jump to the next position.
queue.append((nxt, i))The child was made by changing position i, so the child should skip i.

8Dry run (Example 1, what gets skipped)

popped (word, skip)positions triedpushedqueue AFTER
(hit, −1)0, 1, 2(hot, 1)[(hot,1)]
(hot, 1)0, 2 (skip 1)(dot, 0), (lot, 0)[(dot,0), (lot,0)]
(dot, 0)1, 2 (skip 0, so hot/lot aren't even built)(dog, 2)[(lot,0), (dog,2)]
(lot, 0)1, 2(log, 2)[(dog,2), (log,2)]
(dog, 2)0, 1(cog, 0)[(log,2), (cog,0)]
(log, 2)0, 1–[(cog,0)]
(cog, 0)end word–return 5

9Complexity & remember

Remember the optimisationPush (word, index changed). When you pop it, don't touch that index again, because the parent already tried all 26 letters there. The begin word uses −1. It's a nice idea, but not a big win.

Part C · Revision page

Part A: plain BFSPart B: skip changed index
queue holdswords(word, skip index)
positions tried per wordall LL − 1 (all L for the begin word)
visitedremove the word from the set when it's pushed
answerthe level at which the end word is popped (level starts at 1)
timeO(N·L·26·L)O(N·(L−1)·26·L), the same big-O
spaceO(N·L)
graph ideain this problem
nodea word
edgethe two words differ in exactly one position (undirected, weight 1)
finding neighbourseach position × 26 letters, then check the set (an implicit graph, no adjacency list)
shortest pathBFS levels in an unweighted graph
unreachablethe end word isn't in the list, or it's in another connected group → 0
If you remember only 5 lines 1. Words are nodes, and one changed letter is an edge. Shortest chain = BFS levels.
2. Put the list in a set. If the end word isn't in it, return 0.
3. Queue the begin word with level = 1. Process the queue level by level.
4. For each word, try every position × a–z. A hit in the set gets pushed and removed (visited). Put the letter back.
5. Return the level when the end word is popped. Optional: skip the position that created the word.
Mistakes to avoid ✗ starting level at 0 (the answer counts words, including the begin word)
✗ forgetting to put the original letter back (later positions get tried on "zit")
✗ not removing found words from the set (words get revisited, loops, wrong counts)
✗ stopping the a–z loop at the first match (a position can have several neighbours)
✗ forgetting the "end word not in list → 0" check
✗ using DFS (the first chain it finds may not be the shortest)
test it yourself (paste under either solution above)
s = Solution()
print(s.ladderLength("hit", "cog", ["hot", "dot", "dog", "lot", "log", "cog"]))  # 5
print(s.ladderLength("hit", "cog", ["hot", "dot", "dog", "lot", "log"]))         # 0
print(s.ladderLength("a", "c", ["a", "b", "c"]))                                 # 2
print(s.ladderLength("hot", "dog", ["hot", "dog"]))                              # 0
print(s.ladderLength("abc", "xyz", ["xbc", "xyc", "xyz"]))                       # 4

Based on this video: Word Ladder | Graph · BFS