2

I am exploring how a Dynamic Programming design approach relates to the underlying combinatorial properties of problems.

For this, I am looking at the canonical instance of the coin change problem: Let S = [d_1, d_2, ..., d_m] and n > 0 be a requested amount. In how many ways can we add up to n using nothing but the elements in S?

If we follow a Dynamic Programming approach to design an algorithm for this problem that would allow for a solution with polynomial complexity, we would start by looking at the problem and how it is related to smaller and simpler sub-problems. This would yield a recursive relation describing an inductive step representing the problem in terms of the solutions to its related subproblems. We can then implement either a memoization technique or a tabulation technique to efficiently implement this recursive relation in a top-down or a bottom-up manner, respectively.

A recursive relation to solve this instance of the problem could be the following (Python 3.6 syntax and 0-based indexing):

def C(S, m, n):
    if n < 0:
        return 0
    if n == 0:
        return 1
    if m <= 0:
        return 0
    count_wout_high_coin = C(S, m - 1, n)
    count_with_high_coin = C(S, m, n - S[m - 1])
    return count_wout_high_coin + count_with_high_coin

This recursive relation yields a correct amount of solutions but disregarding the order. However, this relation:

def C(S, n):
  if n < 0:
    return 0
  if n == 0:
    return 1
  return sum([C(S, n - coin) for coin in S])

yields a correct amount of solutions while regarding the order.

I am interested in capturing more subtle combinatorial patterns through a recursion relation that can be further optimized via memorization/tabulation.

For example, this relation:

def C(S, m, n, p):
    if n < 0:
        return 0
    if n == 0 and not p:
        return 1
    if n == 0 and p:
        return 0
    if m == 0:
        return 0
    return C(S, m - 1, n, p) + C(S, m, n - S[n - 1], not p)

yields a solution disregarding order but counting only solutions with an even number of summands. The same relation can be modified to regard order and counting number of even number of summands:

def C(S, n, p):
    if n < 0:
        return 0
    if n == 0 and not p:
        return 1
    if n == 0 and p:
        return 0
    return sum([C(S, n - coin, not p) for coin in S])

