Jc-alt logo
jc

LeetCode: Trees I BFS

LeetCode: Trees I BFS
41 min read
data structures and algorithms

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: 42513
(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 InputOutput
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
AspectTime ComplexitySpace ComplexityTime RemarksSpace 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
AspectTime ComplexitySpace ComplexityTime RemarksSpace 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 InputOutput
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
AspectTime ComplexitySpace ComplexityTime RemarksSpace 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
AspectTime ComplexitySpace ComplexityTime RemarksSpace 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 InputOutput
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
AspectTime ComplexitySpace ComplexityTime RemarksSpace 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 InputOutput
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 -1

Solution 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 -1

433. 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 InputOutput
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 -1

Solution 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 -1

127. 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 InputOutput
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 -1

Solution 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 -1

1926. 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 InputOutput
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 -1

1197. 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 InputOutput
x = 2, y = 11
x = 5, y = 54

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 -1

Solution 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