Backtracking


Document last modified: 

Overview

Exponential time algorithms with multiple solutions

Many problems have multiple solutions. Some problems grow exponentially but have solutions that may be found in less than exponential time.

Many of the techniques for finding solutions to difficult problems are commonly employed in artificial intelligence but the same techniques are useful in other application areas also.

Problem solving is often a search through the space of the different states possible for the problem.

Example The figure at right, n=3 number of bits, has 8 = 23 states in the solution space.

Example A list of four unique integers has 4! = 24 permutations but only one that is sorted.

The search space contains each of the 24 permutations.

An exhaustive or brute-force search could be used but would expend more effort than necessary and could be unviable for large problem size.

Consider one possible solution in the search space of sorting the list in ascending order: <5,2,4,1>

After examining 5 and 2, the search can be terminated and another possible solution considered rather than examining 5, 2, 4, 1 as would BFS or DFS.

While merge sort obviously is predictably more efficient O(n lg n), the point here is that the n! state space can often be searched and a solution found in considerably less than n! time.

The key is to recognize when a partial solution is nonpromising in order to terminate the search and try other possible solutions.

Example: The search space of a sorted list <5, 2, 4> would include all 3! permutations of solutions, as in the one at left below.

The one at right shows only those necessary to be examined to determine which permutation is the sorted (descending) solution, <5, 4, 2>.

Full search space examined by brute-force

Partial search space examined by backtracking

Our strategy for searching the solution space might be to explore each descendant until either a complete solution:

  1. is solution found then stop
  2. is solution not found but descendants represent a promising solution then search a descendant
  3. is solution not found and remaining descendant represent an unpromising solution, back up to a previous node and try an unexplored descendant

Employing this strategy would examine the nodes in the following order to find a descending sort:

  • a, b - no possible solution with <2,4> or <2,5>, backup to a
  • a, c, g - no solution, backup g, c, a
  • a, d, m - no solution, backup m, d
  • a, d, n, p - solution

Question 1 - (For the search space above): What is the best case search for a ascending sorted solution? W(n) on left side.

A descending sorted solution?  W(n) if starting on right side.

The worst case? Recall that a complete tree of height h has 2h+1−1 nodes, the decision tree for sorting n items has height ³ lg n! for n! possible permutations (leafs). Brute force search of the complete space has exponential Q(2lg n!+1) number of nodes.

Example: For n bits there are 2n encodings. A tree could represent for n=3 the 23 values as below:

To find a value greater than 4 (100) in the organized search space, a binary search algorithm could easily do so in lg n time, the height.

Assuming the tree had the most significant bit at the top, any of the right branches would be a solution.

If the tree had the least significant bit at the top, finding a value greater than 4 could not be accomplished in lg n time without knowing beforehand the location of 5 (101), 6 (110) or 7 (111).

Our strategy for searching the solution space might be to explore descendant in DFS until either a complete solution:

  1. is found then stop
  2. is not found but descendants may hold the solution then search a descendant
  3. is not found and no descendant may hold the solution, back up to a previous node and try another descendant

Employing this strategy would examine the nodes in the following order to find a value of 5 or greater:

  • a, b, d, h - no solution, backup to d
  • i - no solution, backup to d, b
  • e, j - no solution, backup to e
  • k - solution 110 or 6

Note that we did better than exponential time but searching for the maximal value equal 7 would be still exponential. Recall that a complete tree of height h has 2h+1−1 nodes, the decision tree for sorting n items has height n. Brute force search of the complete space, Q(2n+1).

 

Traversing a binary tree

Traversing and printing a binary tree in pre-order is:

preorder( T )

print T.node

preorder( T.left )

preorder( T.right )

Printing the tree nodes at right as: abdhiejkcflmgnp

Notice that the algorithm traverses the tree in Depth-First order.

Whenever a left-branch has been fully traversed, it backs-up to traverse the right-branch.

Question 2 - Which node(s) does the algorithm back-up to after printing node k?

 

Generating the binary tree

The following generates the binary tree in a Depth-First, pre-order fashion.

This is the essence of the job of search algorithms, generating and exploring the search-space.

When the size of the solution X=3, the solution X is printed.

After a solution is generated, the algorithm backs-up to Line 3, generating the next solution.

All solutions are generated, none are excluded.