However, what if we have more than 1 person among which we want to split the coins? Say I want to split n among 2 persons s.t. each person gets the same number of coins, regardless of the total sum each gets. From the 14 solutions, only 7 include an even number of coins so that I can split them evenly. But I want to exclude redundant assignments of coins to each person. For example, 1 + 2 + 2 + 1 and 1 + 2 + 1 + 2 are different solutions when order matters, BUT they represent the same split of coins to two persons, i.e. person B would get 1 + 2 = 2 + 1. I am having a hard time coming up with a recursion to count splits in a non-redundant manner.

  • You are trying to solve the [partition problem](https://en.wikipedia.org/wiki/Partition_problem). The cited reference gives a pseudopolynomial algorithm based on dynamic programming. Note that the problem is actually NP-complete (the theoretical running time is a function of the numbers involved). – collapsar Feb 15 '18 at 14:55
  • @collapsar I don't think this is the partition problem. The OP states, "I want to split n among 2 persons s.t. each person gets the same number of coins, regardless of the total sum each gets." – גלעד ברקן Feb 16 '18 at 01:36
  • Bottom-up implementation added. This produces an answer for `f(500, [1, 2, 6, 12, 24, 48, 60])` in about 2 seconds. – גלעד ברקן May 17 '18 at 00:20
  • I think there's a mistake in the code counting even number of sumands while ignoring order... Shouldn't it read `return C(S, m - 1, n, p) + C(S, m, n - S[m - 1], not p)`? I.e. `n - S[m - 1]` rather than `n - S[n - 1]`? – Eduardo May 22 '18 at 22:48

2 Answers2

1

(Before I elaborate on a possible answer, let me just point out that counting the splits of the coin exchange, for even n, by sum rather than coin-count would be more or less trivial since we can count the number of ways to exchange n / 2 and multiply it by itself :)

Now, if you'd like to count splits of the coin exchange according to coin count, and exclude redundant assignments of coins to each person (for example, where splitting 1 + 2 + 2 + 1 into two equal size parts is only either (1,1) | (2,2), (2,2) | (1,1) or (1,2) | (1,2) and element order in each part does not matter), we could rely on your first enumeration of partitions where order is disregarded.

However, we would need to know the multiset of elements in each partition (or an aggregate of similar ones) in order to count the possibilities of dividing them in two. For example, to count the ways to split 1 + 2 + 2 + 1, we would first count how many of each coin we have:

def partitions_with_even_number_of_parts_as_multiset(n, coins):
  results = []

  def C(m, n, s, p):
    if n < 0 or m <= 0:
      return

    if n == 0:
      if not p:
        results.append(s)
      return

    C(m - 1, n, s, p)

    _s = s[:]
    _s[m - 1] += 1

    C(m, n - coins[m - 1], _s, not p)

  C(len(coins), n, [0] * len(coins), False)

  return results

Output:

=> partitions_with_even_number_of_parts_as_multiset(6, [1,2,6])
=> [[6, 0, 0], [2, 2, 0]]
                ^ ^ ^ ^ this one represents two 1's and two 2's

Now since we are counting the ways to choose half of these, we need to find the coefficient of x^2 in the polynomial multiplication

(x^2 + x + 1) * (x^2 + x + 1) = ... 3x^2 ...

which represents the three ways to choose two from the multiset count [2,2]:

2,0 => 1,1
0,2 => 2,2
1,1 => 1,2

In Python, we can use numpy.polymul to multiply polynomial coefficients. Then we lookup the appropriate coefficient in the result.

For example:

import numpy    

def count_split_partitions_by_multiset_count(multiset):
  coefficients = (multiset[0] + 1) * [1]

  for i in xrange(1, len(multiset)):
    coefficients = numpy.polymul(coefficients, (multiset[i] + 1) * [1])

  return coefficients[ sum(multiset) / 2 ]

Output:

=> count_split_partitions_by_multiset_count([2,2,0])
=> 3
גלעד ברקן
  • 23,602
  • 3
  • 25
  • 61
1

Here is a table implementation and a little elaboration on algrid's beautiful answer. This produces an answer for f(500, [1, 2, 6, 12, 24, 48, 60]) in about 2 seconds.

The simple declaration of C(n, k, S) = sum(C(n - s_i, k - 1, S[i:])) means adding all the ways to get to the current sum, n using k coins. Then if we split n into all ways it can be partitioned in two, we can just add all the ways each of those parts can be made from the same number, k, of coins.

The beauty of fixing the subset of coins we choose from to a diminishing list means that any arbitrary combination of coins will only be counted once - it will be counted in the calculation where the leftmost coin in the combination is the first coin in our diminishing subset (assuming we order them in the same way). For example, the arbitrary subset [6, 24, 48], taken from [1, 2, 6, 12, 24, 48, 60], would only be counted in the summation for the subset [6, 12, 24, 48, 60] since the next subset, [12, 24, 48, 60] would not include 6 and the previous subset [2, 6, 12, 24, 48, 60] has at least one 2 coin.

Python code (see it here; confirm here):

import time

def f(n, coins):
  t0 = time.time()

  min_coins = min(coins)

  m = [[[0] * len(coins) for k in xrange(n / min_coins + 1)] for _n in xrange(n + 1)]

  # Initialize base case
  for i in xrange(len(coins)):
    m[0][0][i] = 1

  for i in xrange(len(coins)):
    for _i in xrange(i + 1):
      for _n in xrange(coins[_i], n + 1):
        for k in xrange(1, _n / min_coins + 1):
          m[_n][k][i] += m[_n - coins[_i]][k - 1][_i]

  result = 0

  for a in xrange(1, n + 1):
    b = n - a

    for k in xrange(1, n / min_coins + 1):
      result = result + m[a][k][len(coins) - 1] * m[b][k][len(coins) - 1]

  total_time = time.time() - t0

  return (result, total_time)

print f(500, [1, 2, 6, 12, 24, 48, 60])
גלעד ברקן
  • 23,602
  • 3
  • 25
  • 61