← Retour à l'épreuve

Kazakhstan FAIO 2024 · Correction IOAI Training

A. Choosing rocket

Cette page donne toute la méthode. Lisez-la une fois que vous avez cherché.

Métrique
exact answer on hidden tests
Évaluée sur
self-generated tests (official tests are hidden)
Notre score
exact on 2000+ checks
Niveau attendu
exact C(n, k) on every hidden test

Niveau attendu atteint

Nous avons résolu l'épreuve nous-mêmes : téléchargé les données, mené les expériences et mesuré le score ci-dessous. L'énoncé et les données appartiennent à leurs organisateurs. Cette correction est rédigée en anglais.

The problem in brief

A crew must pick kk rocket components out of nn available ones, and the order in which they are picked does not matter. Your program reads the two natural numbers nn and kk and prints the number of possible choices. This is the binomial coefficient (nk)\binom{n}{k}, the most frequently used number in combinatorics and in probability. The only real difficulties are computing it without overflow or rounding errors, and handling edge cases.

How you are scored

The judge compares your printed integer with the exact answer on hidden tests (time limit 1 s, memory 64 MB). The statement gives no upper bound for nn, so the answer may be a huge integer: an exact computation is the target.

The key idea

Count ordered choices, then divide by the orderings you over-counted. Picking the components one at a time, in order, there are nn choices for the first, n−1n-1 for the second, ..., n−k+1n-k+1 for the kk-th. That counts each set of kk components once per possible order, that is k!k! times. Hence

(nk)=n(n−1)⋯(n−k+1)k!=n!k! (n−k)!.\binom{n}{k} = \frac{n(n-1)\cdots(n-k+1)}{k!} = \frac{n!}{k!\,(n-k)!}.

Two more facts make the computation clean:

  • Symmetry: choosing the kk components you take is the same as choosing the n−kn-k you leave, so (nk)=(nn−k)\binom{n}{k} = \binom{n}{n-k}. Always compute with the smaller of kk and n−kn-k.
  • Pascal's rule: (nk)=(n−1k−1)+(n−1k)\binom{n}{k} = \binom{n-1}{k-1} + \binom{n-1}{k} (a given component is either taken or not). It gives a dynamic-programming alternative and a good self-check.

Left: the row n = 40 of Pascal's triangle is symmetric and sharply peaked at k = 20. Right: the number of digits of C(n, k) for n = 40, 70 and 100; the middle values pass the largest 64-bit integer from n = 67.

The figure shows why overflow matters: (10050)\binom{100}{50} already has 30 digits. In C++ or Java, a 64-bit integer overflows from (6733)\binom{67}{33} on, and computing n!n! first overflows much earlier (21!>26321! > 2^{63}). Using floating point (math.factorial(n) / ... or scipy.special.comb without exact=True) silently loses digits beyond about 2532^{53}.

The full solution

Multiply and divide alternately, so the running value stays an exact integer and never grows much bigger than the answer:

def comb(n, k):
    if k < 0 or k > n:
        return 0                      # no way to choose more than you have
    k = min(k, n - k)                 # symmetry
    r = 1
    for i in range(1, k + 1):
        r = r * (n - k + i) // i      # r is now C(n-k+i, i), always an integer
    return r

Why is the floor division exact? After step ii, r=(n−k+ii)r = \binom{n-k+i}{i}, which is an integer, and (n−k+ii)=(n−k+i−1i−1)⋅n−k+ii\binom{n-k+i}{i} = \binom{n-k+i-1}{i-1}\cdot\frac{n-k+i}{i}. So r⋅(n−k+i)r \cdot (n-k+i) is always divisible by ii. The loop runs min⁡(k,n−k)\min(k, n-k) times; Python integers have arbitrary precision, so the answer is exact even with thousands of digits. (In Python 3.8+ math.comb(n, k) does the same thing; knowing how it works matters when you code in another language or need the value modulo a prime.)

Edge cases: k=0k = 0 or k=nk = n give 1; k>nk > n gives 0.

solution.py --check verifies the function against brute-force enumeration of all subsets for n≤10n \le 10, against math.comb and Pascal's rule on 2000 random cases with n≤3000n \le 3000.

Results

