Jc-alt logo
jc
data structures and algorithms

LeetCode: Trees I BFS

LeetCode: Trees I BFS
17 min read
#data structures and algorithms

Tree Intro

What is a Tree

Trees are hierarchical data structures representing relationships between entities, often in a parent-child format.

102. Binary Tree Level Order Traversal ::2:: - Medium

Topics: 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.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force: iterative

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: 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 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, 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, if you were to stand on the right side, return all nodes that you would have a direct line of sight to (not hidden by right-er nodes)

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force: iterative

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

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

something!

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: [BFS] BFS And BFS 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: 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

similar to 433 minimum genetic mutation

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

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

433. Minimum Genetic Mutation ::1:: - Medium

Topics: Hash Table, String, Breadth First Search

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

similar to 752 open the lock

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

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