夜深人静写算法(三十)- 二分快速幂

文章目录

  • 一、前言
  • 二、模幂运算
    • 1、朴素算法
    • 2、循环节
  • 三、二分快速幂
    • 1、递归实现
    • 2、二进制优化实现
  • 四、模数为 64 位整数
  • 五、时间复杂度总结
  • 六、二分快速幂相关题集整理

一、前言

  刷题的时候遇到不会的数论题,真的是很揪心,从头学起吧,内容实在是太多了,每个知识点都要证明吃透,不然下次遇到还是不会;不学吧,又不甘心,就是单纯的想把这个题过了,真是进退两难!
  如果你也和我有一样的困扰,那接下来就让我们一起来专攻一下数论的知识吧!额 … … 介于明天又要上班,就先来个基础的数论算法吧。二分快速幂,就当开胃菜了,适当也要劳逸结合不是嘛!夜深人静写算法(三十)- 二分快速幂_第1张图片

二、模幂运算

【例题一】已知三个正整数 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 次乘法:
    夜深人静写算法(三十)- 二分快速幂_第2张图片
    图二-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 \begin{aligned} ab \ mod \ c &= (r_a + m_ac) (r_b + m_bc) \ mod \ c\\ &= (r_ar_b + r_a m_bc + m_ar_bc + m_am_bc^2) \ mod \ c \\ &= r_ar_b \ mod \ c \\ &= (a \ mod \ c) (b \ mod \ c) \ mod \ c \end{aligned} ab mod c=(ra+mac)(rb+mbc) mod c=(rarb+rambc+marbc+mambc2) mod c=rarb mod c=(a mod c)(b mod c) mod 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 ansa 的范围就是 [ 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,i1,c) mod c
  • f ( a , i − 1 , c ) f(a,i-1,c) f(a,i1,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
    夜深人静写算法(三十)- 二分快速幂_第3张图片
  • 随着 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(b1)/2)2 mod c(ab/2)2 mod cb0bb
  • 利用程序的递归思想,可以把函数描述成如下的形式:
    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,2b1,c)2 mod cf(a,2b,c)2 mod cb0bb
  • 这个算法的时间复杂度为 O ( l o g b ) O(log_b) O(logb)
    夜深人静写算法(三十)- 二分快速幂_第4张图片
    图三-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} ... a1a2a4a8a16... ,然后将 指数 二进制分解中那一位是 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} ... a1a2a4a8a16...
  • 这个算法的时间复杂度为 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 \begin{aligned} 1981 * 22 & \ mod \ 181 \\ = 1981 * (16+4+2) & \ mod \ 181 \\ = 1981 * (1*16+0*8 + 1*4+1*2 + 0*1) & \ mod \ 181 \end{aligned} 198122=1981(16+4+2)=1981(116+08+14+12+01) mod 181 mod 181 mod 181
  • 我们发现,只要能够求出 1981 的 1倍、2倍、4倍、8倍、16倍,然后在 22 的对应二进制位去找,如果对应位是 1 的就加到结果中,最后这些累加的结果值模181就是最终的答案,和二进制优化二分快速幂的原理是一样的;

算法实现如下:
  1)对于 a ∗ b   m o d   c a*b \ mod \ c ab 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=0K1bi2i 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 ★★★★☆ 逆元 + 组合公式 + 费马小定理 + 二分快速幂

你可能感兴趣的:(夜深人静写算法,算法,面试,数论,二分快速幂,递归)