数学 / 排列组合 · 从计数原理到 P(n,k) 与 C(n,k) / 组合数的计算与溢出 待审核 23 / 25
迭代式 · BigInt · Lucas

组合数的计算与溢出

C(60,30)=118264581564861424C(60, 30) = 118264581564861424,约 1.18×10171.18 \times 10^{17};而定义式分子上的 60!60! 有 82 位十进制数字,量级在 108110^{81}。中间量比答案本身大出六十多个数量级,这段落差在双精度浮点里无处安放。本页量出四种求值实现各自失真的位置,并给出把 nn 放大到任意规模时仍然可算的模素数路线。

1 · 阶乘定义式的量级落差

JavaScript 的 Number 是 IEEE 754 双精度,有效位 53 位,可精确表示的最大整数 Number.MAX_SAFE_INTEGER90071992547409919007199254740991,约 9.01×10159.01 \times 10^{15}。越过这个界,相邻可表示整数之间开始出现空隙,整数加乘不再封闭。

阶乘越界得很早。流行的说法是 21!21! 起就不再精确,理由是 21!5.11×101921! \approx 5.11 \times 10^{19} 已超过 2532^{53}。实测把这个界推后了两位:21!21!22!22!Number 里仍与精确值逐位相符,第一个存不下的阶乘是 23!23!。原因在于阶乘的二进制表示尾部带着大量零位:21!21!2182^{18} 这个因子,剔掉 18 个零位后有效部分只剩 48 位;23!23! 的零位是 19 个,有效部分升到 56 位,这才装不下。

警示 · 阶乘式的失效方式不止「差几个数」。Number 能表示的最大值约 1.8×103081.8 \times 10^{308}170!170! 尚在其内,171!171! 溢出为 Infinity。此时 C(171,1)C(171, 1) 按定义式算得 Infinity,而答案只是 171171C(200,100)C(200, 100) 的分子与分母同为 Infinity,商是 NaN

2 · 乘除交替的迭代式

避开大阶乘的办法是让乘与除交替进行,C(n,k)C(n, k) 的展开式(见组合 C(n, k))改写成 kk 个因子的连乘:

C(n,k)=i=0k1nii+1C(n, k) = \prod_{i=0}^{k-1} \frac{n-i}{i+1}

按这个次序求值,每一步的中间量都是整数,除法从不留余数。

定理 2.1Rj=i=0j1nii+1R_j = \prod_{i=0}^{j-1} \frac{n-i}{i+1},则 Rj=C(n,j)R_j = C(n, j)0jk0 \le j \le k 成立。

证明 RjR_j 展开即 n(n1)(nj+1)j!\frac{n(n-1)\cdots(n-j+1)}{j!},分子是 jj 个连续整数之积。jj 个连续整数之积必被 j!j! 整除,商恰为 C(n,j)C(n, j)。逐步看则有 Rj=Rj1nj+1jR_j = R_{j-1} \cdot \frac{n-j+1}{j},其中 Rj1=C(n,j1)R_{j-1} = C(n, j-1) 已是整数,而 C(n,j1)(nj+1)=C(n,j)jC(n, j-1)(n-j+1) = C(n, j) \cdot j 保证乘出的分子被 jj 整除。∎

kn/2k \le n/2 之后 RjR_j 单调递增,中间量的峰值就是 C(n,k)C(n, k) 本身。把 Number 换成 BigInt、把 / 换成整数除法,整条链精确无损,代价是 kk 次大整数乘除。

3 · 只用加法的递推

第三条路径绕开除法:Pascal 递推 C(n,k)=C(n1,k1)+C(n1,k)C(n, k) = C(n-1, k-1) + C(n-1, k) 只做加法(见 Pascal 三角与二项式定理)。滚动一行数组、每行从高位往低位覆写即可,O(nk)O(nk) 次加法、O(k)O(k) 空间。它不能跳算:求单个 C(n,k)C(n, k) 也得把前 nn 行推一遍。

换来的是干净的误差行为。加法在 Number 上只要两个加数与和都不越过 2532^{53} 就是精确的,而递推路径上出现的全是 C(i,j)C(i, j) 本身,没有比答案更大的量。

