Combinatorics contents
Stars and Bars
Count the ways to distribute identical items into distinct boxes, with lower and upper bounds.
Read first: Binomial Coefficients
Stars and bars counts the ways to split identical items (stars) into distinct boxes. It answers:
How many integer solutions does have, if the must be non-negative?
The theorem
Draw the items as stars in a row and separate the boxes with bars. For example, with and :
★★|★★★| -> x = (2, 3, 0)
|★|★★★★ -> x = (0, 1, 4)Every arrangement of stars and bars in a row of positions corresponds to exactly one solution, and we choose which positions hold the bars:
from math import comb
from itertools import product
def count_nonneg(n, k):
"""Solutions of x1 + ... + xk = n with xi >= 0."""
return comb(n + k - 1, k - 1)
def brute_nonneg(n, k):
return sum(sum(x) == n for x in product(range(n + 1), repeat=k))
assert count_nonneg(5, 3) == 21
assert all(count_nonneg(n, k) == brute_nonneg(n, k) for n in range(8) for k in range(1, 5))Positive integers
If every , first give one item to each box: substitute . Then , and
(each of the gaps between the stars may hold a bar; choose of them). These are the compositions of into parts.
def count_positive(n, k):
return comb(n - 1, k - 1) if n >= k else 0
def brute_positive(n, k):
return sum(sum(x) == n for x in product(range(1, n + 1), repeat=k))
assert count_positive(5, 3) == 6 # 1+1+3, 1+3+1, 3+1+1, 1+2+2, 2+1+2, 2+2+1
assert all(count_positive(n, k) == brute_positive(n, k) for n in range(1, 9) for k in range(1, 5))Lower bounds
If , substitute . The new total is :
def count_lower(n, lower):
rest = n - sum(lower)
return count_nonneg(rest, len(lower)) if rest >= 0 else 0
assert count_lower(10, [1, 2, 3]) == count_nonneg(4, 3)
assert count_lower(5, [3, 3]) == 0Upper bounds: inclusion-exclusion
For count all non-negative solutions and remove those violating some bound. Violating bound means , which reduces to lower-bound counting; do this for each subset of violated variables with the inclusion-exclusion principle:
(terms with a negative top part are 0).
def count_bounded(n, bounds):
k = len(bounds)
total = 0
for mask in range(1 << k):
removed = sum(b + 1 for i, b in enumerate(bounds) if mask >> i & 1)
rest = n - removed
if rest >= 0:
total += (-1) ** bin(mask).count("1") * comb(rest + k - 1, k - 1)
return total
def brute_bounded(n, bounds):
return sum(sum(x) == n for x in product(*[range(b + 1) for b in bounds]))
assert count_bounded(6, [2, 3, 4]) == brute_bounded(6, [2, 3, 4])
import random
random.seed(2)
for _ in range(100):
bounds = [random.randint(0, 5) for _ in range(random.randint(1, 4))]
n = random.randint(0, 12)
assert count_bounded(n, bounds) == brute_bounded(n, bounds)A classic instance: the number of ways to roll dice with faces to get sum is count_bounded(s - k, [d - 1] * k).
assert count_bounded(7 - 2, [5, 5]) == 6 # two 6-sided dice summing to 7
assert count_bounded(12 - 3, [5, 5, 5]) == 25 # three dice summing to 12Multisets
Choosing a multiset of size from kinds (repetition allowed, order irrelevant) is the same as with :
from itertools import combinations_with_replacement
assert count_nonneg(4, 3) == len(list(combinations_with_replacement(range(3), 4)))