Jc-alt logo
jc

LeetCode: Backtracking

LeetCode: Backtracking
73 min read
data structures and algorithms

Backtracking intro

LeetCode problems with backtracking solutions.

What is Backtracking

Backtracking is a systematic technique for exploring all possible solutions to a problem by building them incrementally and abandoning ('backtracking') as soon as it becomes clear that a candidate cannot lead to a valid solution.

It is often implemented recursively, but can also be simulated iteratively with a stack.

While backtracking often has exponential time complexity in the worst case, it is the most practical way to solve problems that require exploring many possibilities with pruning.

Backtracking Characteristics

  1. Build -> partial solution
  2. Prune -> remove branches that violate constraints
  3. Explore -> recurse or continue to extend the current partial solution
  4. Backtrack -> undo the last step and try another option

Backtracking Representation

  • Tree representation: Each node represents a decision state

  • Children: represent choices from the current states

  • Leaves: Either full solutions or dead nodes

Generating subsets of [1,2,3] forms a decision tree where at each step, we decide to include or exclude a number

    Decision tree for [1,2,3]:
    
                         []
                      /      \
                   [1]        []
                 /    \     /     \
             [1,2]   [1] [2]       []
            /   \      ... ...
       [1,2,3]  ...

Backtracking IRL

In the context of solving a maze, at each intersection, you choose a path (a decision). If the path leads to a dead end, you backtrack to the intersection and try another route. Eventually, you either find the exit (solution) or exhaust all paths (no solutions).

Backtracking Application: DFS Generate All Combinations Or Subsets

Traversal Order: Root -> Choices Mindset: Process the current subset as soon as you build it, then explore further elements Trick: Ill record the current subset first, then decide which elements to include next. We can explore all subsets, permutations, or combinations by recursively building solutions and backtracking when needed.

Ex: Generate all subsets of a set

    def subsets(nums):
        res = []
        
        def dfs_backtrack(start, path):

            # Process Root -> : record current subset first
            res.append(path[:]) 
            
            # Process -> Choices : decide which to include/exclude
            for i in range(start, len(nums)):

                # Build: include nums[i]
                path.append(nums[i])

                # Explore: recurse to next index
                dfs_backtrack(i + 1, path)

                # Backtrack: remove last element
                path.pop()
        
        dfs_backtrack(0, [])
        return res

    # Example: subsets([1,2,3]) -> [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]]

Backtracking Application: DFS Generate While Constraint Satisfaction

Traversal Order: Root -> Choices Mindset: Build sequences step by step, only adding valid elements and backtracking when constraints are violated Tricks: Ill try to add '(' or ')' next only if rules allow, then undo if needed. As we explore all permutations, we can enforce rules in order to prune invalid branches early.

Ex: Generate all valid parentheses

    def generateParenthesis(n):
        res = []
        
        def dfs_backtrack(open_count, close_count, path):
            
            # Process Root -> : check if sequence complete
            if len(path) == 2 * n:
                res.append("".join(path))
                return
            
            # Process -> Choices : add '(' if possible
            if open_count < n:
                # Build
                path.append("(")
                # Explore
                dfs_backtrack(open_count + 1, close_count, path)
                # Backtrack
                path.pop()
            
            # Process -> Choices : add ')' if it will not break validity
            if close_count < open_count:
                # Build
                path.append(")")
                # Explore
                dfs_backtrack(open_count, close_count + 1, path)
                # Backtrack
                path.pop()
        
        dfs_backtrack(0, 0, [])
        return res

    # Example: generateParenthesis(3) -> ["((()))","(()())","(())()","()(())","()()()"]

Backtracking Application: Path Finding In Search Space

Traversal Order: Root -> Choices (neighboring paths) Mindset: Explore each path step by step, backtracking when reaching dead ends. Trick: Ill walk one direction fully before trying another, undoing my steps if blocked. We can explore fully explore paths, by stepping through and backtracking when reaching dead ends for different search spaces (grids, graphs, networks).

Ex: Word Search in grid

    def exist(board, word):
        rows, cols = len(board), len(board[0])
        
        def dfs_backtrack(r, c, idx):

            # Early exit:
            # Process Root -> : matched full word
            if idx == len(word):
                return True

            # Early Pruning -> : check boundaries
            if r < 0 or c < 0 or r >= rows or c >= cols:
                return False

            # Early Pruning -> : check current cell
            if board[r][c] != word[idx]:
                return False
            

            # Process -> Choices : explore all 4 directions

            # Build: mark current cell as exploring
            tmp, board[r][c] = board[r][c], "#"

            # Explore: recurse to neighbor cell
            found = (dfs_backtrack(r+1, c, idx+1) or
                     dfs_backtrack(r-1, c, idx+1) or
                     dfs_backtrack(r, c+1, idx+1) or
                     dfs_backtrack(r, c-1, idx+1))

            # Backtrack: Restore cell
            board[r][c] = tmp
            return found
        
        # dfs_backtrack starting from all cells
        for r in range(rows):
            for c in range(cols):
                if dfs_backtrack(r, c, 0):
                    return True

        return False

    # Example: exist([["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], "ABCCED") -> True

Backtracking Application: Combinatorial Optimization

Traversal Order: Root -> Choices (column positions) Mindset: Place one queen at a time, pruning invalid columns, and backtrack when stuck Trick: Ill place a queen, explore further rows, then remove it if it leads to a conflict. We can search for an optimal solution while pruning bad candidates.

Ex: N Queens Problem

    def solveNQueens(n):
        res = []
        cols = set()
        diag1 = set()  # r - c
        diag2 = set()  # r + c
        
        board = [["."] * n for _ in range(n)]
        
        def dfs_backtrack(r):
            # Process Root -> : all queens placed
            if r == n:
                res.append(["".join(row) for row in board])
                return
            
            # Process -> Choices : try each column
            for c in range(n):
                if c in cols or (r-c) in diag1 or (r+c) in diag2:
                    continue
                
                # Build: place queen
                board[r][c] = "Q"
                cols.add(c); diag1.add(r-c); diag2.add(r+c)
                
                # Explore: recurse to next row
                dfs_backtrack(r + 1)
                
                # Backtrack: remove queen
                board[r][c] = "."
                cols.remove(c); diag1.remove(r-c); diag2.remove(r+c)
        
        dfs_backtrack(0)
        return res

    # Example: solveNQueens(4) -> [
    #   [".Q..","...Q","Q...","..Q."],
    #   ["..Q.","Q...","...Q",".Q.."]
    # ]    
    

Backtracking Application: Decision Trees And Partitioning

Traversal Order: Root -> Choices (substring partitions) Mindset: Partition strings step by step, backtracking when a substring is not a palindrome Trick: Ill add a palindromic piece, explore further, then remove it if it doesn't lead to a solution. We can make binary or k-ary decision at each step, exploring all outcomes

Ex: Partition a string into palindromic substrings

    def partition(s):
        res = []

        def is_palindrome(sub):
            return sub == sub[::-1]
        
        def dfs_backtrack(start, path):
            # Process Root -> : reached end of string
            if start == len(s):
                res.append(path[:])
                return
            
            # Process -> Choices : try all possible substrings
            for end in range(start+1, len(s)+1):

                # Constraint check: palindrome check
                if is_palindrome(s[start:end]):

                    # Build: add substring
                    path.append(s[start:end])

                    # Explore: recurse to next string
                    dfs_backtrack(end, path)

                    # Backtrack: remove substring
                    path.pop()
        
        dfs_backtrack(0, [])
        return res

    # Example: partition("aab") -> [["a","a","b"], ["aa","b"]]

78. Subsets ::3:: - Medium

Topics: Array, Backtracking, Bit Manipulation

Intro

Given an integer array nums of unique elements, return all possible subsets (the power set). A subset of an array is a selection of elements (possibly none) of the array. The solution set must not contain duplicate subsets. Return the solution in any order.

