Back to Number Theory Dsa

Modular Combinatorics Calculator

September 23, 2026 Wasil Zafar 14 min read

Create a domain-aware combinatorics calculator.

Table of Contents

  1. Intuition, Vocabulary and Foundations
  2. Step-by-Step Derivations and Algorithms
  3. Implementation, Complexity and Engineering
  4. Applications, Case Studies and Exercises

Intuition, Vocabulary and Foundations

A modular combinatorics calculator should behave like a route planner, not like one formula with % modulus appended. For example, $\binom{10}{3}=120$, so the answer modulo $7$ is $1$ and modulo $12$ is $0$. The integer quantity is the same, but the efficient and valid modular computation differs because every nonzero residue modulo a prime is invertible, whereas factorial factors such as $3$ and $4$ are not invertible modulo $12$.

Factorials count orderings, permutations count ordered selections, and combinations count unordered selections:

$$ n!=1\cdot2\cdots n,\qquad P(n,k)=\frac{n!}{(n-k)!},\qquad \binom nk=\frac{n!}{k!(n-k)!}. $$

These quotients are exact over the integers, but modular division is valid only when the denominator has an inverse. For a prime power $p^q$, separate each factorial into its $p$-adic exponent and its unit part. Compute the unit quotient using modular inverses, restore the remaining power of $p$, and combine answers for distinct prime powers with the Chinese Remainder Theorem.

Catalan-style formulas illustrate why the expression matters. Although $C_n=\binom{2n}{n}/(n+1)$, division by $n+1$ may be invalid modulo a composite number. The equivalent identity $C_n=\binom{2n}{n}-\binom{2n}{n+1}$ uses no modular division and remains valid for every positive modulus.

ConceptConcrete exampleNon-example or boundaryAssumption and question answered
Factorial$5!=120$Negative factorial inputFor $n\ge0$, how many orders contain all $n$ distinct objects?
Permutation$P(5,2)=5\cdot4=20$$P(3,5)$, because more objects are selected than existFor $0\le k\le n$, how many ordered selections are possible?
Combination$\binom{5}{2}=10$Replacing exact division by an unavailable modular inverseFor $0\le k\le n$, how many unordered selections are possible?
Prime factorial method$\binom{6}{2}\equiv1\pmod7$Using a factorial inverse when $n\ge p$, so $n!\equiv0\pmod p$Requires prime $p$ and typically $n\lt p$
Lucas theorem$\binom{10}{3}\equiv1\pmod7$ from base-$7$ digitsApplying Lucas directly to a composite modulusRequires a prime modulus and reduces the query digit by digit
$p$-adic valuation$v_2(10!)=5+2+1=8$Treating factors of $p$ as invertible modulo $p^q$How many copies of $p$ survive in a factorial quotient?
Prime-power residueCompute separately modulo $8$ and modulo $9$Assuming a field modulo $8$Unit factorials are invertible after all $p$-factors are removed
CRT reconstructionResidues modulo $8$ and $9$ determine one residue modulo $72$Combining moduli that were not separated into coprime factorsDistinct prime powers are pairwise coprime
Catalan identity$C_3=\binom63-\binom64=5$Dividing by $4$ modulo $8$Use a division-free identity when invertibility is uncertain

Step-by-Step Derivations and Algorithms

For a prime modulus $p$ with $n\lt p$, precompute factorials and inverse factorials. Fermat's theorem gives $x^{-1}\equiv x^{p-2}\pmod p$ for nonzero $x$, so

$$ \binom nk\equiv n!\,(k!)^{-1}\,((n-k)!)^{-1}\pmod p. $$

One table build costs $O(p)$ or $O(N)$ up to the largest supported $n$, and each query then costs $O(1)$.

When $n\ge p$, write $n=\sum n_i p^i$ and $k=\sum k_i p^i$. Lucas theorem states

$$ \binom nk\equiv\prod_i\binom{n_i}{k_i}\pmod p. $$