4 · 浮点实现的失真边界

IEEE 754 double · 53-bit · MAX_SAFE_INTEGER = 9007199254740991
图 4-1 · 阶乘式、迭代式、Pascal 递推与 BigInt 精确值的同台对照。可拖动 nnkk 观察哪几行先与精确值分道,并切换素数看模 pp 的余数。

三种 Number 实现的首处失真位置各不相同,扫描 n100n \le 100 的全部 (n,k)(n, k)BigInt 精确值逐对比对,结果如下:

实现 首处失真 该处精确值 是否越过 2532^{53}
阶乘式 · Number C(55,24)C(55, 24) 2488589544741300
迭代式 · Number C(56,23)C(56, 23) 3167295784216200
Pascal 递推 · Number C(57,25)C(57, 25) 9929472283517787
迭代式 · BigInt 不适用 不适用

Pascal 递推的失真点与「第一个越过 2532^{53} 的组合数」重合:99294722835177879929472283517787 正是所有 C(n,k)C(n, k) 中首个超过 90071992547409919007199254740991 的值。扫描 n300n \le 300 内全部未越界的 (n,k)(n, k),递推实现一处不差——它撑到了信息论允许的最后一格。乘除式则不然。

警示 · 本仓库 combinatorics/core/combinatorics.tscomb() 走的是浮点迭代式加 Math.round。它与精确值的第一处分歧在 C(56,23)C(56, 23):精确值 31672957842162003167295784216200comb() 给出 31672957842162013167295784216201,差 11,相对误差 3.2×10163.2 \times 10^{-16}。这个精确值只有 3.17×10153.17 \times 10^{15},仍在安全整数以内,失真来自中间量的舍入累积而非结果装不下。n80n \le 80 范围内共 23 个 (n,k)(n, k) 落在这类分歧上。本系列既有页面用到的 nn 都在 10 以内,远在这条界线之内;此处记的是适用范围。

5 · 模素数的组合数与 Lucas 定理

Lucas · C(n, k) ≡ ∏ C(nᵢ, kᵢ) (mod p)

许多场合只需要 C(n,k)modpC(n, k) \bmod ppp 为素数时,费马小定理给出 ap11(modp)a^{p-1} \equiv 1 \pmod p,于是 ap2a^{p-2} 就是 aa逆元(modular inverse),除以 k!k! 改写为乘上 (k!)p2modp(k!)^{p-2} \bmod p,全程在 [0,p)[0, p) 内运算。这条路要求 n<pn < p:否则 n!n! 含因子 pp,模 pp 得零,逆元不存在。

nn 超过 pp 时接上 Lucas 定理。把 nnkk 写成 pp 进制,n=inipin = \sum_i n_i p^ik=ikipik = \sum_i k_i p^i,则

C(n,k)iC(ni,ki)(modp)C(n, k) \equiv \prod_i C(n_i, k_i) \pmod p

每位的 nin_ikik_i 都小于 pp,逐位取组合数再相乘即可,位数只有 logpn+1\lfloor \log_p n \rfloor + 1。某一位上 ki>nik_i > n_i 时该位的组合数为零,整个乘积随之归零——这也给出了 pp 整除 C(n,k)C(n, k) 的一个判据(Kummer 定理的特例)。

图 5-1 · nnkkpp 进制分解与逐位组合数。可切换素数 pp 观察位数如何伸缩,某一位上 kk 的数字超过 nn 的数字时乘积归零。

6 · 参考文献

  1. Binomial coefficient. Wikipedia. 组合数的多种计算方法与整除性质。https://en.wikipedia.org/wiki/Binomial_coefficient
  2. Lucas's theorem. Wikipedia. Lucas 定理的陈述与 pp 进制证明。https://en.wikipedia.org/wiki/Lucas%27s_theorem
  3. Fermat's little theorem. Wikipedia. 费马小定理及其在模素数求逆上的用法。https://en.wikipedia.org/wiki/Fermat%27s_little_theorem
  4. Double-precision floating-point format. Wikipedia. IEEE 754 双精度的 53 位有效位与安全整数范围。https://en.wikipedia.org/wiki/Double-precision_floating-point_format