preorderBT( X[1..i] )
--  pre: X[1..i] specifies first i values
--  post: Solution X[1..i]
  1. if size(X[1..i]) = 3 then print X[1..i]

  2. else

  3.    for each x Î {0, 1} do

  4.            X[i+1] ¬ x

  5.            preorderBT( X[1..i+1] )

Printed

000
001
010
011
100
101
110
111

Question 3 - Trace execution to generate the call tree for the first 4 print executions. Initial call is: preOrderBT( X[] )

 

Backtracking is based on constructing a search-space-tree whose nodes are specific choices of a solution's components (sub-solutions).

When none of a node's descendants (sub-solutions) can lead to a solution, DFS terminates and backtracks to the previous node with descendants (sub-solutions) that may lead to a solution.

By terminating searches and not exploring non-solution descendants, backtracking reduces the number of nodes examined and can often solve exponential time problems in a reasonable amount of time.

Backtracking is not an optimization method, simply attempts to reduce the size of the search-space examined.

Question - Why not use dynamic programming for sorting? It can solve difficult problems in linear time, sometimes.

Answer - Are there repeated solutions in any of the problems above? That is, is there an dynamic programming substructure?

 

Generic Backtracking Algorithm - Line 4 is only difference with preorderBT algorithm above.

DFSbt( X[1..i] )
--  pre: X[1..i] specifies first i components of a solution
--  post: Solution X[1..i]
--           Si+1 is solution i+1
  1. if X[1..i] is a solution then print X[1..i]

  2. else

  3.    for each x Î Si+1 do

  4.       if x is consistent with X[1..i] and valid option for problem constraints then

  5.            X[i+1] ¬ x

  6.            DFSbt( X[1..i+1] )

The idea of backtracking is to construct a solution one component at a time and evaluate each partially constructed candidates as follows:

  1. If a solution is found ...
  2. If a partially constructed solution can be developed further without violating the problem's constraints, take the first remaining valid option for the next component.
  3. If there is no valid option for the next component, no alternative for any remaining component need be considered. In this case the algorithm backtracks to replace the last component of the partially constructed solution with the next valid option.

 

The difference between backtracking and brute-force

Brute-force examines all the search space, including non-viable solutions.

Backtracking avoids the search space with no viable solutions.

Line 4 above is the key difference between the two methods. Without Line 4, unviable solutions would be examined.

 

Binary backtracking to find 3-bit binary values greater than 5, starting at least significant bit

Pseudocode version
  1. DFSbt( X[1..i] )
  2.       if binary( X[1..i] ) > 5 then print(X)
  3.       else
  4.          for x Î { 0, 1 } do
  5.             if i+1 £then         -- valid solution
  6.                 X[i+1] ¬ x
  7.                 DFSbt( X )
  8.      return  -- try different solution

Output: 110
             111

Java version
  1. void DFSbt( int X[], int i ) {
  2.    if( binary(X, i) > 5 ) print(X);
  3.    else
  4.      for(int x=0; x<=1; x++) 
  5.        if (i+1 <= 3) {
  6.          X[i+1] = x;
  7.          DFSbt( X, i+1 );
  8.        }
  9. }

Backtracking can be observed to build the partial solution from the components of the two bits, 00, of b and d for example.

Tree at right has edges marked with 0 or 1, the value of x at line 4.

Example - Generate low bits first.

Note that all viable solutions (3 bits or less) are produced in exponential time, Q(2h+1). The valid solution test at line 5 is too strong to reduce the search space.

Any solution would be satisfactory and the algorithm could stop by exiting the for, often in below exponential time.

Question - Trace the recursion down to node j.

Change

The following makes change using { 25, 10, 5, 1 } coins.

DFSbtChange( X[1..i], change )
--  pre: X[1..i] specifies first i components of a solution
--  post: Solution X[1..i]
  1. if change = 0 then print X[1..i]

  2. else

  3.    for each x Î {25, 10, 5, 1} do

  4.       if change - x >= 0 then

  5.            X[i+1] ¬ x

  6.            DFSbtChange( X[1..i+1], change - x )

Question 4a - Give the first solution found for DFSbtChange( X[1..i], 11 ).

Greedy - Some minor changes can produce behavior based on minimizing the number of coins used, the algorithm always chooses the minimum number of coins as the solution.

Line 3 and 6 introduce the greedy behavior.

Line 3 chooses largest coins first.

