(良心原创)阶乘逆元解决组合数模大质数P的问题详解

阶乘逆元解决组合数模大质数P的问题

在ACM竞赛中经常会遇到这样一类问题,求组合数Com,并且由于结果较大,因此要求对一个大质数P取模(通常这个大质数为1e9+7) 。对于没有学过数论的同学们这道题可以算是ACM起步阶段的噩梦了。
网上有很多关于对这类题解法的博客都大同小异,无非是由费马小定理得出阶乘逆元打表,然后就完事了,弄的刚入门的小伙伴云里雾里。本文就这一问题的解法会详细拆解,即使对数论不太了解的小伙伴们也能很快看懂。

例题展示

我们将这类题目的各种花里胡哨的外衣去掉,得出以下的裸题:
1.要求:给正整数m,n,求组合数C(m,n)。其中m,n均小于1e6。

2.时间限制:1s

3.由于结果比较大,因此输出结果取1000000007的余数。

例题分析

首先,求组合数大家都能想到的是用阶乘表达的组合数公式
C(m,n)= n!/(m!·(n-m)!)
写到这里,很快大家便能发现第一个问题,n为10的6次方以内的数,n的阶乘这个数多大呢,64位计算机整型范围就算是无符号也是小于2的64次方的这个数大概在1e20以内,n!好像是爆了,而且爆了不止一点半点,就算n取100也爆了,而且爆了很多很多,可想而知,这个阶乘运算不能乘法做完再取模,而要边取模边做乘法。还好,模运算的基本四则运算中有这样一条:(a * b) % p = (a % p * b % p) % p 。这里,也许有人要问了,有必要这么严谨吗。答案显然易见,有必要!因为在算完分母之后,会遇到一个除法取模问题,这也是这道题目的核心。因为取模运算除法没有类似的公式,并且实际上,四则运算中只有除法取模不满足如上的“分配律”,所以对数论没有接触的ACMer便在这里卡壳儿了。如果不能先取模再做除法,那我们刚刚计算的一切就白费了。
到了这里,就得介绍介绍我们解决这个问题所用到的核心知识——费马小定理了。
费马小定理实际上是欧拉定理的一种特殊情况,它的内容如下:
如果p是一个质数,而整数a不是p的倍数,则有a^(p-1)≡1(mod p)。看到这里,有些小伙伴可能情不自禁地摆出了黑人问号的表情,这跟算除法有关系吗?
我们不着急,再看一个知识点“逆元”。数论中,如果一个数字 a 存在一个对 p 的逆元 x,就可以写成 ax≡1 mod p的形式(a与p互质)。并且逆元有如下性质:
假设b关于p的逆元为c,则 (a/b)% p = (a
c)% p 。
这个性质可以成功将除法取模转化为乘以分母的逆元再取模,剩下的问题就是求逆元了。而求逆元的方法有不止一种,最快最便捷的就是利用费马小定理了(当然还有扩展欧几里得等等,在这里不过多讨论)。
根据费马小定理,此处p为大质数,且为所有数据的取模对象,那必然p与能出现的所有数互质。那么a^(p-1)≡1(mod p)则可以转化为
a^(p-2)* a ≡1(mod p)。 所以 a^(p-2)是 a 对 p的逆元* 。
到了这里,似乎一切问题都迎刃而解了,一切仿佛惊人的顺利。然而,这是不可能的,好事多磨嘛,怎么能这么简单就让你们AC呢。目光如炬的小伙伴可能已经发现了,p为1e9+7,那么a^(p-1)的计算量级便是1e9 。众所周知,ACM中时间限制往往是1s,而对应的计算机的计算量级为1e8~1e9 ,或者5*1e8,所以Time Limit!这也就是关于这类问题的博客上都整齐划一地附上了快速幂代码的原因。那么何为快速幂,简单地说就是复杂度为O(logn)的一个求幂算法,为啥复杂度这么低呢,怎么算的呢,这些问题以及费马小定理的证明还有关于逆元的一些问题,如果有需要我会日后单独详解。(此处提前透露一丢丢,可以看看代码,把指数以二进制的形式表示出来,然后冥想,或许你就能大彻大悟,没有谁是天生就知道的,自己理解出来总比只知道这么个东西要强)。

