跳转至

阶乘取模

引入

本文讨论了某一模数下阶乘计算的相关结论,并提供一种时间复杂度线性相关于模数大小的计算方法,因而该方法主要适用于模数不太大()的情形。除了本文介绍的方法外,根据场景不同,还可以应用 多项式技术 进行快速计算。

根据 中国剩余定理,阶乘取模问题可以转化为模数为素数幂 的情形。在处理这类问题时,常常需要对于素数 和正整数 ,将阶乘 中的所有因子 都提取出来,进而得到分解:

其中, 表示阶乘 的素因数分解中 的幂次, 表示在阶乘 的结果中去除所有 的幂次得到的整数。本文将讨论 在素数(幂)模下的余数以及幂次 的具体计算方法。

这种分解在解决阶乘同时出现在所求表达式的分子和分母的问题时尤为有用,比如 计算某一模数下的二项式系数。对于这类问题,分子和分母中 的幂次可以直接相减,而与 互素的部分 则可以利用 乘法逆元 计算。

本文还介绍了与上述问题相关的 Wilson 定理及其推广、Legendre 公式和 Kummer 定理等内容。

Wilson 定理

Wilson 定理给出了判断某个自然数是素数的一个充分必要条件。

Wilson 定理

对于自然数 ,当且仅当 是素数时,

证明

首先,证明对于素数 。对于这一点,可以利用 同余方程原根 得到两种简洁的证明,此处略去不表。下面提供前置知识较少的一种证明方法:

时,命题显然成立。下面设 ,继而要证明 中所有非零元素(即同余类)的积为 。因为 中所有非零元素 都有逆元 ,于是 中彼此互逆的元素乘积为 。但是要注意 可能相等:,当且仅当 ,即

从而,。这说明 中所有元素的乘积为 ,进而 中所有非零元素的积为

反过来,对于合数 的情形,要证明 。利用反证法,不妨设 ,亦即存在整数 使得 成立。因为 是合数,必然存在素数 使得 ,所以 。但是,乘积 中必然已经出现 ,故而一定有 。这一矛盾就说明了

利用本文的记号,Wilson 定理可以写作

推广

Wilson 定理可以推广到一般模数的情形。

定理(Gauss)

对于自然数 ,有

而且,余数中的 取值为 当且仅当模 原根存在,即 时,其中 是奇素数且 是正整数。

证明

这个定理可以通过 整数乘法群 的结构简单地证明。此处给出思路相仿,但是较为初等的证明。

对于 的情形,有 。对于其他存在原根的情形,设原根为 ,则所有满足小于 且与它互素的正整数 都可以唯一地表示为 的形式,其中 Euler 函数。直接验证可知, 一定是偶数。因为 互为乘法逆元,所以在乘积中将它们两两配对,就有

因为 是唯一的不等于 且乘法逆元就是它自身的元素,所以它就等于 。这就说明了此时的余数等于

对于模 的原根不存在的情形,要证明余数等于 。为此,可以首先做质因数分解 ,然后应用 中国剩余定理 可知,只需要证明

对所有因子 都成立。中国剩余定理说明,每一个可能的余数组合 ,其中,,都唯一地对应着一个 使得 成立。所以,对于某个余数 ,都恰好有 使得 成立。利用这一点,可以对乘积进行分组,就有

