数论,古老,纯粹,优美,数学王国的皇后。 —— 题记
在OI中,数论常以千变万化的面貌出现,对数学思维和能力要求较高。 然而万变不离其宗,再难的问题都是由简单的问题扩展而来的。
所以总结了本文,记录自己学习到的数论中那一点点皮毛,那一些 最* 基本的东西。
【基本概念】
- 带余除法
- 取模
- 同余
- 因子
- 互质
等等,过于基础,不再具体介绍。
【模意义下的运算】
常见的加减乘在模意义下都是封闭的,即: (a+b) (a−b) a∗b
注意这里的%
是取模而不是取余。
也就是说如果只涉及加减乘,最后结果取模,那么计算时可以随时取模,结果不变。
至于除法,并不满足这个性质,接下来的逆元
部分会讨论。
注意,代码的世界并不像数学公式一样美好。
很多时候,题目中给出的模数是在int
范围内的,因此最后的结果取模后在int
范围内。
然而如果你~~naive地~~直接两个将int
直接相乘,中间结果可能会产生溢出的现象。
这时我们要将其转化为long long
的临时类型,很多地方都会用到,为保持代码整洁,写成函数。
当然变量本身直接用long long
也无不可。
typedef long long int64; // 这样写只是个习惯
inline int mulMod(int a, int b){
return (int64)(a % MOD) * (b % MOD) % MOD;
}
如果确定传入的a b
都是取过模的,相乘前可以不取模以加快速度。
要是连long long
也不够用怎么办,就只能手写~~(讨厌的)~~高精度了吗?幸运的是,还是有着简单解决方案的,这一点将在下边【快速乘】部分讨论。
【算数基本定理】
算数基本定理,又称正整数唯一分解定理,内容为:
算数基本定理 任何一个大于1的自然数N,如果N不为质数,那么N可以唯一分解成有限个质数的乘积 ,记作N=Pa11Pa22Pa33......Pann,这里P1<P2<P3......<Pn且均为质数,其中指数ai是正整数
这样的分解式称作N的标准分解式,也可记作: N=∏paii
算术基本定理是初等数论中一个基本的定理,也是许多其他定理的逻辑支撑点和出发点。
【最大公约数】
两数x,y的最大公约数(Greatest Common Divisor, GCD),指两数共有约数中最大的一个,记作gcd(x,y)。
常见的求最大公约数的方法有 更相减损术与辗转相除法。
【辗转相除法】
辗转相除法,又称欧几里得算法,用于求解两数的最大公约数,其正确性基于恒等式:gcd(a,b)=gcd(b,a,该恒等式与边界条件gcd(a,0)=a共同构成了该算法。
【代码】->
int gcd(int a, int b){
return b ? gcd(b, a % b) : a;
}
时间复杂度O(logN),其中N=max(a,b),值得一提的是,让该算法递归层数最深的是斐波那契数列中的相邻项。
【更相减损术】
更相减损术,同样用于求解两数的最大公约数,但应用相对较少。 其正确性基于恒等式:gcd(a,b)=gcd(a−b,b)。
多次更相减损等价于一次辗转相除,与辗转相除法的本质相同。
更相减损术稍作优化,便可以得到复杂度也有对数阶保证的Stein
算法(好吧其实很少叫这名字)。
简单来说: 若当前两数均为偶数,则令其均除以二,并将结果乘二。 若有一个为奇数,则将偶数除以二。 若均为奇数,则相减后除以二。
一般情况下我们使用辗转相除法较多,但改进后的更相减损术,在某些场景下也有着不错的表现。
一个例子是高精度整数的最大公约数。
如果采用辗转相除法,意味着我们要编写高精度取模,不仅难写而且效率十分低下。
如果采用优化过的更相减损术,则效率更高,编程复杂度也更低。
【代码】 ->
省略了高精度代码。
BigInt bigGcd(BigInt x, BigInt y){
if(x < y) std::swap(x, y);
if(y.isZero()) return x;
bool a = x.isEven(), b = y.isEven();
if(a && b) return 2 * bigGcd(x / 2, y / 2);
else if(a) return bigGcd(x / 2, y);
else if(b) return bigGcd(x, y / 2);
else return bigGcd((x - y) / 2, y);
}
【最小公倍数】
我们也可以借助最大公约数来求最小公倍数。
对于数a,b,有ab=gcd(a,b)lcm(a,b)。 这个结论可以简单地由唯一分解定理得到。
简要证明,设: x=∏Paii y=∏PbiI
那么: gcd(x,y)=∏Pmin(ai,bi)i lcm(x,y)=∏Pmax(ai,bi)i
乘起来: xy=∏Pai+bii gcd(x,y)lcm(x,y)=∏Pmin(ai,bi)+max(ai,bi)i 而这一点是显然的: ai+bi=min(ai,bi)+max(ai,bi)
证毕。
【最小公倍数 代码】 ->
inline int lcm(int a, int b){
return a / gcd(a, b) * b; // 先除后乘防炸
}
【快速幂】
计算a^k \pmod{MOD}
首先我们知道,计算过程中可以随时取模。
显然我们有着复杂度为O(k)的朴素算法,但有时候这样的复杂度无法满足要求。
考虑二进制的思想,由二进制计数原理可知,指数k必然可以为若干个2的幂之和。
那么a^k必然可以表示为若干个a的 2的幂 次方的积,有点绕口,看个例子。
所以我们只需要计算 a^0, a^1, a^2, a^4, a^8 ...,而后一项是可以由前一项平方得到。
这样就将复杂度降到了 O(logk)。
给个栗子: 比如说a^{11},11的二进制表示为(1011)_2。 那么 a^{11} = a^{2^0 + 2^1 + 2^3} 即 a^{11} = a^{2^0} * a^{2^1} * a^{2^3}
代码写法如下:
【快速幂 迭代代码】 ->
inline int fastPowMod(int x, int k){
int ans = 1;
for(; k; x = mulMod(x, x), k >>= 1) if(k & 1) ans = mulMod(ans, x);
return ans;
}
快速幂还有一种递归写法,更加易于理解。
如果指数为奇数,就减一,然后结果乘上x。 如果指数为偶数,就除二,然后结果平方。
【快速幂 递归代码】 ->
int fastPowMod(int x, int k){
if(k == 1) return x;
else if(k & 1) return mulMod(fastPowMod(x, k - 1), x);
else return fastPowMod(mulMod(x, x), k >> 1);
}
这种写法的优势在于易于理解,而且易于扩展。 底数可以为任意类型 —— 只要它支持乘法操作就够了。
【Eratosthenes 筛法】
埃拉托斯特尼筛选,下简称埃式筛法。
用埃式筛法求素数的基本思路是:
首先生成1到n的序列,暂且认为每个数都是素数。 1不是素数,首先把它筛掉。 在剩下的数中选择最小的数,该数必然是素数,然后筛掉它的倍数。 为什么这个最小的数必然是素数呢,因为它已经被小于它的数字筛过一遍了。 依次类推,直到筛完为止。
给出代码:
bool isPrime[MAXN + 1];
std::vector<int> primes;
inline void initPrime(int n) {
std::fill(isPrime + 1, isPrime + n + 1, true);
isPrime[1] = false;
int m = (int)sqrt(n) + 1;
for(int i = 2; i <= m; i++) if(isPrime[i]){
for(int j = i * i; j <= n; j += i) isPrime[j] = false;
}
for(int i = 2; i <= n; i++) if(isPrime[i]) primes.push_back(i);
}
一些优化。 首先,外层质数枚举到 \sqrt n 即可,因为一个合数 n 必然有至少有一个质因子 k ,使得 k ≤ \sqrt n。 其次,内层循环可以从 i^2 开始,因为所有小于 i^2 的 i 的倍数 x ,已经在之前被 x/i筛掉了。
时间复杂度:O(nloglogn),不是线性,但实际表现还是不错的。
真正的线性筛回来和数论函数放到一起写吧。
筛法还可以顺便处理处 [1, n] 中每个数的最小质因子,因为每次一个数必然是被其最小质因子筛掉的。
设作 minPrimeFact 数组,这玩意儿有什么用呢?且看下文。
【分解质因数】
如何枚举一个数的所有质因子?
暴力一点的 O(\sqrt n) 做法。 在 [2, \sqrt n] 中从小到大枚举因子,每次找到后将这个因子"除干净"。 这样即可保证每次枚举到的因子都是质因子。 为什么这样做是对的呢?类似筛法,每次将一个因子"除干净"相当于筛掉了该因子的倍数。
std::vector<int> facts;
int m = (int)sqrt(n);
for(int i = 2; i <= m; i++) if(n % i == 0){
primeFacts.push_back(i);
while(n % i == 0) n /= i; // 事实上在这一层循环处理掉了质因子i的幂。
}
if(n != 1) primeFacts.push_back(n);
如果我们已经用筛法预处理出了每个数的最小质因子,那么我们可以在 O(质因子个数) ≈ O(logn) 的时间内完成分解质因数。
std::vector<int> primeFacts;
while(n != 1){
int i = minPrimeFact[n];
primeFacts.push_back(i);
while(n % i == 0) n /= i;
}
有点长,断开好惹QwQ 。
传送门 -> 数论基础学习笔记(二)