Example InputOutput
nums = [1,2,3][[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
nums = [0][[],[0]]

Constraints:

1 ≤ nums.length ≤ 10

-10 ≤ nums[i] ≤ 10

All the numbers of nums are unique.

Abstraction

Given a list of numbers, all unique, return the power set.

Power Set - every possible subset, including empty and full.

Backtracking Choice: 2 Choices - include or exclude number

Set: does not matter, [0, 1] and [1, 0] are the same set.

Pseudocode

Sol 1: DFS on Global Path to Generate All Subsets
1. res = []
2. path = []
3. dfs(start):
   a. res.append(path[:])
   b. for i in range(start, len(nums)):
      path.append(nums[i])
      dfs(i+1)
      path.pop()
4. dfs(0)
5. Return res

Sol 2: Yield Generator For O(n)
1. path = []
2. dfs(start):
   a. yield path[:]
   b. for i in range(start, len(nums)):
      path.append(nums[i])
      yield from dfs(i+1)
      path.pop()
3. generator = dfs(0)
4. res = list(generator)
5. Return res

Solution 1: [Backtracking] [DFS] 2 Choices Include Integer Path Or Exclude And Continue Exploration - Backtracking/Generate All Combinations Or Subsets

    def subsets(self, nums: List[int]) -> List[List[int]]:

        # Backtracking:
        # DFS recursion builds the power set solution incrementally,
        # by exploring all possible choices at each step.

        # Choices:
        # At each step we can either include or exclude a number,
        # giving us 2 options, so we get 2^n 

        # Shallow Copy:
        # A shallow copy means that the top level object is copied, 
        # but the elements inside are not duplicated, 
        # and still reference the original object.
        # In python this is fine because integers are immutable
        # tc: copy in O(n), since path can have up to n elements
        # sc: copy in O(n), list of n elements added to the result list

        # Backtracking Recursion Stack Storage:
        # All subsets of n elements for 2^n subsets
        # sc: O(n * 2^n)
        res = []

        # Path Storage:
        # holds current subset path during recursion exploration, max length n
        # sc: O(n)
        path = []  

        def dfs(start: int):
            
            # 2 Paths determined by 2 choices:
            # include or exclude the current number

            # Path 1:
            # Include current number,
            # stop including any further integers from here on
            # tc: O(n)
            res.append(path[:])

            # Path 2:
            # Do not include current number.
            # DFS exploration will test the following next 2 choices
            # tc: O(n)
            for i in range(start, len(nums)):

                # Prepare To Explore:
                # (Will Become Path 1 on exploration)
                # Add integer to exploration path
                # and pass start index to ensure we only include new integers
                # tc: O(1)
                path.append(nums[i])

                # Recurse And Explore
                # tc: O(2^n)
                dfs(i+1)

                # Backtrack: 
                # Revert path 2 integer to try another integer via the for loop
                # tc: O(1)
                path.pop()

        # Start backtracking at "root"
        # tc: O(2^n)
        dfs(0)

        # overall: tc O(n * 2^n)
        # overall: sc O(n * 2^n)
        return res

Solution 2: [Backtracking] [DFS] Yield Generator For Streaming To Avoid O(n * 2^n) Storage [SC Opt] - Backtracking/Generate All Combinations Or Subsets

    def subsets(self, nums: List[int]) -> Iterable[List[int]]:

        # Generator / Yield vs Result List:
        # Used to avoid storing/holding all subsets in memory at once

        # Yield:
        # Using 'yield', we generate subsets one at a time instead of storing them all.
        # The generator pauses at each 'yield' and resumes when the next subset is requested.
        # As a result, only the current path and recursion stack are kept in memory,
        # reducing auxiliary space to O(n), which is due to the size O(n) of any subset
        # but still independent by the total number of subsets.

        # Generator / Yield Auxiliary space (not counting the output):
        # Only the current subset path and recursion stack are in memory, 
        # O(n) instead of O(n * 2^n)

        # Path Storage:
        # holds current subset path during recursion exploration, max length n
        # sc: O(n)
        path = []


        def dfs(start):
            
            # 2 Paths determined by 2 choices:
            # include or exclude the current number

            # Process Root:
            # Yield the current subset (shallow copy of path)
            # tc: O(n) to copy path
            # sc: O(n) for copy, added to output by caller if collected
            yield path[:]

            # Explore All Choices:
            # Iterate over remaining elements starting from index `start`
            # tc: O(n) per level, total recursive calls = 2^n
            for i in range(start, len(nums)):

                # Build New Path:
                # Append current number to path
                # tc: O(1), sc: O(1)
                path.append(nums[i])


                yield from dfs(i + 1)

                # Backtrack To Current Path:
                # Remove last number to explore alternative paths
                # tc: O(1), sc: O(1)
                path.pop()

        # Generator:
        # We create a generator which is passed back to the caller.
        # At this moment we have not found and stored any of the subsets
        # tc: O(1) to create generator
        # sc: O(1) auxiliary for the generator object itself
        generator = dfs(0)

        # Consuming:
        # To get subsets, we need to consume the generator,
        # either by Streaming or Storing In Memory

        # 1. Streaming: iterate over res directly
        #   for subset in res:
        #         print(subset)        ->   subsets will be generated one by one
        
        # 2. Storing In Memory: convert res to a list to store all subsets in memory:
        #   allSubsets = list(res)    ->   now all subsets are generated and stored

        # Note:
        # Choice 2 ignores the issue we were trying to solve of storing everything in memory at one time,
        # Choice 1 would be better as at any moment we are only storing/printing O(1) subset

        # Here we are forced to use Choice 2 because LeetCode's auto-grader inspects 
        # our function to return a value of type List[List[int]]
        res = list(generator)

        # overall: tc O(n * 2^n)    # must generate all subsets, each copied
        # overall: sc O(n)          # only care about size of single subset, allowed by generator
        # overall: sc O(n * 2^n)    # saving size of single subset per each subset, after consuming generator
        return res

90. Subsets II ::1:: - Medium

Topics: Array, Backtracking, Bit Manipulation

Intro

Given an integer array nums that may contain duplicates, return all possible subsets (the power set). The solution set must not contain duplicate subsets. Return the solution in any order.

Example InputOutput
nums = [1,2,2][[],[1],[1,2],[1,2,2],[2],[2,2]]
nums = [0][[],[0]]

Constraints:

1 ≤ nums.length ≤ 10

-10 ≤ nums[i] ≤ 10

Abstraction

Given a list of numbers, with duplicates, return the power set.

Power Set - every possible subset, including empty and full.

Backtracking Choice: 2 Choices - include or exclude number

Set: order does not matter, so [0, 1] and [1, 0] are the same thing.

With duplicates: Given the list [1, 2i, 2j, 3, 4], [1, 2i, 2j] and [1, 2j, 2i] are duplicate sets.

We ignore duplicate choice forks at the same depth level, The same depth level meaning [1, 2i, ...] and [1, 2j, ...] will result in the same exploration.

Pseudocode

  text will go here

Solution 1: [Backtracking] [DFS] 2 Choices Include Integer Path Or Exclude And Continue Exploration While Checking Previous Index For Duplicate - Backtracking/Generate While Constraint Satisfaction

    def subsetsWithDup(self, nums: List[int]) -> List[List[int]]:

        # Backtracking:
        # Recursive function will build subsets incrementally,
        # by using DFS to explore all possible choices at each step.

        # Duplicate Pruning:
        # Sorting brings equal values next to each other, so within a single
        # level of the recursion we only take the first occurrence of a
        # duplicate value and skip the rest -- this avoids generating the same
        # subset more than once (the same value is still reachable deeper in
        # the recursion, just not as a second choice at the same level).

        # sc: O(n * 2^n) 2^n subsets each length up to n
        res = []

        # sc: O(n), holds current subset during recursion of max length n
        path = []

        # Sort ascending to bring duplicates together
        nums.sort()

        def dfs(start):

            # New Valid subset:
            # add to final res

            # Path 1:
            # record current subset, valid at every node not just leaves
            # tc: O(n) subset max length n
            res.append(path[:])

            # Path 2:
            # try including any element we have not added yet,
            # ignoring those we have included via start index
            # tc: O(n)
            for i in range(start, len(nums)):

                # Duplicate Pruning:
                # only allow the first copy in a group of equal values at this level,
                # i == start is always allowed since there's nothing valid to compare yet
                if i > start and nums[i] == nums[i - 1]:
                    continue

                # Add element to testing subset
                path.append(nums[i])

                # Explore:
                # explore this subset and move to next index to avoid reusing current element
                # tc: O(2^n) total recursive calls (all possible subsets)
                dfs(i + 1)

                # Backtrack:
                # Revert adding element to try another element via the for loop
                path.pop()

        # Start backtracking at "root"
        dfs(0)

        # overall: tc O(n * 2^n)
        # overall: sc O(n * 2^n)
        return res

1863. Sum of All Subset XOR Totals ::1:: - Easy

Topics: Array, Math, Backtracking, Bit Manipulation, Combinatorics, Enumeration

Intro

The XOR total of an array is defined as the bitwise XOR of all its elements, or 0 if the array is empty. For example, the XOR total of the array [2,5,6] is 2 XOR 5 XOR 6 = 1. Given an array nums, return the sum of all XOR totals for every subset of nums. Note: Subsets with the same elements should be counted multiple times. An array a is a subset of an array b if a can be obtained from b by deleting some (possibly zero) elements of b.

Example InputOutput
nums = [1,3]6
nums = [5,1,6]28
nums = [3,4,5,6,7,8]480

Constraints:

1 ≤ nums.length ≤ 12

1 ≤ nums[i] ≤ 20

Abstraction

Given a list of numbers, all unique, return the power set.

Power Set - every possible subset, including empty and full.

Backtracking Choice: 2 Choices - include or exclude number

Set: does not matter, [0, 1] and [1, 0] are the same.

As we generate the power set, we keep a running XOR.

Pseudocode

  text will go here

Solution 1: [Backtracking] [DFS] 2 Choices Include Integer Path Or Exclude And Continue Exploration With Final XOR - Backtracking/Generate All Combinations Or Subsets

    def subsetXORSum(self, nums: List[int]) -> int:

        # Backtracking:
        # Recursive function will explore every subset incrementally,
        # by using DFS to explore all possible choices at each step,
        # the same traversal shape as generating all subsets (LC 78),
        # except instead of storing each subset we accumulate its XOR total.

        # At each step we can either include or exclude a number,
        # giving us 2 options, so we get 2^n subsets total.

        # sc: O(n), holds current path during recursion of max length n
        path = []

        # sc: O(1), running sum of every subset's XOR total
        self.total = 0

        # tc: O(n * 2^n)  2^n subsets, each XOR computed and summed
        # sc: O(n)        recursion stack + path, no result list needed
        def dfs(start: int, curXor: int):

            # New Valid subset:
            # add its XOR total to the running sum

            # Path 1:
            # stop including any further integers from here on, so no need to keep exploring
            # tc: O(1)
            self.total += curXor

            # Path 2:
            # try including any integer we have not added yet,
            # ignoring those we have included via start index
            # tc: O(n)
            for i in range(start, len(nums)):

                # Add integer to testing subset
                path.append(nums[i])

                # Explore:
                # explore this subset, XOR the new number into curXor,
                # and pass start index for including new integers
                # tc: O(2^n)
                dfs(i + 1, curXor ^ nums[i])

                # Backtrack:
                # Revert adding integer to try another integer via the for loop
                path.pop()

        # Start backtracking at "root" with an empty subset (XOR total 0)
        dfs(0, 0)

        # overall: tc O(n * 2^n)
        # overall: sc O(n)
        return self.total

Solution 2: [Bit Manipulation] OR Then Shift Bit Contribution Counting - Bit Manipulation/Counting Set Bit Contributions Across All Subsets

    def subsetXORSum(self, nums: List[int]) -> int:

        # Bit Manipulation:
        # Instead of enumerating all 2^n subsets, we reason about each bit
        # position independently: for a fixed bit position, how many of the
        # 2^n subsets have that bit set in their XOR total?

        # Key Insight:
        # A given bit position ends up set in a subset's XOR total only if an
        # ODD number of elements in that subset have that bit set. If at least
        # one number in nums has a bit set, then across all 2^n subsets,
        # exactly half (2^(n-1)) of them contain an odd count of that bit --
        # this holds because for every subset with an odd count, pairing it
        # with/without any one fixed "bit-set" element flips it to an even
        # count and back, giving a perfect 1:1 split between odd and even.
        # If NO number has that bit set, 0 subsets contribute it, obviously.

        # Reduction:
        # So a bit contributes to the final sum only if it's set in at least
        # one number (captured by OR-ing every number together), and when it
        # does contribute, it contributes exactly 2^(n-1) times. This means
        # the final answer is just: (OR of all nums) * 2^(n-1), which is the
        # same as left-shifting the OR result by (n - 1) bits.

        # sc: O(1), single accumulator
        orTotal = 0

        # tc: O(n), OR every number together
        for num in nums:

            # Combine:
            # OR in this number's bits, any bit set here is set in at least
            # one subset element and will contribute to the final sum
            orTotal |= num

        # overall: tc O(n)
        # overall: sc O(1)
        return orTotal << (len(nums) - 1)

39. Combination Sum ::4:: - Medium

Topics: Array, Backtracking

Intro

Given an array of distinct integers candidates and a target integer target, return a list of all unique combinations of candidates where the chosen numbers sum to target. You may return the combinations in any order. The same number may be chosen from candidates an unlimited number of times. Two combinations are unique if the frequency of at least one of the chosen numbers is different. The test cases are generated such that the number of unique combinations that sum up to target is less than 150 combinations for the given input.

Example InputOutput
candidates = [2,3,6,7], target = 7[[2,2,3],[7]]
candidates = [2,3,5], target = 8[[2,2,2,2],[2,3,3],[3,5]]
candidates = [2], target = 1[]

Constraints:

1 ≤ candidates.length ≤ 30

2 ≤ candidates[i] ≤ 40

All the numbers of nums are unique.

1 ≤ target ≤ 40

Abstraction

Given a list of integers, return all possible combinations that add up to target.

All combinations: assortment of elements using elements multiple or no times where order does not matter

Backtracking Choice: 2 Choices at each step - include or exclude the current number, but including it does NOT advance the index (reuse allowed), and stop adding copies of the current number once sum > target

Pseudocode

  text will go here

Solution 1: [Backtracking] [DFS] Early Pruning Sorted Forward Backtracking On Global Path - Backtracking/Generate While Constraint Satisfaction

    def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
        
        # Backtracking:
        # Recursive function will build valid combinations incrementally,
        # by using DFS to explore only choices that can still reach the target.

       # Early Pruning:
        # Sorting candidates ascending lets us skip candidates that exceed the
        # remaining sum before ever recursing into them, pruning branches from
        # the root itself instead of recursing first and discovering they're invalid.

        # sc: O(n)
        res = []

        # sc: O(depth), holds current subset during recursion
        path = []

        # Sort ascending to allow early pruning
        candidates.sort()

        def dfs(start, remaining):

            # New Valid combination:
            # add to final res

            # Path 1:
            # remaining sum has hit zero, no further candidates needed
            # tc: O(depth) subset max length depth
            if remaining == 0:
                res.append(path[:])
                return

            # Path 2:
            # try including any candidate we have not added yet,
            # ignoring those we have skipped via start index
            # tc: O(n)
            for i in range(start, len(candidates)):

                candidate = candidates[i]

                # Early Pruning:
                # candidates are sorted ascending, 
                # so if this one exceeds remaining all others after it will too,
                # so we can stop exploring this branch entirely 
                if candidate > remaining:
                    break

                # Add candidate to testing subset
                path.append(candidate)

                # Explore:
                # explore this subset and pass same index to allow reuse of current candidate
                # tc: O(n^(target/min_cand))
                dfs(i, remaining - candidate)

                # Backtrack:
                # Revert adding candidate to try another candidate via the for loop
                path.pop()

        # Start backtracking at "root"
        dfs(0, target)

        # overall: tc O(n^(target/min_cand))
        # overall: sc O(target/min_cand + output size)
        return res

40. Combination Sum II ::2:: - Medium

Topics: Array, Backtracking

Intro

Given a collection of candidate numbers (candidates) and a target number (target), find all unique combinations in candidates where the candidate numbers sum to target. Each number in candidates may only be used once in the combination. Note: The solution set must not contain duplicate combinations.

Example InputOutput
candidates = [10,1,2,7,6,1,5], target = 8[[1,1,6], [1,2,5], [1,7], [2,6]]
candidates = [2,5,2,1,2], target = 5[[1,2,2], [5]]

Constraints:

1 ≤ candidates.length ≤ 100

2 ≤ candidates[i] ≤ 30

1 ≤ target ≤ 30

Abstraction

Given a list of integers, return all possible combinations that add up to target, which happens to be the same as all sets within the power set that add up to the target

All combinations: assortment of elements using elements one or no times where order does not matter All sets in power set: collection of subsets of list that add up to element

Backtracking Choice: 2 Choices - include or exclude number

Set: order does not matter, so [0, 1] and [1, 0] are the same thing.

With duplicates, ignore choice forks at the same depth level, we have already hit: Given the list: [1, 2i, 2j, 3, 4] - [1, 2i, 2j] and [1, 2j, 2i] are duplicates.

The same depth level meaning [1, 2i, ...] and [1, 2j, ...] will result in the same exploration.

Pseudocode

  text will go here

Solution 1: Recursive Sorted Early Pruning Forward Backtracking on Current Combination - Backtracking/Generate While Constraint Satisfaction

    def combinationSum2(self, candidates: List[int], target: int) -> List[List[int]]:
       
        # Backtracking:
        # Recursive function will build valid combinations incrementally,
        # by using DFS to explore only choices that can still reach the target.
        # Unlike combinationSum, each candidate can only be used once, and the
        # input may contain duplicate values that must not produce duplicate combinations.

        # Early Pruning:
        # Sorting candidates ascending lets us skip candidates that exceed the
        # remaining sum before ever recursing into them, pruning branches from
        # the root itself instead of recursing first and discovering they're invalid.

        # Duplicate Pruning:
        # Sorting also aligns equal values next to each other, so within a single
        # level of the recursion we only take the first occurrence of a duplicate
        # value and skip the rest -- this avoids generating the same combination
        # more than once (a later, later-occurring duplicate is still reachable
        # deeper in the recursion, just not as a second choice at the same level)

        # sc: O(depth)
        res = []
        
        # sc: O(depth), holds current subset during recursion
        path = []

        # Sort ascending to allow early pruning and duplicate pruning
        candidates.sort() 

        def dfs(start, remaining):

            # Process Root -> : check if valid combination, shallow copy
            if remaining == 0:
                res.append(path[:])
                return
            
            # Process Choices -> : explore numbers from current index onward
            for i in range(start, len(candidates)):
                
                # Duplicate Pruning:
                # Only use the first copy of an integer, check if previous index is a duplicate.
                # i > start ensures the first iteration is always valid,
                # only candidates after the first iteration get compared for avoiding negative indexing
                if i > start and candidates[i] == candidates[i - 1]:
                    continue

                # Early Pruning:
                # candidates are sorted ascending, so if this one exceeds remaining
                # all others after it will too, so we can stop exploring this branch entirely
                if candidates[i] > remaining:
                    break

                # Add candidate to testing subset
                path.append(candidates[i])

                # Explore:
                # explore this subset and move to next index, since each candidate
                # can only be used once (no reuse of current index, unlike combinationSum)
                # tc: O(2^n) in the worst case, fewer in practice due to pruning
                dfs(i + 1, remaining - candidates[i])

                # Backtrack:
                # Revert adding candidate to try another candidate via the for loop
                path.pop()

        # Start backtracking at "root"
        dfs(0, target)

        # overall: tc O(n * 2^n)
        # overall: sc O(n + output size)
        return res

216. Combination Sum III ::2:: - Medium

Topics: Array, Backtracking

Intro

Find all valid combinations of k numbers that sum up to n such that the following conditions are true: Only numbers 1 through 9 are used. Each number is used at most once. Return a list of all possible valid combinations. The list must not contain the same combination twice, and the combinations may be returned in any order.

Example InputOutput
k = 3, n = 7[[1,2,4]]
k = 3, n = 9[[1,2,6],[1,3,5],[2,3,4]]
k = 4, n = 1[]

Constraints:

2 ≤ k ≤ 9

1 ≤ n ≤ 60

Abstraction

Given a list of integers [1, 2, 3, 4, 5, 6, 7, 8, 9], return all possible combinations that add up to target, which happens to be the same as all sets within the power set that add up to the target

All combinations: assortment of elements using elements one or no times where order does not matter All sets in power set: collection of subsets of list that add up to element

Backtracking Choice: 2 Choices - include or exclude number

Set: order does not matter, so [0, 1] and [1, 0] are the same thing.

With duplicates: Given the list [1, 2i, 2j, 3, 4], [1, 2i, 2j] and [1, 2j, 2i] are duplicate sets.

We ignore duplicate choice forks at the same depth level, The same depth level meaning [1, 2i, ...] and [1, 2j, ...] will result in the same exploration.

Pseudocode

  text will go here

Solution 1: [Backtracking] [DFS] Early Pruning Recursive Sorted Forward Backtracking On Global Path With Count Constraint - Backtracking/Generate While Constraint Satisfaction

    def combinationSum3(self, k: int, n: int) -> List[List[int]]:

        # Backtracking:
        # Recursive function will build valid combinations incrementally,
        # by using DFS to explore only choices that can still reach both the
        # target sum and the exact count k, same shape as Combination Sum
        # (LC 39), with two constraints tracked together instead of one.

        # Early Pruning:
        # Candidates 1-9 are naturally sorted ascending, so once a candidate
        # exceeds the remaining sum, every candidate after it will too --
        # prune that branch immediately instead of recursing into it.

        # Count Pruning:
        # Since each number is used at most once and only 1-9 are available,
        # we can also stop exploring a branch early if there aren't enough
        # remaining candidates left to reach k numbers, or if the path has
        # already reached k numbers without reaching n.

        # sc: O(k)
        res = []

        # sc: O(k), holds current path during recursion, max length k
        path = []

        def dfs(start: int, remaining: int):

            # New Valid combination:
            # add to final res

            # Path 1:
            # path has exactly k numbers and remaining sum has hit zero
            # tc: O(k) subset max length k
            if len(path) == k and remaining == 0:
                res.append(path[:])
                return

            # Path 2:
            # path already has k numbers but sum hasn't hit zero, or no
            # candidates remain -- neither can lead to a valid combination
            if len(path) == k or start > 9:
                return

            # Path 3:
            # try including any candidate from 'start' through 9 we have not added yet
            # tc: O(9) per call, bounded by fixed digit range
            for candidate in range(start, 10):

                # Early Pruning:
                # candidates are ascending, so if this one exceeds remaining
                # all others after it will too, so we can stop exploring this branch entirely
                if candidate > remaining:
                    break

                # Add candidate to testing subset
                path.append(candidate)

                # Explore:
                # explore this subset and move to next digit, since each candidate
                # can only be used once (no reuse of current digit)
                # tc: O(C(9, k)) total combinations in the worst case, fewer with pruning
                dfs(candidate + 1, remaining - candidate)

                # Backtrack:
                # Revert adding candidate to try another candidate via the for loop
                path.pop()

        # Start backtracking at "root"
        dfs(1, n)

        # overall: tc O(C(9, k) * k)
        # overall: sc O(k)
        return res

22. Generate Parentheses ::4:: - Medium

Topics: String, Stack, Dynamic Programming, Backtracking

Intro

Given n pairs of parentheses, write a function to generate all combinations of well-formed parentheses.

InputOutput
1["()"]
3["((()))","(()())","(())()","()(())","()()()"]

Constraints:

1 ≤ n ≤ 8

Abstract

Given a number, which will determine the list of parentheses ['(', '(', ')', ')'] return all possible combinations that have a valid sequence of parentheses.

All combinations: assortment of elements using elements one or no times where order does not matter All sets in power set: collection of subsets of list that have a valid sequence of parentheses.

Backtracking Choice: 2 Choices - include or exclude parentheses

Set: order does not matter, so [0, 1] and [1, 0] are the same thing.

With duplicates, ignore choice forks at the same depth level, we have already hit: Given the list: ['(', '(', ')', ')'] - ['(', '(', ')', ')'] and ['(', ')', '(', ')'] are duplicates.

The same depth level meaning ['(', '(', ...] and ['(', '(', ...] will result in the same exploration.

To avoid that, we don't actually iterate over a list, we pretend to encounter them via 2 if statements and only add parentheses when the conditions allow.

Pseudocode

  text will go here

Solution 1: [Backtracking] [DFS] Recursive with Mutable List Appending - Stack/Backtracking by Tracking History or State

    def generateParenthesis(self, n: int) -> List[str]:
        
        # Backtracking:
        # Recursive function will build valid parenthesis sequences incrementally,
        # by using DFS to explore only choices that keep the sequence valid so far.


        # List Mutability:
        # We use a single list 'curr' and modify it in place using append/pop.
        # The list only gets converted when we find a valid combination of length O(n)
        # There are Catalan(n) valid sequences each of length O(n)

        # sc: O(Catalan(n) * n), holds all valid sequences once complete
        res = []
        
        def dfs(current, openCount, closeCount):

            # New Valid sequence:
            # add to final res

            # Path 1:
            # both open and close counts have reached n, valid sequence found
            # tc: O(n) to join current into a string
            if openCount == n and closeCount == n:
                res.append("".join(current))
                return

            # Path 2a:
            # add an open paren, if don't have n total yet,
            # ensuring the sequence never has more '(' than allowed
            if openCount < n:

                # Choose:
                # append open '('
                current.append('(')

                # Explore:
                # recurse with updated counts
                dfs(current, openCount + 1, closeCount)

                # Backtrack:
                # remove last open '('
                current.pop()

            # Path 2b:
            # add a close paren, if we have less than the number of open paren,
            # ensuring every close paren has an open paren to pair with it
            if closeCount < openCount:

                # Choose:
                # append close ')'
                current.append(')')

                # Explore:
                # recurse with updated counts
                dfs(current, openCount, closeCount + 1)

                # Backtrack:
                # remove last close ')'
                current.pop()

        # Start backtracking at "root", empty list passed
        backtrack([], 0, 0)

        # overall: tc O(Catalan(n) * n)
        # overall: sc O(Catalan(n) * n)
        return res
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
Recursive pathsO(Catalan(n))O(n)Exponential branchingStack depth of 2n
String creationO(n) per complete pathO(n) per resultOnly joined once per resultEach string result is size 2n
OverallO(Catalan(n) * n)O(Catalan(n) * n)Avoids repeated copiesStores all valid combinations

Solution 2: [Dynamic Programming] Two Pointer Opposite Ends Catalan Pattern To Build Parentheses Combinations - Stack/Dynamic Programming State Compression

    def generateParenthesis(self, n: int) -> List[str]:
        
        # Dynamic Programming:
        # We exploit the recursive structure of Catalan numbers
        # by building a list of all valid parentheses combinations for each level n

        # Two Pointer:
        # If we have a list of valid combinations of parenthesis, 
        # we can append them in a format that generate more valid combinations

        # dp:    list of list of parentheses combinations 
        # dp[n]: list containing all valid parenthesis combinations for the nth level (n pair of parenthesis)
        dp = []
        for _ in range(n + 1):
            dp.append([])     

        # Base case: valid combination for n = 0
        dp[0] = [""]  
        
        # Iterate: create valid lists from dp[1] to dp[n]
        # Current: building list for ith pair
        # tc: iterate over list of n length O(n) 
        for i in range(1, n + 1): 

            # iterate over stored lists up to now
            for j in range(i):  

                # Opposite Ends Two Pointer Variation:
                # Since pointers will meet in middle and cross each other, 
                # they will get all variations of combinations already built,
                # which allows us to format them to generate more valid combinations

                # Setup: Opposite Ends Two Pointer Variation
                # Forward and Reverse Iteration

                # Forward Iteration List 1: 
                # Iterate over each valid string of parentheses for level j,
                # starting at first parentheses pairs level of 0 pairs of parenthesis
                for left in dp[j]:

                    # Reverse Iteration List 2: 
                    # Iterate over each valid string of parentheses for level (i - 1 - j),
                    # starting at last parenthesis pairs level of largest number pairs currently available
                    for right in dp[i - 1 - j]:

                        # Generation Format:
                        # ({left}){right} or {left}({right}) are both valid formulas
                        # Since we are doing Opposite Ends and left and right will cross over each other,
                        # left and right will both individually use all values
                        # which means with these 2 iterations
                        # we are eventually doing both the same thing
                        # dp[i].append(f"{left}({right})")
                        dp[i].append(f"({left}){right}")

        # overall: tc O(Catalan(n) * n)
        # overall: sc O(Catalan(n) * n)
        return dp[n]
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
DP State buildO(Catalan(n))O(Catalan(n))Combines all previous resultsdp[0] to dp[n] built cumulatively
String constructionO(n) per combinationO(n) per resultEach result takes O(n) to formFinal dp[n] holds all combinations
OverallO(Catalan(n) * n)O(Catalan(n) * n)No recursion but same asymptotic boundStore all intermediate and final results

46. Permutations ::1:: - Medium

Topics: Array, Backtracking

Intro

Given an array nums of distinct integers, return all the possible . You can return the answer in any order. A permutation is a rearrangement of all the elements of an array.

Example InputOutput
nums = [1,2,3][[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
nums = [0,1][[0,1],[1,0]]
nums = [1][[1]]

Constraints:

1 ≤ nums.length ≤ 6

2 ≤ nums[i] ≤ 30

All the integers of nums are unique.

Abstraction

Given a list of numbers, all unique, return all possible permutations.

All Permutations: every possible ordering of the list

Backtracking Choice: N choices at each step - pick any unused number for the current position (N choices for position 1, N-1 for position 2, etc.)

Permutation: order does matter and must use all elements: [0, 1] and [1, 0] are distinct permutations.

Pseudocode

  text will go here

Solution 1: [Backtracking] [DFS] Recursive DFS Backtracking on Current Permutation Path with Element Usage Tracking - Backtracking/Generate While Constraint Satisfaction

    def permute(self, nums: List[int]) -> List[List[int]]:

        # Backtracking:
        # Recursive function will build permutations incrementally,
        # by using DFS to explore every not-yet-used element at each position.

        # Element Usage Tracking:
        # Unlike subset/combination problems, order matters here and every
        # element must appear exactly once per permutation, so instead of a
        # start index we track which elements have already been placed in the
        # current path with a 'used' boolean array, and skip those.

        # sc: O(n)
        res = []

        # sc: O(n), holds current path during recursion, max length n
        path = []

        # sc: O(n), tracks which elements are already in the current path
        used = [False] * len(nums)

        def dfs():

            # New Valid permutation:
            # add to final res

            # Path 1:
            # path length has reached n, every element has been placed
            # tc: O(n) subset max length n
            if len(path) == len(nums):
                res.append(path[:])
                return

            # Path 2:
            # try including any element that has not been used yet
            # tc: O(n)
            for i in range(len(nums)):

                # Early Candidate Pruning:
                # skip elements already placed in the current path
                if used[i]:
                    continue

                # Add element to testing path
                path.append(nums[i])
                used[i] = True

                # Explore:
                # explore this path, all remaining unused elements are still candidates
                # tc: O(n!) total recursive calls (all possible permutations)
                dfs()

                # Backtrack:
                # Revert adding element to try another element via the for loop
                path.pop()
                used[i] = False

        # Start backtracking at "root"
        dfs()

        # overall: tc O(n * n!)
        # overall: sc O(n)
        return res

47. Permutations II ::1:: - Medium

Topics: Array, Backtracking, Sorting

Intro

Given a collection of numbers, nums, that might contain duplicates, return all possible unique permutations in any order.

Example InputOutput
nums = [1,1,2][[1,1,2], [1,2,1], [2,1,1]]
nums = [1,2,3][[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

Constraints:

1 ≤ nums.length ≤ 8

-10 ≤ nums[i] ≤ 10

Abstraction

Given a list of numbers, with duplicates, return all possible permutations.

All Permutations: every possible ordering of the list

Backtracking Choice: N choices at each step - pick any unused number for the current position (N choices for position 1, N-1 for position 2, etc.)

Permutation: order does matter and must use all elements, so [0, 1] and [1, 0] are two distinct permutations.

With duplicates: Given the list [1, 2i, 2j, 3, 4], [1, 2i, 2j] and [1, 2j, 2i] are duplicate sets.

We ignore duplicate choice forks at the same depth level, The same depth level meaning [1, 2i, ...] and [1, 2j, ...] will result in the same exploration.

Pseudocode

  text will go here

Solution 1: [Backtracking] [DFS] Recursive DFS Backtracking On Current Permutation Path With Sorted Duplicate Skipping - Backtracking/Generate While Constraint Satisfaction

    def permuteUnique(self, nums: List[int]) -> List[List[int]]:

        # Backtracking:
        # Recursive function will build permutations incrementally,
        # by using DFS to explore every not-yet-used element at each position,
        # same traversal shape as Permutations (LC 46), extended to skip
        # duplicate values so no permutation is generated more than once.

        # Element Usage Tracking:
        # Order matters here and every element must appear exactly once per
        # permutation, so instead of a start index we track which elements
        # have already been placed in the current path with a 'used' boolean
        # array, and skip those.

        # Duplicate Pruning:
        # Sorting brings equal values next to each other. At each position in
        # the permutation (each level of recursion), we only allow the first
        # not-yet-used copy of a repeated value to be placed -- if an earlier
        # copy of the same value hasn't been used yet, placing a later copy
        # first would just produce a permutation identical to one we'll
        # already generate by placing the earlier copy first.

        # sc: O(n)
        res = []

        # sc: O(n), holds current path during recursion, max length n
        path = []

        # sc: O(n), tracks which elements are already in the current path
        used = [False] * len(nums)

        # Sort ascending so duplicates sit next to each other
        nums.sort()

        def dfs():

            # New Valid permutation:
            # add to final res

            # Path 1:
            # path length has reached n, every element has been placed
            # tc: O(n) subset max length n
            if len(path) == len(nums):
                res.append(path[:])
                return

            # Path 2:
            # try including any not-yet-used element
            # tc: O(n)
            for i in range(len(nums)):

                # Early Candidate Pruning:
                # skip elements already placed in the current path
                if used[i]:
                    continue

                # Duplicate Pruning:
                # skip a repeated value if the previous identical value hasn't
                # been used yet -- ensures duplicates are only ever placed in
                # left-to-right order at this level, avoiding duplicate permutations
                if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]:
                    continue

                # Add element to testing path
                path.append(nums[i])
                used[i] = True

                # Explore:
                # explore this path, all remaining unused elements are still candidates
                # tc: O(n!) total recursive calls in the worst case, fewer with duplicates
                dfs()

                # Backtrack:
                # Revert adding element to try another element via the for loop
                path.pop()
                used[i] = False

        # Start backtracking at "root"
        dfs()

        # overall: tc O(n * n!)
        # overall: sc O(n)
        return res

797. All Paths From Source To Target ::2:: - Medium

Topics: Backtracking, Depth First Search, Breadth First Search, Graph Theory, Adjacency List, Directed Acyclic Graph

Intro

Given a directed acyclic graph (DAG) of n nodes labeled from 0 to n - 1, find all possible paths from node 0 to node n - 1 and return them in any order. The graph is given as follows: graph[i] is a list of all nodes you can visit from node i (i.e., there is a directed edge from node i to node graph[i][j]).

Example InputOutput
graph = [[1,2],[3],[3],[]][[0,1,3],[0,2,3]]
graph = [[4,3,1],[3,2,4],[3],[4],[]][[0,4],[0,3,4],[0,1,3,4],[0,1,2,3,4],[0,1,4]]

Constraints:

n == graph.length

2 ≤ n ≤ 15

0 ≤ graph[i][k] < n

graph[i][j] != i (i.e., there will be no self-loops).

All the elements of graph[i] are unique.

The input graph is guaranteed to be a DAG.

Abstraction

Find all possible paths from node to node.

Pseudocode

  text will go here

Solution 1: [Backtracking] [DFS] DFS Backtracking Path Building - Tree/DFS Recursive Backtracking

    def allPathsSourceTarget(self, graph: List[List[int]]) -> List[List[int]]:

        # Backtracking:
        # Recursive function will build paths from node 0 to node n-1
        # incrementally, by using DFS to extend the current path along every
        # outgoing edge. Since the graph is guaranteed to be a DAG, there are
        # no cycles to worry about, so no visited set is needed -- every path
        # DFS explores is guaranteed to terminate.

        # Adjacency List (as given):
        # graph = [
        #           [1, 2],     0 -> 1, 0 -> 2
        #           [3],        1 -> 3
        #           [3],        2 -> 3
        #           [],         3 has no outgoing edges
        #         ]
        # Unlike most graph problems, the input here is already an Adjacency
        # List -- graph[i] is literally the list of nodes reachable from i.
        # There's no Edge List to transform first.

        n = len(graph)

        # sc: O(2^V * V) worst case, DAG can have exponentially many paths
        res = []

        # sc: O(V), holds current path during recursion, max length V
        path = [0]

        def dfs(node):

            # New Valid path:
            # reached the target, record a copy of the current path
            # tc: O(V) to copy path
            if node == n - 1:
                res.append(path[:])
                return

            # Path 1:
            # try extending the path along every outgoing edge from this node
            for nei in graph[node]:

                # Choose:
                # extend path with neighbor
                path.append(nei)

                # Explore:
                # recursively explore neighbor
                dfs(nei)

                # Backtrack:
                # remove neighbor so sibling branches can reuse path
                path.pop()

        # Start backtracking at "root", node 0
        dfs(0)

        # overall: tc O(2^V * V), up to 2^V paths in the worst case DAG, each up to V nodes to copy
        # overall: sc O(2^V * V), for storing all paths in res
        #   (auxiliary space, excluding output, is O(V) for recursion stack + path)
        return res
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Solution 2: [Backtracking] [BFS] BFS Iterative Path Building - Tree/BFS Iterative Backtracking

    def allPathsSourceTarget(self, graph: List[List[int]]) -> List[List[int]]:

        # Backtracking (Iterative):
        # Same idea as Solution 1 -- build paths from node 0 to node n-1 by
        # extending along every outgoing edge -- but explored via a queue
        # instead of recursion. Since BFS has no call stack to backtrack on,
        # each queue entry must carry its OWN independent copy of the path
        # so far -- there's no shared mutable list to undo after a node.

        # Unlike single-target BFS problems, we can't stop at the first time
        # the target is reached, since every path is needed, not just the
        # shortest one. Every time a path reaches the target it's recorded,
        # but BFS continues until the queue is empty so all other
        # in-progress paths are still explored.

        n = len(graph)

        # sc: O(2^V * V) worst case, DAG can have exponentially many paths
        res = []

        # sc: O(2^V * V), queue can hold many in-progress paths at once,
        # each up to O(V) long, with its own independent copy
        queue = deque([[0]])

        while queue:

            # Pop a path from the queue
            path = queue.popleft()

            # Grab the last node in this path to explore its neighbors
            node = path[-1]

            # New Valid path:
            # reached the target, record this complete path
            if node == n - 1:
                res.append(path)
                continue

            # Path 1:
            # try extending the path along every outgoing edge from this node
            for nei in graph[node]:

                # Choose + Explore:
                # build a new path with neighbor appended, queue for further exploration
                # tc: O(V) per enqueue to copy and extend path
                queue.append(path + [nei])

        # overall: tc O(2^V * V), up to 2^V paths in the worst case DAG, each up to V nodes to build/copy
        # overall: sc O(2^V * V), for storing all paths in the queue and res
        return res
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

113. Path Sum II ::2:: - Medium

Topics: Backtracking, Tree, Depth First Search, Breadth First Search, Binary Tree

Intro

Given the root of a binary tree and an integer targetSum, return all root-to-leaf paths where the sum of the node values in the path equals targetSum. Each path should be returned as a list of the node values, not node references. A root-to-leaf path is a path starting from the root and ending at any leaf node. A leaf is a node with no children.

Example InputOutput
root = [5,4,8,11,null,13,4,7,2,null,null,5,1], targetSum = 22[[5,4,11,2],[5,8,4,5]]
root = [1,2,3], targetSum = 5[]
root = [1,2], targetSum = 0[]

Constraints:

The number of nodes in the tree is in the range [0, 5000].

-1000 ≤ Node.val ≤ 1000

-1000 ≤ targetSum ≤ 1000

Abstraction

Check if a root to leaf path exists that adds to target and return all possible paths.

Pseudocode

  text will go here

Solution 1: [Backtracking] [DFS] Recursive DFS Pre Order Backtracking Path Collection - Tree/DFS Pre Order Recursive One Sided Top Down Backtracking

    def pathSum(self, root: Optional[TreeNode], targetSum: int) -> List[List[int]]:

        # Backtracking:
        # Recursive function will trace root-to-leaf paths incrementally,
        # by using DFS to descend into every subtree while tracking the
        # running path and remaining sum still needed. Unlike a problem
        # that can stop at the first valid path, every root-to-leaf path
        # must be explored, and any that sum to targetSum must be collected.

        # sc: O(n) worst case (skewed tree), holds all collected paths
        res = []

        # sc: O(h), holds current path during recursion, max length = tree height h
        path = []

        def dfs(node, remaining):

            # Path 1:
            # reached past a leaf, empty subtree cannot complete a path
            if not node:
                return

            # Choose:
            # add this node to the current path before descending
            path.append(node.val)
            remaining -= node.val

            # New Valid path:
            # true leaf -- no left AND no right child -- record this path
            # only if it exactly accounts for the remaining amount
            if not node.left and not node.right:
                if remaining == 0:
                    # tc: O(h) to copy path, since path keeps mutating
                    res.append(path[:])

            # Path 2:
            # a node with only one child is NOT a leaf, keep exploring
            # whichever sides exist
            else:

                # Explore:
                # recurse into left and right subtrees with updated remaining
                dfs(node.left, remaining)
                dfs(node.right, remaining)

            # Backtrack:
            # remove this node before returning to the parent call,
            # so it doesn't leak into sibling paths
            path.pop()

        # Start backtracking at "root"
        dfs(root, targetSum)

        # overall: tc O(n^2) worst case, n calls each doing O(n) path copy on a skewed tree
        # overall: sc O(n) worst case, recursion stack + path, skewed tree
        return res
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Solution 2: [Backtracking] [BFS] Iterative BFS Pre Order Per Node Path Copy Queue - Tree/BFS Pre Order Across Level No Explicit Level Sized Grouping Full Across Top Down

    def pathSum(self, root: Optional[TreeNode], targetSum: int) -> List[List[int]]:

        # Backtracking (Iterative):
        # Same "remaining sum" and "true leaf" rules as Solution 1, but
        # explored level by level via a queue instead of recursion. Since
        # BFS has no call stack to backtrack on, each queue entry must carry
        # its OWN independent copy of the path so far -- there's no shared
        # mutable list to undo after processing a node.

        # Path 1:
        # empty tree has no path at all
        if not root:
            return []

        # sc: O(n) worst case (skewed tree), holds all collected paths
        res = []

        # sc: O(n), holds (node, remaining, path) tuples currently queued,
        # each path copy up to O(h) long
        queue = deque([(root, targetSum - root.val, [root.val])])

        while queue:

            # Pop next node, its remaining amount, and its path so far
            node, remaining, path = queue.popleft()

            # New Valid path:
            # true leaf -- no left AND no right child -- record this path
            # only if it exactly accounts for the remaining amount
            if not node.left and not node.right:
                if remaining == 0:
                    res.append(path)
                continue

            # Choose + Explore:
            # enqueue children that exist, each with its own path copy
            # extended by that child's value -- a missing side simply
            # never gets enqueued
            # tc: O(h) per enqueue to copy and extend path
            if node.left:
                queue.append((node.left, remaining - node.left.val, path + [node.left.val]))
            if node.right:
                queue.append((node.right, remaining - node.right.val, path + [node.right.val]))

        # overall: tc O(n^2) worst case, each of n nodes does an O(n) path copy on a skewed tree
        # overall: sc O(n^2) worst case, every queued path independently copies up to O(n) values
        return res
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

79. Word Search ::2:: - Medium

Topics: Array, String, Backtracking, Depth First Search, Matrix

Intro

Given an m x n grid of characters board and a string word, return true if word exists in the grid. The word can be constructed from letters of sequentially adjacent cells, where adjacent cells are horizontally or vertically neighboring. The same letter cell may not be used more than once.

Example InputOutput
board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "ABCCED"true
board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "SEE"true

Constraints:

m == board.length

n = board[i].length

1 ≤ m, n ≤ 6

1 ≤ word.length ≤ 15

board and word consists of only lowercase and uppercase English letters.

Follow up: Could you use search pruning to make your solution faster with a larger board?

Abstraction

Find if word exists in grid.

Pseudocode

  text will go here

Solution 1: [Backtracking] [DFS] DFS Backtracking on Current Path - Backtracking/Path Finding in Search Space

    def exist(self, board: List[List[str]], word: str) -> bool:

        # Backtracking:
        # Recursive function will trace a path through the grid incrementally,
        # by using DFS to try extending the current path in all 4 directions,
        # only continuing a branch while it still matches the word so far.

        # sc: O(1), rows/cols are fixed reference values, not per-cell storage
        rows, cols = len(board), len(board[0])

        def dfs(r, c, i):

            # Path 1:
            # every character in word has been matched, full path found
            if i == len(word):
                return True

            # Path 2:
            # cell is outside the grid, this direction can't be explored
            if r < 0 or c < 0 or r >= rows or c >= cols:
                return False

            # Path 3:
            # current cell doesn't match the next expected letter
            if board[r][c] != word[i]:
                return False

            # Choose:
            # mark current cell as visited by overwriting it, so the same
            # cell can't be reused later in this same path
            tmp, board[r][c] = board[r][c], "#"

            # Explore:
            # recurse in all 4 directions, short-circuit on first match found
            # tc: O(4^L) total recursive calls in the worst case, L = len(word)
            found = (dfs(r + 1, c, i + 1) or
                     dfs(r - 1, c, i + 1) or
                     dfs(r, c + 1, i + 1) or
                     dfs(r, c - 1, i + 1))

            # Backtrack:
            # restore original cell value so other starting points can reuse it
            board[r][c] = tmp
            return found

        # Try starting DFS from every cell on the board
        # tc: O(rows * cols) starting points
        for r in range(rows):
            for c in range(cols):
                if dfs(r, c, 0):
                    return True

        return False

        # overall: tc O(rows * cols * 4^L), L = len(word)
        # overall: sc O(L), recursion stack depth bounded by word length

Solution 2: [Backtracking] [DFS] Word Reversal Optimization DFS Backtracking on Current Path - Backtracking/Path Finding in Search Space

    def exist(self, board: List[List[str]], word: str) -> bool:

        # Backtracking:
        # Same traversal shape as Solution 1 -- trace a path through the grid
        # via DFS in all 4 directions, only continuing while the path still
        # matches the word so far.

        # Optimization vs Solution 1:
        # Before searching, we can rule out impossible cases and cut down the
        # branching factor of the search:
        #   1. If the board doesn't contain enough of any letter the word
        #      needs, no path can possibly exist -- return False immediately.
        #   2. Starting the search from the rarer of the word's first/last
        #      letter reduces how many cells trigger a full DFS attempt, and
        #      tends to fail faster when there's no valid path, since rarer
        #      starting letters mean fewer branches explored overall.

        # sc: O(1), rows/cols are fixed reference values, not per-cell storage
        rows, cols = len(board), len(board[0])

        # sc: O(1), fixed alphabet size (26 letters) regardless of input size
        word_count = Counter(word)
        board_count = Counter(c for row in board for c in row)

        # Early Word Pruning:
        # if the board doesn't contain enough of any needed letter,
        # no path can possibly spell out the word
        # tc: O(len(word)) to check every distinct letter needed
        for char, count in word_count.items():
            if board_count[char] < count:
                return False

        # Early Recurse Optimization:
        # search from whichever end of the word is rarer on the board,
        # since starting from a rarer letter triggers fewer DFS attempts overall
        if board_count[word[0]] > board_count[word[-1]]:
            word = word[::-1]

        def dfs(r, c, i):

            # Path 1:
            # every character in word has been matched, full path found
            if i == len(word):
                return True

            # Path 2:
            # cell is outside the grid, this direction can't be explored
            if r < 0 or c < 0 or r >= rows or c >= cols:
                return False

            # Path 3:
            # current cell doesn't match the next expected letter
            if board[r][c] != word[i]:
                return False

            # Choose:
            # mark current cell as visited by overwriting it, so the same
            # cell can't be reused later in this same path
            tmp, board[r][c] = board[r][c], "#"

            # Explore:
            # recurse in all 4 directions, short-circuit on first match found
            # tc: O(4^L) total recursive calls in the worst case, L = len(word)
            found = (dfs(r + 1, c, i + 1) or
                     dfs(r - 1, c, i + 1) or
                     dfs(r, c + 1, i + 1) or
                     dfs(r, c - 1, i + 1))

            # Backtrack:
            # restore original cell value so other starting points can reuse it
            board[r][c] = tmp
            return found

        # Process Choices:
        # only start DFS from cells that match the (possibly reversed) first letter,
        # instead of every cell on the board like Solution 1
        # tc: O(rows * cols) worst case, fewer attempts in practice
        for r in range(rows):
            for c in range(cols):
                if board[r][c] == word[0]:
                    if dfs(r, c, 0):
                        return True

        return False

        # overall: tc O(rows * cols * 4^L), L = len(word), same bound as Solution 1
        # overall: sc O(L), recursion stack depth bounded by word length

212. Word Search II ::2:: - Hard

Topics: Hash Table, String, Design, Trie

Intro

Given an m x n board of characters and a list of strings words, return all words on the board. Each word must be constructed from letters of sequentially adjacent cells, where adjacent cells are horizontally or vertically neighboring. The same letter cell may not be used more than once in a word.

Example InputOutput
look at LeetCode question diagram["eat","oath"]

Constraints:

m == board.length

n == board[i].length

1 ≤ m, n ≤ 12

board[i][j] is a lowercase English letter.

1 ≤ words.length ≤ 3 * 104

1 ≤ works[i].length ≤ 10

words[i] consists of lowercase English letters.

All the strings of words are unique.

Abstraction

Given a board and a list of words, return all words from the list that are present on the board.

Pseudocode

  text will go here

Solution 1: [Backtracking] [DFS] [Trie] Trie Implementation - Trie/Trie Insert and Search Recursive

class TrieNode:

    def __init__(self):
        # sc: O(1) per node, grows to O(sum of word lengths) across whole Trie
        # dict of char subtrees for current node: char -> TrieNode
        self.subtrees = {}
        # marks whether this node is the end of a valid word
        self.isWord = False

    def addWord(self, word):

        # updated current node
        node = self

        # tc: O(len(word))
        for c in word:

            # if subtree does not exist, create
            if c not in node.subtrees:
                node.subtrees[c] = TrieNode()

            # iterate to subtree
            node = node.subtrees[c]

        # set as valid end of word
        node.isWord = True


class Solution:

    def findWords(self, board, words):

        # Backtracking + Trie:
        # Recursive function will explore every path on the board incrementally,
        # by using DFS to extend the current path only while it still matches
        # a valid prefix in the Trie built from 'words'. The Trie lets us check
        # "is this still a valid prefix" in O(1) per character instead of
        # comparing against every word individually.

        # sc: O(sum of len(word) for word in words), Trie holds every word's characters
        root = TrieNode()
        for word in words:
            root.addWord(word)

        # sc: O(1), rows/cols are fixed reference values, not per-cell storage
        rows, cols = len(board), len(board[0])

        # sc: O(k), holds found words, k = number of matching words
        # sc: O(L), holds cells currently on the path, L = max word length
        res, visit = set(), set()

        def dfs(r, c, node, path):

            # Path 1:
            # cell is outside the grid, this direction can't be explored
            if r < 0 or c < 0 or r >= rows or c >= cols:
                return

            # Path 2:
            # current board character isn't a valid next step in the Trie,
            # this path can never spell out a word from 'words'
            if board[r][c] not in node.subtrees:
                return

            # Path 3:
            # cell already used earlier in this same path, avoid reuse
            if (r, c) in visit:
                return

            # Choose:
            # mark current cell as visited and step into its Trie subtree
            visit.add((r, c))
            node = node.subtrees[board[r][c]]
            path += board[r][c]

            # New Valid word:
            # this node marks the end of a word in the Trie, record the path
            if node.isWord:
                res.add(path)

            # Explore:
            # recurse in all 4 directions, still bounded by the current Trie node
            # tc: O(4^L) total recursive calls in the worst case, L = max word length
            for dr, dc in [(1, 0), (-1, 0), (0, 1), (0, -1)]:
                dfs(r + dr, c + dc, node, path)

            # Backtrack:
            # remove current cell from visited so other paths can reuse it
            visit.remove((r, c))

        # Try starting DFS from every cell on the board
        # tc: O(rows * cols) starting points
        for r in range(rows):
            for c in range(cols):
                dfs(r, c, root, "")

        # overall: tc O(rows * cols * 4^L), L = max word length
        # overall: sc O(W + L), W = total characters across all words (Trie size)
        return list(res)

Solution 2: [Backtracking] [DFS] [Trie] Trie Implementation Optimal - Trie/Trie Insert and Search Recursive

class TrieNode:
    def __init__(self):
        # sc: O(1) per node, grows to O(sum of word lengths) across whole Trie
        # dict of char subtrees for current node: char -> TrieNode
        self.subtrees = {}
        # stores the complete word at its end node, None if not a word end
        self.word = None


class Solution:
    def findWords(self, board: List[List[str]], words: List[str]) -> List[str]:

        # Backtracking + Trie:
        # Same core idea as Solution 1 -- DFS from every cell, only continuing
        # a path while it matches a valid prefix in the Trie built from 'words'.

        # Optimization vs Solution 1:
        #   1. In-place marking: instead of a separate 'visit' set, we
        #      temporarily overwrite the board cell itself ('#'), avoiding the
        #      extra O(L) set and its hashing overhead per cell.
        #   2. Leaf pruning: once a Trie subtree has no children left (every
        #      word through it has already been found), we delete it from its
        #      parent. This shrinks the Trie as words are found, so later DFS
        #      calls stop earlier instead of re-exploring dead branches.

        # sc: O(sum of len(word) for word in words), Trie holds every word's characters
        root = TrieNode()
        for word in words:
            node = root
            for c in word:

                # if subtree does not exist, create
                if c not in node.subtrees:
                    node.subtrees[c] = TrieNode()

                # iterate to subtree
                node = node.subtrees[c]

            # store complete word at its end node
            node.word = word

        # sc: O(1), rows/cols are fixed reference values, not per-cell storage
        rows, cols = len(board), len(board[0])

        # sc: O(k), holds found words, k = number of matching words
        result = []

        def dfs(r: int, c: int, parent: TrieNode):

            # grab curr char and its Trie node
            letter = board[r][c]
            curr_node = parent.subtrees[letter]

            # New Valid word:
            # this node marks the end of a word, record it and clear it out
            # to prevent the same word being added again via another path
            if curr_node.word:
                result.append(curr_node.word)
                curr_node.word = None

            # Choose:
            # mark current cell as visited in place, so it can't be reused
            # later in this same path
            board[r][c] = "#"

            # Explore:
            # recurse in all 4 directions, only into cells that are both
            # in-bounds and still a valid next character in the Trie
            # tc: O(4^L) total recursive calls in the worst case, L = max word length
            for (dr, dc) in [(-1, 0), (1, 0), (0, -1), (0, 1)]:

                (nr, nc) = r + dr, c + dc

                if (0 <= nr < rows and
                    0 <= nc < cols and
                    board[nr][nc] in curr_node.subtrees):
                    dfs(nr, nc, curr_node)

            # Backtrack:
            # restore original cell value so other paths can reuse it
            board[r][c] = letter

            # Leaf Pruning:
            # if this Trie node has no remaining subtrees (every word through
            # it has already been found), remove it from its parent so future
            # DFS calls stop here immediately instead of descending into a
            # now-dead branch
            if not curr_node.subtrees:
                parent.subtrees.pop(letter)

        # Process Choices:
        # only start DFS from cells that match a valid first character in the Trie
        # tc: O(rows * cols) starting points
        for r in range(rows):
            for c in range(cols):
                if board[r][c] in root.subtrees:
                    dfs(r, c, root)

        # overall: tc O(rows * cols * 4^L), L = max word length, same bound as Solution 1
        # overall: sc O(W), W = total characters across all words (Trie size), no separate visited set
        return result

131. Palindrome Partitioning ::2:: - Medium

Topics: String, Dynamic Programming, Backtracking

Intro

Given a string s, partition s such that every substring of the partition is a palindrome. Return all possible palindrome partitioning of s.

Example InputOutput
s = "aab"[["a","a","b"],["aa","b"]]
s = "a"[["a"]]

Constraints:

1 ≤ s.length ≤ 16

s contains only lowercase English letters.

Abstraction

Split string s in a way such that every substring of the partition is a palindrome.

Pseudocode

  text will go here

Solution 1: [Backtracking] [DFS] DFS Backtracking on Current Path to Match Word - Backtracking/Path Finding in Search Space

    def partition(self, s: str) -> List[List[str]]:

        # Backtracking:
        # Recursive function will build partitions incrementally, by using
        # DFS to try every possible next substring and only continuing a
        # branch while that substring is a palindrome.

        # sc: O(n) worst case, holds all valid partitions
        res = []

        # sc: O(n), holds current partition during recursion, max length n
        path = []

        def isPalindrome(l, r) -> bool:

            # Check candidate substring s[l:r+1] from both ends inward
            # tc: O(n) per call in the worst case
            while l < r:
                if s[l] != s[r]:
                    return False
                l += 1
                r -= 1
            return True

        def dfs(start):

            # New Valid partition:
            # reached end of string, every character has been partitioned
            if start == len(s):
                res.append(path[:])
                return

            # Path 1:
            # try every substring starting at 'start', extending one char at a time
            for end in range(start, len(s)):

                # Early Pruning:
                # only continue this branch if the candidate substring is a palindrome
                # tc: O(n) per check
                if isPalindrome(start, end):

                    # Choose:
                    # add palindromic substring to path
                    path.append(s[start:end + 1])

                    # Explore:
                    # recurse starting right after this substring
                    dfs(end + 1)

                    # Backtrack:
                    # remove last substring to try the next end position
                    path.pop()

        # Start backtracking at "root"
        dfs(0)

        # overall: tc O(n * 2^n), 2^n possible partitions worst case, each palindrome check O(n)
        # overall: sc O(n), recursion stack + path (auxiliary, excluding output)
        return res

Solution 2: [Backtracking] [DFS] DFS Backtracking on Current Path to Match Word - Backtracking/Path Finding in Search Space

    def partition(self, s: str) -> List[List[str]]:

        # Backtracking:
        # Same traversal shape as Solution 1 -- build partitions incrementally
        # via DFS, only continuing a branch while the candidate substring is
        # a palindrome.

        # Optimization vs Solution 1:
        # Solution 1 re-checks whether a substring is a palindrome from
        # scratch every time it's considered, O(n) per check, even though the
        # same substrings get re-examined across different branches. Here we
        # precompute every substring's palindrome status once in O(n^2) using
        # DP, so each check during backtracking becomes an O(1) lookup.

        n = len(s)

        # sc: O(n) worst case, holds all valid partitions
        res = []

        # sc: O(n), holds current partition during recursion, max length n
        path = []

        # sc: O(n^2), isPal[i][j] is True if s[i:j+1] is a palindrome
        isPal = [[False] * n for _ in range(n)]

        # Backwards -> Forwards:
        # to compute isPal[0][4], we need isPal[1][3] already known.
        # Looping i backwards ensures that by the time we reach row i,
        # row i + 1 (all substrings starting one index later) is already filled in.

        # tc: O(n^2), every substring checked exactly once
        for i in range(n - 1, -1, -1):
            for j in range(i, n):

                # Substring Length:
                # (j - i + 1) <= 3 covers base cases directly: "a", "aa", "aba"

                # Inner Substring:
                # if s[i+1:j] is a palindrome and the outer characters match,
                # then s[i:j+1] is also a palindrome
                if s[i] == s[j] and ((j - i + 1) <= 3 or isPal[i + 1][j - 1]):
                    isPal[i][j] = True

        def dfs(start):

            # New Valid partition:
            # reached end of string, every character has been partitioned
            if start == n:
                res.append(path[:])
                return

            # Path 1:
            # try every substring starting at 'start', extending one char at a time
            for end in range(start, n):

                # Early Pruning:
                # O(1) table lookup instead of Solution 1's O(n) re-scan
                if isPal[start][end]:

                    # Choose:
                    # add palindromic substring to path
                    path.append(s[start:end + 1])

                    # Explore:
                    # recurse starting right after this substring
                    dfs(end + 1)

                    # Backtrack:
                    # remove last substring to try the next end position
                    path.pop()

        # Start backtracking at "root"
        dfs(0)

        # overall: tc O(n^2 + n * 2^n), O(n^2) to build the table, O(n * 2^n) worst case for backtracking
        # overall: sc O(n^2), dominated by the palindrome table (recursion stack + path are O(n))
        return res

17. Letter Combinations of a Phone Number ::1:: - Medium

Topics: Hash Table, String, Backtracking

Intro

Given a string containing digits from 2-9 inclusive, return all possible letter combinations that the number could represent. Return the answer in any order. A mapping of digits to letters (just like on the telephone buttons) is given below. Note that 1 does not map to any letters.

Example InputOutput
digits = "23"["ad","ae","af","bd","be","bf","cd","ce","cf"]
digits = ""[]
digits = "2"["a","b","c"]

Constraints:

0 ≤ digits.length ≤ 4

digits[i] is a digit in the range ['2', '9'].

Abstraction

Given a mini phone number, find all the possible letter combinations.

Pseudocode

  text will go here

Solution 1: [Backtracking] [DFS] Recursive DFS Backtracking on Current Letter Path - Backtracking/Generate All Combinations

    def letterCombinations(self, digits: str) -> List[str]:

        # Backtracking:
        # Recursive function will build letter combinations incrementally,
        # by using DFS to try every letter mapped to the current digit,
        # one digit position at a time.

        # Path 1:
        # empty input has no digits to map, no combinations possible
        if not digits:
            return []

        # sc: O(1), fixed mapping of 8 digits to their letters
        digit_map = {
            "2": "abc", "3": "def", "4": "ghi", "5": "jkl",
            "6": "mno", "7": "pqrs", "8": "tuv", "9": "wxyz"
        }

        # sc: O(4^n * n) worst case, up to 4^n combinations each of length n
        res = []

        # sc: O(n), holds current combination during recursion, max length n = len(digits)
        path = []

        def dfs(index):

            # New Valid combination:
            # path length matches digits length, every digit has been mapped
            # tc: O(n) to join path into a string
            if index == len(digits):
                res.append("".join(path))
                return

            # Path 2:
            # try every letter mapped to the digit at this position
            # tc: O(4) per call, bounded by max letters per digit (7 and 9 map to 4)
            for char in digit_map[digits[index]]:

                # Choose:
                # add letter to path
                path.append(char)

                # Explore:
                # recurse to the next digit position
                dfs(index + 1)

                # Backtrack:
                # remove last letter to try the next option via the for loop
                path.pop()

        # Start backtracking at "root"
        dfs(0)

        # overall: tc O(4^n * n), n = len(digits), up to 4 choices per digit, n chars to join per combination
        # overall: sc O(n), recursion stack + path (auxiliary, excluding output)
        return res

51. N Queens ::2:: - Hard

Topics: Array, Backtracking

Intro

The n-queens puzzle is the problem of placing n queens on an n x n chessboard such that no two queens attack each other. Given an integer n, return all distinct solutions to the n queens puzzle. You may return the answer in any order. Each solution contains a distinct board configuration of the n queens' placement, where 'Q' and '.' both indicate a queen and an empty space, respectively.

Example InputOutput
n = 4[[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
n = 1[["Q"]]

Constraints:

1 ≤ n ≤ 9

Abstraction

Find all the possible ways to possible n queens on a board of n x n size.

Pseudocode

  text will go here

Solution 1: [Backtracking] [DFS] Recursive DFS Backtracking on Current Row with Column/Diagonal Tracking - Backtracking/Combinatorial Optimization

    def solveNQueens(self, n: int) -> List[List[str]]:

        # Backtracking:
        # Recursive function will place one queen per row incrementally,
        # by using DFS to try every column in the current row and only
        # continuing a branch while that placement doesn't threaten any
        # queen already placed in an earlier row.

        # sc: O(n) worst case, holds all valid board configurations
        res = []

        # sc: O(n^2), n x n board
        board = [["."] * n for _ in range(n)]

        # 3 Sets to track threatened positions:

        # sc: O(n), columns currently occupied
        cols = set()
        # rows are inherently tracked by recursion depth, no separate set needed

        # sc: O(n), r - c diagonals: all cells on this diagonal share the same row - col value
        diag1 = set()
        # sc: O(n), r + c diagonals: all cells on this diagonal share the same row + col value
        diag2 = set()

        def dfs(row: int):

            # New Valid board:
            # every row has a queen placed, record this board configuration
            # tc: O(n^2) to join board into strings
            if row == n:
                res.append(["".join(r) for r in board])
                return

            # Path 1:
            # try every column in the current row
            for col in range(n):

                # Early Pruning:
                # skip this column if it or either diagonal is already threatened
                if col in cols or (row - col) in diag1 or (row + col) in diag2:
                    continue

                # Choose:
                # place queen at (row, col), mark column and both diagonals occupied
                board[row][col] = "Q"
                cols.add(col)
                diag1.add(row - col)
                diag2.add(row + col)

                # Explore:
                # recurse to the next row, row + 1 guarantees rows never overlap
                dfs(row + 1)

                # Backtrack:
                # remove queen and free column/diagonals to try the next column
                board[row][col] = "."
                cols.remove(col)
                diag1.remove(row - col)
                diag2.remove(row + col)

        # Start backtracking at "root", row 0
        dfs(0)

        # overall: tc O(n!), n choices in row 0, at most n-1 in row 1, pruned further by diagonals
        # overall: sc O(n^2), board dominates; cols/diag1/diag2/recursion stack are each O(n)
        return res

Solution 2: [Backtracking] [DFS] Recursive DFS Backtracking Using Bitmasking for Columns/Diagonals - Backtracking/Combinatorial Optimization

    def solveNQueens(self, n: int) -> List[List[str]]:

        # Backtracking:
        # Same traversal shape as Solution 1 -- place one queen per row via
        # DFS, only continuing while the placement doesn't threaten an
        # earlier queen -- but threatened positions are tracked with integer
        # bitmasks instead of three separate sets.

        # Optimization vs Solution 1:
        # Solution 1's set lookups (col in cols, etc.) and the n x n board
        # array add overhead per check and per placement. Here, 'cols',
        # 'diag1', and 'diag2' are single integers where each bit represents
        # a threatened column/diagonal position. Combining them with bitwise
        # OR and checking availability with a single AND/NOT turns each row's
        # "which columns are free" computation into O(1) bitwise ops instead
        # of n individual set lookups, and shifting diag1/diag2 by 1 each row
        # naturally re-aligns them for the next row without recomputing from
        # row/col arithmetic.

        # sc: O(n) worst case, holds all valid board configurations
        res = []

        # sc: O(n), holds chosen column index per row during recursion
        path = []

        def dfs(row, cols, diag1, diag2):

            # New Valid board:
            # every row has a queen placed, convert path (row -> column) into board strings
            # tc: O(n^2) to build the board
            if row == n:
                board = []
                for r in path:
                    row_str = ["." for _ in range(n)]
                    row_str[r] = "Q"
                    board.append("".join(row_str))
                res.append(board)
                return

            # Path 1:
            # compute every column in this row that's not threatened, as a bitmask
            # tc: O(1) bitwise ops instead of n individual set lookups
            available = ((1 << n) - 1) & (~(cols | diag1 | diag2))

            # try every available column, extracting the lowest set bit each time
            while available:

                # Choose:
                # isolate the rightmost available bit and remove it from 'available'
                pos = available & -available
                available &= available - 1
                col = (pos - 1).bit_length()

                path.append(col)

                # Explore:
                # recurse to the next row with updated masks; diag1/diag2 shift by
                # one bit to stay aligned with the diagonal's position on the next row
                dfs(row + 1, cols | pos, (diag1 | pos) << 1, (diag2 | pos) >> 1)

                # Backtrack:
                # remove last chosen column, cols/diag1/diag2 are never mutated in
                # place (new masks are passed per call), so nothing else to undo
                path.pop()

        # Start backtracking at "root", row 0, no columns/diagonals threatened yet
        dfs(0, 0, 0, 0)

        # overall: tc O(n!), same combinatorial bound as Solution 1, fewer constant-factor ops per check
        # overall: sc O(n), no n x n board maintained during recursion, only path + bitmask args
        return res

37. Sudoku Solver ::1:: - Hard

Topics: Array, Hash Table, Backtracking, Matrix

Intro

Write a program to solve a Sudoku puzzle by filling the empty cells. A sudoku solution must satisfy all of the following rules: Each of the digits 1-9 must occur exactly once in each row. Each of the digits 1-9 must occur exactly once in each column. Each of the digits 1-9 must occur exactly once in each of the 9 3x3 sub-boxes of the grid. The '.' character indicates empty cells.

Example InputOutput
board, look at LeetCodesomething!

Constraints:

board.length == 9

board[i].length == 9

board[i][j] is a digit or '.'.

It is guaranteed that the input board has only one solution.

Abstraction

Write a program to solve sudoku

Pseudocode

  text will go here

Solution 1: [Backtracking] [DFS] Recursive Cell-By-Cell Backtracking With Row/Col/Box Constraint Checking - Backtracking/Generate While Constraint Satisfaction

    def solveSudoku(self, board: List[List[str]]) -> None:

        # Backtracking:
        # Same traversal shape as Solution 1 -- fill empty cells incrementally
        # via DFS, trying every digit 1-9 and only keeping placements that
        # satisfy the row, column, and 3x3 box constraints.

        # Optimization vs Solution 1:
        # Solution 1's isValid() rescans the entire row, column, and box from
        # scratch on every single check, O(n) per check. Here we instead
        # maintain three sets of "digits already used" -- one per row, one
        # per column, one per box -- updated incrementally as digits are
        # placed and removed. This turns each validity check into an O(1)
        # set lookup instead of an O(n) scan, which is what actually matters
        # for passing within LeetCode's time limit on this problem.

        n = 9

        # sc: O(n), one set per row tracking digits already placed in that row
        rows = [set() for _ in range(n)]

        # sc: O(n), one set per column tracking digits already placed in that column
        cols = [set() for _ in range(n)]

        # sc: O(n), one set per 3x3 box tracking digits already placed in that box
        boxes = [set() for _ in range(n)]

        # sc: O(m), list of empty cell coordinates to fill, m = number of empty cells
        empties = []

        # Preprocess board: seed rows/cols/boxes sets and collect empty cells
        # tc: O(n^2), fixed 81-cell scan regardless of input
        for r in range(n):
            for c in range(n):
                digit = board[r][c]
                if digit == '.':
                    empties.append((r, c))
                else:
                    b = (r // 3) * 3 + c // 3
                    rows[r].add(digit)
                    cols[c].add(digit)
                    boxes[b].add(digit)

        def dfs(idx: int) -> bool:

            # New Valid board:
            # every empty cell has been filled validly
            if idx == len(empties):
                return True

            row, col = empties[idx]
            b = (row // 3) * 3 + col // 3

            # Path 1:
            # try every digit 1-9 in this empty cell
            # tc: O(9) per call, bounded by fixed digit range
            for digit in "123456789":

                # Early Pruning:
                # O(1) set lookups instead of Solution 1's O(n) row/col/box scan
                if digit in rows[row] or digit in cols[col] or digit in boxes[b]:
                    continue

                # Choose:
                # place digit on board and mark it used in all three constraint sets
                board[row][col] = digit
                rows[row].add(digit)
                cols[col].add(digit)
                boxes[b].add(digit)

                # Explore:
                # recurse to the next empty cell, if this eventually completes
                # the whole board, propagate success back up immediately
                if dfs(idx + 1):
                    return True

                # Backtrack:
                # digit didn't lead to a solution, revert cell and all three
                # constraint sets, then try the next digit via the for loop
                board[row][col] = '.'
                rows[row].discard(digit)
                cols[col].discard(digit)
                boxes[b].discard(digit)

            # Exhausted: no digit works at this cell, this branch has failed
            return False

        # Start backtracking at the first empty cell, mutates board in place
        dfs(0)

        # overall: tc O(9^m), m = number of empty cells, each check now O(1) instead of O(n)
        # overall: sc O(m), recursion stack depth + rows/cols/boxes sets
        return