由于数论的离散和……技术性?算法竞赛里很喜爱涉及这部分的内容,例如:
我们从最熟悉的最大公倍数(gcd)和最小公约数(lcm)讲起。对于求gcd,我们很容易回想起欧几里得算法,也就是辗转相除法,回忆其计算过程 g c d ( a , b ) = g c d ( b , a mod b ) gcd(a,b)=gcd(b,a \text{ mod }b) gcd(a,b)=gcd(b,a mod b)我们会发现和程序设计中常用的递归非常相似,再考虑边界条件 g c d ( a , b ) = a , when a ∣ b gcd(a,b)=a\text{, when }a|b gcd(a,b)=a, when a∣b就可以得到这个程序:
int gcd(int a,int b){ //注意ab间的大小关系是无所谓的
return b%a==0? a:gcd(b,a%b);
}
利用最小公倍数,我们很容易得到最大公约数:
int lcm(int a,int b){
return a/gcd(a,b)*b; // 这里先除再乘是为了防止溢出
}
gcd是我们解决很多不定方程(就是求某方程整数解)的基础工具,例如:
这个算法的目的是找出一对整数 ( x , y ) (x,y) (x,y),使得 a x + b y = g c d ( a , b ) ax+by=gcd(a,b) ax+by=gcd(a,b)
我们先不加证明地了解一下裴蜀定理:
a , b ∈ Z + a,b\in\Z^+ a,b∈Z+,不定方程 a x + b y = c ax+by=c ax+by=c有整数解当且仅当 c ∣ g c d ( a , b ) c|gcd(a,b) c∣gcd(a,b)
同时,还有:
若 ( x 0 , y 0 ) (x_0,y_0) (x0,y0)是方程 a x + b y = c ax+by=c ax+by=c的一组整数解,那么其任意整数解 ( x ′ , y ′ ) = ( x 0 + k a d , y 0 + k b d ) (x',y')=(x_0+k\frac ad,y_0+k\frac bd) (x′,y′)=(x0+kda,y0+kdb),其中 d = g c d ( a , b ) , k ∈ Z d=gcd(a,b),k\in\Z d=gcd(a,b),k∈Z
那么,要如何用欧几里得算法求出 a x + b y = c ax+by=c ax+by=c的解呢?我们(同样不加证明地)发现,如果 ( x 0 , y 0 ) (x_0,y_0) (x0,y0)是不定方程 b x + ( a mod b ) y = c bx+(a\text{ mod }b)y=c bx+(a mod b)y=c的解,那么我们可以由此得到不定方程 a x + b y = c ax+by=c ax+by=c的解 ( x 0 ′ , y 0 ′ ) (x_0',y_0') (x0′,y0′),具体而言:
{ x 0 ′ = y 0 y 0 ′ = x 0 − ⌊ a b ⌋ y 0 \left\{\begin{matrix}x_0'=y_0&\\y_0'=x_0&-\left \lfloor \frac{a}{b} \right \rfloor y_0\end{matrix}\right. {x0′=y0y0′=x0−⌊ba⌋y0
我们再回忆,gcd
算法的边界条件中有 a ∣ b a|b a∣b,而不定方程 a x + b y = a ax+by=a ax+by=a有一个很平凡的解 ( 1 , 0 ) (1,0) (1,0),所以只要在递归边界上设置x=1;y=0
,然后在递归回溯时通过上述公式更新x,y
的值,就能得到 a x + b y = g c d ( a , b ) ax+by=gcd(a,b) ax+by=gcd(a,b)的一个解了:
int exgcd(int a,int b,int &x,int &y){
// python可以将xy声明成全局变量,或者用列表当做函数的变量
if(!b){x=1;y=0;return a;}
else{return gcd(b,a%b,y,x);y-=x*(a/b);} // 注意xy互换了
}
由于素数很特殊,所以快速地求出某区间内的素数也是数论相关算法中的一个重要基础。
我们知道能够写成 k p , k ∈ Z + , p ∈ Z + kp,k\in\Z^+,p\in\Z^+ kp,k∈Z+,p∈Z+形式的数一定不是素数,所以一个很朴素的想法就出现了:沿着数轴搜索,我们找到第一个素数 p 0 p_0 p0(也就是 2 2 2),那么所有的 k p 0 kp_0 kp0都不是素数,我们将其打上标记(筛去),然后我们继续搜索,没被筛去的就是下一个素数 p 1 p_1 p1,然后我们将所有 k p 1 kp_1 kp1筛去……如此重复。这就是埃氏筛(Sieve of Eratosthenes)
我们注意一个小问题,在区间 D = [ a , b ] \mathcal{D}=[a,b] D=[a,b]上的合数 c c c,我们记它的最小素因子为 p ‾ \underline p p,那么一定有 p ‾ 2 ≤ c \underline p^2\leq c p2≤c(因为 2 2 2是最小素数),也就是 p ‾ ≤ c \underline p\leq \sqrt c p≤c。因此,我们只要用区间 [ a , b ] [a,\sqrt b] [a,b]上的素数去筛就好了。类似地,对每一个素数 p p p,我们可以从合数 p 2 p^2 p2开始筛。
埃氏筛的实现如下:
int isnp[]; //初始全0,记录是否不是素数(被筛掉)
void init(int n){ //区间上界
for(int i=2;i*i<=n;i++)
if(!isnp[i])
for(int j=i*i;j<=n;j+=i)
isnp[j]=0;
}
他的复杂度为 O ( n log log n ) O(n\log\log n) O(nloglogn)(不证了)
我们注意到,埃氏筛中,一个数常常被重复筛到(例如 30 30 30就同时被 2 , 3 , 5 2,3,5 2,3,5筛到),为了让每个数只被筛一次。我们只让每个合数被最小的素因数筛掉。这种方法就是欧拉筛(也叫线性筛,所以就不分析他的复杂度了)。
具体而言,它维护一个质数表 p r i m e = { p i } i = 0 k prime=\{p_i\}_{i=0}^k prime={pi}i=0k,对于每一个数 n n n, n p i np_i npi都应当被筛掉,当 p i ∣ n p_i|n pi∣n时,中断这次筛除;随后,对 n + 1 n+1 n+1进行相同的操作:
vector<int> prime;
int isnp[];
void init(int n){
for(int i=2;i<=n;i++){
if(!isnp[i])
prime.push_back(i);
for(int p:prime){
if(i*p>n)break;
isnp[i*p]=1;
if(i%p==0)break;
}
}
}
其正确性证明略。(其实蛮容易的,可以自己试试)
一些非常大的在c++中会溢出,虽然Python没有溢出问题,但大数之间的运算速度较慢,为此,许多题目会要求输出答案除以某个素数 p p p的余数。接下来,我们看下如何在这个要求下进行运算。
我们同样不加证明地考虑这个性质:
如果 p p p是质数,那么加减法和乘法对取模都是封闭的。
具体来说,就是:(为了方便,我们将 x mod p x\text{ mod }p x mod p记为 P ( x ) P(x) P(x))
P ( x ± y ) = P [ P ( x ) ± P ( y ) ] P ( x y ) = P [ P ( x ) P ( y ) ] P ( x y ) = P { P ( x ) ] y } \begin{aligned} &P(x\pm y)=P[P(x)\pm P(y)]\\ &P(xy)=P[P(x)P(y)]\\ &P(x^y)=P\{P(x)]^y\} \end{aligned} P(x±y)=P[P(x)±P(y)]P(xy)=P[P(x)P(y)]P(xy)=P{P(x)]y}
也就是我们可以在运算中处处取模,这样计算得到的答案就是取模后的结果。但是,取模运算对除法是不封闭的(随便举个例子)。我们要如何处理运算中的除法呢?
这里我们先直接给出结果:(为了方便,我们将 i n v ( x ) inv(x) inv(x)简记为 x − 1 x^{-1} x−1,下一小节我们会知道这种记法非常河里)
P ( x / y ) = P [ P ( x ) P ( i n v ( x ) ) ] = Δ P [ P ( x ) P ( x − 1 ) ] P(x/y)=P[P(x)P(inv(x))]\overset{\Delta}{=} P[P(x)P(x^{-1})] P(x/y)=P[P(x)P(inv(x))]=ΔP[P(x)P(x−1)]
其中
x x − 1 ≡ 1 mod p xx^{-1}\equiv1\text{ mod }p xx−1≡1 mod p
那么,如何求这个 x − 1 x^{-1} x−1呢?我们一般有两种方法。
对于素数 p p p,我们有 g c d ( a , p ) = 1 , ∀ a ∈ Z + gcd(a,p)=1\text{, }\forall a\in\Z^+ gcd(a,p)=1, ∀a∈Z+,所以不定方程 a x + p y = 1 ax+py=1 ax+py=1必定有解,而且此时:
a x + p y = 1 ( a x + p y ) mod p = 1 mod p a x mod p = 1 mod p ( a x ) ≡ 1 ( mod p ) i n v ( a ) = x \begin{aligned} &ax+py=1\\ &(ax+py)\text{ mod }p=1\text{ mod }p\\ &ax\text{ mod }p=1\text{ mod }p\\ &(ax)\equiv 1(\text{mod }p)\\ &inv(a)=x \end{aligned} ax+py=1(ax+py) mod p=1 mod pax mod p=1 mod p(ax)≡1(mod p)inv(a)=x
int inv(int a,int p){ //假设p是素数,inv(a)一定存在
int x,y;
gcd(a,p,x,y);
return (x%p+p)%p; // 保证返回一个合法的正整数
}
除此以外,我们还有著名的费马小定理:
对于素数 p p p,且 g c d ( a , p ) = 1 gcd(a,p)=1 gcd(a,p)=1,那么 a p − 1 ≡ 1 ( mod p ) a^{p-1}\equiv1(\text{mod }p) ap−1≡1(mod p)
所以,我们有 i n v ( a ) = a p − 2 inv(a)=a^{p-2} inv(a)=ap−2,计算的时候直接快速幂(记得处处取模):
int inv(int a,int p){
return qpow(a,p-2,p); // 快速幂代码可以参照下文
}
代数数论很有意思,不过这里只简单地引用一些我在有关“gplt-整除光棍“的正确性证明里给出的一些结论:(虽然略去了证明,这个证明其实是容易的)
除此以外,我们还可以发现:
暂时略了,必讲不完
这一节Python选手就不用看了
在计算过程中,常常会出现int
甚至long long
无法储存中间结果的情况,这时候,我们需要自己写一个大整数类去储存中间结果。由于大整数类在竞赛里往往是个工具,不要求健壮和稳健,所以没什么技术细节,直接给代码了:(题目用到什么运算写什么运算,)
class BigInt{ //正的大整数类
public:
static const int base=10000;
static const int width=4;
vector<int> s;
int len;
// 构造函数
BigInt(long long x=0){
do{
s[len++]=x%base;
x*=base;
}while(x);
return *this;
}
//重载运算符=,有三种,主要用于赋值
BigInt operator=(long long x){
return BigInt(x);
}
BigInt operator=(const BigInt &x){
BigInt res;
for(int i=0;i<x.len;i++)
res.s.push_back(x.s[i]);
res.len=x.len;
return res;
}
BigInt operator=(const string &x){
BigInt res;
res.len=(x.length()-1)/width+1;
for(int i=0;i<len;i++){
int tmp=0;
for(int j=0;j<width;j++)
tmp=tmp*10+x[i*width+j]-'0';
res.s.push_back(tmp);
}
return res;
}
// 重载加法
BigInt operator+(const BigInt &x){
BigInt res;
int r=0,&i=res.len;
for(i=0;;i++){
if(r==0&&i>=len&&i>=x.len)break;
int tmp=r;
if(i<len)tmp+=s[i];
if(i<x.len)tmp+=x.s[i];
res.s[i]=tmp%base;
r=tmp/base;
}
return res;
}
// 重载减法,默认this大于x,结果是正数
BigInt operator-(const BigInt &x){
BigInt res;
int r=0,&i=res.len;
for(i=0;;i++){
if(r==0&&i>=x.len)break;
int tmp=r;
if(s[i]<x.s[i]){
s[i+1]-=1;
s[i]+=base;
}
if(i<len)tmp+=s[i];
if(i<x.len)tmp-=x.s[i];
res.s.push_back(tmp%base);
r=tmp/base;
}
int tmp=0;
for(;i<len&&s[i]!=0;i++){
tmp+=s[i];
res.s.push_back(tmp%base);
tmp/=base;
}
return res;
}
// 重载乘法
BigInt operator*(const BigInt &x){
//todo
}
// 重载除法(都大整数了就不取模了)
BigInt operator/(const BigInt &x){
//todo
}
// 重载取模
BigInt operator%(const BigInt &x){
//todo
}
// 重载>
// 重载<
// 重载==
// 重载!=
// 重载+=
// 重载-=
// 重载*=
// 重载/=
}
int qpow(int a,int x,int p){
int res=1;
while(x){
if(x&1) //如果剩余次幂是奇数,那么res乘上a
res=res%p*a%p;
//如果剩余次幂是偶数,那么a自乘,此时res不变
a=a%p*a%p;
x>>=1; // x减半
}
return res%p;
}