approachcorrect on large tests?why
multiplicative formula with exact integersyesexact, O(min⁡(k,n−k))O(\min(k, n-k)) steps
math.combyessame algorithm, built in
n!/(k!(n−k)!)n!/(k!(n-k)!) with Python integersyes, but slowerhuge intermediate numbers
same with 64-bit integers (C++)no, from n=21n = 2121!21! overflows
floating-point factorialsno, from about n=60n = 60only 53 bits of mantissa
enumerate all subsetsno(4020)≈1.4×1011\binom{40}{20} \approx 1.4 \times 10^{11} subsets

What to remember

  • Count ordered selections, divide by the number of orderings: this reasoning recovers (nk)\binom{n}{k} and many other counting formulas.
  • Use symmetry, and alternate multiplications and divisions, so that every intermediate value is an exact integer.
  • Combinatorial numbers explode: never compute factorials in fixed-size integers or floats when an exact answer is required.
  • In ML, (nk)\binom{n}{k} is everywhere: the number of feature subsets of size kk (why exhaustive feature selection is hopeless), the binomial distribution (nk)pk(1−p)n−k\binom{n}{k}p^k(1-p)^{n-k} behind accuracy confidence intervals, the number of pairs (n2)\binom{n}{2} in a similarity matrix.
Code complet (solution.py)
"""FAIO 2024 (Kazakhstan) qualification, problem A "Choosing rocket".

Number of ways to choose k components out of n, order irrelevant: C(n, k).

    echo "5 2" | python solution.py     -> 10   (the contest submission)
    python solution.py --check          -> self-tests + figure
"""
import sys


def comb(n: int, k: int) -> int:
    """C(n, k) with exact integers, without ever computing a factorial.

    After step i the running value equals C(n - k + i, i), always an integer,
    so the floor division is exact."""
    if k < 0 or k > n:
        return 0
    k = min(k, n - k)                 # symmetry C(n, k) = C(n, n - k): fewer steps
    r = 1
    for i in range(1, k + 1):
        r = r * (n - k + i) // i
    return r


def check():
    import math, itertools, random
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt

    # 1. brute force: enumerate the subsets themselves
    for n in range(0, 11):
        for k in range(0, 12):
            assert comb(n, k) == sum(1 for _ in itertools.combinations(range(n), k))
    # 2. Pascal's rule and the library, on big random cases
    for _ in range(2000):
        n = random.randint(1, 3000); k = random.randint(0, n)
        assert comb(n, k) == math.comb(n, k)
        if 0 < k < n:
            assert comb(n, k) == comb(n - 1, k - 1) + comb(n - 1, k)
    print("all checks passed; C(5,2) =", comb(5, 2), "; C(100,50) has",
          len(str(comb(100, 50))), "digits")

    # figure: the row n = 40 of Pascal's triangle, linear and log scale
    plt.rcParams.update({"font.size": 12, "axes.spines.top": False, "axes.spines.right": False})
    n = 40; ks = list(range(n + 1)); vals = [comb(n, k) for k in ks]
    fig, axs = plt.subplots(1, 2, figsize=(12, 4.2), dpi=150)
    axs[0].bar(ks, vals, color="#2e86ab")
    axs[0].set_title("C(40, k): symmetric, peaked at k = 20")
    axs[0].set_xlabel("k"); axs[0].set_ylabel("number of subsets")
    axs[1].plot(ks, [math.log10(v) for v in vals], "o-", color="#d1495b", label="n = 40")
    axs[1].axhline(math.log10(2**63 - 1), ls="--", color="#555",
                   label="largest signed 64-bit integer")
    for nn, c in [(70, "#3a7d44"), (100, "#8d5a97")]:
        axs[1].plot(range(nn + 1), [math.log10(comb(nn, k)) for k in range(nn + 1)],
                    color=c, label=f"n = {nn}")
    axs[1].set_title("log10 C(n, k): the middle value overflows 64 bits from n = 67")
    axs[1].set_xlabel("k"); axs[1].set_ylabel("digits (log10)")
    axs[1].legend(frameon=False, fontsize=10)
    fig.tight_layout(); fig.savefig("figures/pascal_row.png", facecolor="white")


if __name__ == "__main__":
    if "--check" in sys.argv:
        check()
    else:
        n, k = map(int, sys.stdin.read().split()[:2])
        print(comb(n, k))