一、前言
刷题的时候遇到不会的数论题,真的是很揪心,从头学起吧,内容实在是太多了,每个知识点都要证明吃透,不然下次遇到还是不会;不学吧,又不甘心,就是单纯的想把这个题过了,真是进退两难!
如果你也和我有一样的困扰,那接下来就让我们一起来专攻一下数论的知识吧!额 … … 介于明天又要上班,就先来个基础的数论算法吧。二分快速幂,就当开胃菜了,适当也要劳逸结合不是嘛!
二、模幂运算
【例题一】已知三个正整数 a , b , c ( a , b , c < = 1 0 5 ) a, b, c(a , b, c<=10^5) a,b,c(a,b,c<=105),求: f ( a , b , c ) = a b m o d c f(a,b,c) = a^b \ mod \ c f(a,b,c)=ab mod c
- 这里求 a a a 的 b b b 次幂(次方)对 c c c 取余数的计算,被称为模幂运算,本文会针对以上的式子,根据数据范围提供完全不同的算法来进行讲解,确保正确性的前提下,以求达到时间和空间的平衡;
- 高端的算法往往只需要采用最朴素的诠释方式。
1、朴素算法
1)枚举
- 直接一个 b b b 次的循环,枚举对 a a a 的 b b b 次乘法:
图二-1-1 - 最后进行取模运算(c++中的 ‘%’ 符号等价于数学中的取模 m o d mod mod);
- c++代码实现如下:
int f(int a, int b, int c) {
int ans = 1;
while(b--)
ans = ans * a;
return ans % c;
}
- 这段代码有没有问题呢?我们来尝试用一些数据测试一下:
f ( 3 , 5 , 7 ) = 5 f(3, 5, 7) = 5 f(3,5,7)=5
f ( 3 , 10 , 7 ) = 4 f(3, 10, 7) = 4 f(3,10,7)=4
f ( 3 , 100 , 7 ) = − 2 f(3, 100, 7) = -2 f(3,100,7)=−2
f ( 3 , 1000000000 , 7 ) = . . . f(3, 1000000000, 7) = ... f(3,1000000000,7)=...
-
1、溢出问题:首先,在数据量比较大的时候,运算结果会出现负数,这是因为 i n t int int 的取值范围是 [ − 2 31 , 2 31 ) [-2^{31}, 2^{31}) [−231,231),超过后就会溢出,结果就和预期的不符了;
图二-1-2 -
2、时间复杂度:其次,这个算法的时间复杂度是 O ( b ) O(b) O(b) 的,当 b b b 很大的时候,明显是无法满足时间要求的,尤其是写服务器代码的时候,时间复杂度尤为重要,10个、100个、1000 个玩家出现这样的计算时,时间消耗都是成倍增长的,非常占用服务器 CPU 资源;
-
那么,我们先保证正确性,确保计算过程中不会溢出,这里就需要用到数论里面一些简单的知识:模乘。
2)模乘
- 模乘满足如下公式:
a b m o d c = ( a m o d c ) ( b m o d c ) m o d c a b \ mod \ c = (a \ mod \ c) (b \ mod \ c) \ mod \ c ab mod c=(a mod c)(b mod c) mod c - 证明如下:
- 令 a = r a + m a c a = r_a + m_ac a=ra+mac, b = r b + m b c b = r_b + m_bc b=rb+mbc, 其中 ( 0 < = r a , r b < c ) (0 <= r_a, r_b < c) (0<=ra,rb<c),将乘法代入原式,得到:
a b m o d c = ( r a + m a c ) ( r b + m b c ) m o d c = ( r a r b + r a m b c + m a r b c + m a m b c 2 ) m o d c = r a r b m o d c = ( a m o d c ) ( b m o d c ) m o d c - 所以我们可以对朴素算法进行一些改进,改进如下:
int f(int a, int b, int c) {
int ans = 1 % c;
while(b--)
ans = ans * a % c;
return ans;
}
- 利用模乘运算,可以先模再乘,每次运算完毕确保数字是小于模数的,这样确保数字不会太大而造成溢出,但是这里没有考虑一种情况,就是 a a a 有可能是负数;
3)负数考虑
- 需要再进行一次改进,改进版如下:
int f(int a, int b, int c) {
a = (a % c + c) % c;
int ans = 1 % c;
while(b--)
ans = ans * a % c;
return ans;
}
- 我们发现只需要多加一句话:
图二-1-3 - a % c a \% c a%c 是为了确保模完以后 a a a 的绝对值小于 c c c,如图二-1-3所示;
图二-1-4 - 再加上 c c c 是为了保证结果是正数,如图二-1-4所示;
图二-1-5 - 最后又模上 c c c 是为了确保 a a a 是正数的情况也能准确让结果落在 [ 0 , c ) [0,c) [0,c) 的范围内,如图二-1-5所示;
- 这样改完还有哪些问题呢???
- 1、溢出问题:溢出问题仍然存在,只要 c c c 的范围是 [ − 2 31 , 2 31 ) [-2^{31}, 2^{31}) [−231,231), a n s ans ans 和 a a a 的范围就在 [ 0 , c ) [0, c) [0,c), a n s ∗ a ans * a ans∗a 的范围就是 [ 0 , c 2 ) [0, c^2) [0,c2),最坏情况就是 [ 0 , 2 62 ) [0, 2^{62}) [0,262),还是超过了 i n t int int 的范围;
- 2、时间复杂度:时间复杂度仍然是 O ( b ) O(b) O(b) 的,而且每次都有一个取模运算,常数时间变大了;
- 为了改善溢出问题,我们可以在相乘的时候转成 l o n g long long l o n g long long 来避免,c++ 代码实现如下:
int f(int a, int b, int c) {
a = (a % c + c) % c;
int ans = 1 % c;
while(b--)
ans = (long long)ans * a % c;
return ans;
}
- 溢出问题暂且告一段落,接下来让我们考虑下如何改善时间复杂度的问题(也许有人会问,如果 模数 c c c 的范围是 l o n g long long l o n g long long 又该怎么办呢?这个问题我打算在本文的末尾进行讲解);
2、循环节
1)小数据分析
【例题二】已知三个正整数 a , b , c ( a , b < = 1 0 18 , c < = 1 0 5 ) a, b, c(a , b<=10^{18}, c <= 10^5) a,b,c(a,b<=1018,c<=105),求:
f ( a , b , c ) = a b m o d c f(a,b,c) = a^b \ mod \ c f(a,b,c)=ab mod c
- 虽然 a a a 和 b b b 的数据量很大,但是 c c c 很小,根据上文提到的模乘的性质, a a a可以通过模 c c c 首先进行一次降数量级的操作,起码范围可以缩小到 [ 0 , c ) [0, c) [0,c),再来看上面这个函数,根据模乘的性质,满足一个递推式:
f ( a , i , c ) = a f ( a , i − 1 , c ) m o d c f(a,i,c) = af(a,i-1,c) \ mod \ c f(a,i,c)=af(a,i−1,c) mod c - f ( a , i − 1 , c ) f(a,i-1,c) f(a,i−1,c) 的取值范围必定是 [ 0 , c ) [0, c) [0,c),所以当最多计算 c c c 次以后必定能够产生一个循环,举个例子:
- a = 10 , b = 181217 , c = 42 a = 10, b = 181217, c = 42 a=10,b=181217,c=42
- 随着 b b b 的逐步递增,得到的余数是一个以 6 为一个循环的序列,如下:
- 1 { 10 , 16 , 34 , 4 , 40 , 22 } ⏟ 6 { 10 , 16 , 34 , 4 , 40 , 22 } ⏟ 6 { 10 , 16 , 34 , 4 , 40 , 22 } ⏟ 6 . . . . . . 1\underbrace{\{10,16,34,4,40,22\}}_{\rm 6} \underbrace{\{10,16,34,4,40,22\}}_{\rm 6} \underbrace{\{10,16,34,4,40,22\}}_{\rm 6}... ... 16 { 10,16,34,4,40,22}6 { 10,16,34,4,40,22}6 { 10,16,34,4,40,22}......
2)算法实现
利用循环节的算法实现如下:
1)枚举最多 c c c 次,将前 i i i 次的运算结果存储在 F [ i ] F[i] F[i],并且用 F P o s [ i ] FPos[i] FPos[i] 来哈希每次取模得到的结果的第一次出现的位置,初始化 F P o s [ i ] FPos[i] FPos[i] 都为 − 1 -1 −1;
2)每次枚举计算完 F [ i ] F[i] F[i] 后,通过哈希寻找它第一次出现的位置,如果是 − 1 -1 −1,代表从来没出现过,更新 F P o s [ F [ i ] ] = i FPos[ F[i] ] = i FPos[F[i]]=i;
3)否则表示找到了和之前相同的值,循环节就是两次相同位置的差值,记为 K K K,后面无论 b b b 多大,都是每 K K K 次一个循环,可以通过 b b b 对 K K K 取模算出最终的答案;
- 算法时间复杂度为 O ( K ) O(K) O(K)(这个 O K OK OK 真是太有灵性了);
- 基于 K K K 的不确定性,并且时间复杂度应该是算最坏情况下的,所以这个算法的实际时间复杂度是 O ( c ) O(c) O(c);
- c++代码实现如下:
#define MAXN 100005
int F[MAXN+1], FPos[MAXN+1];
int f(int a, int b, int c) {
memset(FPos, -1, sizeof(FPos));
F[0] = 1 % c;
FPos[ F[0] ] = 0;
for(int i = 1; i <= b; ++i) {
F[i] = F[i-1] * a % c;
int &pre = FPos[ F[i] ];
if( pre == -1) {
pre = i;
}else {
int K = i - pre;
int Index = ( b - pre ) % K + pre;
return F[Index];
}
}
return F[b];
}
三、二分快速幂
- 说了这么多,终于轮到主角登场了!
【例题三】已知三个正整数 a , b , c ( a , b < = 1 0 18 , c < = 1 0 9 ) a, b, c(a , b<=10^{18}, c<=10^{9}) a,b,c(a,b<=1018,c<=109),求: f ( a , b , c ) = a b m o d c f(a,b,c) = a^b \ mod \ c f(a,b,c)=ab mod c
- 这里 a a a 和 b b b 的数据量没变,但是 c c c 的数据量一下就上来了,如果还是采用找循环节的算法,当 a a a 和 c c c 互素的情况,时间复杂度会达到最坏情况 O ( c ) O(c) O(c),肯定是无法接受的;
- 于是,我们可以采用二分的思想,对原式进行分治法,有如下公式:
a b m o d c = { 1 m o d c b 为 0 a ( a ( b − 1 ) / 2 ) 2 m o d c b 为 奇 数 ( a b / 2 ) 2 m o d c b 为 正 偶 数 a^b \ mod \ c = \begin {cases} 1 \ mod \ c & b 为 0 \\ a(a^{(b-1)/2})^2 \ mod \ c & b为奇数\\ (a^{b/2})^2 \ mod \ c & b为正偶数\\ \end{cases} ab mod c=⎩⎪⎨⎪⎧1 mod ca(a(b−1)/2)2 mod c(ab/2)2 mod cb为0b为奇数b为正偶数 - 利用程序的递归思想,可以把函数描述成如下的形式:
f ( a , b , c ) = { 1 m o d c b 为 0 a f ( a , b − 1 2 , c ) 2 m o d c b 为 奇 数 f ( a , b 2 , c ) 2 m o d c b 为 正 偶 数 f(a,b,c) = \begin {cases} 1 \ mod \ c & b 为 0 \\ af(a, \frac {b-1}{2},c)^2 \ mod \ c & b为奇数\\ f(a, \frac {b}{2},c)^2 \ mod \ c & b为正偶数\\ \end{cases} f(a,b,c)=⎩⎪⎨⎪⎧1 mod caf(a,2b−1,c)2 mod cf(a,2b,c)2 mod cb为0b为奇数b为正偶数 - 这个算法的时间复杂度为 O ( l o g b ) O(log_b) O(logb);
图三-1-1
1、递归实现
#define ll long long
ll f(ll a, ll b, ll c) {
if (b == 0)
return 1 % c;
ll v = f(a*a % c, (b>>1), c);
if (b & 1)
v = v * a % c;
return v;
}
- 代码实现非常简单,根据 c++ 整数除法是取下整的特性,偶数和奇数的除法可以统一处理,然后用位运算进行一部分优化;
- 1)右移一位相当于 除2;
- 2)位与(&) 1 用于判断奇偶性;
2、二进制优化实现
- 由于递归实现会有一些堆栈及函数调用的性能消耗,所以我们可以采用迭代的方式化解递归,来看一个例子:
a ( 22 ) 10 = a ( 10110 ) 2 = a ( 10000 ) 2 + ( 100 ) 2 + ( 10 ) 2 = a 1 ∗ ( 10000 ) 2 + 0 ∗ ( 1000 ) 2 + 1 ∗ ( 100 ) 2 + 1 ∗ ( 10 ) 2 + 0 ∗ ( 1 ) 2 = a 1 ∗ ( 16 ) 10 + 0 ∗ ( 8 ) 10 + 1 ∗ ( 4 ) 10 + 1 ∗ ( 2 ) 10 + 0 ∗ ( 1 ) 10 \begin{aligned}a^{(22)_{10}} &= a^{(10110)_{2}} \\ &= a^{(10000)_2 + (100)_2 + (10)_2}\\ &= a^{1*(10000)_2 + 0*(1000)_2 + 1*(100)_2 + 1*(10)_2 + 0*(1)_2}\\ &= a^{1*(16)_{10} + 0*(8)_{10} + 1*(4)_{10} + 1*(2)_{10} + 0*(1)_{10}} \end {aligned} a(22)10=a(10110)2=a(10000)2+(100)2+(10)2=a1∗(10000)2+0∗(1000)2+1∗(100)2+1∗(10)2+0∗(1)2=a1∗(16)10+0∗(8)10+1∗(4)10+1∗(2)10+0∗(1)10 - 我们发现只要依次求出 a 1 、 a 2 、 a 4 、 a 8 、 a 16 . . . a^1、a^2、a^4、a^8、a^{16} ... a1、a2、a4、a8、a16... ,然后将 指数 二进制分解中那一位是 1 1 1 的对应的次幂项都乘起来,就是原先的 a a a 的次幂;
- c++ 代码实现如下:
#define ll long long
ll f(ll a, ll b, ll c){
ll ans = 1;
while(b) {
if(b & 1) ans = ans * a % c; // 1)
a = a * a % c; // 2)
b >>= 1; // 3)
}
return ans;
}
- 1)和 3)是配合着做的,检查 n n n 的第 i i i 位二进制低位否是1,如果是则乘上对应的 a a a 次幂: a n i ( 其 中 n i = 2 i ) a^{n_i} (其中 n_i = 2^i) ani(其中ni=2i)
- 2)依次求出 a 1 、 a 2 、 a 4 、 a 8 、 a 16 . . . a^1、a^2、a^4、a^8、a^{16} ... a1、a2、a4、a8、a16...;
- 这个算法的时间复杂度为 O ( l o g b ) O(log_b) O(logb),相比递归算法减少了一些常数消耗;
四、模数为 64 位整数
【例题4】已知三个正整数 a , b , c ( a , b , c < = 1 0 18 ) a, b, c(a,b,c<=10^{18}) a,b,c(a,b,c<=1018), 求: f ( a , b , c ) = a b m o d c f(a,b,c) = a^b \ mod \ c f(a,b,c)=ab mod c
- 和之前问题唯一不同的点在与: c c c 的范围不再是 32 32 32 位整数 i n t int int,所以无论是朴素算法、二分快速幂,都会面临一个问题:模乘运算的中间过程有很大概率会超过 64 64 64 位整数 (c++ 64位机器能够表示的最大整数);
- 思考一下之前的二进制优化的快速幂,容易溢出的地方在于乘法,那么我们现在要解决的问题就变成了如何将两个 64 位整数相乘取模的中间过程不会超过 64 位整数的最大值;
- 举个例子:
1981 ∗ 22 m o d 181 = 1981 ∗ ( 16 + 4 + 2 ) m o d 181 = 1981 ∗ ( 1 ∗ 16 + 0 ∗ 8 + 1 ∗ 4 + 1 ∗ 2 + 0 ∗ 1 ) m o d 181 - 我们发现,只要能够求出 1981 的 1倍、2倍、4倍、8倍、16倍,然后在 22 的对应二进制位去找,如果对应位是 1 的就加到结果中,最后这些累加的结果值模181就是最终的答案,和二进制优化二分快速幂的原理是一样的;
算法实现如下:
1)对于 a ∗ b m o d c a*b \ mod \ c a∗b mod c,对 b b b 进行二进制分解;
2) b b b 的对应二进制位为 b i b_i bi,且 b b b 最多 K K K 位,那么 a n s = a ∑ i = 0 K − 1 b i ∗ 2 i m o d c ans = a \sum_{i=0}^{K-1}b_i * 2^{i} \ mod \ c ans=ai=0∑K−1bi∗2i mod c
- c++ 代码实现如下:
#define ll long long
ll Product_Mod(ll a, ll b, ll c) {
ll sum = 0;
while(b) {
if(b & 1) sum = (sum + a) % c;
a = (a + a) % c;
b >>= 1;
}
return sum;
}
ll f(ll a, ll b, ll c) {
ll ans = 1;
while(b) {
if(b & 1) sum = Product_Mod(sum, a, c);
a = Product_Mod(a, a, c);
b >>= 1;
}
return ans;
}
五、时间复杂度总结
- 最后,对于各种 a b m o d c a^b \ mod \ c ab mod c 的算法,总结一下各个算法的时间复杂度;
- 后续章节,将会介绍 b b b 非常大的情况,需要用到 扩展欧拉定理 降幂,暂时不作展开。
算法 | 时间复杂度 | 解析 |
---|---|---|
朴素算法 | O ( b ) O(b) O(b) | 最常规的理解方式,适用于指数 b b b 比较小的问题 |
循环节算法 | O ( c ) O(c) O(c) | 适用于模数 c c c 比较小的问题 |
二分快速幂 - 递归 | O ( l o g b ) O(log_b) O(logb) | 分而治之的思想,适用于 b b b 比较大的情况 |
二分快速幂 - 二进制优化 | O ( l o g b ) O(log_b) O(logb) | 相对于递归来说,减少了递归函数调用带来的消耗 |
- 关于 二分快速幂 的内容到这里就全部结束了,如果还有不懂的问题可以留言告诉作者或者添加作者的微信公众号。
- 本文所有示例代码均可在以下 github 上找到:github.com/WhereIsHeroFrom/Code_Templates
六、二分快速幂相关题集整理
题目链接 | 难度 | 解析 |
---|---|---|
洛谷 P1226 快速幂 | ★☆☆☆☆ | 二分快速幂 模板题 |
LeetCode 50 Pow(x, n) | ★☆☆☆☆ | 实数域的 二分快速幂 |
LeetCode 372 Super Pow | ★☆☆☆☆ | 循环节 + 大数取模 |
HDU 1061 Rightmost Digit | ★☆☆☆☆ | 循环节 / 二分快速幂 |
HDU 2035 人见人爱A^B | ★☆☆☆☆ | 循环节 / 二分快速幂 |
PKU 1995 Raising Modulo Numbers | ★☆☆☆☆ | 二分快速幂 |
PKU 3641 Pseudoprime numbers | ★☆☆☆☆ | 二分快速幂 + 素数判定 |
HDU 3003 Pupu | ★★☆☆☆ | 公式推导 + 二分快速幂 |
HDU 4506 小明系列故事——师兄帮帮忙 | ★★☆☆☆ | 循环节 + 二分快速幂 |
HDU 6273 Master of GCD | ★★☆☆☆ | 区间处理 + 二分快速幂 |
HDU 2879 HeHe | ★★★☆☆ | 素筛 + 二分快速幂 |
HDU 2886 Lou 1 Zhuang | ★★★☆☆ | 找规律 + 二分快速幂 |
HDU 6755 Fibonacci Sum | ★★★★☆ | 逆元 + 组合公式 + 费马小定理 + 二分快速幂 |
转载:https://blog.csdn.net/WhereIsHeroFrom/article/details/116460319