Skip to main content

Backtracking

Combinatorial Search pattern

  • Combinatorial search problems involve finding grouping and assignments of objects that satisfy certain conditions
  • Finding all permutations/subsets, solving sudoku, and 8-queens are classic combinatorial problems

Permutations

  • permutation means arranging things with an order
    • i.e. permutation of [a, b, c] is [a, b, c], [a, c, b], [b, a, c], [b, c, a], [c, a, b], [c, b, a]
  • important to review basic high school combinatorics to get an intuition for combinatorial problems
  • The number of permutations is given by n!
  • i.e. a bag of 3 letters, total number of choices is 3 * 2 * 1 = 6, have 6 leaf nodes in a tree

permutation

Complexity

  • The complexity of combinatorial problems often grows rapidly with the size of the problem
    • i.e. the number of permutation of 3 objects is only 6
      • However, number of permutation of 10 objects is about 3 million
      • The number of permutations of 11 objects is about 40 million
    • The rapid growth of solution space with even a small increase in problem size is called combinatorial explosion

Combinatorial search == DFS on tree

  • In combinatorial search problems, search space is in the shape of a tree
  • The tree that represents all the possible states is called a State-space Tree
  • Each node of the state-space tree represents a state we can reach in a combinatorial search (by doing a particular combination)
    • Leaf nodes are the solutions to the problem
  • Combinatorial search problems boil down to DFS / backtracking on the state-space tree
  • Since the search space can be quite large, we often have to prune the tree
    • i.e. discard branches

Three steps to conquer combinatorial search problems

  1. Identify the state(s)

    • we want to answer the following 2 questions to identify the states
      1. What state do we need to know whether we have reached a solution (and using it to construct a solution if the problem asks for it)
        • i.e. we keep track of the letters we have already selected when we do DFS for [a, b, c] example
      2. What state do we need to decide which child nodes should be visited next and which ones should be pruned
        • i.e. we have to know what are the letters left that we can still use (since each letter can only be used once) for [a, b, c] example
  2. Draw the state-space tree

    • you want to draw the tree (on a piece of paper if you have one)
    • A small test case that's big enough to reach one solution (leaf node)
    • after drawing the tree, simply traverse the tree depth-first
  3. DFS / backtrack on the state-space tree

    • apply the following backtracking template
    function dfs(node, state):
    if state is a solution:
    report(state) # e.g. add state to final result list
    return

    for child in children:
    if child is a part of a potential solution:
    state.add(child) # make move
    dfs(child, state)
    state.remove(child) # backtrack
  • unlike the following Ternary Tree Paths example which has an explicit tree, in combinatorial search problems we have to find our own tree

DFS with states

Ternary Tree Paths

  • each node of the tree has at most three children, find all root-to-leaf paths
  • use path to keep track of the nodes we have visited to reach the current node and use it to construct our solution when we reach leaf nodes

Javascript

class Node {
constructor(val, children = []) {
this.val = val;
this.children = children;
}
}

function dfs(root, path, res) {
// exit condition, reached leaf node, append paths to results
if (root.children.length === 0) {
path.push(root.val); // add current node value to path if children is null
res.push(path.join("->"));
path.pop(); // remove recent add node value from path
return;
}
// dfs on each non-null child
for (const child of root.children) {
if (child) {
path.push(root.val); // add current node value to path if children exist
dfs(child, path, res);
path.pop();
}
}
}

function ternaryTreePaths(root) {
let res = [];
if (root) dfs(root, [], res);
return res;
}

const one = new Node(1);
const two = new Node(2);
const four = new Node(4);
const six = new Node(6);
const three = new Node(3);

one.children = [two, four, six];

two.children = [three];

const result = ternaryTreePaths(one);
console.log(result);

Memoization pattern

  • It means saving the previous function call result in a dictionary and reading from it when we do the exact same call
  • Memoization is particularly useful for combinatorial problems that have large repeated state-space tree branches

without memoization results in a lot of repeated computations

withoutMemoization

Javascript

function fib(n) {
if (n === 0 || n === 1) {
return n;
}
return fib(n - 1) + fib(n - 2);
}

with memoization

  • The solution is saving previous results in a map of function argument to results (the "memo")
    • and checking it and returning previous results if it has been done before
    • Otherwise, we carry out the computation and save the results in the map

Javascript

function fib(n, memo = {}) {
// check in memo, if found, retrieve and return right away
if (n in memo) return memo[n];
if (n === 0 || n === 1) return n;
const res = fib(n - 1, memo) + fib(n - 2, memo);
// save result to memo before returning
memo[n] = res;
return res;
}

Pruning pattern

  • cut branches off the space-state tree
  • The fewer branches, the faster the algorithm
  • When do we want to prune a branch? When it's clear that going into that branch would not yield a final state

Dedup pattern

Combination Sum