此处的指数 要成为奇数,必然要求 ,因为欧拉函数 对于 都是偶数。如果 是奇素数,因为模 的原根不存在,必然有 ;如果 ,因为模 的原根不存在,必然有 含有某个奇素因子,故而大于 :这两种情形指数 都是偶数。而上式中括号里的项已经证明是模 的,所以这个幂模 的余数一定是 。剩余的情形只有 时,对于这个情形,可以直接证明`

仿照前文的证明思路,可以将所有 的奇数 两两配对而消去,那些无法配对的必然是方程 的解。该方程意味着 。令 ,就必然有 ,而 必然一奇一偶,所以 。故而,有 是整数。模 的余数中,只有 四个。因此,有

这就完成了所有情形的证明。

在计算中,尤为重要的是模数为素数幂的情形:

推论

对于素数 和正整数 ,有

注意,左侧并非 ,因为后者还需要统计 的倍数的贡献。

阶乘余数的计算

本节讨论余数 的计算。

素数模的情形

算式 有明显的递归结构。为注意到这一点,首先考察一个具体的例子:

例子

要计算 ,可以做如下递归计算:

可以看出,利用模 余数的周期性,可以将这一乘积划分为若干个长度为 的块,每一块的唯一差异就是最后一个元素的余数。因为 除以 得到的商是 且余数是 ,所以,该乘积可以划分为 个完整的块和最后一段长度为 的不完整的块。因此,可以将前 个块除了最后一个元素之外的部分提取出来(这一部分恰好是 Wilson 定理能够解决的),再乘上最后一个不完整的块的乘积,最后乘上前 个块的最后一个元素的连乘积。每个块的最后一个元素都是 的倍数,去掉 的幂次后,它们的连乘积恰好是 。这就将原来的问题转化为了规模更小的问题。

将该例子中的递归的结构一般化,就得到如下递推公式:

递推公式

对于素数 和正整数 ,有

证明

的素因数分解中去除所有 的幂次的结果。于是,有

这就完成了证明。下面对于该形式证明提供具体的解释。

要计算 的值。仿照上面的例子,有

可以清楚地看到,除了最后一个块外,阶乘被划分为几个长度相同的完整的块。

除了块的最后一个元素外,完整的块的主要部分 很容易计算,可以应用 Wilson 定理:

总共有 个完整的块,因此需要将 写到 的指数上。

最后一个部分块的值就是 ,可以单独计算。

剩下的就是每个块的最后一个元素。如果隐藏已处理的元素,可以看到以下模式:

这也是一个修正的阶乘,只是长度短得多。它是:

将各部分乘起来,就得到上面的递推公式。

利用该递推式做计算,递归深度为 。如果每次都重新计算中间那一项,那么每层计算的复杂度都是 的,总的时间复杂度是 ;如果对所有 都预先处理了 ,那么预处理的复杂度是 的,每层计算的复杂度都是 的,总的时间复杂度是 的。

在实现时,因为是尾递归,可以用迭代实现。下面的实现对前 个阶乘做了预计算,如果需要多次调用,可以将预计算放到函数外进行。

参考实现
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// Calculate (n!)_p mod p.
int factmod(int n, int p) {
  // Pretreatment.
  std::vector<int> f(p);
  f[0] = 1;
  for (int i = 1; i < p; ++i) {
    f[i] = (long long)f[i - 1] * i % p;
  }
  // Recursion.
  int res = 1;
  while (n > 1) {
    if ((n / p) & 1) res = p - res;
    res = (long long)res * f[n % p] % p;
    n /= p;
  }
  return res;
}

如果空间有限,无法存储所有阶乘,也可以将函数调用中实际用到的阶乘 中的 都计算出来,然后对它们进行排序,从而可以在最后一次性计算出来这些阶乘的值,汇总到最终结果中,而避免存储所有阶乘的值。

素数幂模的情形

对于素数幂模的情形,可以仿照素数模的情形解决,只需要将 Wilson 定理替换成它的推广形式。本节两个结论中的 ,均特指这样的定义:当模数 时取 ,其余情形取

递推公式

对于素数 和正整数 ,有

其中, 的取值如同 Wilson 定理的推广 中规定的那样。

证明

证明思路和素数模的情形完全一致。记 为去除 的素因数分解中 的全部幂次的结果,则

与素数模的情形不同之处,除了 可能需要替换为 之外,还需要注意预处理的数据的不同。对于素数幂模的情形,需要对所有不超过 的正整数 预处理自 但并非 的倍数的所有整数的乘积,即

在素数模的情形,它退化为 ,但是该表达式在一般的素数幂的情形不再适用。

下面提供了在素数幂模的情形下计算阶乘余数的例子,以便理解上述方法:

例子

要计算 ,可以做如下递归计算:

的算式分解的结果同样可以分为三部分:

  • 完整的块:由 之间所有不被 整除的整数的乘积,共 块;
  • 尾部不完整的块:所有不被 整除的整数从 一直乘到
  • 所有被 整除的整数的乘积,对比倒数第二个等号的结果可知,这就是它的前 项,亦即

最后一个括号里的递归求解即可,这样就将原问题转化为了更小的问题。

由此,就可以得到如下递推结果:

递推结果

对于素数 和正整数 ,有

其中, 的取值与上文所述相同。

素数幂模的情形的实现和素数模的情形类似,只有一些细节上的区别。与上文类似,同样可以将预处理放到函数外进行。

参考实现
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// Calculate (n!)_p mod pa.
int factmod(int n, int p, int pa) {
  // Pretreatment.
  std::vector<int> f(pa);
  f[0] = 1;
  for (int i = 1; i < pa; ++i) {
    f[i] = i % p ? (long long)f[i - 1] * i % pa : f[i - 1];
  }
  // Recursion.
  bool neg = p != 2 || pa <= 4;
  int res = 1;
  while (n > 1) {
    if ((n / pa) & neg) res = pa - res;
    res = (long long)res * f[n % pa] % pa;
    n /= p;
  }
  return res;
}

预处理的时间复杂度为 ,单次询问的时间复杂度为

幂次的计算

本节讨论阶乘 的幂次 的计算,它可以用于计算二项式系数的余数。因为二项式系数中,分子和分母都会出现阶乘,而分子和分母中素数 能否互相抵消,就成为了决定最后的余数的重要因素。

Legendre 公式

阶乘 中素数 的幂次可以通过 Legendre 公式计算,而且与 进制下的表示有关。

Legendre 公式

对于正整数 ,阶乘 中含有的素数 的幂次

其中, 进制下 的各个数位的和。特别地,阶乘中 的幂次是

证明

因为

其中, 的倍数的乘积为 ,而 可能继续出现 的倍数。所以,对于幂次,有递推关系:

将它展开就得到 Legendre 公式。

要证明第二个等号,首先将 展开为 进制,这相当于将它写作如下和式:

因此,有

求阶乘中素数幂次的参考实现如下:

参考实现
1
2
3
4
5
6
7
8
9
// Obtain multiplicity of p in n!.
int multiplicity_factorial(int n, int p) {
  int count = 0;
  do {
    n /= p;
    count += n;
  } while (n);
  return count;
}

它的时间复杂度为

Kummer 定理

组合数对一个数取模的结果,往往构成分形结构,例如谢尔宾斯基三角形就可以通过组合数模 得到。

如果仔细分析, 是否整除组合数其实和上下标在 进制下减法是否需要借位有关。这就有了 Kummer 定理

Kummer 定理

素数 在组合数 中的幂次,恰好是 进制下 减掉 需要借位的次数,亦即

特别地,组合数中 的幂次是 .

证明

首先证明下面的表达式。为此,利用 Legendre 公式,有

该表达式可以理解为 进制下 减掉 需要借位的次数。因为如果在计算第 位(最低位下标是 )时存在不够减需要借位的情况,那么相减的结果中第 位之前的数字 ,其实是 中第 位之前的数字 ,减去一(即借掉的一),再减去 中第 位之前的数字得到的差值 ,所以,差值

当且仅当发生了一次借位;否则,该差值为 。因此,上述表达式中的求和式就可以理解为借位发生的次数。这就得到了 Kummer 定理的文字表述。

例题

例题 HDU 2973 - YAPTCHA

给定 , 计算

解题思路

是质数,则

不是质数,则有 ,即

,则

因此

参考代码
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>

constexpr int M = 1e6 + 5, N = 3 * M + 7;

bool not_prime[N];
int sum[M];

int main() {
  for (int i = 2; i < N; ++i)
    if (!not_prime[i])
      for (int j = 2; j * i < N; ++j) not_prime[j * i] = true;
  for (int i = 1; i < M; ++i) sum[i] = sum[i - 1] + !not_prime[3 * i + 7];

  int t;
  std::cin >> t;
  while (t--) {
    int n;
    std::cin >> n;
    std::cout << sum[n] << std::endl;
  }
}

参考资料

本页面主要译自博文 Вычисление факториала по модулю 与其英文翻译版 Factorial modulo p。其中俄文版版权协议为 Public Domain + Leave a Link;英文版版权协议为 CC-BY-SA 4.0。内容有改动。