Week 8: Notes

more combinatorial recursion

Last week we saw how to use recursion to solve combinatorial problems such as generating all possible strings of a given length using a fixed alphabet, or generating all subsets of a set.

As another example, consider Czech coins, which have the following denominations: 1 Kč, 2 Kč, 5 Kč, 10 Kč, 20 Kč, 50 Kč. Suppose that we'd like to print out all possible ways of forming a sum N using Czech coins. For the moment, let's assume that order matters, e.g. 1 + 2 and 2 + 1 are distinct sums.

We can solve this problem using recursion. As we saw last week, as we generate any possible solution we will make a series of choices. We need to remember these choices somehow. As we saw before, one easy way to do that is using a string. Here is a possible solution:

int[] coins = { 1, 2, 5, 10, 20, 50 };

// Print all ways to make the sum n, where order matters (e.g.
// (1 + 2) and (2 + 1) are distinct).
void sum(int n) {

    void go(string prefix, int n) {
        string plus = prefix == "" ? "" : " + ";
        if (n == 0)
            WriteLine(prefix);
        else if (n > 0)
            foreach (int c in coins)
                go(prefix + plus + c, n - c);
    }

    go("", n);
}

Our recursive helper function go() takes a prefix string containing the choices we've made so far, plus an integer n indicating the remaining sum that we need to form. If n is negative, no solution is possible, so we return without printing anything. If n is 0, we have found a solution, so we print out the string. Otherwise n is positive, and we recurse once for every possible coin size c, subtracting it from the remaining sum that we need to form.

The call sum(5) will produce this output:

1 + 1 + 1 + 1 + 1
1 + 1 + 1 + 2
1 + 1 + 2 + 1
1 + 2 + 1 + 1
1 + 2 + 2
2 + 1 + 1 + 1
2 + 1 + 2
2 + 2 + 1
5

Now suppose that order does not matter - for example, 1 + 2 and 2 + 1 are considered to be the same sum, so we only want to print one of these. How can we achieve this? In theory we could use the previous function to generate all possible sums, then somehow filter out duplicates, but that would be extremely inefficient since each sum can be permuted in many possible ways. Instead, let's modify our code so that it only considers sums in which integers appear in non-increasing order. For example, if N = 10 then it will print 5 + 2 + 2 + 1, but not any other permutation of these numbers.

We can accomplish this by making a small change to our recursive helper function: it will take an extra argument max indicating the maximum value of any coin which may be chosen at any future point on the current branch of the decision tree. For example, after we have chosen the coins 5 and 2, we will pass max = 2 so that any coin chosen after that cannot be more than 2. That will force each solution to be non-increasing. Here is the modified code:

int[] coins = { 1, 2, 5, 10, 20, 50 };

// Print all ways to make the sum n, where order does not matter.
void sum2(int n) {

    // Only use coins of size <= max.
    void go(string prefix, int n, int max) {
        string plus = prefix == "" ? "" : " + ";
        if (n == 0)
            WriteLine(prefix);
        else if (n > 0)
            foreach (int c in coins)
                if (c <= max)
                    go(prefix + plus + c, n - c, c);
    }

    go("", n, n);
}

The call sum2(5) will produce this output:

1 + 1 + 1 + 1 + 1
2 + 1 + 1 + 1
2 + 2 + 1
5

dynamic programming

Dynamic programming is a general technique that we can use to solve a wide variety of problems. Many of these problems involve optimization, i.e. finding the shortest/longest/best solution to a certain problem.

Problems solvable using dynamic programming generally have the following characteristics:

Usually we can dramatically improve the running time by arranging so that each subproblem will be solved only once. There are two ways to do that:

Generally we prefer a bottom-up solution, because

We will first study one-dimensional dynamic programming problems, which have a straightforward recursive structure. Next week, we will look at two-dimensional problems, which are a bit more complex.

Fibonacci numbers

Computing the Fibonacci number Fn is a trivial example of dynamic programming. Recall that the Fibonacci numbers are defined as

