Skip to content

Commit 7c203b9

Browse files
agene0001YeungOnion
authored andcommitted
fix: compute binomial and multinomial coefficients exactly
Both were computed as `floor(0.5 + exp(ln n! - ln k! - ln (n-k)!))`, which returns the wrong integer well before f64 runs out of precision: C(50, 25) 126410606437750 exact 126410606437752 (off by 2) C(60, 30) ...863664 exact ...861424 (off by 2240) C(67, 33) ...170752 exact ...288370 (off by 116736, 57 ulp) `C(50, 25)` is exactly representable in f64, so this is not a representation limit. Uses the recurrence `C(n, i+1) = C(n, i) * (n - i) / (i + 1)` in `u128`. The division is exact at every step because `C(n, i+1)` is an integer, so the whole computation stays in integer arithmetic; `u128 -> f64` is correctly rounded, so the result is the nearest double even past 2^53. Only coefficients that overflow `u128` fall back to logs. `checked_multinomial` gets the same treatment via `n! / (n1! ... nk!) == prod_i C(s_i, n_i)` with `s_i` the running prefix sums, sharing the same kernel. Tested by brute force: every `C(n, k)` for `n <= 170` round-trips exactly against a `u128` reference, and every two-part multinomial equals the corresponding binomial. Cost: the exact path for `C(67, 33)` is 190 ns against ~25 ns before; small-k cases are unaffected (`C(1000, 3)` is 8 ns).
1 parent b152c47 commit 7c203b9

1 file changed

Lines changed: 130 additions & 11 deletions

File tree

‎src/function/factorial.rs‎

