Mathematical Algorithms/Combinatorics & Counting

Lesson 10.43,627 words

Combinatorics & Counting

Counting is the arithmetic of finite sets. We build up from permutations $n!

╌╌╌╌

Modular exponentiation and, through Fermat's little theorem, the modular inverse underlie most of practical combinatorics: almost every counting answer is a ratio of factorials, and a ratio modulo a prime is a product with an inverse. This lesson covers the counting tools (permutations, combinations, Pascal's rule, stars and bars) and then shows how to evaluate those quantities modulo a prime in constant time after a linear precompute. We finish with two structural principles: inclusion–exclusion for counting unions, and the Chinese Remainder Theorem for combining congruences.

Permutations and combinations

A permutation is an ordering of distinct objects. There are choices for the first position, for the second, and so on, giving

If we order only of the objects, we stop the product after factors:

A combination counts subsets of size , where orderings no longer matter. Each -subset can be ordered in ways, so dividing by removes the overcount:

The quantity , read choose , is the binomial coefficient.1 It is symmetric, (choosing which to include is the same as choosing which to exclude), and the two boundary values are .

permutations_combinations.pypython
def factorial(number: int) -> int:
  """
    The product n! = n * (n-1) * ... * 1, with 0! = 1.\n
    Raises ValueError on a negative argument.\n
  """
  if number < 0:
    raise ValueError("factorial is undefined for negative integers")

  # multiply the integers 2 .. number into a running product.
  result: int = 1
  for factor in range(2, number + 1):
    result *= factor

  return result


def permutations_count(total: int, chosen: int) -> int:
  """
    nPr = n! / (n-r)! — the number of ordered arrangements of `chosen`\n
    objects drawn from `total` distinct objects.\n
    Zero when `chosen` exceeds `total` or either argument is negative.\n
  """
  if chosen < 0 or total < 0 or chosen > total:
    return 0

  # multiply the top `chosen` factors of total! directly: no division needed.
  result: int = 1
  for factor in range(total, total - chosen, -1):
    result *= factor
  return result


def binomial(total: int, chosen: int) -> int:
  """
    The binomial coefficient C(n, r) = n! / (r! (n-r)!) — the number of\n
    `chosen`-element subsets of a `total`-element set.\n
    Zero when `chosen` is out of the range [0, total].\n
  """
  if chosen < 0 or chosen > total or total < 0:
    return 0

  # exploit symmetry C(n, r) = C(n, n-r) to keep the loop short.
  chosen = min(chosen, total - chosen)
  numerator: int = 1
  denominator: int = 1
  for step in range(chosen):
    numerator *= total - step
    denominator *= step + 1
  return numerator // denominator

Pascal's rule

Binomial coefficients satisfy a recurrence that lets us build them additively, with no division at all:

Arranging these values in rows is Pascal's triangle: each interior entry is the sum of the two directly above it.

Pascal's triangle — each cell