yielding the sequence 1, 1, 2, 3, 5, 8, 13, 21, …

Here is a recursive function that naively computes the n-th Fibonacci number:

  int fib(int n) =>

      n < 3 ? 1 : fib(n - 1) + fib(n – 2);

What is its running time? The running time T(n) obeys the recurrence

T(n) = T(n – 1) + T(n – 2)

This is the recurrence that defines the Fibonacci numbers themselves! In other words,

T(n) = O(Fn)

The Fibonacci numbers themselves increase exponentially. It can be shown mathematically that

Fn = O(φn)

where

φ = (1 + √5) / 2

So fib runs in exponential time! That may come as a surprise, since the function looks so direct and simple. Fundamentally it is inefficient because it is solving the same subproblems repeatedly. For example, fib(10) will compute fib(9) + fib(8). The recursive call to fib(9) will compute fib(8) + fib(7), and so we see that we already have two independent calls to fib(8). Each of those calls in turn will solve smaller subproblems over and over again. In a problem with a recursive structure such as this one, the repeated work multiplies exponentially, so that the smallest subproblems (e.g. fib(3)) are computed an enormous number of times.

top-down dynamic programming (memoization)

As mentioned above, one way we can eliminate the repeated work is to use a cache that stores answers to subproblems we have already solved. This technique is called memoization. Here is a memoized implementation of fib, using a local method:

  int fib(int n) {
      int[] cache = new int[n + 1];
      cache[1] = 1;
      cache[2] = 1;
        
      int f(int i) {
          if (cache[i] == 0)
              cache[i] = f(i - 1) + f(i - 2);
          return cache[i];
      }
        
      return f(n);
  }

Above, the cache array holds all the Fibonacci numbers we have already computed. In other words, if we have already computed Fi, then cache[i] = Fi. Otherwise, cache[i] = 0.

This memoized version runs in linear time, because the line

runs only once for each value of i. This is a dramatic improvement!

bottom-up dynamic programming

In this particular recursive problem, the subproblem structure is quite straightforward: each Fibonacci number Fn depends only on the two Fibonacci numbers below it, i.e. Fn-1 and Fn-2. Thus, to compute all the Fibonacci numbers up to Fn we may simply start at the bottom, i.e. the values F1 and F2, and work our way upward, first computing F3 using F1 and F2, then F4 and so on. Here is the implementation:

  int fib(int n) {
      int[] a = new int[n + 1];
      a[1] = a[2] = 1;
      for (int k = 3 ; k <= n ; ++n)
          a[k] = a[k - 1] + a[k - 2];

      return a[n];
  }

Clearly this will also run in linear time. Note that this implementation is not even a recursive function. This is typical: a bottom-up solution consists of one or more loops, without recursion.

This example may seem trivial. But the key idea is that we can efficiently solve a recursive problem by solving subproblem instances in a bottom-up fashion, and as we will see we can apply this idea to many other problems.

rod cutting

The rod cutting problem is a classic dynamic programming problem. Suppose that we have a rod that is n cm long. We may cut it into any number of pieces that we like, but each piece's length must be an integer. We will sell all the pieces, and we have a table of prices that tells us how much we will receive for a piece of any given length. The problem is to determine how to cut the rod so as to maximize our profit.

We can express an optimal solution recursively. Let prices[i] be the given price for selling a piece of size i. We want to compute profit(n), which is the maximum profit that we can attain by chopping a rod of length n into pieces and selling them. Any partition of the rod will begin with a piece of size i cm for some value 1 ≤ i ≤ n. Selling that piece will yield a profit of prices[i]. The maximum profit for dividing and selling the rest of the rod will be profit(n – i). So profit(n), i.e. the maximum profit for all possible partitions, will equal the maximum value for 1 ≤ i ≤ n of

prices[i] + profit(n – i)

naive recursive solution