Lines changed: 130 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,43 @@ pub fn ln_factorial(x: u64) -> f64 {
4040
///
4141
/// # Remarks
4242
///
43-
/// Returns `0.0` if `k > n`
43+
/// Returns `0.0` if `k > n`.
44+
///
45+
/// The result is exact whenever `C(n, k)` fits in a `u128`, and correctly
46+
/// rounded to the nearest `f64` beyond `2^53`. Larger coefficients fall back
47+
/// to `exp(ln_factorial(n) - ln_factorial(k) - ln_factorial(n - k))`, which
48+
/// is accurate to about `1e-12` relative.
4449
pub fn binomial(n: u64, k: u64) -> f64 {
4550
if k > n {
46-
0.0
47-
} else {
48-
(0.5 + (ln_factorial(n) - ln_factorial(k) - ln_factorial(n - k)).exp()).floor()
51+
return 0.0;
52+
}
53+
match binomial_u128(n, k) {
54+
Some(exact) => exact as f64,
55+
// Overflow: the result exceeds ~3.4e38 / n, far past 2^53, so
56+
// exactness is unattainable anyway; fall back to logs.
57+
None => (ln_factorial(n) - ln_factorial(k) - ln_factorial(n - k))
58+
.exp()
59+
.round(),
60+
}
61+
}
62+
63+
/// Computes `C(n, k)` exactly in integer arithmetic, or `None` if an
64+
/// intermediate value overflows a `u128`. Requires `k <= n`.
65+
///
66+
/// `C(n, i + 1) = C(n, i) * (n - i) / (i + 1)`, and the division is exact at
67+
/// every step, so the whole computation stays in integer arithmetic. The
68+
/// previous `f64` implementation rounded `exp(ln n! - ln k! - ln (n-k)!)` to
69+
/// the nearest integer, which was off by up to ~1e5 (57 ulp) for coefficients
70+
/// near 2^63 that f64 could still represent to full precision.
71+
fn binomial_u128(n: u64, k: u64) -> Option<u128> {
72+
// C(n, k) == C(n, n - k); walking up the smaller side minimises both the
73+
// iteration count and the chance of overflowing the integer path.
74+
let k = k.min(n - k);
75+
let mut acc: u128 = 1;
76+
for i in 0..k {
77+
acc = acc.checked_mul((n - i) as u128)? / (i as u128 + 1);
4978
}
79+
Some(acc)
5080
}
5181

5282
/// Computes the natural logarithm of the binomial coefficient
@@ -75,16 +105,32 @@ pub fn multinomial(n: u64, ni: &[u64]) -> f64 {
75105
/// Computes the multinomial coefficient: `n choose n1, n2, n3, ...`
76106
///
77107
/// Returns `None` if the elements in `ni` do not sum to `n`.
108+
///
109+
/// # Remarks
110+
///
111+
/// The result is exact whenever the coefficient (and every intermediate
112+
/// prefix product) fits in a `u128`, and correctly rounded to the nearest
113+
/// `f64` beyond `2^53`. Larger coefficients fall back to
114+
/// `exp(ln n! - sum ln ni!)`, accurate to about `1e-12` relative.
78115
pub fn checked_multinomial(n: u64, ni: &[u64]) -> Option<f64> {
79-
let (sum, ret) = ni.iter().fold((0, ln_factorial(n)), |acc, &x| {
80-
(acc.0 + x, acc.1 - ln_factorial(x))
81-
});
116+
if ni.iter().sum::<u64>() != n {
117+
return None;
118+
}
82119

83-
if sum == n {
84-
Some((0.5 + ret.exp()).floor())
85-
} else {
86-
None
120+
// n! / (n1! n2! ... nk!) == prod_i C(s_i, n_i) with s_i = n_1 + ... + n_i:
121+
// a product of binomial coefficients, each computed exactly.
122+
let mut acc: u128 = 1;
123+
let mut prefix: u64 = 0;
124+
for &k in ni {
125+
prefix += k;
126+
let Some(product) = binomial_u128(prefix, k).and_then(|c| acc.checked_mul(c)) else {
127+
// Overflow: fall back to logs (the old implementation's only path).
128+
let ret = ni.iter().fold(ln_factorial(n), |a, &x| a - ln_factorial(x));
129+
return Some(ret.exp().round());
130+
};
131+
acc = product;
87132
}
133+
Some(acc as f64)
88134
}
89135

90136
// Initialization for pre-computed cache of 171 factorial
@@ -158,6 +204,49 @@ mod tests {
158204
assert_eq!(binomial(5, 7), 0.0);
159205
}
160206

207+
/// Every `C(n, k)` that fits in a `u128` must round-trip exactly (the
208+
/// conversion `u128 -> f64` is correctly rounded, so `expected as f64` is
209+
/// the best possible double). The old `exp(ln ...)`-based implementation
210+
/// failed this for e.g. `C(50, 25)` (off by 2) and `C(67, 33)` (off by
211+
/// 116736).
212+
#[test]
213+
fn test_binomial_is_exact_where_representable() {
214+
for n in 0..=170u64 {
215+
let mut expected: u128 = 1;
216+
for k in 0..=n / 2 {
217+
assert_eq!(
218+
binomial(n, k),
219+
expected as f64,
220+
"C({n}, {k}) should be {expected}"
221+
);
222+
assert_eq!(binomial(n, n - k), expected as f64, "C({n}, {}) symmetry", n - k);
223+
let Some(product) = expected.checked_mul((n - k) as u128) else {
224+
break;
225+
};
226+
expected = product / (k as u128 + 1);
227+
}
228+
}
229+
}
230+
231+
/// Coefficients too large for the integer path fall back to logs; check the
232+
/// fallback is close and consistent with `ln_binomial`.
233+
#[test]
234+
fn test_binomial_log_fallback() {
235+
// C(200, 100) = 9.0548514656103281165404177077e58 (overflows u128)
236+
prec::assert_relative_eq!(
237+
binomial(200, 100),
238+
9.0548514656103281165e58,
239+
epsilon = 0.0,
240+
max_relative = 1e-11
241+
);
242+
prec::assert_relative_eq!(
243+
binomial(1000, 500),
244+
ln_binomial(1000, 500).exp(),
245+
epsilon = 0.0,
246+
max_relative = 1e-11
247+
);
248+
}
249+
161250
#[test]
162251
fn test_ln_binomial() {
163252
assert_eq!(ln_binomial(1, 1), 1f64.ln());
@@ -176,6 +265,36 @@ mod tests {
176265
assert_eq!(35.0, multinomial(7, &[3, 4]));
177266
}
178267

268+
/// A two-part multinomial is a binomial coefficient; a three-part one has
269+
/// the closed form `C(n, a) * C(n - a, b)`. Both must be exact where
270+
/// representable (mirrors `test_binomial_is_exact_where_representable`).
271+
#[test]
272+
fn test_multinomial_is_exact_where_representable() {
273+
for n in 0..=170u64 {
274+
for k in 0..=n / 2 {
275+
assert_eq!(multinomial(n, &[k, n - k]), binomial(n, k), "n={n} k={k}");
276+
}
277+
}
278+
// 60! / (20!)^3 = 577831214478475823831865900 (fits u128); the
279+
// conversion `u128 -> f64` is correctly rounded:
280+
assert_eq!(
281+
multinomial(60, &[20, 20, 20]),
282+
577831214478475823831865900_u128 as f64
283+
);
284+
}
285+
286+
#[test]
287+
fn test_multinomial_log_fallback() {
288+
// 300! / (100!)^3 overflows u128; check against ln-space value
289+
let ln_ref = ln_factorial(300) - 3.0 * ln_factorial(100);
290+
prec::assert_relative_eq!(
291+
multinomial(300, &[100, 100, 100]),
292+
ln_ref.exp(),
293+
epsilon = 0.0,
294+
max_relative = 1e-11
295+
);
296+
}
297+
179298
#[test]
180299
#[should_panic]
181300
fn test_multinomial_bad_ni() {

0 commit comments

Comments
 (0)