Python Arena
Step 3 of 6

Combinations and pruning

A combination is a choice of k items in which the order doesn't matter: from 1, 2, 3 and 4 you can choose two numbers in 6 ways, and choosing 2 and 1 is the same as choosing 1 and 2. To build each combination only once, pick the numbers in increasing order: after choosing x, the next number starts from x + 1.

def combos(n, k, start, path):
    if len(path) == k:
        print(path)
        return
    for x in range(start, n + 1):
        path.append(x)
        combos(n, k, x + 1, path)
        path.pop()


combos(4, 2, 1, [])
Output
[1, 2]
[1, 3]
[1, 4]
[2, 3]
[2, 4]
[3, 4]

itertools.combinations(range(1, 5), 2) gives the same six, as tuples and in the same order, and math.comb(n, k) counts them without making them: n! / (k! (n - k)!).

Pruning: stop where a branch can't work

Often a branch is hopeless long before it reaches the bottom of the tree. Say you're choosing 5 numbers from 1 to 9, you have chosen 2 so far, and the loop is about to try 8 as the third. After 8 only 9 is left, a single number, but the combination would still need two more: whatever the call for 8 does, it can't finish. Not making that call at all is called pruning: one check cuts a whole subtree off the tree. With need numbers still to pick (this one included), the numbers from x to n must hold at least need of them, so x can go up to n - need + 1 and no further. This program counts the calls the search makes with and without that bound, choosing 12 numbers from 1 to 15:

calls = 0


def combos(n, k, start, path, prune):
    global calls
    calls += 1
    if len(path) == k:
        return 1
    need = k - len(path)
    last = n - need + 1 if prune else n
    found = 0
    for x in range(start, last + 1):
        path.append(x)
        found += combos(n, k, x + 1, path, prune)
        path.pop()
    return found


for prune in [False, True]:
    calls = 0
    found = combos(15, 12, 1, [], prune)
    print("prune:", prune, found, "combinations,", calls, "calls")
Output
prune: False 455 combinations, 32647 calls
prune: True 455 combinations, 1820 calls

Both searches find all 455 combinations, but the pruned one makes 18 times fewer calls. The other spends almost all its time in branches that run out of numbers before they hold 12.

Pruning on a running total

Numbers that add up give even better chances to prune. Say you have a gift card worth 10 and a list of prices, and you want to know how many sets of items cost exactly 10. Each call picks one more price, always from further right in the list than the last one it picked (another way to build every subset exactly once), and passes down what's still left on the card. If the prices are sorted, the first price that doesn't fit tells you that none of the later ones fit either, so break leaves the whole loop at once. (continue would give the same count, but it would go on trying prices that are bigger still.)

calls = 0


def ways(prices, start, remaining):
    global calls
    calls += 1
    if remaining == 0:
        return 1
    total = 0
    for i in range(start, len(prices)):
        if prices[i] > remaining:
            break                   # sorted: the rest are bigger still
        total += ways(prices, i + 1, remaining - prices[i])
    return total


prices = sorted([9, 3, 7, 1, 8, 2, 6, 4, 5, 10, 12, 11])
print(ways(prices, 0, 10), "ways,", calls, "calls")
Output
10 ways, 43 calls

The take-or-leave search of the first step makes 8,191 calls for 12 prices, whatever the target.

Forgetting to sort

The break is only right because the prices are sorted. Run the same search on the prices in the order they came:

def ways(prices, start, remaining):
    if remaining == 0:
        return 1
    total = 0
    for i in range(start, len(prices)):
        if prices[i] > remaining:
            break
        total += ways(prices, i + 1, remaining - prices[i])
    return total


prices = [9, 3, 7, 1, 8, 2, 6, 4, 5, 10, 12, 11]
print(ways(prices, 0, 10), "ways without sorting")
print(ways(sorted(prices), 0, 10), "ways after sorting")
Output
5 ways without sorting
10 ways after sorting

Half the ways are gone, with no error. After taking 9, only 1 is left on the card, and the next price, 3, is too big: break ends the loop before it reaches the 1 further along, so 9 + 1 is never counted. In an unsorted list, a price that doesn't fit says nothing about the prices after it. When a pruned search finds too few answers, check that the order the break relies on is really there.

What pruning buys

Pruning doesn't change the worst case: when nothing can be cut (many small numbers and a big target), the search is still exponential. But it often turns an impossible search into an instant one. For the numbers 1 to 40 and a target of 30, take-or-leave walks through all 2⁴⁰ subsets, about a trillion, while the pruned search finds the 296 ways in 2,035 calls. Sorting costs O(n log n), nothing next to the search, and it is what lets break cut the rest of the loop. The recursion goes only as deep as the numbers in one subset, so it stays shallow.

Your task

Challenge 1 of 3

Fill in the blanks: the end of the range, so that the loop stops at the last useful start (remember that range stops one before its end), and the start for the next call, so that each combination is built once. The program should print:

[1, 2, 3]
[1, 2, 4]
[1, 2, 5]
[1, 3, 4]
[1, 3, 5]
[1, 4, 5]
[2, 3, 4]
[2, 3, 5]
[2, 4, 5]
[3, 4, 5]
20 calls

Challenge 2

Write count_ways(numbers, target): how many subsets of numbers add up to exactly target? All the numbers are positive whole numbers, and equal numbers at different places count as different choices. It must be fast for 60 numbers and a small target, so sort first and prune with break.

For example, count_ways([5, 3, 2, 8, 1], 8) returns 3, for 8, 5 + 3 and 5 + 2 + 1:

3

Challenge 3

A shop sells packs of several sizes, and you can buy as many packs of each size as you like. Write pack_combinations(sizes, amount), which returns every way to buy exactly amount items. Each way is a list of pack sizes from the smallest to the largest, and the ways come in sorted order. The sizes are different positive numbers, in any order.

This time a size can be picked more than once. So after picking sizes[i], the deeper call starts its loop at i again, not at i + 1, and it never goes back to the left of i. That builds each way with its sizes in increasing order, [4, 6, 6] and never [6, 4, 6], so no way comes out twice.

For example, pack_combinations([10, 4, 6], 16) returns:

[[4, 4, 4, 4], [4, 6, 6], [6, 10]]