Each digit lies below $p$, so the factorial method applies locally. If any $k_i>n_i$, the corresponding digit combination is zero and therefore the entire result is zero.

For a prime power $M=p^q$, Legendre's formula computes

$$ v_p(n!)=\sum_{j\ge1}\left\lfloor\frac{n}{p^j}\right\rfloor. $$

Let $g(n)$ be the factorial product with all factors of $p$ removed, evaluated modulo $M$. Then

$$ \binom nk\equiv g(n)\,g(k)^{-1}\,g(n-k)^{-1}\, p^{\,v_p(n!)-v_p(k!)-v_p((n-k)!)} \pmod M. $$

If the remaining exponent is at least $q$, the residue is zero. Otherwise, all three unit factorials are coprime to $M$, so their inverses exist. Finally, factor a composite modulus into pairwise coprime prime powers, compute one residue for each, and reconstruct the unique result modulo their product with CRT.

Modular Combinatorics Calculator algorithm map
flowchart TD
    A[Validate query and modulus] --> B[Factor the modulus]
    B --> C{Factorization shape}
    C -->|Prime and n below p| D[Cached factorial tables]
    C -->|Prime and large n| E[Lucas digit decomposition]
    C -->|Prime power| F[Valuation plus unit factorial]
    C -->|Several prime powers| G[Evaluate every component]
    G --> H[CRT reconstruction]
    D --> I[Return residue and certificate]
    E --> I
    F --> I
    H --> I

Correctness follows from preserving the exact factorial quotient while separating nonunits from invertible unit factors. With a precomputed table of size $p^q$, a prime-power query uses $O(\log_p n)$ arithmetic steps and $O(p^q)$ cached space; Lucas uses $O(\log_p n)$ digit queries, and CRT adds logarithmic extended-gcd work per component.

Implementation, Complexity and Engineering

A calculator must state its scale policy. The reference implementation precomputes unit-factorial tables only when a prime power does not exceed table_limit; it rejects a larger table rather than risking an unexpected allocation. Tables are cached by prime power and reused across queries. The result includes a certificate naming the selected route, modulus factorization, component residues, and verification result.

The API defines out-of-range combinations and permutations as zero, accepts modulus $1$ with the unique residue zero, and rejects negative dimensions or nonpositive moduli. Catalan numbers use a subtraction identity instead of modular division. Python's unbounded integers avoid overflow in CRT products; fixed-width ports need checked multiplication or a wider intermediate type.

from dataclasses import dataclass
from math import comb


@dataclass(frozen=True)
class CertifiedResult:
    value: int
    modulus: int
    method: str
    factors: tuple[tuple[int, int], ...]
    component_residues: tuple[tuple[int, int], ...]
    verified: bool


def factor_prime_powers(modulus: int) -> list[tuple[int, int, int]]:
    if modulus <= 0:
        raise ValueError("modulus must be positive")
    remaining = modulus
    factors = []
    prime = 2
    while prime * prime <= remaining:
        if remaining % prime == 0:
            exponent = 0
            prime_power = 1
            while remaining % prime == 0:
                remaining //= prime
                exponent += 1
                prime_power *= prime
            factors.append((prime, exponent, prime_power))
        prime += 1 if prime == 2 else 2
    if remaining > 1:
        factors.append((remaining, 1, remaining))
    return factors


def extended_gcd(a: int, b: int) -> tuple[int, int, int]:
    old_r, r = a, b
    old_s, s = 1, 0
    old_t, t = 0, 1
    while r:
        quotient = old_r // r
        old_r, r = r, old_r - quotient * r
        old_s, s = s, old_s - quotient * s
        old_t, t = t, old_t - quotient * t
    return old_r, old_s, old_t


def inverse_mod(value: int, modulus: int) -> int:
    divisor, coefficient, _ = extended_gcd(value % modulus, modulus)
    if divisor != 1:
        raise ValueError("value is not invertible")
    return coefficient % modulus