解题思路

经过刚刚一通分析,这道题的解题思路大体上也就出来了。

  1. 将组合数以阶乘除法的形式表达出来。
  2. 利用小费马定理和逆元知识将除法取模转化为乘法取模。
  3. 利用快速幂进行幂运算。

然后我们算一下计算量。解题过程中我们计算了n!,m!,(n-m)!,这些复杂度为O(n),此外还有幂运算a^(p-1),复杂度为O(log p),总计算量加起来是1e6+log 1e9,所以总计算量还是1e6级,远小于1e8级。O(n)时间复杂度,可以AC!

附上代码(代码为手码,有问题望指正)

以下为阶乘逆元打表版,唯一困难步骤给了详细推导过程。

#include 
using namespace std;
#define LL long long      
const LL mod = 1e9+7;     //大质数P
const LL N = 1e6+5;       //n的范围
LL fac[N] = {0}//阶乘数组
LL inv[N] = {0}//阶乘逆元

LL power(LL a, int x)     //这里边做快速幂边取模
{
	LL ans = 1;
	while(x) 
	{
		if(x&1) 
			ans = (ans * a) %mod;
		a = (a * a) %mod;
		x >>= 1;
	}
	return ans;
}
void Init()		//这里给阶乘及逆元打表,以备让你求多次组合数
{
	fac[0] = 1;     //0的阶乘默认为1,方便计算
	for(int i = 1; i < N; i++)
	{
		fac[i] = fac[i-1] * i % mod;
	}
	inv[N-1] = power(fac[N-1],mod-2);
	//逆元的打表仍用递推(从后往前),这样幂运算便只算一次
	for(int i = N-2; i >= 0; i++)
	{
		//此处可能有人第一次看看不太懂,所以我给出推导
		/*
		%mod我统一省略不写以方便书写,所有乘法计算之后默认取模
		inv[i+1] = ((i+1)!)^mod-2
			 = (i+1)^mod-2 * (i!)^mod-2
			 = (i+1)^mod-2 * inv[i]
		inv[i+1] * (i+1) = (i+1)^mod-1 * inv[i]
			 = 1 * inv[i]
			 = inv[i]
		其中 (i+1)^mod-1 = 1 是根据费马小定理 a^(p-1)≡1(mod p)
		*/
		inv[i] = inv[i+1] * (i+1) % mod;
	}
}
int main()
{
	int m,n;
	cin>>m>>n;
	LL res = fac[n] * inv[m] % mod *inv[n-m] % mod;
	cout<<res;
	return 0;
}

倘若问题是求T个组合数,打表不会增加任何计算量,这也是递推(反向求阶乘逆元)的优势。
当然,还有优化方式在这里不予讨论。

体悟

对于初学者来说,严谨详细的推导步骤是非常必要的,关于此问题的解法网上几乎全都是给出大致方法,贴出代码,千篇一律,人云亦云。并且绝大多数没有说明白为什么要用该定理,关键步骤的推导含糊不清。
本文致力于从初等数论的角度完整地分析这个问题,并给出严禁详细的推导过程,为初次接触的同伴们解惑,不至于似懂非懂。
在最后的给逆元逆向打表过程中,大家也充分感受到了数学在计算机学科中的重要作用,运用小费马定理成功给打表降低了两个数量级的工作量
(因为log 100000007大约等于30多)。
首次发文章希望大家不吝赐教,有所疑惑的伙伴也可以留下联系方式,本人看到之后会第一时间给予答复。

你可能感兴趣的:((良心原创)阶乘逆元解决组合数模大质数P的问题详解)