Line 6 excludes solutions that exceed an upper bound, the minimum number of coins used so far for a complete solution.

min ¬ ¥ is assumed initialization of global variable.

DFSbtChange( X[1..i], change )
--  pre: X[1..i] specifies first i components of a solution
--  post: Solution X[1..i]
  1. if change = 0 then print X[1..i]

  2. else

  3.    for each x Î {25, 10, 5, 1} do

  4.       if change - x >= 0 then

  5.            X[i+1] ¬ x

  6.            DFSbtChange( X[1..i+1], change - x )

Question - Does x Î {1, 5, 10, 25} change the search?

DFSbtChange( X[1..i], change )
--  pre: X[1..i] specifies first i components of a solution
--  post: Solution X[1..i]
  1. if change = 0 then

  2.    print X[1..i]

  3.    if i < min then min ¬ i;

  4. else

  5.    for each x Î {25, 10, 5, 1} do

  6.       if change - x >= 0 and i+1 < min then

  7.            X[i+1] ¬ x

  8.            DFSbtChange( X[1..i+1], change - x )

No greedy condition

Greedy

N-Queens

N-Queens is the quintessential backtracking problem because it clearly delineates the nonpromising solutions from the promising.

Queens can move along any diagonal, row or column.

The following are legal chess moves (arrows) for the Queen to attack other Queens.

  1 2 3 4
1 % # &  
2 ¬ Q ® ®
3 ' $ (  
4   $   (

The goal of 4-Queens is to place 4 queens in non-attacking positions on the chess board.

The board below is a valid solution since no row, column or diagonal has more than one queen.

  1 2 3 4
1   Q    
2       Q
3 Q      
4     Q  
X
row 1 2 3 4
column 2 4 1 3

The board is encoded in the state array X where the index is the row and the value is the column; see above for encoding of the one solution.

Promising moves are those that do not conflict with previous promising moves and do not violate the problem constraints; only one queen per row and must be on the board.

Promising moves are determined by function conflicted.

conflicted(X, row, column) - Function that returns True if placing a Queen at (row, column) conflicts with any previously placed Queens.

conflicted( X, 3, 1) returns True below; i.e. trying to place a Queen in (3, 1) conflicts with at least one other, Queen in (1, 1).

  1 2 3 4
1 Q      
2     Q  
3 Q      
4        
X
row 1 2 3 4
column 1 3    

Question 4b - Can a Queen be placed anywhere above in row 3 without conflicting?

Pseudocode version
DFSbtQ( X[1..i], row )
  1.       if row = 4 then print(X)
  2.       else
  3.              for column Î {1, 2, 3, 4 }
  4.                 if not conflicted(X, row+1, column) then
  5.                     X[row+1] ¬ column
  6.                     DFSbtQ( X, row+1 )
  7.      return               -- backtrack to try different solution

When conflicted next column is examined.

When last column tried, backtrack.

When all rows solved, print solution.

conflicted( X, row, column )

--  pre: X[1..i] first i components of a solution
--  post: True if X[i+1] conflicts at (row, column) with X[1..i]
             False otherwise

Several solutions:

  Q    
      Q
Q      
    Q  
    Q  
Q      
      Q
  Q    
Java version (download)
void DFSbtQ( int row ) {
    if( row == n-1 ) print( X );
    else
       for (int col=0; col<n; col++)
          if(!conflicted(X, row+1, col)) {
              X[row+1]=col;
              DFSbtQ( row+1 );
         }
}

A partial search space for the 4-Queens problems is below, which includes the first solution.

Node numbers indicate the order in which the space is explored.

The x's indicate search space not explored because the partial solution does not have any valid components, due to queen conflicts.

For example, with a queen in row 1, column 1 of partial solution 1, there was no need to explore placing a queen in row 2, column 1 or 2.

After examining partial solution 4, no solution can exist for a queen in row 1, column 1.

The algorithm backtracks to row 1 and places a queen in row 1, column 2, which does have a solution.

Call by: DFSbtQ( X[], 0)

DFSbtQ( X[1..i], row )
  1.   if row = 4 then print(X)
  2.   else
  3.       for column Î {1, 2, 3, 4 } do
  4.           if not conflicted(X, row+1, column)
  5.              X[row+1] ¬ column
  6.              DFSbtQ( X, row+1 )
  7.    return

Question 5

Conclusion

Backtracking: