Jc-alt logo
jc
LeetCode: Backtracking

LeetCode: Backtracking

··
74 min read
·data structures and algorithms

Backtracking intro

LeetCode problems with backtracking solutions.

Cheat sheet

Backtracking is inherently dfs or bfs.

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

3483. Unique 3-Digit Even Numbers ::1:: - Easy

Topics: Backtracking, Depth First Search, Array, Hash Table, Recursion, Enumeration

Intro

You are given an array of digits called digits. Your task is to determine the number of distinct three-digit even numbers that can be formed using these digits. Note: Each copy of a digit can only be used once per number, and there may not be leading zeros.

Example InputOutput
digits = [1,2,3,4]12
digits = [0,2,2]2
digits = [6,6,6]1
digits = [1,3,5]0

Constraints:

3 ≤ digits.length ≤ 10

0 ≤ digits[i] ≤ 9

Abstraction

Find all even combinations of 3 digits numbers using array

Pseudocode

  oh! pseudocode hasn't been written yet, try another card! :)

Solution 1: [Backtracking] [DFS] Digit Value Recursion Backtracking Over Positions And Num Count - Tree/DFS Recursive Backtracking

    def totalNumbers(self, digits: List[int]) -> int:

        # Backtracking:
        # Recursive function will build a 3-digit number position by position
        # (hundreds -> tens -> ones), 
        # trying every digit VALUE 0-9 at each position 
        # rather than every INDEX in `digits`. 
        # This keeps branching factor fixed at 10 regardless of len(digits),
        # so the recursion tree size is a hard constant, not dependent on n.

        # Digit Counts (as given):
        # We track how many copies of each value remain, 
        # so duplicate digits (like the two 2's) are handled naturally 
        # without needing to dedupe by index.

        # sc: O(1)
        count = [0] * 10
        for d in digits:
            count[d] += 1

        # bounded by 450 possible distinct 3 digit even numbers
        # sc: O(1)
        seen = set()

        # Holds current 3 digit number during recursion, max length 3
        # sc: O(1)
        current = []

        def backtrack(position):

            # New Valid number:
            # filled hundreds/tens/ones, record the assembled number
            # tc: O(1)
            if position == 3:
                num = current[0] * 100 + current[1] * 10 + current[2]
                seen.add(num)
                return

            # Position 0 (hundreds):
            # - no leading zero allowed, so start at 1
            start = 1 if position == 0 else 0

            # Position 2 (ones):
            # - right most num must be even, so step by 2
            step = 2 if position == 2 else 1

            # Path 1:
            # try every valid digit value at this position
            for val in range(start, 10, step):

                # skip if no copies of this digit value remain
                if count[val] == 0:
                    continue

                # Choose:
                # use one copy of val at this position
                count[val] -= 1
                current.append(val)

                # Explore:
                # recursively fill the next position
                backtrack(position + 1)

                # Backtrack:
                # restore the copy so sibling branches can reuse it
                current.pop()
                count[val] += 1

        # Start backtracking at position 0, the hundreds digit
        backtrack(0)

        # overall: tc O(1), recursion bounded by fixed branching (<=10) and
        #   fixed depth (3), so at most ~9*10*5 = 450 leaf numbers explored
        # overall: sc O(1), count array, recursion stack, current, and seen
        #   are all bounded by constants independent of len(digits)
        return len(seen)

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

Topics: Hash Table, String, Backtracking, Depth First Search

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

  oh! pseudocode hasn't been written yet, try another card! :)

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

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

  oh! pseudocode hasn't been written yet, try another card! :)

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

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

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

  oh! pseudocode hasn't been written yet, try another card! :)

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

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

78. Subsets ::3:: - Medium

Topics: Array, Backtracking, Depth First Search, 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, Depth First Search, 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

  oh! pseudocode hasn't been written yet, try another card! :)

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, Depth First Search, 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

  oh! pseudocode hasn't been written yet, try another card! :)

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, Depth First Search

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

  oh! pseudocode hasn't been written yet, try another card! :)

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, Depth First Search

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

  oh! pseudocode hasn't been written yet, try another card! :)

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, Depth First Search

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

  oh! pseudocode hasn't been written yet, try another card! :)

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, Depth First Search

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

  oh! pseudocode hasn't been written yet, try another card! :)

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

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]

