LeetCode: Trees I BFS

BFS Intro
What is BFS
Trees are hierarchical data structures representing relationships between entities, often in a parent-child format.
Breadth First Search is a way of traversing those trees.
Breadth First Search Diagram
4 <- level 0
/ \
2 5 <- level 1
/ \
1 3 <- level 2
BFS visit order: 4 → 2 → 5 → 1 → 3
(visit all nodes on one level before moving to the next)102. Binary Tree Level Order Traversal ::2:: - Medium
Topics: Tree Traversal, Tree, Breadth First Search, Binary Tree
Intro
Given the root of a binary tree, return the level order traversal of its nodes' values. (i.e., from left to right, level by level).
| Example Input | Output |
|---|---|
| root = [3,9,20,null,null,15,7] | [[3],[9,20],[15,7]] |
| root = [1] | [[1]] |
| root = [] | [] |
Constraints:
The number of nodes in the root tree is in the range [1, 2000].
-1000 ≤ Node.val ≤ 1000
Abstraction
Traverse a tree and return list of nodes grouped by level.
Pseudocode
Sol 1: BFS Iterative
1. if not root:
a. return []
2. (groups = [])
3. (queue = deque([root]))
4. While queue:
a. depthLevelSize = len(queue)
b. level = []
c. for _ in range(depthLevelSize):
node = queue.popleft()
level.append(node.val)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
d. groups.append(level)
5. Return groups
Sol 2: DFS Pre Order Recursive
1. (groups = [])
2. dfs(node, depth):
a. if not node:
return
b. if len(groups) == depth:
groups.append([])
c. groups[depth].append(node.val)
d. dfs(node.left, depth + 1)
e. dfs(node.right, depth + 1)
3. dfs(root, 0)
4. Return groups
Solution 1: [BFS] BFS Iterative - Tree/DFS Pre order Traversal
def levelOrder(self, root: Optional[TreeNode]) -> List[List[int]]:
# Note:
# BFS level order: process nodes level by level,
# (left -> right) within each level
# Empty check:
# Tree is empty, return empty list
if not root:
return []
# List of groups by level
groups = []
# Iterative queue for BFS:
# holds nodes to process, starting with root as first level
# sc: O(n)
queue = deque([root]) # start with root
#
while queue:
# Number of nodes remaining in deque,
# which represent the nodes at the current depth level
depthLevelSize = len(queue)
# List of nodes at current level
level = []
# For the number of nodes remaining in deque and this level,
# pop and process each node in this level
for _ in range(depthLevelSize):
# FIFO: pop leftmost node from queue and process
node = queue.popleft()
level.append(node.val)
# after processing, enqueue children for next level,
# to represent the next level of the tree for BFS
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
# Add nodes at current level to
groups.append(level)
# overall: tc O(n)
# overall: sc O(n)
return groups| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
Solution 2: [DFS] DFS Pre Order Recursive - Tree/DFS Pre order Traversal
def levelOrder(self, root: Optional[TreeNode]) -> List[List[int]]:
# Note:
# DFS pre order: root -> left -> right
# 1. Create list of groups by depth level
# 2. Process root and track current depth level during traversal
# and add node to corresponding depth group
# Depth level groups
# sc: O(n)
groups = []
def dfs(node, depth):
# Empty check:
# Reached leaf, return
if not node:
return
# Check:
# if next depth level has been reached, add a new group
if len(groups) == depth:
groups.append([])
# For each node we encounter, add its value for pre order
# to the corresponding depth group
groups[depth].append(node.val)
# Track current depth level and recurse to children
dfs(node.left, depth + 1)
dfs(node.right, depth + 1)
dfs(root, 0)
# overall: tc O(n)
# overall: sc O(n)
return groups| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
199. Binary Tree Right Side View ::2:: - Medium
Topics: Tree Traversal, Tree, Breadth First Search, Binary Tree
Intro
Given the root of a binary tree, imagine yourself standing on the right side of it, return the values of the nodes you can see ordered from top to bottom.
| Example Input | Output |
|---|---|
| root = [1,2,3,null,5,null,4] | [1,3,4] |
| root = [1,2,3,4,null,null,null,5] | [1,3,4,5] |
| root = [1,null,3] | [1,3] |
| root = [] | [] |
Constraints:
The number of nodes in the root tree is in the range [1, 100].
-100 ≤ Node.val ≤ 100
Abstraction
Given a tree, return all the nodes on the right most side per level.
Pseudocode
Sol 1: BFS Pre Order Iterative Grab Last Element Per Level
1. if not root:
a. return []
2. (res = [])
3. (queue = deque([root]))
4. While queue:
a. depthLevelSize = len(queue)
b. for i in range(depthLevelSize):
node = queue.popleft()
if i == depthLevelSize - 1:
res.append(node.val)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
5. Return res
Sol 2: DFS Modified Pre Order Root => Right => Left Add On New Depth Trigger
1. res = []
2. dfs(node, depth):
a. if not node:
return
b. if depth == len(res):
res.append(node.val)
c. dfs(node.right, depth + 1)
d. dfs(node.left, depth + 1)
3. dfs(root, 0)
4. Return res
Solution 1: [BFS] BFS Pre Order Iterative Grab Last Element Per Level - Tree/DFS Pre order Traversal
def rightSideView(self, root: Optional[TreeNode]) -> List[int]:
# Note:
# BFS pre order: process level : root -> left -> right
# 1. For each level
# 2. Process root -> :
# grab length of level, if root is last element in group, add to res
# 3. Process -> left -> right :
# Result: right most element of each level added
# Empty check:
# No rightmost to return, return empty list
if not root:
return []
# List of the right most element for each level
res = []
# Iterative BFS queue
queue = deque([root])
while queue:
# Number of nodes remaining in deque,
# which represent the nodes at the current depth level
depthLevelSize = len(queue)
# For the number of nodes remaining in deque and this level,
# pop and process each node in this level
for i in range(depthLevelSize):
# Grab the leftmost node from queue and process
node = queue.popleft()
# Check:
# Validate if this node is the right most at this level
if i == depthLevelSize - 1:
res.append(node.val)
# Queue children for processing later
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
# overall: tc O(n)
# overall: sc O(n)
return res| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
Solution 2: [DFS] DFS Modified Pre Order Root => Right => Left Add On New Depth Trigger - Tree/DFS Pre order Traversal
def rightSideView(self, root: Optional[TreeNode]) -> List[int]:
# Note:
# DFS Modified Pre Order: (root -> right -> left) instead of (root -> left -> right)
# Modified pre order goes root -> right, which guarantees we explore
# the farthest right possible, before exploring left,
# and with that assumption, every time we reach a new depth,
# we are guaranteed to be at the right most element for that depth level
# Tracking right most elements for each level
# sc: O(n)
res = []
def dfs(node, depth):
# Empty check:
# Reached leaf, return
if not node:
return
# Check:
# If we have reached a new depth, using our assumption of
# (root -> right -> left), we can guarantee that we are at the right most node
# for this level, and can add it to our right most list
if depth == len(result):
res.append(node.val)
# Modified Pre Order:
# Explore right subtree first, then left subtree
dfs(node.right, depth + 1)
dfs(node.left, depth + 1)
# Init depth tracker at 0
dfs(root, 0)
# overall: tc O(n)
# overall: sc O(n) for skewed trees / O(log(n)) for balanced trees
return res| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
103. Binary Tree Zigzag Level Order Traversal ::1:: - Medium
Topics: Tree Traversal, Tree, Breadth First Search, Binary Tree
Intro
Given the root of a binary tree, return the zigzag level order traversal of its nodes' values. (i.e., from left to right, then right to left for the next level and alternate between).
| Example Input | Output |
|---|---|
| root = [3,9,20,null,null,15,7] | [[3],[20,9],[15,7]] |
| root = [1] | [[1]] |
| root = [] | [] |
Constraints:
The number of nodes in the tree is in the range [0, 2000].
-100 ≤ Node.val ≤ 100
Abstraction
BFS Layer by Layer Iteration, except every layer you switch the direction from left to right to then right to left to then back to left to right etc.
Pseudocode
Sol 1: BFS And BFS Pruning Optimization
1. if not root:
return []
2. (queue = deque([root]))
3. (reverseFlag = True)
4. res = []
5. While queue:
a. depthLevelSize = len(queue)
b. group = []
c. for _ in range(depthLevelSize):
node = queue.popleft()
group.append(node.val)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
d. res.append(group if reverseFlag else group[::-1])
e. reverseFlag = not reverseFlag
6. Return res
Solution 1: [BFS] BFS With Pruning Optimization - Tree/DFS Post Order Recursive Two Sided Bottom Up
def zigzagLevelOrder(self, root: Optional[TreeNode]) -> List[List[int]]:
# Note:
# BFS level order + zigzag
# 1. Standard BFS level traversal, always append left -> right (natural order)
# 2. To create a zig zag effect,
# reverse the completed level list when direction is right -> left
# Empty check:
# tree is empty
if not root:
return []
# Iterative BFS Queue
queue = deque([root])
# Reverse flag used to determine if we need to flip this iteration
reverseFlag = True
# zig zagged groups
res = []
while queue:
# Number of nodes remaining at this depth level
depthLevelSize = len(queue)
# Nodes at this depth level
group = []
for _ in range(depthLevelSize):
node = queue.popleft()
# Always append normally
group.append(node.val)
# Normal BFS expansion (unaffected by direction)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
# Reverse the queue to apply zigzag effect if reverse flag is on
res.append(group if reverseFlag else group[::-1])
# Flip reveres flag for next iteration
reverseFlag = not reverseFlag
# overall: tc O(n)
# overall: sc O(n)
return res| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
752. Open the Lock ::1:: - Medium
Topics: BFS Shortest Path Unweighted Graph, Array, Hash Table, String, Breadth First Search, Rule Based Graph, Graph Theory
Intro
You have a lock in front of you with 4 circular wheels. Each wheel has 10 slots: '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'. The wheels can rotate freely and wrap around: for example we can turn '9' to be '0', or '0' to be '9'. Each move consists of turning one wheel one slot. The lock initially starts at '0000', a string representing the state of the 4 wheels. You are given a list of deadends dead ends, meaning if the lock displays any of these codes, the wheels of the lock will stop turning and you will be unable to open it. Given a target representing the value of the wheels that will unlock the lock, return the minimum total number of turns required to open the lock, or -1 if it is impossible.
| Example Input | Output |
|---|---|
| deadends = ["0201","0101","0102","1212","2002"], target = "0202" | 6 |
| deadends = ["8888"], target = "0009" | 1 |
| deadends = ["8887","8889","8878","8898","8788","8988","7888","9888"], target = "8888" | -1 |
Constraints:
1 ≤ deadends.length ≤ 500
deadends[i].length == 4
target.length == 4
target will not be in the list deadends
target and deadends[i] consist of digits only
Abstraction
Find the minimum number of single-step moves from the starting state to the target state, while avoiding a set of forbidden states along the way.
Each lock combination is a node in an implicit graph and each single wheel turn is an edge to a neighboring combination, with invalid combinations being forbidden states.
BFS outward from the start state finds the shortest-path on an unweighted graph, while treating dead ends as boundaries for BFS.
Pseudocode
Sol 1: Optimized BFS
1. (dead = set(deadends))
2. if "0000" in dead:
return -1
3. (queue = deque([("0000", 0)]))
4. (visited = set(["0000"]))
5. While queue:
a. (state, moves) = queue.popleft()
b. if state == target:
Return moves
c. for i in range(4):
digit = int(state[i])
for change in (-1, 1):
new_digit = (digit + change) % 10
newState = state[:i] + str(new_digit) + state[i+1:]
if newState not in visited and newState not in dead:
visited.add(newState)
queue.append((newState, moves + 1))
6. Return -1
Sol 2: Bidirectional BFS
1. (bankSet = set(bank))
2. if endGene not in bankSet:
return -1
3. (gene_chars = ['A', 'C', 'G', 'T'])
4. (frontStart = {startGene: 0})
5. (frontEnd = {endGene: 0})
6. (visited = set([startGene, endGene]))
7. While frontStart and frontEnd:
a. if len(frontStart) > len(frontEnd):
swap frontStart, frontEnd
b. nextFront = {}
c. for each (geneString, mutations) in frontStart:
for i in range(len(geneString)):
for c in gene_chars:
if c == geneString[i]: continue
rotatedGene = geneString[:i] + c + geneString[i+1:]
if rotatedGene in frontEnd:
Return mutations + frontEnd[rotatedGene] + 1
if rotatedGene in bankSet and rotatedGene not in visited:
visited.add(rotatedGene)
nextFront[rotatedGene] = mutations + 1
d. frontStart = nextFront
8. Return -1
Solution 1: [BFS] Optimized BFS - Graph/BFS Lock Number Rotation Exploration
def openLock(self, deadends: List[str], target: str) -> int:
# BFS over the lock's state graph
# Each 4-digit combination is represented as a node in an implicit graph,
# meaning there is no literal grid/list to build,
# but instead states are generated on the fly.
# An edge connects two states if one wheel-turn transforms one into another,
# as each of the 4 wheels can turn +1 or -1 with wraparound 0 <-> 9
# BFS explores states level by level, so the first time a 'target' is dequeued,
# it is guaranteed to be the shortest number of turns
# Dead end states set
# sc: O(n)
dead = set(deadends)
# Edge Case:
# If starting position is a dead end, we cannot move.
if "0000" in dead:
return -1
# Iterative BFS Queue:
# set starting position as "0000" as begin exploring
queue = deque([("0000", 0)])
visited = set(["0000"])
while queue:
# Pop a state from the queue
state, moves = queue.popleft()
# Check:
# if target reached, we are guaranteed to have minimum number of moves
if state == target:
return moves
# Explore Neighbors:
# turn all 4 wheels in +/- 1 directions
for i in range(4):
# pick 1 of the 4 wheels
digit = int(state[i])
# Two possible rotations:
# +1 and -1 (with wraparound)
for change in (-1, 1):
new_digit = (digit + change) % 10
# Grab original wheel digits,
# except for the one we just rotated
newState = (state[:i] + str(new_digit) + state[i+1:])
# Early Pruning:
# only queue state if:
# - not visited
# - not a dead end
if newState not in visited and newState not in dead:
visited.add(newState)
queue.append((newState, moves + 1))
# Exhausted all possible rotation combinations without finding target
# overall: tc O(10^4)
# overall: sc O(10^4)
return -1Solution 2: [BFS] Bidirectional BFS - Graph/Bidirectional BFS Meet In Middle Exploration
def minMutation(self, startGene: str, endGene: str, bank: List[str]) -> int:
# Bidirectional BFS over the gene mutation graph
# Same implicit graph as single-source BFS: nodes are gene strings, edges
# connect genes differing by exactly one slot. Instead of searching only
# forward from startGene, we simultaneously search backward from endGene.
# We always expand the smaller of the two frontiers first, which keeps
# the branching factor as small as possible at each step.
# The two searches are guaranteed to meet at the shortest path's midpoint,
# since BFS explores level by level from both directions.
# Turn valid gene list into set
# sc: O(n)
bankSet = set(bank)
# Edge Case:
# endGene is not reachable
if endGene not in bankSet:
return -1
# All gene slot chars
gene_chars = ['A', 'C', 'G', 'T']
# Two Frontiers:
# each frontier is a dict mapping gene -> mutation count so far
# start both at 0 mutations
frontStart = {startGene: 0}
frontEnd = {endGene: 0}
# Global visited set shared across both directions,
# prevents re-expanding a gene already claimed by either search
visited = set([startGene, endGene])
while frontStart and frontEnd:
# Greedy Balancing:
# always expand the smaller frontier first to minimize
# the total number of states generated per level
if len(frontStart) > len(frontEnd):
frontStart, frontEnd = frontEnd, frontStart
# Next level's frontier for the side being expanded
nextFront = {}
# Explore Neighbors:
# modify every gene slot to every other possible gene char,
# for every gene currently in the smaller frontier
for geneString, mutations in frontStart.items():
for i in range(len(geneString)):
for c in gene_chars:
# Skip original gene
if c == geneString[i]:
continue
rotatedGene = geneString[:i] + c + geneString[i + 1:]
# Meeting Point:
# if the other frontier already contains this gene,
# the two searches have met -> shortest path found
if rotatedGene in frontEnd:
return mutations + frontEnd[rotatedGene] + 1
# Early Pruning:
# only queue mutations that are valid (in bank)
# and not already visited by either direction
if rotatedGene in bankSet and rotatedGene not in visited:
visited.add(rotatedGene)
nextFront[rotatedGene] = mutations + 1
# Advance the expanded side to its next level
frontStart = nextFront
# Exhausted all reachable mutations without the two searches meeting
# overall: tc O(n), same asymptotic bound as single-source BFS,
# but frontiers stay ~sqrt() the size in practice since branching
# is cut short by meeting in the middle rather than reaching full depth
# overall: sc O(n), for the bankSet/visited set storing up to n genes
return -1433. Minimum Genetic Mutation ::1:: - Medium
Topics: BFS Shortest Path Unweighted Graph, Hash Table, String, Breadth First Search, Rule Based Graph, Graph Theory
Intro
A gene string can be represented by an 8-character long string, with choices from 'A', 'C', 'G', and 'T'. Suppose we need to investigate a mutation from a gene string startGene to a gene string endGene where one mutation is defined as one single character changed in the gene string. For example, "AACCGGTT" --> "AACCGGTA" is one mutation. There is also a gene bank bank that records all the valid gene mutations. A gene must be in bank to make it a valid gene string. Given the two gene strings startGene and endGene and the gene bank bank, return the minimum number of mutations needed to mutate from startGene to endGene. If there is no such a mutation, return -1. Note that the starting point is assumed to be valid, so it might not be included in the bank.
| Example Input | Output |
|---|---|
| startGene = "AACCGGTT", endGene = "AACCGGTA", bank = ["AACCGGTA"] | 1 |
| startGene = "AACCGGTT", endGene = "AAACGGTA", bank = ["AACCGGTA","AACCGCTA","AAACGGTA"] | 2 |
Constraints:
0 ≤ bank.length ≤ 10
startGene.length == endGene.length == bank[i].length == 8
startGene, endGene, and bank[i] consist of only the characters ['A', 'C', 'G', 'T'].
Abstraction
Find the minimum number of single-step moves from the starting state to the target state, while avoiding a set of forbidden states along the way.
Each genetic combination is a node in an implicit graph and each single gene swap is an edge to a neighboring combination, with combinations not in the bank being forbidden.
BFS outward from the start state finds the shortest-path on an unweighted graph, while treating dead ends as boundaries for BFS.
Pseudocode
Sol 1: Optimized BFS
1. (bankSet = set(bank))
2. if endGene not in bankSet:
return -1
3. (gene_chars = ['A', 'C', 'G', 'T'])
4. (queue = deque([(startGene, 0)]))
5. visited = {startGene}
6. While queue:
a. (geneString, mutations) = queue.popleft()
b. if geneString == endGene: Return mutations
c. for i in range(len(geneString)):
for c in gene_chars:
if c == geneString[i]: continue
rotatedGene = geneString[:i] + c + geneString[i+1:]
if rotatedGene in bankSet and rotatedGene not in visited:
visited.add(rotatedGene)
queue.append((rotatedGene, mutations + 1))
7. Return -1
Sol 2: Bidirectional BFS
1. bankSet = set(bank)
2. if endGene not in bankSet:
return -1
3. gene_chars = ['A', 'C', 'G', 'T']
4. (startFrontier = {startGene})
5. (endFrontier = {endGene})
6. (visited = {startGene, endGene})
7. (mutations = 0)
8. While startFrontier and endFrontier:
a. if len(startFrontier) > len(endFrontier):
swap startFrontier, endFrontier
b. nextFrontier = set()
c. for each geneString in startFrontier:
for i in range(len(geneString)):
for c in gene_chars:
if c == geneString[i]: continue
rotatedGene = geneString[:i] + c + geneString[i+1:]
if rotatedGene in endFrontier:
Return mutations + 1
if rotatedGene in bankSet and rotatedGene not in visited:
visited.add(rotatedGene)
nextFrontier.add(rotatedGene)
d. startFrontier = nextFrontier
e. mutations += 1
9. Return -1
Solution 1: [BFS] Optimized BFS - Graph/BFS Lock Number Rotation Exploration
def minMutation(self, startGene: str, endGene: str, bank: List[str]) -> int:
# BFS over the gene mutation graph
# Each gene string combination is represented as a node in an implicit graph,
# meaning there is no literal grid/list to build,
# but instead states are generated on the fly.
# An edge connects two states if one gene slot modification transforms
# one into another, as there are 4 possible gene letters.
# BFS explores states level by level, so the first time a 'target' is dequeued,
# it is guaranteed to be the shortest number of turns
# as well as guaranteed to have all possible gene combinations
# Turn valid gene list into set
# sc: O(n)
bankSet = set(bank)
# Edge Case:
# endGene is not reachable, no transformation sequence exists
if endGene not in bankSet:
return -1
# All gene slot chars
gene_chars = ['A', 'C', 'G', 'T']
# Iterative BFS Queue
queue = deque([(startGene, 0)])
# Tracking
# sc: O(n^4)
visited = {startGene}
while queue:
geneString, mutations = queue.popleft()
# Reached target gene:
# Ensured we have taken the minimum number of steps to get to
# the final combination, which implies we have all possible previous
# combinations in the mutations list
if geneString == endGene:
return mutations
# Modify each gene slot
for i in range(len(geneString)):
# Explore Neighbor:
# Replace each gene slot with all other 3 genes
for c in gene_chars:
# Skip original gene
if c == geneString[i]:
continue
# Grab all original gene slots,
# except for the slot we just modified
rotatedGene = geneString[:i] + c + geneString[i + 1:]
# Early Pruning:
# Only queue mutations that are valid (in bank) and unseen
if rotatedGene in bankSet and rotatedGene not in visited:
visited.add(rotatedGene)
queue.append((rotatedGene, mutations + 1))
# Exhausted all reachable mutations without finding endGene
# overall: tc O(n)
# overall: sc O(n)
return -1Solution 2: [BFS] Bidirectional BFS - Graph/Bidirectional BFS Meet In Middle Exploration
def minMutation(self, startGene: str, endGene: str, bank: List[str]) -> int:
# Bidirectional BFS:
# Regular BFS explores outward from startGene alone, and in the
# worst case its frontier can grow to cover almost the entire
# search space before reaching endGene. Bidirectional BFS
# instead grows TWO frontiers at once -- one from startGene,
# one from endGene -- and alternates expanding whichever
# frontier is smaller. The two searches meet in the middle,
# which shrinks the total explored space dramatically since
# each side only needs to cover roughly half the distance.
# Why swapping to expand the smaller frontier matters:
# if one side's frontier is much smaller than the other, it's
# cheaper to expand a full layer of the small side than a full
# layer of the large side -- this keeps the total work bounded
# by whichever side is currently "cheapest," rather than always
# paying the cost of the (potentially huge) start-side frontier.
bankSet = set(bank)
# Edge Case:
# endGene is not reachable, no transformation sequence exists
if endGene not in bankSet:
return -1
# All gene slot chars
gene_chars = ['A', 'C', 'G', 'T']
# Two Frontiers:
# each is a set of gene strings currently reachable in the
# same number of mutations from their respective origin
# sc: O(n) per frontier
startFrontier = {startGene}
endFrontier = {endGene}
# Tracking:
# genes already visited from EITHER direction, so neither
# search wastes time re-expanding the same state
# sc: O(n)
visited = {startGene, endGene}
mutations = 0
# tc: O(n) overall, each gene expanded at most once across
# both frontiers combined
while startFrontier and endFrontier:
# Swap to Smaller:
# always expand whichever frontier currently has fewer
# states -- keeps each layer's expansion cost minimal
if len(startFrontier) > len(endFrontier):
startFrontier, endFrontier = endFrontier, startFrontier
nextFrontier = set()
# Expand Frontier:
# generate every one-mutation neighbor for every gene
# currently in the smaller frontier
for geneString in startFrontier:
for i in range(len(geneString)):
for c in gene_chars:
# Skip original gene
if c == geneString[i]:
continue
rotatedGene = geneString[:i] + c + geneString[i + 1:]
# Meet in the Middle:
# if this mutation already exists in the OTHER
# frontier, the two searches have connected --
# this mutation count plus the other side's
# equal-depth mutation count gives the answer
if rotatedGene in endFrontier:
return mutations + 1
# Early Pruning:
# only continue expanding through valid,
# unseen mutations
if rotatedGene in bankSet and rotatedGene not in visited:
visited.add(rotatedGene)
nextFrontier.add(rotatedGene)
# Advance:
# the smaller frontier becomes this newly generated layer,
# one mutation deeper than before
startFrontier = nextFrontier
mutations += 1
# Exhausted all reachable mutations from both directions
# without the two searches ever meeting
# overall: tc O(n), n = number of valid genes in bank,
# but with a much smaller effective search space
# than single-direction BFS in practice
# overall: sc O(n), for the two frontiers and visited set
return -1127. Word Ladder ::2:: - Hard
Topics: BFS Shortest Path Unweighted Graph, Hash Table, String, Breadth First Search, Rule Based Graph, Graph Theory
Intro
A transformation sequence from word beginWord to word endWord using a dictionary wordList is a sequence of words beginWord -> s1 -> s2 -> ... -> sk such that: Every adjacent pair of words differs by a single letter. Every si for 1 ≤ i ≤ k is in wordList. Note that beginWord does not need to be in wordList sk == endWord Given two words, beginWord and endWord, and a dictionary wordList, return the number of words in the shortest transformation sequence from beginWord to endWord, or 0 if no such sequence exists.
| Example Input | Output |
|---|---|
| beginWord = "hit", endWord = "cog", wordList = ["hot","dot","dog","lot","log","cog"] | 5 |
| beginWord = "hit", endWord = "cog", wordList = ["hot","dot","dog","lot","log"] | 0 |
Constraints:
1 ≤ beginWord.length ≤ 10
endWord.length == beginWord.length
1 ≤ wordList.length ≤ 5000
wordList[i].length == beginWord.length
beginWord, endWord, and wordList[i] consist of lowercase English letters.
beginWord != endWord
All the words in wordList are unique.
Abstraction
Find the minimum number of single-step moves from the starting state to the target state, while avoiding a set of forbidden states along the way.
Each string is a node in an implicit graph and each single character swap is an edge to a neighboring string, with combinations not in the wordList being forbidden.
BFS outward from the start state finds the shortest-path on an unweighted graph, while treating dead ends as boundaries for BFS.
Pseudocode
Sol 1: BFS
1. (wordSet = set(wordList))
2. if endWord not in wordSet:
return 0
3. (letters = 'abcdefghijklmnopqrstuvwxyz')
4. (queue = deque([(beginWord, 1)]))
5. (visited = set([beginWord]))
6. While queue:
a. ((word, steps) = queue.popleft())
b. if word == endWord:
return steps
c. for i in range(len(word)):
for c in letters:
if c == word[i]:
continue
newWord = word[:i] + c + word[i+1:]
if newWord in wordSet and newWord not in visited:
queue.append((newWord, steps+1))
visited.add(newWord)
7. Return -1
Sol 2: Bidirectional BFS
1. (wordSet = set(wordList))
2. if endWord not in wordSet:
return -1
3. (letters = 'abcdefghijklmnopqrstuvwxyz')
4. (frontBegin = {beginWord: 1})
5. (frontEnd = {endWord: 1})
6. visited = set([beginWord, endWord])
7. While frontBegin and frontEnd:
a. if len(frontBegin) > len(frontEnd):
swap frontBegin, frontEnd
b. nextFront = {}
c. for each (word, length) in frontBegin:
for i in range(len(word)):
for c in letters:
if c == word[i]: continue
newWord = word[:i] + c + word[i+1:]
if newWord in frontEnd:
Return length + frontEnd[newWord]
if newWord in wordSet and newWord not in visited:
visited.add(newWord)
nextFront[newWord] = length + 1
d. frontBegin = nextFront
8. Return -1
Solution 1: [BFS] BFS - Graph/something
def ladderLength(self, beginWord: str, endWord: str, wordList: List[str]) -> int:
# BFS over the word transformation graph
# Each word is represented as a node in an implicit graph,
# meaning there is no literal grid/list to build,
# but instead states are generated on the fly.
# An edge connects two words if they differ by exactly one letter
# as each of the L letter slots can be swapper to any of the other 25 letters
# BFS explores states level by level, so the first time 'endWord' is dequeued,
# it is guaranteed to be reached via the shortest transformation sequence
# Valid word list as a set
# sc: O(V)
wordSet = set(wordList)
# Empty Check:
# endWord is not reachable, no transformation sequence exists
# tc: O(1)
if endWord not in wordSet:
return 0
# All possible letters for each slot
letters = 'abcdefghijklmnopqrstuvwxyz'
# Iterative BFS Queue:
# set starting position as beginWord, sequence steps starts at 1
# (LeetCode counts the beginWord itself as the first word in the sequence)
queue = deque([(beginWord, 1)])
visited = set([beginWord])
while queue:
# Pop a transformation
word, steps = queue.popleft()
# Check:
# if endWord reached, we are guaranteed to have the minimum sequence steps
if word == endWord:
return steps
# Explore Neighbors:
# swap every letter slot to every other possible letter
# tc: O(L)
for i in range(len(word)):
# Grab original letter,
# except for the one we're about to swap
for c in letters:
# Skip original letter
if c == word[i]:
continue
# New transformation
newWord = word[:i] + c + word[i + 1:]
# Early Pruning:
# - not in wordSet, invalid word
# - already visited, we reached this via a shorter sequence
if newWord in wordSet and newWord not in visited:
# queue new valid transformation
queue.append((newWord, steps+1))
visited.add(newWord)
# Exhausted all possible transformations without finding endWord
# overall: tc O(n * L^2), where n = len(wordList), l = len(beginWord)
# for each word popped, we try l slots * 26 letters = O(l * 26),
# and each candidate word construction/hashing costs O(l)
# overall: sc O(n * L), for the wordSet/visited set storing up to n words of steps l
return -1Solution 2: [BFS] Bidirectional BFS - Graph/Bidirectional BFS Meet In Middle
def ladderLength(self, beginWord: str, endWord: str, wordList: List[str]) -> int:
# Bidirectional BFS over the word transformation graph
# Same implicit graph as single-source BFS: nodes are words, edges connect
# words differing by exactly one letter. Instead of searching only forward
# from beginWord, we simultaneously search backward from endWord.
# We always expand the smaller of the two frontiers first, which keeps
# the branching factor as small as possible at each step.
# The two searches are guaranteed to meet at the shortest path's midpoint,
# since BFS explores level by level from both directions.
# Valid word list as a set
# sc: O(n)
wordSet = set(wordList)
# Edge Case:
# If endWord is not reachable, no transformation sequence exists
if endWord not in wordSet:
return -1
# All possible letters for each slot
letters = 'abcdefghijklmnopqrstuvwxyz'
# Two Frontiers:
# each frontier is a dict mapping word -> sequence length so far
# start both at length 1 (beginWord/endWord count as step 1)
frontBegin = {beginWord: 1}
frontEnd = {endWord: 1}
# Global visited set shared across both directions,
# prevents re-expanding a word already claimed by either search
visited = set([beginWord, endWord])
while frontBegin and frontEnd:
# Greedy Balancing:
# always expand the smaller frontier first to minimize
# the total number of states generated per level
if len(frontBegin) > len(frontEnd):
frontBegin, frontEnd = frontEnd, frontBegin
# Next level's frontier for the side being expanded
nextFront = {}
# Explore Neighbors:
# swap every letter slot to every other possible letter,
# for every word currently in the smaller frontier
for word, length in frontBegin.items():
for i in range(len(word)):
for c in letters:
# Skip original letter
if c == word[i]:
continue
newWord = word[:i] + c + word[i + 1:]
# Meeting Point:
# if the other frontier already contains this word,
# the two searches have met -> shortest path found
if newWord in frontEnd:
return length + frontEnd[newWord]
# Early Pruning:
# only queue state if:
# - it's a valid word (in wordSet)
# - not already visited by either direction
if newWord in wordSet and newWord not in visited:
visited.add(newWord)
nextFront[newWord] = length + 1
# Advance the expanded side to its next level
frontBegin = nextFront
# Exhausted all possible transformations without the two searches meeting
# overall: tc O(n * l^2), same asymptotic bound as single-source BFS,
# but frontiers stay ~sqrt() the size in practice since branching
# is cut short by meeting in the middle rather than reaching full depth
# overall: sc O(n * l), for wordSet/visited storing up to n words of length l
return -11926. Nearest Exit from Entrance in Maze ::2:: - Medium
Topics: BFS Shortest Path Unweighted Graph, Array, Breadth First Search, Matrix, Grid, Graph Theory
Intro
You are given an m x n matrix maze (0-indexed) with empty cells (represented as '.') and walls (represented as '+'). You are also given the entrance of the maze, where entrance = [entrancerow, entrancecol] denotes the row and column of the cell you are initially standing at. In one step, you can move one cell up, down, left, or right. You cannot step into a cell with a wall, and you cannot step outside the maze. Your goal is to find the nearest exit from the entrance. An exit is defined as an empty cell that is at the border of the maze. The entrance does not count as an exit. Return the number of steps in the shortest path from the entrance to the nearest exit, or -1 if no such path exists.
| Example Input | Output |
|---|---|
| maze = [["+","+",".","+"],[".",".",".","+"],["+","+","+","."]], entrance = [1,2] | 1 |
| maze = [["+","+","+"],[".",".","."],["+","+","+"]], entrance = [1,0] | 2 |
| maze = [[".","+"]], entrance = [0,0] | -1 |
Constraints:
maze.length == m
maze[i].length == n
1 ≤ m, n ≤ 100
maze[i][j] is either '.' or '+'.
entrance.length == 2
0 ≤ entrance row < m
0 ≤ entrance col < n
entrance will always be an empty cell.
Abstraction
Find the minimum number of single-step moves from the starting state to the target state, while avoiding a set of forbidden states along the way.
Each cell is a node in an implicit graph and each single step up, down, left, right is an edge to a neighboring cell, with walls being forbidden states.
BFS outward from the start state finds the shortest-path on an unweighted graph, while treating forbidden ends as boundaries for BFS.
Pseudocode
Sol 1: BFS Grid Traversal
1. (rows, cols = width, height)
3. (entranceRow, entranceCol) = entrance
4. (queue = deque([(entranceRow, entranceCol, 0)]))
5. (visited = set([(entranceRow, entranceCol)]))
6. While queue:
a. (row, col, steps) = queue.popleft()
b. if steps > 0 and cell is on border:
Return steps
c. for neighbor (nr, nc)
if neighbor (nr, nc) bounds:
if maze[nr][nc] == '.' and (nr, nc) not in visited:
visited.add((nr, nc))
queue.append((nr, nc, steps + 1))
7. Return -1
Solution 1: [BFS] BFS Grid Traversal - Graph/BFS Grid Traversal
def nearestExit(self, maze: List[List[str]], entrance: List[int]) -> int:
# BFS over the maze grid
# Each cell is represented as a node in an implicit graph, the
# grid itself acts as the adjacency structure, no separate
# graph needs to be built.
# An edge connects two cells if they're adjacent (up/down/left/right)
# and the destination cell is empty ('.').
# BFS explores cells level by level, so the first time a border
# cell is dequeued, it is guaranteed to be reached via the
# shortest number of steps.
rows, cols = len(maze), len(maze[0])
# 4 cardinal directions
directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]
# Iterative BFS Queue:
# start at entrance, 0 steps taken
entranceRow, entranceCol = entrance
queue = deque([(entranceRow, entranceCol, 0)])
visited = set([(entranceRow, entranceCol)])
while queue:
# Pop a cell from the queue
row, col, steps = queue.popleft()
# Check:
# if current cell is a border cell and not the entrance,
# we are guaranteed to have the minimum number of steps
# (entrance is excluded up front by only checking after the first pop)
if steps > 0 and (row == 0 or row == rows - 1 or
col == 0 or col == cols - 1):
return steps
# Explore Neighbors:
# move to all 4 adjacent cells
for dr, dc in directions:
nr, nc = row + dr, col + dc
# Bounds Check:
# stay within the grid
if 0 <= nr < rows and 0 <= nc < cols:
# Early Pruning:
# only queue cell if:
# - not a wall
# - not already visited
if maze[nr][nc] == '.' and (nr, nc) not in visited:
visited.add((nr, nc))
queue.append((nr, nc, steps + 1))
# Exhausted all reachable cells without finding a border exit
# overall: tc O(m * n), each cell is visited and processed at most once
# overall: sc O(m * n), for the visited set and queue storing up to every cell in the grid
return -11197. Minimum Knight Moves ::2:: - Medium
Topics: BFS Shortest Path Unweighted Graph, Breadth First Search, Rule Based Graph, Graph Theory
Intro
You have a knight piece on an infinite chessboard, initially positioned at coordinate [0, 0]. The chessboard extends infinitely in all directions (from negative infinity to positive infinity). A knight in chess moves in an "L" shape, it can move exactly 2 squares in one cardinal direction (horizontal or vertical) and then 1 square perpendicular to that direction, or 1 square in a cardinal direction and then 2 squares perpendicular. This gives the knight 8 possible moves from any position: (-2, 1), (-1, 2), (1, 2), (2, 1), (2, -1), (1, -2), (-1, -2), (-2, -1) Given a target position [x, y], you need to find the minimum number of moves required for the knight to reach that position from its starting position [0, 0]. The problem guarantees that a solution always exists, the knight can always reach any position on the infinite board given enough moves.
| Example Input | Output |
|---|---|
| x = 2, y = 1 | 1 |
| x = 5, y = 5 | 4 |
Constraints:
-300 ≤ x, y ≤ 300
0 ≤ abs(x) + abs(y) ≤ 300
Abstraction
Find the minimum number of single-step moves from the starting state to the target state, while avoiding a set of forbidden states along the way.
Given a knight at (0, 0), find the minimum number of moves to the target coordinates.
BFS outward from the start state finds the shortest-path on an unweighted graph, while treating forbidden ends as boundaries for BFS.
Pseudocode
Sol 1: BFS With Quadrant Symmetry Pruning
1. x, y = abs(x), abs(y)
2. if x == 0 and y == 0:
return 0
3. moves = 8 knight move deltas
4. queue = deque([(0, 0, 0)])
5. visited = set([(0, 0)])
6. While queue:
a. (cx, cy, moveCount) = queue.popleft()
b. if cx == x and cy == y: Return moveCount
c. for each (dx, dy) in moves:
nx, ny = cx + dx, cy + dy
if -2 <= nx <= x+2 and -2 <= ny <= y+2:
if (nx, ny) not in visited:
visited.add((nx, ny))
queue.append((nx, ny, moveCount + 1))
7. Return -1
Sol 2: Bidirectional BFS
1. x, y = abs(x), abs(y)
2. if x == 0 and y == 0: return 0
3. moves = 8 knight move deltas
4. frontStart = {(0, 0): 0}
5. frontEnd = {(x, y): 0}
6. visited = set([(0, 0), (x, y)])
7. While frontStart and frontEnd:
a. if len(frontStart) > len(frontEnd):
swap frontStart, frontEnd
b. nextFront = {}
c. for each ((cx, cy), moveCount) in frontStart:
for each (dx, dy) in moves:
nx, ny = cx + dx, cy + dy
if (nx, ny) in frontEnd:
Return moveCount + frontEnd[(nx, ny)] + 1
if (nx, ny) not in visited:
visited.add((nx, ny))
nextFront[(nx, ny)] = moveCount + 1
d. frontStart = nextFront
8. Return -1
Solution 1: [BFS] BFS With Quadrant Symmetry Pruning - Graph/BFS Implicit Grid
def minKnightMoves(self, x: int, y: int) -> int:
# BFS over the knight's move graph
# Each square on the infinite chessboard is represented as a node
# in an implicit graph, meaning there is no literal grid/list to
# build, but instead squares are generated on the fly.
# An edge connects two squares if a single knight move (one of
# the 8 "L" shaped jumps) transforms one into the other.
# BFS explores squares level by level, so the first time (x, y)
# is dequeued, it is guaranteed to be reached via the minimum
# number of moves.
# Symmetry Pruning:
# The knight's movement is symmetric across both axes, a move
# that reaches (x, y) can be mirrored to reach (-x, y), (x, -y),
# or (-x, -y) in the same number of moves. So we only ever need
# to search within the first quadrant (x >= 0, y >= 0), and take
# the absolute value of the target up front. This roughly
# quarters the search space compared to searching the full plane.
# Normalize target into the first quadrant
x, y = abs(x), abs(y)
# Edge Case:
# already at the target
if x == 0 and y == 0:
return 0
# All 8 possible knight moves
moves = [(-2, 1), (-1, 2), (1, 2), (2, 1),
(2, -1), (1, -2), (-1, -2), (-2, -1)]
# Iterative BFS Queue:
# start at origin, 0 moves taken
queue = deque([(0, 0, 0)])
visited = set([(0, 0)])
while queue:
# Pop a position from the queue
cx, cy, moveCount = queue.popleft()
# Check:
# if target reached, we are guaranteed to have the minimum number of moves
if cx == x and cy == y:
return moveCount
# Explore Neighbors:
# apply all 8 possible knight moves
for dx, dy in moves:
nx, ny = cx + dx, cy + dy
# Pruning Bound:
# clamp exploration to a small buffer past the first quadrant,
# since occasionally dipping to -1 or -2 is needed to route
# around the target efficiently, but going further negative
# or far beyond the target never helps
if -2 <= nx <= x + 2 and -2 <= ny <= y + 2:
# Early Pruning:
# only queue position if not already visited
if (nx, ny) not in visited:
visited.add((nx, ny))
queue.append((nx, ny, moveCount + 1))
# overall: tc O(max(x, y)^2), bounded search space after symmetry
# and pruning keeps the frontier close to the target region
# overall: sc O(max(x, y)^2), for the visited set storing explored squares
return -1Solution 2: [BFS] Bidirectional BFS - Graph/Bidirectional BFS Meet In Middle
def minKnightMoves(self, x: int, y: int) -> int:
# Bidirectional BFS over the knight's move graph
# Same implicit graph as single-source BFS: nodes are board
# squares, edges connect squares one knight move apart. Instead
# of searching only forward from the origin, we simultaneously
# search backward from the target. We always expand the smaller
# of the two frontiers first, which keeps the branching factor
# as small as possible at each step.
# The two searches are guaranteed to meet at the shortest path's
# midpoint, since BFS explores level by level from both directions.
# Symmetry Pruning:
# The knight's movement is symmetric across both axes, so we
# normalize the target into the first quadrant up front, the
# same way as single-source BFS. This roughly quarters the
# search space compared to searching the full plane.
# Normalize target into the first quadrant
x, y = abs(x), abs(y)
# Edge Case:
# already at the target
if x == 0 and y == 0:
return 0
# All 8 possible knight moves
moves = [(-2, 1), (-1, 2), (1, 2), (2, 1),
(2, -1), (1, -2), (-1, -2), (-2, -1)]
# Two Frontiers:
# each frontier is a dict mapping position -> move count so far
# start both at 0 moves
frontStart = {(0, 0): 0}
frontEnd = {(x, y): 0}
# Global visited set shared across both directions,
# prevents re-expanding a square already claimed by either search
visited = set([(0, 0), (x, y)])
while frontStart and frontEnd:
# Greedy Balancing:
# always expand the smaller frontier first to minimize
# the total number of states generated per level
if len(frontStart) > len(frontEnd):
frontStart, frontEnd = frontEnd, frontStart
# Next level's frontier for the side being expanded
nextFront = {}
# Explore Neighbors:
# apply all 8 possible knight moves,
# for every position currently in the smaller frontier
for (cx, cy), moveCount in frontStart.items():
for dx, dy in moves:
nx, ny = cx + dx, cy + dy
# Meeting Point:
# if the other frontier already contains this position,
# the two searches have met -> shortest path found
if (nx, ny) in frontEnd:
return moveCount + frontEnd[(nx, ny)] + 1
# Early Pruning:
# only queue position if not already visited by either direction
if (nx, ny) not in visited:
visited.add((nx, ny))
nextFront[(nx, ny)] = moveCount + 1
# Advance the expanded side to its next level
frontStart = nextFront
# overall: tc O(max(x, y)^2), same asymptotic bound as single-source
# BFS, but frontiers stay ~sqrt() the size in practice since
# branching is cut short by meeting in the middle
# overall: sc O(max(x, y)^2), for the visited set storing explored squares
return -1