Lecture 4/29: Backtracking 1


April 29, 2020

đź“‚Associated files

Recursive Backtracking

CS 106B: Programming Abstractions

Spring 2020, Stanford University Computer Science Department

Lecturers: Chris Gregg and Julie Zelenski

A backpack ('knapsack') that says it can hld 15kg, and a number of weights with a dollar amount and a weight. The weights are $4, 12kg; $2, 2kg; $2, 1kg; $10, 4kg, and $1, 1kg


Slide 2

Announcements


Slide 3

Today's Goals:


Slide 4

Subsets

The power set of set S is the set of all possible subsets of S. If the input contains the single element C, there are two possible subsets, one including C and the other empty:

{C}  {}

If the input contained element B in addition to C, we will also consider whether to include B. This power set contains four subsets:

{BC}  {B}  {C}  {}

With a third element A under consideration, the power set has eight subsets:

{ABC}  {AB}  {AC}  {A}  {BC}  {B}  {C}  {}

Note how the size of power set doubles as we add an element. To construct the size N power set, we build on the N-1 power set and try adding the new element or not to each.

Let's draw a decision tree for generating subsets. Each level of the tree corresponds an element from the input that is being considered. The possible options for the element are to either include it in the current subset or not.

In the diagram below, each left arm in the tree indicates the option to include the current element, the right arm is without. Each path from the top to the bottom represents a sequence of recursive calls that has reached the base case. That path is one subset. You can determine which elements are contained in that subset by tracing the sequence of yes/no turns it takes.

                                 |
                        +--------+---------+
                        |                  |   
                      A? yes              A? no
                        |                  |
                 +------+------+     +-----+------+
                 |             |     |            | 
               B? yes      B? no    B? yes      B? no

Did you notice that the decision tree for subsets is structurally similar to the decision tree for coin-flipping? Each decision point has two options. The total number of paths to explore is 2^N.

As before, the self-similarity leads to a very compact recursive solution:

void listSubsets(string input, string soFar)
{
    if (input.empty()) {
        cout << "{" << soFar << "}" < endl;
    } else {
        char consider = input[0];
        string rest = input.substr(1);
        listSubsets(rest, soFar + consider);  // explore with
        listSubsets(rest, soFar);             // explore without
    }
}

Slide 5

Tracing down and back

It is tempting to think that that recursion traverses the tree level-by-level or left to right, but this is not correct. Let's look at the path for the string "ABC": it goes down to the far left, around the bottom, and back up the right side of each subtree.

                                            |
                        +-------------------+--------------------+
                        |                                        |   
                      A? yes                                   A? no
                        |                                        |
               +--------+--------+                      +--------+--------+
               |                 |                      |                 | 
             B? yes            B? no                  B? yes            B? no
               |                 |                      |                 |
        +------+------+    +-----+------+        +------+------+    +-----+------+
        |             |    |            |        |             |    |            | 
      C? yes      C? no   C? yes      C? no    C? yes      C? no   C? yes      C? no

The first path explored is the one that goes all the way left. At the base case, it prints that subset and control returns to the previous decision point, where the previous decision is undone and the other option is tried.

Many students can follow how the recursion moves downward in the tree, but are perplexed at how backtracking moves upwards. As we traverse downward, we move toward the base case. When we arrive at the base case, there is no further exploration possible from here. Control should now return to the previous decision point to explore other alternatives.

Let's set a breakpoint on the base case so we can stop in the debugger at the bottom of the tree. If we look at the call stack we can see the sequence of recursive calls so far. Each stack frame represents a decision point on the path from the start to here. Those stack frames waiting on the call stack are the "memory" of how we got here. When we reach the base case, we will pop its stack frame from the call stack and uncover the previous stack frame, which becomes the new topmost frame. Execution resumes in that frame and we pick up where we left off.

Understanding how/when/why the code backtracks to a previous decision point is perhaps the trickiest part of all in recursive backtracking. I highly recommend that you sketch a decision tree and walk through its traversal and/or step in the debugger to confirm your understanding of how it moves up and down the tree.


Slide 6

Choose-explore-unchoose

This choose-explore-unchoose structure is a classic pattern for recursive backtracking. Here it is summarized in pseudocode:

void explore(options, soFar)
{
    if (no more decisions to make) {
        // base case
    } else {
        // recursive case, we have a decision to make
        for (each available option) {
            choose (update options/soFar)
            explore (recur on updated options/soFar)
            unchoose (undo changes to options/soFar)
        }
    }
}

The details in the pseudocode are intentionally vague, e.g. what it means to "update options/soFar" or what is meant by "each available option". These details are specific to a particular search space. If you apply the general pattern to generating sequendes of coin flips, the concrete details become:

Can you apply the general pattern to the letter sequences, permutations, and subsets?


Slide 7

Partitionable: determine whether a solution exists


Slide 8

Partitionable: let's code!

The Qt Creator logo


Slide 9

Partitionable: solution

bool partitionable(Vector<int>& nums) {
    return partitionable(nums, 0, 0); // no sums yet
}

bool partitionable(Vector<int>& rest, int sum1, int sum2) {
    if (rest.isEmpty()) {
        return sum1 == sum2;
    } else {
        int n = rest[0];
        rest.remove(0);
        bool answer = partitionable(rest, sum1 + n, sum2)
                   || partitionable(rest, sum1, sum2 + n);
        rest.insert(0, n);
        return answer;
    }
}

Slide 10

What if we wanted to actually find a correct partition?


Slide 11

What if we want to keep track of all solutions?