46. Permutations ::1:: - Medium

Topics: Array, Backtracking, Depth First Search

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 so no 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: [0, 1] and [1, 0] are distinct permutations.

Pseudocode

  oh! pseudocode hasn't been written yet, try another card! :)

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, 
        # track elements already placed in the current path with a 'used' boolean array, 
        # and skip those.

        # Worst case all distinct values
        # sc: O(n * n!)
        res = []

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

        n = len(nums)

        # Used Elements Tracker:
        # tracks which elements are already in the current path
        # sc: O(n)
        used = [False] * n

        def dfs():

            # New Valid permutation:
            # add to final res

            # Reached target path length:
            # found new valid permutation of elements
            # tc: O(n)
            if len(path) == n:
                res.append(path[:])
                return

            # Explore Remaining Unused Numbers:
            # not reached target path length yet,
            # continue path generation (N!, N-1, N-2, ...) 
            # adding all elements currently marked as not used yet to depth d
            # tc: O(n)
            for i in range(n):

                # No Reusing Elements:
                # skip elements already in path
                if used[i]:
                    continue

                # Try new element at this depth
                path.append(nums[i])

                # Mark element as used
                used[i] = True

                # Explore:
                # explore this path with new element at this depth
                # to generate all possible permutations with remaining unused elements
                # tc: O(n!) 
                dfs()

                # Backtrack:
                # Remove new element at this depth from path
                # and mark as unused for future adding
                path.pop()
                used[i] = False

        # Start backtracking at root with empty list and all elements unused
        dfs()

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

47. Permutations II ::1:: - Medium

Topics: Array, Backtracking, Depth First Search, 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, duplicates allowed, 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

  oh! pseudocode hasn't been written yet, try another card! :)

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,
        # and we 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. 
        # orting allows us to block multile occurences of the same integer, 
        # from being used at the same index in the permutation

        # [1a, 1b, 2,  3]  =>  [1b, 1a, 2,  3] => [1, 1, 2, 3]
        #  0   1   2   3        0   1   2   3

        # by avoiding both 1's being put at index 0, which lead to duplicate sets 

        # [2,  1a, 1b, 3]  =>  [2,  1b, 1a, 3] => [2, 1, 1, 3]
        #  0   1   2   3        0   1   2   3

        # by avoiding both 1's being put at index 0, which lead to duplicate sets 

        # etc...


        # Best case all n elements are identical, list of n size O(n)
        # Worst case all distinct values O(n * n!)
        # sc: O(n * n!)
        res = []

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

        n = len(nums)

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

        # 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) == n:
                res.append(path[:])
                return

            # Path 2:
            # continue path generation (N!, N-1, N-2, ...) adding any element not already in path
            # tc: O(n)
            for i in range(n):

                # No Reusing Elements:
                # skip elements already in path
                if used[i]:
                    continue

                # Duplicate Pruning:
                # skip a duplicate value for the current index to avoid duplicates at the same index

                # [1a, 1b, 2,  3]  =>  [1b, 1a, 2,  3]
                #  0   1   2   3        0   1   2   3

                # when used[i-1] == True, 
                # that means we can place the duplicate without risk of duplicate combinations, 
                # since that means a previous duplicate has already been placed,
                # we do not have to worry about the current duplicate being placed
                # at the same index as the previous
                # but when used[i-1] == False, 
                # that means we are at risk of duplicating a previous combination,
                # since that means a previous duplicate has been placed then removed,
                # so there is a chance the current duplicate is going to be placed
                # at the same previous depth
                if 0 < i and nums[i] == nums[i - 1] and not used[i - 1]:
                    continue

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

                # Explore:
                # explore this path and to reach all possible permutations, 
                # will add remaining unused elements
                # tc: O(n!) 
                dfs()

                # Backtrack:
                # Remove element from path and allow future adding
                path.pop()
                used[i] = False

        # Start backtracking at root with empty list and all elements unused
        dfs()

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

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

  oh! pseudocode hasn't been written yet, try another card! :)

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 3 directions,
        # only continuing a branch while it still matches the word so far.

        # Board len
        rows, cols = len(board), len(board[0])

        # tc: O(L) per call chain ~ O(L^3)
        def dfs(r, c, wordIndex):

            # Target Found:
            # every character in word has been matched
            if wordIndex == len(word):
                return True

            # Out Of Bounds:
            # cell is outside the grid, can't explore in this direction
            if r < 0 or c < 0 or rows <= r or cols <= c:
                return False

            # Letter Isn't The Expected Letter:
            # only continue exploration if we can keep forming the word
            if board[r][c] != word[wordIndex]:
                return False

            # Correct Letter and In Bounds, Mark Cell As Overwritten:
            # mark current cell as visited by temporarily 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 3 directions, short circuit on first match found
            # we actually at every cell is explore 4 ways, but 1 fails immediately since
            # we can't go back to the previous cell we just came since we marked it with '#'
            # tc: O(3^L)
            found = (dfs(r+1, c, wordIndex+1) or
                     dfs(r-1, c, wordIndex+1) or
                     dfs(r, c+1, wordIndex+1) or
                     dfs(r, c-1, wordIndex+1))

            # Backtrack:
            # revert cell from '#' to original value so other paths can use it
            board[r][c] = tmp
            return found

        # Starting DFS from every cell on the board
        # tc: O(r * c)
        firstLetter = word[0]
        for r in range(rows):
            for c in range(cols):
                if board[r][c] == firstLetter:
                    if dfs(r, c, 0):
                        return True

        # overall: tc O(rows * cols * 3^L)
        # overall: sc O(L), recursion stack depth bounded by word length
        return False

Solution 2: [Follow Up] [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:

        # Larger Board Optimizations

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

        # Work Minimum Count Early Exit:
        # - If the board doesn't contain enough of any letter the word
        #   needs, no path can possibly exist -- return False immediately.
        
        # Rarer First Or Last Letter Search Start DFS Branch Optimization:
        # - Starting the search from either the first or last letter,
        #   depending on which is rarer (less occurrences),
        #   will reduces how many cells trigger a full DFS attempt, 
        #   (less cells will trigger a DFS attempt, since there are less
        #    of the expected starting letter),
        #   and tend to fail faster when theres no valid path
        #   as there are less DFS so less branches explored overall

        # Board len
        rows, cols = len(board), len(board[0])

        # Fixed alphabet size (26 letters) regardless of input size
        # tc: O(L)
        # sc: O(26) ~ O(1)
        wordCount = defaultdict(int)
        for c in word:
            wordCount[c] += 1

        # tc: O(r * c)
        # sc: O(r * c)
        boardCount = defaultdict(int)
        for r in range(rows):
            for c in range(cols):
                boardCount[board[r][c]] += 1

        # 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 wordCount.items():
            if boardCount[char] < count:
                return False

        # Start FEarly Recurse Optimization:
        # search from whichever end of the word is rarer on the board,
        # this will trigger less DFS search attempts
        firstLetterCount = boardCount[word[0]]
        lastLetterCount = boardCount[word[-1]]
        if lastLetterCount < firstLetterCount:
            word = word[::-1]

        # tc: O(L) per call chain ~ O(L^3)
        def dfs(r, c, wordIndex):

            # Target Found:
            # every character in word has been matched
            if wordIndex == len(word):
                return True

            # Out Of Bounds:
            # cell is outside the grid, can't continue in this direction
            if r < 0 or c < 0 or r >= rows or c >= cols:
                return False

            # Letter Isn't The Expected Letter:
            # only continue exploration if we can keep forming the word
            if board[r][c] != word[wordIndex]:
                return False

            # Correct Letter and In Bounds, Mark Cell As Overwritten:
            # 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 3 directions, short circuit on first match found
            # we actually at every cell is explore 4 ways, but 1 fails immediately since
            # we can't go back to the previous cell we just came since we marked it with '#'
            # tc: O(3^L)
            found = (dfs(r+1, c, wordIndex+1) or
                     dfs(r-1, c, wordIndex+1) or
                     dfs(r, c+1, wordIndex+1) or
                     dfs(r, c-1, wordIndex+1))

            # Backtrack:
            # revert cell from '#' to original value so other paths can use it
            board[r][c] = tmp
            return found

        # Starting DFS from every cell on the board
        # tc: O(r * c)
        firstLetter = word[0]
        for r in range(rows):
            for c in range(cols):
                if board[r][c] == firstLetter:
                    if dfs(r, c, 0):
                        return True

        # overall: tc O(r * c * 3^L), L = len(word)
        # overall: sc O(L), recursion stack depth bounded by word length
        return False

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

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

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

  oh! pseudocode hasn't been written yet, try another card! :)

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


# A TrieNode Has:
#   - dict from (c -> TreeNode)
#   - bool saying it this node is the end of a word
class TrieNode:

    # Node within Trie Structure
    def __init__(self):

        # Dict of all words found tree,
        # L of words * number of words
        # sc: O(L * W)
        self.subtrees = {}

        # Marks whether this node is the end of a valid word
        self.word = None

    # Trie Structure Add New Word:
    # - add word to prefix structure currently held by Trie
    def addWord(self, word):

        # addWord() is called like this:
        #   - root = TrieNode()
        #   - root.addWord(word)
        # Self is passed as a TrieNode,
        # create an iterator so we can traverse across the Trie Structure
        currNode = self

        # Add char from word
        # tc: O(L)
        for c in word:

            # Add TrieNode Path:
            # current node does not point to char we are adding,
            # creating new node
            if c not in currNode.subtrees:
                currNode.subtrees[c] = TrieNode()

            # Jump to next TrieNode in word path
            currNode = currNode.subtrees[c]

        # End Flag:
        # reached end of node flag for end of word
        currNode.word = word

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.

        # Trie Structure:
        #
        #      ROOT
        #     /    \
        #    c      d
        #    |      |
        #    a      o
        #   / \     |
        #  t   r    g

        # Root TrieNode holds all starting valid chars for all words,
        # and the Trie structure contains the words themselves
        # sc: O(L * W)
        root = TrieNode()
        for word in words:
            root.addWord(word)

        # Board len
        rows, cols = len(board), len(board[0])

        # sc: O(L * W)
        res = []

        def dfs(r, c, parent):

            # Out Of Bounds:
            # cell is outside the grid, can't continue in this direction
            if r < 0 or c < 0 or rows <= r or cols <= c:
                return

            # Grab current char
            letter = board[r][c]

            # Letter Isn't One Of The Expected Letters, Or '#':
            # only continue exploration if we can keep forming the word
            if letter not in parent.subtrees:
                return

            # Jump to the TrieNode representing the current character
            currNode = parent.subtrees[letter]

            # New Valid word:
            # this node marks the end of a word in the Trie,
            # add word to the result list,
            # remove stored word to prevent duplicates of the same word being added
            if currNode.word:
                res.append(currNode.word)
                currNode.word = None

            # Jump to next Trie node
            currNode = parent.subtrees[letter]

            # 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(3^L)
            dfs(r+1, c, currNode)
            dfs(r-1, c, currNode)
            dfs(r, c+1, currNode)
            dfs(r, c-1, currNode)

            # Backtrack:
            # revert cell from '#' to original value
            board[r][c] = letter

            # Dead Last Char Pruning:
            # This TrieNode is not the end of a word.
            # If it also has no remaining subtrees, we are safe to remove it
            # to prevent unnecessary future DFS exploration
            if not currNode.subtrees:
                parent.subtrees.pop(letter)

        # Starting DFS from every cell on the board
        # tc: O(r * c)
        for r in range(rows):
            for c in range(cols):
                if board[r][c] in root.subtrees:
                    dfs(r, c, root)

        # overall: tc O(r * c * 3^L)
        # overall: sc O(W)
        return res

131. Palindrome Partitioning ::2:: - Medium

Topics: String, Dynamic Programming, Backtracking, Depth First Search

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

  oh! pseudocode hasn't been written yet, try another card! :)

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

51. N Queens ::2:: - Hard

Topics: Array, Backtracking, Depth First Search

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

  oh! pseudocode hasn't been written yet, try another card! :)

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, Depth First Search, 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

  oh! pseudocode hasn't been written yet, try another card! :)

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