def crt_pairwise(residues: list[tuple[int, int]]) -> tuple[int, int]:
    value, combined_modulus = 0, 1
    for residue, modulus in residues:
        inverse = inverse_mod(combined_modulus, modulus)
        step = ((residue - value) * inverse) % modulus
        value += combined_modulus * step
        combined_modulus *= modulus
        value %= combined_modulus
    return value, combined_modulus


class ModularCombinatorics:
    def __init__(self, table_limit: int = 200_000):
        if table_limit < 2:
            raise ValueError("table_limit must be at least two")
        self.table_limit = table_limit
        self.unit_prefix_cache: dict[tuple[int, int], tuple[int, ...]] = {}
        self.prime_factorial_cache: dict[int, tuple[tuple[int, ...], tuple[int, ...]]] = {}

    def _prime_tables(self, prime: int) -> tuple[tuple[int, ...], tuple[int, ...]]:
        if prime > self.table_limit:
            raise ValueError("prime exceeds the configured table limit")
        cached = self.prime_factorial_cache.get(prime)
        if cached is not None:
            return cached
        factorial = [1] * prime
        for value in range(1, prime):
            factorial[value] = factorial[value - 1] * value % prime
        inverse_factorial = [1] * prime
        inverse_factorial[-1] = pow(factorial[-1], prime - 2, prime)
        for value in range(prime - 1, 0, -1):
            inverse_factorial[value - 1] = inverse_factorial[value] * value % prime
        answer = tuple(factorial), tuple(inverse_factorial)
        self.prime_factorial_cache[prime] = answer
        return answer

    def _small_binomial_prime(self, n: int, k: int, prime: int) -> int:
        if k < 0 or k > n:
            return 0
        factorial, inverse_factorial = self._prime_tables(prime)
        return factorial[n] * inverse_factorial[k] * inverse_factorial[n - k] % prime

    def _lucas(self, n: int, k: int, prime: int) -> int:
        result = 1
        while n or k:
            n_digit, k_digit = n % prime, k % prime
            if k_digit > n_digit:
                return 0
            result = result * self._small_binomial_prime(n_digit, k_digit, prime) % prime
            n //= prime
            k //= prime
        return result

    def _unit_prefix(self, prime: int, prime_power: int) -> tuple[int, ...]:
        key = (prime, prime_power)
        cached = self.unit_prefix_cache.get(key)
        if cached is not None:
            return cached
        if prime_power > self.table_limit:
            raise ValueError("prime-power table exceeds the configured limit")
        prefix = [1] * (prime_power + 1)
        for value in range(1, prime_power + 1):
            prefix[value] = prefix[value - 1]
            if value % prime:
                prefix[value] = prefix[value] * value % prime_power
        answer = tuple(prefix)
        self.unit_prefix_cache[key] = answer
        return answer

    @staticmethod
    def _factorial_valuation(n: int, prime: int) -> int:
        result = 0
        while n:
            n //= prime
            result += n
        return result

    def _unit_factorial(self, n: int, prime: int, prime_power: int, prefix: tuple[int, ...]) -> int:
        if n == 0:
            return 1
        complete_blocks, remainder = divmod(n, prime_power)
        result = pow(prefix[prime_power], complete_blocks, prime_power)
        result = result * prefix[remainder] % prime_power
        return result * self._unit_factorial(n // prime, prime, prime_power, prefix) % prime_power

    def _binomial_prime_power(self, n: int, k: int, prime: int, exponent: int, prime_power: int) -> int:
        if k < 0 or k > n:
            return 0
        valuation = self._factorial_valuation(n, prime) - self._factorial_valuation(k, prime) - self._factorial_valuation(n - k, prime)
        if valuation >= exponent:
            return 0
        prefix = self._unit_prefix(prime, prime_power)
        numerator = self._unit_factorial(n, prime, prime_power, prefix)
        denominator_left = self._unit_factorial(k, prime, prime_power, prefix)
        denominator_right = self._unit_factorial(n - k, prime, prime_power, prefix)
        result = numerator
        result = result * inverse_mod(denominator_left, prime_power) % prime_power
        result = result * inverse_mod(denominator_right, prime_power) % prime_power
        return result * pow(prime, valuation, prime_power) % prime_power

    def binomial(self, n: int, k: int, modulus: int) -> CertifiedResult:
        if n < 0:
            raise ValueError("n must be nonnegative")
        if modulus <= 0:
            raise ValueError("modulus must be positive")
        if modulus == 1:
            return CertifiedResult(0, 1, "trivial-modulus", (), (), True)
        factors = factor_prime_powers(modulus)
        residues = []
        methods = []
        for prime, exponent, prime_power in factors:
            if exponent == 1 and n < prime:
                residue = self._small_binomial_prime(n, k, prime)
                methods.append("prime-factorial")
            elif exponent == 1:
                residue = self._lucas(n, k, prime)
                methods.append("lucas")
            else:
                residue = self._binomial_prime_power(n, k, prime, exponent, prime_power)
                methods.append("prime-power")
            residues.append((residue, prime_power))
        value, reconstructed_modulus = crt_pairwise(residues)
        method = methods[0] if len(set(methods)) == 1 else "crt[" + ",".join(methods) + "]"
        factor_certificate = tuple((prime, exponent) for prime, exponent, _ in factors)
        verified = reconstructed_modulus == modulus and all(value % component_modulus == residue for residue, component_modulus in residues)
        return CertifiedResult(value, modulus, method, factor_certificate, tuple(residues), verified)

    def permutation(self, n: int, k: int, modulus: int) -> CertifiedResult:
        if n < 0:
            raise ValueError("n must be nonnegative")
        if modulus <= 0:
            raise ValueError("modulus must be positive")
        if k < 0 or k > n:
            value = 0
        else:
            value = 1 % modulus
            for term in range(n - k + 1, n + 1):
                value = value * term % modulus
        return CertifiedResult(value, modulus, "direct-product", tuple((p, e) for p, e, _ in factor_prime_powers(modulus)), (), True)

    def catalan(self, n: int, modulus: int) -> CertifiedResult:
        if n < 0:
            raise ValueError("n must be nonnegative")
        left = self.binomial(2 * n, n, modulus)
        right = self.binomial(2 * n, n + 1, modulus)
        value = (left.value - right.value) % modulus
        return CertifiedResult(value, modulus, "division-free-catalan", left.factors, left.component_residues, left.verified and right.verified)


calculator = ModularCombinatorics(table_limit=10_000)
examples = [(10, 3, 7), (10, 3, 12), (100, 40, 72), (1_000, 123, 13)]
for n, k, modulus in examples:
    result = calculator.binomial(n, k, modulus)
    assert result.value == comb(n, k) % modulus
    assert result.verified
assert calculator.permutation(5, 2, 12).value == 8
assert calculator.permutation(3, 5, 11).value == 0
assert calculator.catalan(3, 8).value == 5
for modulus in range(1, 100):
    for n in range(25):
        for k in range(n + 2):
            result = calculator.binomial(n, k, modulus)
            expected = comb(n, k) % modulus if k <= n else 0
            assert result.value == expected
            assert result.verified
for invalid in (lambda: calculator.binomial(-1, 0, 7), lambda: calculator.binomial(5, 2, 0), lambda: ModularCombinatorics(1)):
    try:
        invalid()
        raise AssertionError("invalid input was accepted")
    except ValueError:
        pass
trace = calculator.binomial(100, 40, 72)
print("trace:", trace)
print("all tests passed")
MethodPreconditions and complexitySuitable workloadFailure mode and mitigation
Direct integer calculationExact arithmetic; cost grows with $n$ and integer sizeSmall inputs and testing oracleLarge intermediate integers; use only below an explicit scale limit
Prime factorial tablesPrime $p$, normally $n\lt p$; $O(p)$ build and $O(1)$ queryMany queries under one moderate primeFactorial becomes zero when $n\ge p$; route such queries to Lucas
Lucas theoremPrime $p$; $O(p)$ table and $O(\log_p n)$ digit workHuge $n$ with manageable $p$Table infeasible for a very large prime; reject or choose another method
Prime-power unit factorialModulus $p^q$; $O(p^q)$ table and $O(\log_p n)$ queryRepeated queries for manageable prime powersInverting factors containing $p$; remove them and track their valuation
CRT reconstructionPairwise-coprime prime-power componentsGeneral composite modulusProduct overflow in fixed-width code; use widened or checked arithmetic
Direct permutation product$0\le k\le n$; $O(k)$ time and $O(1)$ spaceOccasional permutation queriesSlow for very large $k$; add factorial or prime-power routing if needed
Division-free CatalanTwo binomial queries; valid for every modulusComposite moduli and uniform APIsUsing $(n+1)^{-1}$ when it does not exist; prefer subtraction identity
Cached tablesReuses work by prime or prime powerHigh query volume with repeated moduliUnbounded cache memory; impose a byte or entry budget and expose metrics

Applications, Case Studies and Exercises

An approachable calculator might answer classroom queries such as $\binom{100}{40}\bmod72$. It factors $72=2^3\cdot3^2$, evaluates the binomial modulo $8$ and $9$, reconstructs the answer with CRT, and reports a certificate containing both component residues. This makes the result inspectable rather than merely numerical.

A professionally realistic service receives heterogeneous moduli and very large indices. It must group requests by modulus, reuse tables, reject infeasible prime powers before allocation, and expose the chosen algorithm in logs or responses. Workload measurements should determine cache size and preprocessing thresholds. Correctness certificates can be sampled by recomputing small cases exactly and always checking reconstructed values against every prime-power residue.

Exercises should progress from explaining when factorial division is valid, through deriving the valuation formula and Lucas decomposition, to implementing multinomial coefficients. Debugging tasks can target forgotten zero residues, $k>n$, modulus $1$, incorrect CRT normalization, and tables that exceed policy. Extension tasks can add bounded cache eviction, batch grouping, and an independent exact-arithmetic verification mode.

Deliverable

Apply Modular Combinatorics Calculator

Case study 1 — inspect one composite query. Evaluate $\binom{100}{40}\bmod72$. Checkpoint 1: factor $72$ into $8$ and $9$. Checkpoint 2: record the residue produced for each prime power. Checkpoint 3: reconstruct one value in [0, 72) and verify it against comb(100, 40) % 72. Success requires a certificate whose factorization multiplies to $72$ and whose CRT value matches both component residues.

Case study 2 — mixed production batch. Create 10,000 deterministic queries drawn from repeated prime moduli, small prime powers, and composite products, with some $n$ values above their prime modulus. Record the selected method, cache hits, table bytes, preprocessing time, median query time, and rejection count. Cross-check every query with $n\le500$ using exact arithmetic. Success requires zero mismatches, no allocation above the configured limit, and an explicit reason for every rejected query.

Hands-on deliverable. Implement CLI commands for choose, permute, and catalan, returning JSON with value, modulus, method, factors, component_residues, and verified. Add boundary tests for $k=0$, $k=n$, $k>n$, modulus $1$, a prime with $n\ge p$, a zero residue modulo a prime power, and an unsupported table size. The calculator is complete when its documentation states every domain restriction and at least 5,000 randomized small cases match exact arithmetic.

Select factorial tables only when their inverses exist, Lucas only for prime moduli, and unit-factorial plus CRT methods for prime powers and composites. The recurring misconceptions are that exact integer division permits modular division, that every prime-sized table is affordable, and that a residue without its domain assumptions is a sufficient result; the prerequisite composite-binomial machinery supplies the proof obligations for each route.