Here is a naive recursive solution to the problem:

  // Return the largest possible profit for cutting a rod of length n,
  // given a table with the prices for pieces from lengths 1 .. n.
  int profit(int n, int[] prices) {
      int best = 0;
      for (int i = 1 ; i <= n ; ++i)
          best = Max(best, prices[i] + profit(n - i, prices));

      return best;
  }

This solution runs in exponential time, because for each possible size k it will recompute profit(k, prices) many times.

bottom-up dynamic programming

This solution uses bottom-up dynamic programming:

  // Return the largest possible profit for cutting a rod of length n,
  // given a table with the prices for pieces from lengths 1 .. n.
  int profit(int n, int[] prices) {
      int[] best = new int[n + 1];  // best possible price for each size

      for (int k = 1 ; k <= n ; ++k) {
          // Compute the best price best[k] for a rod of length k.
          best[k] = 0;
          for (int i = 1 ; i <= k ; ++i)
              best[k] = Max(best[k], prices[i] + best[k - i]);
      }

      return best[n];  // best price for a rod of length n
  }

Once again, this bottom-up solution is not a recursive function. Notice its double loop structure. Each iteration of the outer loop computes best[k], i.e. the solution to the subproblem of finding the best price for a rod of size k. To compute that, the inner loop must loop over all smaller subproblems, which have already been solved, looking for a maximum possible profit.

This version runs in O(N2).

bottom-up dynamic programming, extended

Often when we use dynamic programming to solve an optimization problem such as this one, we want not only the value of the optimal solution, but also the solution itself. For example, the function in the previous section tells us the best possible price that we can obtain for a rod of size n, but it doesn't tell us the sizes of the pieces in which the rod should be cut!

So we'd like to extend our solution to return that information. To do that, for each size k < n we must remember not only the best possible prices for a rod of size k, but also the size of the first piece that we should slice off from a rod of that size in order to obtain that price. With that information, we can reconstruct the solution at the end:

  // Return the largest possible profit for cutting a rod of length n,
  // given a table with the prices for pieces from lengths 1 .. n.
  // Also return an array of the sizes into which to cut the rod
  // in order to achieve that profit.
  (int, int[]) profit(int n, int[] prices) {
      int[] best = new int[n + 1];   // best possible price for a given rod size
      int[] cut = new int[n + 1];    // size of first piece to slice from a given rod size

      for (int k = 1 ; k <= n ; ++k) {
          // Compute the best price best[k] for a rod of length k.
          best[k] = 0;
          for (int i = 1 ; i <= k ; ++i) {      // for every size <= k
              int p = prices[i] + best[k - i];
              if (p > best[k]) {
                  best[k] = p;
                  cut[k] = i;  // remember the best size to cut
              }
          }
      }

      List<int> l = new List<int>();
      for (int s = n ; s > 0 ; s -= cut[s])
          l.Add(cut[s]);
      sizes = l.ToArray();
      return (best[n], sizes);
  }

Study this function to understand how it works. Like the preceding version, it runs in O(N2).

As another possibility, instead of storing the size of the first piece to slice for any given rod size k, we can store a string containing the sizes of all pieces into which we should cut a rod of size k. In other words, this string will contain the actual solution for rod size k. That will allow us to avoid the extra loop at the end. Our code might look like this:

// Return the largest possible profit for cutting a rod of length n,
// given a table with the prices for pieces from lengths 1 .. n.
// Also return a string containing the sizes into which to cut the rod
// in order to achieve that profit.
(int, string) profit(int n, int[] prices) {
    int[] best = new int[n + 1];   // best possible price for a given rod size
    string[] sizes = new string[n + 1];    // sizes into which to cut a given rod size

    for (int k = 1 ; k <= n ; ++k) {
        // Compute the best price best[k] for a rod of length k.
        best[k] = 0;
        for (int i = 1 ; i <= k ; ++i) {      // for every size <= k
            int p = prices[i] + best[k - i];
            if (p > best[k]) {
                best[k] = p;
                sizes[k] = sizes[k - i] + i + " ";
            }
        }
    }

    return (best[n], sizes[n]);
}