The highlighted cell is . Because each entry needs only the row above, the whole triangle up to row is an dynamic program, the right approach when is small or when no modulus is involved (and the basis for Pascal's Triangle II and grid-path problems like Unique Paths, whose answer is exactly ).

pascals_triangle.pypython
def pascals_triangle(rows: int) -> list[list[int]]:
  """
    The first `rows` rows of Pascal's triangle (rows 0 .. rows-1).\n
    Row n holds the coefficients C(n, 0) .. C(n, n); each interior entry is\n
    the sum of the two directly above it.\n
    Raises ValueError when `rows` is negative.\n
  """
  if rows < 0:
    raise ValueError("number of rows must be non-negative")

  # each row starts all-ones, then interior cells sum the two cells above.
  triangle: list[list[int]] = []
  for row_index in range(rows):
    row: list[int] = [1 for _ in range(row_index + 1)]
    previous: list[int] = triangle[row_index - 1] if row_index > 0 else []
    for position in range(1, row_index):
      row[position] = previous[position - 1] + previous[position]
    triangle.append(row)

  return triangle


def pascal_row(row_index: int) -> list[int]:
  """
    A single row C(n, 0) .. C(n, n) of Pascal's triangle, built in place by\n
    sweeping right-to-left so each cell still sees its old left neighbour.\n
    Raises ValueError when `row_index` is negative.\n
  """
  if row_index < 0:
    raise ValueError("row index must be non-negative")

  # build up to row n in place, each pass sweeping right-to-left.
  row: list[int] = [1 for _ in range(row_index + 1)]
  for filled in range(1, row_index + 1):
    for position in range(filled - 1, 0, -1):
      row[position] = row[position] + row[position - 1]

  return row

That grid-path count is Pascal's rule in disguise: label each lattice node with the number of monotone (right/down) paths reaching it, and each node is the sum of its left and top neighbours, exactly the additive recurrence. On a grid of nodes the corner reads .

Lattice paths on a grid: each node sums its left and top neighbour; corner is

The binomial theorem

The name binomial coefficient comes from the expansion of .

Setting gives : the number of subsets of an -set, counted by size.

pascals_triangle.pypython
def binomial_expansion(power: int) -> list[int]:
  """
    The coefficients of (x + y)^power, in order of decreasing x-exponent:\n
    the term C(power, k) x^(power-k) y^k sits at index k. By the binomial\n
    theorem this is exactly row `power` of Pascal's triangle.\n
    Raises ValueError when `power` is negative.\n
  """
  if power < 0:
    raise ValueError("power must be non-negative")
  return pascal_row(power)

Combinations with repetition: stars and bars

How many ways can we write a non-negative integer as an ordered sum of non-negative parts, with each ? Equivalently, how many multisets of size can we draw from distinct types?

Concretely, with and there are slots; choosing the of them that hold bars fixes the three part sizes at once, so the count is .

Stars and bars: stars and bars in slots encode
permutations_combinations.pypython
def stars_and_bars(items: int, bins: int) -> int:
  """
    The number of ordered non-negative solutions of\n
    x_1 + x_2 + ... + x_bins = items, equivalently the number of multisets\n
    of size `items` drawn from `bins` distinct types.\n
    By the bars-between-stars bijection this is C(items + bins - 1, bins - 1).\n
  """
  if bins <= 0:
    # with no bins there is one empty solution only when nothing to place.
    return 1 if items == 0 and bins == 0 else 0
  if items < 0:
    return 0
  return binomial(items + bins - 1, bins - 1)

Computing

Competitive and large-scale problems ask for counts modulo a prime (typically ) because the true values are astronomically large. The factorial formula has a division by , and division is not defined modulo ; what stands in for it is multiplication by a modular inverse. Since is prime, Fermat gives for any , computed by the modular exponentiation routine.

The plan: precompute the factorials for all , and the inverse factorials . With both tables in hand, every binomial coefficient is a single product.

Algorithm:Precompute-Factorials(N,p)\textsc{Precompute-Factorials}(N, p)O(N)O(N) tables for O(1)O(1) queries
  1. 1
    fact[0]1\text{fact}[0] \gets 1
  2. 2
    for i1i \gets 1 to NN do
  3. 3
    fact[i]fact[i1]imodp\text{fact}[i] \gets \text{fact}[i-1] \cdot i \bmod p
  4. 4
    invfact[N]Mod-Pow(fact[N],p2,p)\text{invfact}[N] \gets \textsc{Mod-Pow}(\text{fact}[N],\, p-2,\, p)
    one Fermat inverse
  5. 5
    for iNi \gets N downto 11 do
  6. 6
    invfact[i1]invfact[i]imodp\text{invfact}[i-1] \gets \text{invfact}[i] \cdot i \bmod p
    peel a factor

The downward loop is the trick that keeps the precompute at rather than : only one modular exponentiation is needed, for ; each smaller inverse factorial follows from . Then each query is constant time:

This -precompute, -query scheme is what Number of Music Playlists and Count Anagrams need, since both reduce to products and ratios of factorials modulo .

binomial_mod_p.pypython
class BinomialModP:
  """
    Precomputed factorial tables for evaluating C(n, k) mod a prime p.\n
    Construct once for a ceiling N, then query any C(n, k) with\n
    0 <= k <= n <= N in constant time.\n
  """

  def __init__(self, max_n: int, prime: int = 1_000_000_007) -> None:
    """
      Build factorial and inverse-factorial tables for indices 0 .. max_n\n
      modulo `prime`. The single modular exponentiation is the Fermat\n
      inverse of the top factorial; smaller inverses peel down from it.\n
    """
    if max_n < 0:
      raise ValueError("table size must be non-negative")
    self.prime: int = prime

    # forward pass: factorial[i] = i! mod p.
    self.factorial: list[int] = [1 for _ in range(max_n + 1)]
    for index in range(1, max_n + 1):
      self.factorial[index] = self.factorial[index - 1] * index % prime

    # one Fermat inverse for the largest factorial, by a^(p-2) = a^-1.
    self.inverse_factorial: list[int] = [1 for _ in range(max_n + 1)]
    self.inverse_factorial[max_n] = pow(self.factorial[max_n], prime - 2, prime)

    # each smaller inverse factorial is the next one times the peeled factor.
    for index in range(max_n, 0, -1):
      self.inverse_factorial[index - 1] = (
        self.inverse_factorial[index] * index % prime
      )

  def binomial(self, total: int, chosen: int) -> int:
    """
      C(total, chosen) mod p, as fact[n] * invfact[k] * invfact[n-k].\n
      Zero when `chosen` falls outside [0, total].\n
    """
    if chosen < 0 or chosen > total or total < 0:
      return 0
    return (
      self.factorial[total]
      * self.inverse_factorial[chosen]
      % self.prime
      * self.inverse_factorial[total - chosen]
      % self.prime
    )

  def permutations(self, total: int, chosen: int) -> int:
    """
      nPr = fact[n] * invfact[n-r] mod p — ordered arrangements modulo p.\n
    """
    if chosen < 0 or chosen > total or total < 0:
      return 0
    return (
      self.factorial[total]
      * self.inverse_factorial[total - chosen]
      % self.prime
    )

def lucas(total: int, chosen: int, prime: int) -> int:
  """
    C(total, chosen) mod `prime` by Lucas' theorem, valid even when total or\n
    chosen exceed `prime`. Writing both in base p, the answer is the product\n
    of the digit-wise small binomials C(total_digit, chosen_digit) mod p.\n
    Each small binomial is computed directly from factorials below p.\n
  """
  if chosen < 0 or chosen > total or total < 0:
    return 0

  # multiply digit-wise small binomials across the base-p digits of n and k.
  result: int = 1
  while total > 0 or chosen > 0:
    total_digit: int = total % prime
    chosen_digit: int = chosen % prime

    # a digit of chosen exceeding the matching digit of total: zero overall.
    if chosen_digit > total_digit:
      return 0

    # fold this digit's binomial in and shift both numbers down one base-p place.
    result = result * _small_binomial(total_digit, chosen_digit, prime) % prime
    total //= prime
    chosen //= prime

  return result

def _small_binomial(total: int, chosen: int, prime: int) -> int:
  """
    C(total, chosen) mod `prime` for 0 <= chosen <= total < prime, via a\n
    short product and one modular inverse of the denominator.\n
  """
  if chosen < 0 or chosen > total:
    return 0

  # exploit symmetry C(n, k) = C(n, n-k) to keep the product short.
  chosen = min(chosen, total - chosen)

  # accumulate numerator and denominator products modulo p.
  numerator: int = 1
  denominator: int = 1
  for step in range(chosen):
    numerator = numerator * ((total - step) % prime) % prime
    denominator = denominator * (step + 1) % prime

  # divide by the denominator via its Fermat modular inverse.
  return numerator * pow(denominator, prime - 2, prime) % prime

Worked example ( modulo a small prime). Take and compute , which is . Build the factorial table : , giving . We need the two inverse factorials and . Fermat gives , and the downward peel fills the rest; the entries we want come out to (since and ) and (since and ). Then

matching . The single Fermat inverse plus a linear peel is all the division the whole computation ever does.

Inclusion–exclusion

To count a union of overlapping sets we cannot simply add their sizes, since elements in several sets get counted several times. Inclusion–exclusion corrects the overcount with alternating signs:

Inclusion–exclusion — add singles, subtract pairs, add the triple (in acc)

Worked example (counting coprime-to-a-set integers). How many integers in are divisible by none of ? Let be the multiples of respectively. Then ; ; and . So

leaving integers divisible by none of , which are exactly . The same alternating sum, applied with = maps position to itself, counts derangements .

Worked example (derangements of four items). How many permutations of leave no element fixed? Let be the permutations fixing position . There are ways to fix a chosen set of positions and permute the rest, so inclusion–exclusion gives

Those nine derangements are the permutations that leave no number in its own slot — for instance , , — and the ratio is already close to the limiting value that approaches, since the alternating sum is the truncated series for .

inclusion_exclusion.pypython
from collections.abc import Hashable, Sequence
from itertools import combinations
from typing import TypeVar

Element = TypeVar("Element", bound=Hashable)

def union_size(sets: Sequence[set[Element]]) -> int:
  """
    The size of the union of `sets`, by explicit inclusion-exclusion over\n
    every non-empty subset of the family: a subset of `size` sets contributes\n
    its intersection size with sign (-1)^(size+1).\n
    O(2^len(sets)) — for small families. Empty family gives 0.\n
  """
  # alternating sum over every non-empty subset of the family.
  total: int = 0
  count: int = len(sets)
  for size in range(1, count + 1):
    sign: int = 1 if size % 2 == 1 else -1

    # each subset contributes its intersection size with the size's sign.
    for chosen in combinations(sets, size):
      intersection: set[Element] = set(chosen[0])
      for member in chosen[1:]:
        intersection &= member
      total += sign * len(intersection)

  return total

def count_divisible_by_none(limit: int, divisors: Sequence[int]) -> int:
  """
    How many integers in [1, limit] are divisible by none of `divisors`.\n
    Inclusion-exclusion on "divisible by d_i": a subset contributes\n
    floor(limit / lcm(subset)) with the alternating sign, then we subtract\n
    that union from `limit`. Divisors that are zero are ignored.\n
  """
  if limit <= 0:
    return 0
  effective: list[int] = [value for value in divisors if value != 0]

  # alternating sum of floor(limit / lcm(subset)) gives the divisible union.
  divisible_union: int = 0
  for size in range(1, len(effective) + 1):
    sign: int = 1 if size % 2 == 1 else -1
    for chosen in combinations(effective, size):
      multiple: int = _lcm_of(chosen)
      divisible_union += sign * (limit // multiple)

  # whatever is left over is divisible by none.
  return limit - divisible_union

def derangements(count: int) -> int:
  """
    The number of permutations of `count` items with no fixed point,\n
    D_n = (n-1) * (D_{n-1} + D_{n-2}), seeded D_0 = 1, D_1 = 0 — the\n
    recurrence equivalent to the inclusion-exclusion sum n! * sum (-1)^j/j!.\n
    Raises ValueError when `count` is negative.\n
  """
  if count < 0:
    raise ValueError("count must be non-negative")
  if count == 0:
    return 1

  # roll the two-term recurrence forward from the seeds D_0 = 1, D_1 = 0.
  previous: int = 1   # D_0
  current: int = 0    # D_1
  for index in range(2, count + 1):
    previous, current = current, (index - 1) * (current + previous)

  return current

def _lcm_of(values: Sequence[int]) -> int:
  """
    The least common multiple of `values`, folded pairwise via gcd.\n
  """
  from math import gcd

  # fold one value at a time: lcm(a, b) = a // gcd(a, b) * b.
  result: int = 1
  for value in values:
    magnitude: int = abs(value)
    result = result // gcd(result, magnitude) * magnitude

  return result

The Chinese Remainder Theorem

Inclusion–exclusion combines counts; the Chinese Remainder Theorem (CRT) combines congruences. Given a system

with the moduli pairwise coprime, CRT guarantees a unique solution modulo .4 The construction is explicit and again uses the modular inverse. Let . Because the are coprime to , so is , hence has an inverse modulo ; call it (or when is prime). Then

Each term is (since there) and modulo every other (since ), so the sum satisfies all congruences simultaneously. Concretely, to solve and each term acts as a selector: one lands on its own residue and vanishes modulo the other, so adding them assembles the answer one congruence at a time.

CRT as a sum of selectors: each hits its own residue and is modulo the other, so the terms add to .
Algorithm:CRT(a[],m[])\textsc{CRT}(a[\,], m[\,]) — combine congruences with pairwise-coprime moduli
  1. 1
    MimiM \gets \prod_i m_i
  2. 2
    x0x \gets 0
  3. 3
    for i1i \gets 1 to nn do
  4. 4
    MiM/miM_i \gets M / m_i
  5. 5
    yiMod-Inverse(Mimodmi,  mi)y_i \gets \textsc{Mod-Inverse}(M_i \bmod m_i,\; m_i)
  6. 6
    x(x+aiMiyi)modMx \gets (x + a_i \cdot M_i \cdot y_i) \bmod M
  7. 7
    return xx
chinese_remainder.pypython
from collections.abc import Sequence

def extended_gcd(left: int, right: int) -> tuple[int, int, int]:
  """
    The extended Euclidean algorithm: returns (g, x, y) with\n
    left * x + right * y = g = gcd(left, right).\n
  """
  # track Bezout coefficients alongside the Euclidean remainders.
  remainder_x: int = 1
  remainder_y: int = 0
  next_x: int = 0
  next_y: int = 1

  # each step reduces the pair and carries the coefficients in lockstep.
  while right != 0:
    quotient: int = left // right
    left, right = right, left - quotient * right
    remainder_x, next_x = next_x, remainder_x - quotient * next_x
    remainder_y, next_y = next_y, remainder_y - quotient * next_y

  return left, remainder_x, remainder_y

def mod_inverse(value: int, modulus: int) -> int:
  """
    The inverse of `value` modulo `modulus`, in [0, modulus), via the\n
    extended gcd. Raises ValueError when no inverse exists (not coprime).\n
  """
  divisor, coefficient, _ = extended_gcd(value % modulus, modulus)
  if divisor != 1:
    raise ValueError("value has no inverse: it is not coprime to the modulus")
  return coefficient % modulus

def chinese_remainder(
  residues: Sequence[int], moduli: Sequence[int]
) -> int:
  """
    The unique x in [0, M) with x = residues[i] (mod moduli[i]) for every i,\n
    where M is the product of the moduli, which must be pairwise coprime.\n
    Computed as sum a_i * M_i * (M_i^-1 mod m_i) reduced modulo M.\n
    Raises ValueError on length mismatch or non-coprime moduli.\n
  """
  if len(residues) != len(moduli):
    raise ValueError("residues and moduli must have equal length")
  if not moduli:
    return 0

  # M is the product of all moduli; the answer is unique modulo M.
  product: int = 1
  for modulus in moduli:
    product *= modulus

  # sum one selector per congruence: a_i * M_i * (M_i^-1 mod m_i).
  solution: int = 0
  for residue, modulus in zip(residues, moduli):
    partial: int = product // modulus
    inverse: int = mod_inverse(partial % modulus, modulus)
    solution = (solution + residue * partial * inverse) % product

  return solution % product

CRT lets us compute modulo a large composite by working independently in each prime-power factor and reassembling the results.

Catalan numbers, generating functions, and symmetry

The four tools above cover most counting problems, but a few structural ideas from enumerative combinatorics recur often enough to name.

Catalan numbers. The count answers many distinct-looking questions: balanced-parenthesis strings of pairs, binary trees on nodes, triangulations of an -gon, and monotone lattice paths that stay below the diagonal. All reduce to the same recurrence , whose closed form is the ratio of binomials above — so with the factorial tables already built, any Catalan count is one query.5 The reflection-principle proof (count all paths, subtract the bad ones by reflecting across the boundary) explains the factor.

Generating functions. Treating a counting sequence as the coefficients of a formal power series turns recurrences into algebra: the Fibonacci generating function is the rational , and stars-and-bars is just the coefficient extraction . Products of generating functions are convolutions, which is why the Fast Fourier Transform later in this module multiplies two counting sequences in .6

Counting up to symmetry (Burnside). When arrangements that differ by a rotation or reflection should count once — necklaces, colorings of a cube's faces — naive counting over-counts by the symmetry group. Burnside's lemma says the number of distinct arrangements equals the average number of arrangements fixed by each symmetry, , and Pólya enumeration packages this into generating functions.7 These are the standard route to count the distinct colorings problems that inclusion–exclusion alone cannot handle.

Takeaways

  • Permutations count orderings (, or ); combinations count subsets, , dividing out the orderings.
  • Pascal's rule (element in or out) builds the triangle additively in with no division.
  • Stars and bars: the ordered non-negative solutions of number , via the bars-between-stars bijection.
  • To compute , precompute factorials and inverse factorials (one Fermat inverse, then peel factors) in , giving per query; use Lucas' theorem when .
  • Inclusion–exclusion counts unions by alternating add/subtract over all intersections; the alternating signs make each element net-counted exactly once.
  • The Chinese Remainder Theorem uniquely solves a system of congruences with coprime moduli via , the inverse again coming from Fermat or the extended gcd.

Footnotes

  1. CLRS, Appendix C — Counting and Probability (§C.1): permutations, combinations, and the binomial coefficient .
  2. Skiena, § — Combinatorics: Lucas' theorem reduces to a product of base- digit binomials when exceed .
  3. CLRS, Appendix C — Counting and Probability (§C.1): the inclusion–exclusion principle and the alternating-sign correction for unions.
  4. CLRS, Ch. 31 — Number-Theoretic Algorithms (§31.5): the Chinese Remainder Theorem and the constructive formula.
  5. R. P. Stanley, Enumerative Combinatorics, Vol. 2, Cambridge University Press, 1999 (Catalan numbers, Exercise 6.19 and its 66+ interpretations).
  6. H. S. Wilf, generatingfunctionology, 2nd ed., Academic Press, 1994 — the standard treatment of ordinary and exponential generating functions.
  7. N. G. de Bruijn, Pólya's theory of counting, in Applied Combinatorial Mathematics (Beckenbach, ed.), 1964; the counting-by-group-action lemma is also in Skiena, § — Combinatorics.
Practice

╌╌ END ╌╌