{"id":"dsa-backtracking-pruning","slug":"pruning","title":"Combinations and pruning","text":"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`.\n\n```python\ndef combos(n, k, start, path):\n    if len(path) == k:\n        print(path)\n        return\n    for x in range(start, n + 1):\n        path.append(x)\n        combos(n, k, x + 1, path)\n        path.pop()\n\n\ncombos(4, 2, 1, [])\n```\n\n```output\n[1, 2]\n[1, 3]\n[1, 4]\n[2, 3]\n[2, 4]\n[3, 4]\n```\n\n`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)!).\n\n### Pruning: stop where a branch can't work\n\nOften 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:\n\n```python\ncalls = 0\n\n\ndef combos(n, k, start, path, prune):\n    global calls\n    calls += 1\n    if len(path) == k:\n        return 1\n    need = k - len(path)\n    last = n - need + 1 if prune else n\n    found = 0\n    for x in range(start, last + 1):\n        path.append(x)\n        found += combos(n, k, x + 1, path, prune)\n        path.pop()\n    return found\n\n\nfor prune in [False, True]:\n    calls = 0\n    found = combos(15, 12, 1, [], prune)\n    print(\"prune:\", prune, found, \"combinations,\", calls, \"calls\")\n```\n\n```output\nprune: False 455 combinations, 32647 calls\nprune: True 455 combinations, 1820 calls\n```\n\nBoth 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.\n\n### Pruning on a running total\n\nNumbers 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.)\n\n```python\ncalls = 0\n\n\ndef ways(prices, start, remaining):\n    global calls\n    calls += 1\n    if remaining == 0:\n        return 1\n    total = 0\n    for i in range(start, len(prices)):\n        if prices[i] > remaining:\n            break                   # sorted: the rest are bigger still\n        total += ways(prices, i + 1, remaining - prices[i])\n    return total\n\n\nprices = sorted([9, 3, 7, 1, 8, 2, 6, 4, 5, 10, 12, 11])\nprint(ways(prices, 0, 10), \"ways,\", calls, \"calls\")\n```\n\n```output\n10 ways, 43 calls\n```\n\nThe take-or-leave search of the first step makes 8,191 calls for 12 prices, whatever the target.\n\n### Forgetting to sort\n\nThe `break` is only right because the prices are sorted. Run the same search on the prices in the order they came:\n\n```python\ndef ways(prices, start, remaining):\n    if remaining == 0:\n        return 1\n    total = 0\n    for i in range(start, len(prices)):\n        if prices[i] > remaining:\n            break\n        total += ways(prices, i + 1, remaining - prices[i])\n    return total\n\n\nprices = [9, 3, 7, 1, 8, 2, 6, 4, 5, 10, 12, 11]\nprint(ways(prices, 0, 10), \"ways without sorting\")\nprint(ways(sorted(prices), 0, 10), \"ways after sorting\")\n```\n\n```output\n5 ways without sorting\n10 ways after sorting\n```\n\nHalf 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.\n\n### What pruning buys\n\nPruning 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.\n","task":"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:\n\n```\n[1, 2, 3]\n[1, 2, 4]\n[1, 2, 5]\n[1, 3, 4]\n[1, 3, 5]\n[1, 4, 5]\n[2, 3, 4]\n[2, 3, 5]\n[2, 4, 5]\n[3, 4, 5]\n20 calls\n```\n","kind":"fill","seed":"calls = 0\n\n\ndef combos(n, k, start, path):\n    global calls\n    calls += 1\n    if len(path) == k:\n        print(path)\n        return\n    need = k - len(path)\n    for x in range(start, [[n - need + 2‖n + 2 - need‖n - (k - len(path)) + 2]]):\n        path.append(x)\n        combos(n, k, [[x + 1]], path)\n        path.pop()\n\n\ncombos(5, 3, 1, [])\nprint(calls, \"calls\")\n","solution":"calls = 0\n\n\ndef combos(n, k, start, path):\n    global calls\n    calls += 1\n    if len(path) == k:\n        print(path)\n        return\n    need = k - len(path)\n    for x in range(start, n - need + 2):\n        path.append(x)\n        combos(n, k, x + 1, path)\n        path.pop()\n\n\ncombos(5, 3, 1, [])\nprint(calls, \"calls\")\n","hints":["The last useful start is `n - need + 1`. A `range` stops one before its end, so the end must be one more than that.","The end of the range is `n - need + 2`. The next call must start after `x`, so that the numbers in a combination keep increasing."],"tests":[{"name":"Output","stdin":"","expect":"[1, 2, 3]\n[1, 2, 4]\n[1, 2, 5]\n[1, 3, 4]\n[1, 3, 5]\n[1, 4, 5]\n[2, 3, 4]\n[2, 3, 5]\n[2, 4, 5]\n[3, 4, 5]\n20 calls","hidden":false}],"require":[],"forbid":[],"more":[{"task":"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`.\n\nFor example, `count_ways([5, 3, 2, 8, 1], 8)` returns 3, for 8, 5 + 3 and 5 + 2 + 1:\n\n```\n3\n```\n","kind":"code","seed":"def count_ways(numbers, target):\n    pass\n\n\nif __name__ == \"__main__\":\n    print(count_ways([5, 3, 2, 8, 1], 8))\n","solution":"def count_from(numbers, start, remaining):\n    if remaining == 0:\n        return 1\n    total = 0\n    for i in range(start, len(numbers)):\n        if numbers[i] > remaining:\n            break\n        total += count_from(numbers, i + 1, remaining - numbers[i])\n    return total\n\n\ndef count_ways(numbers, target):\n    return count_from(sorted(numbers), 0, target)\n\n\nif __name__ == \"__main__\":\n    print(count_ways([5, 3, 2, 8, 1], 8))\n","hints":["Write a helper, `count_from(numbers, start, remaining)`, and call it with the sorted numbers. When `remaining` is 0, the numbers taken so far are one way: return 1 (all the numbers are positive, so taking more can't help).","Otherwise loop `i` from `start`, and for each number that fits, add the ways for `count_from(numbers, i + 1, remaining - numbers[i])`.","When `numbers[i] > remaining`, `break`: the list is sorted, so every number after it is too big as well.","Taking or leaving every number and checking the sum at the end is right too, but for 60 numbers that's 2⁶⁰ subsets: far too slow."],"tests":[{"name":"count_ways([5, 3, 2, 8, 1], 8)","stdin":"","call":"count_ways([5, 3, 2, 8, 1], 8)\n","expect":"3","hidden":false,"returns":true},{"name":"count_ways([2, 2, 2], 4)","stdin":"","call":"count_ways([2, 2, 2], 4)\n","expect":"3","hidden":true,"returns":true},{"name":"count_ways([4, 6], 5)","stdin":"","call":"count_ways([4, 6], 5)\n","expect":"0","hidden":true,"returns":true},{"name":"count_ways([4, 2], 0)","stdin":"","call":"count_ways([4, 2], 0)\n","expect":"1","hidden":true,"returns":true},{"name":"count_ways([30, 20, 10], 60)","stdin":"","call":"count_ways([30, 20, 10], 60)\n","expect":"1","hidden":true,"returns":true},{"name":"count_ways(list(range(1, 13)), 10)","stdin":"","call":"count_ways(list(range(1, 13)), 10)\n","expect":"10","hidden":true,"returns":true},{"name":"count_ways(list(range(1, 41)), 30)","stdin":"","call":"count_ways(list(range(1, 41)), 30)\n","expect":"296","hidden":true,"big":true,"returns":true},{"name":"count_ways(list(range(60, 0, -1)), 40)","stdin":"","call":"count_ways(list(range(60, 0, -1)), 40)\n","expect":"1113","hidden":true,"big":true,"returns":true}],"require":[],"forbid":[]},{"task":"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.\n\nThis 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.\n\nFor example, `pack_combinations([10, 4, 6], 16)` returns:\n\n```\n[[4, 4, 4, 4], [4, 6, 6], [6, 10]]\n```\n","kind":"code","seed":"def pack_combinations(sizes, amount):\n    pass\n\n\nif __name__ == \"__main__\":\n    print(pack_combinations([10, 4, 6], 16))\n","solution":"def search(sizes, start, remaining, path, result):\n    if remaining == 0:\n        result.append(path[:])\n        return\n    for i in range(start, len(sizes)):\n        if sizes[i] > remaining:\n            break\n        path.append(sizes[i])\n        search(sizes, i, remaining - sizes[i], path, result)\n        path.pop()\n\n\ndef pack_combinations(sizes, amount):\n    result = []\n    search(sorted(sizes), 0, amount, [], result)\n    return result\n\n\nif __name__ == \"__main__\":\n    print(pack_combinations([10, 4, 6], 16))\n","hints":["Sort the sizes, then write a helper with `(sizes, start, remaining, path, result)`. When `remaining == 0`, append a copy of the path to the result.","Loop `i` from `start`. When `sizes[i] > remaining`, `break`. Otherwise append the size, call the helper with start `i` (the same size may come again) and `remaining - sizes[i]`, then pop.","Trying the sizes from the smallest one builds the ways in sorted order, so the result needs no sorting at the end."],"tests":[{"name":"pack_combinations([10, 4, 6], 16)","stdin":"","call":"pack_combinations([10, 4, 6], 16)\n","expect":"[[4, 4, 4, 4], [4, 6, 6], [6, 10]]","hidden":false,"returns":true},{"name":"pack_combinations([9, 4, 2], 13)","stdin":"","call":"pack_combinations([9, 4, 2], 13)\n","expect":"[[2, 2, 9], [4, 9]]","hidden":true,"returns":true},{"name":"pack_combinations([10, 4], 7)","stdin":"","call":"pack_combinations([10, 4], 7)\n","expect":"[]","hidden":true,"returns":true},{"name":"pack_combinations([3, 8], 0)","stdin":"","call":"pack_combinations([3, 8], 0)\n","expect":"[[]]","hidden":true,"returns":true},{"name":"pack_combinations([7], 21)","stdin":"","call":"pack_combinations([7], 21)\n","expect":"[[7, 7, 7]]","hidden":true,"returns":true},{"name":"pack_combinations([12, 8, 3], 24)","stdin":"","call":"pack_combinations([12, 8, 3], 24)\n","expect":"[[3, 3, 3, 3, 3, 3, 3, 3], [3, 3, 3, 3, 12], [8, 8, 8], [12, 12]]","hidden":true,"returns":true},{"name":"Test 7","stdin":"","call":"ways = pack_combinations([20, 12, 8, 5, 2], 50)\nprint(len(ways), ways[-3:])\n","expect":"68 [[5, 5, 8, 8, 12, 12], [5, 5, 8, 12, 20], [5, 5, 20, 20]]","hidden":true}],"require":[],"forbid":[]}]}