ClassS04CS141 | recent changes | Preferences

Dynamic programming example: # ways to choose k objects out of n

Define C(n,k) to be the number of different size-k subsets of {1,2,...,n}. C(n,k) is read as "n choose k", it counts the number of ways of choosing k items from a set of n items. Here we assume n and k are non-negative integers.

What can we say about C(n,k)?

If k = 0, then C(n,k) = 1. There is one size-0 subset of any set -- the empty set.

If k > n, then C(n,k) = 0. There is no way to choose a subset larger than the original set.

These are the boundary cases. What about the remaining cases, where 0 < k <= n?

Consider all the size-k subsets of {1,2,...,n}.

Classify them into two groups according to whether or not they contain the element n.

claim: The first group (those not containing n) has size C(n-1, k).

This is because the sets in this group are exactly the size-k subsets of {1,2,...,n-1}.

claim: The second group (those containing n) has size C(n-1,k-1).

This is because the sets in this group correspond exactly to the size-(k-1) subsets of {1,2,...,n-1}. To see this, consider listing the size-(k-1) subsets of {1,2,...,n-1}, and then changing each set by adding n. This gives you exactly the second group of sets.

These two claims together imply that C(n,k) satisfies the recurrence C(n,k) = C(n-1,k) + C(n-1,k-1).

Computing C(n,k)

Consider the following algorithm for computing C(n,k):

 unsigned int C(unsigned int n, unsigned int k) {
   if (k == 0) return 1;
   if (k > n) return 0;
   return C(n-1,k) + C(n-1,k-1);

What is the running time for this algorithm? Consider the recursion tree. Do some examples.

The running time T(n,k) satisfies the recurrence T(n,k) = 1 + T(n-1,k) + T(n-1,k-1). Some consideration of this reveals that T(2k,k) >= 2k. So the running time is very large.

What if instead we cache the answers so we don't recompute them if we already have computed them once?

 unsigned int C(unsigned int n, unsigned int k) {
   static Array<Array<int> > cache;

   if (! (cache.exists(n) && cache[n].exists(k)) {
     if (k == 0) cache[n][k] = 1;
     else if (k > n)  cache[n][k] = 0;
     else cache[n][k] = C(n-1,k) + C(n-1,k-1);
   return cache[n][k];

Now what's the running time? Drawing the recursion diagram, we see that it forms a grid, with a subproblem for each (n',k') pair where 0 ≤ k' ≤ k and 0 ≤ n' ≤ n.

Thus, the number of calls to C() where the answer is not cached is O(n k). Since these calls are the only ones that result in recursive calls, and each results in at most 2 calls, the total number of calls (cached or otherwise) is also O(n k). Since each call (not counting time spent in recursion) takes O(1) time, the total time to compute C(n,k) is O(nk).

Note that we could also just fill out the cache "bottom up":

 unsigned int C(unsigned int n, unsigned int k) {

   if (k = 0) return 1;
   if (n > k) return 0;

   static Array<Array<int> > cache;

   for (int N = 0; N <= n;  ++N)      cache[N][0] = 1;
   for (int K = 1; K <= k;  ++K)      cache[K-1][K] = 0;

   for (int N = 0;  N <= n;  ++N)
       for (int K = 1;  K <= k &&  K <= N;  ++K)
          cache[N][K] = cache[N-1][K-1] + cache[N-1][K];

   return cache[n][k];


ClassS04CS141 | recent changes | Preferences
This page is read-only | View other revisions
Last edited May 4, 2004 9:53 pm by Neal (diff)