如果你刷过算法题,第一次接触“快速幂”这个词,多半是在一道看起来简单、实则折磨人的题目里:给定整数 a、b、m,求 a^b mod m,其中 b 可能大到 10^18。我第一次遇到这种题时,第一反应是老老实实写个 for 循环,结果一提交,屏幕上赫然两个红色大字:TLE。也是从那次开始,我才认真研究了快速幂算法,也理解了为什么它能让指数运算从“跑不完”变成“眨个眼就算完”。这篇文章就把我从头到尾的理解、代码、踩坑和调试经验整理出来,希望能帮你少走弯路。
1. 从一次TLE说起:朴素幂运算的性能瓶颈
1.1 那道让我超时的题目
那年我还在为竞赛刷题,碰到的题大概是这样的:输入 a、b、m,输出 (a^b) % m,范围给得挺大方,a、m 在 int 范围内,b 最大能到 10^18。看到这个数据范围,我当时没太当回事,心想不就是循环 b 次乘 a 再取模嘛。于是写了下面这段标准“萌新代码”:
cpp复制long long naivePow(long long a, long long b, long long mod) {
long long res = 1;
for (long long i = 0; i < b; i++) {
res = res * a % mod;
}
return res;
}
逻辑没问题,乘一次取一次模,结果不会溢出,答案也正确。但问题就出在那个 for (long long i = 0; i < b; i++) 上。b 是 10^18,循环次数就是 10^18,哪怕一台机器每秒能执行 10^9 次循环,也要跑 10^9 秒,换算过来是三十多年。评测机当然不会给你三十多年,一般一秒钟等不到就 TLE 了。
从那以后我明白了一件事:在算法题里,“正确”只是及格线,“能通过数据范围”才是真正的目标。如果你只是做一次普通计算,b 是几百几千,朴素循环完全没问题;但一旦 b 到了 10^9、10^18,就必须换思路。
1.2 为什么循环次数会决定生死
很多人把 TLE 简单归结为“数据太大”,其实更准确地说,是算法的时间复杂度撑不住。朴素幂运算要做 b 次乘法取模,时间复杂度是 O(b)。当 b 是线性级别增长时,运行时间也随之线性增长,这在 b 是 10^18 的规模下完全不可接受。
我后来习惯用一个类比来理解这件事:朴素循环就像你要从 1 数到 10^18,一次只能加 1;而快速幂算法更像你手里有一个计算器,可以直接按几次平方和乘法把结果凑出来。前者是“付出和规模成正比”,后者是“付出只和规模的二进制位数成正比”。
用数据说话:10^18 大约等于 2^60,而快速幂的循环次数大约就是 60 次左右。从 10^18 次降到 60 次,这不是快了几倍,是快了几十亿倍。这种量级的优化,才是竞赛题和很多真实工程场景真正依赖的东西。
1.3 哪些场景会被指数卡住
不要觉得 10^18 的指数只存在于竞赛题里。我做项目、看开源代码时,发现很多地方其实都需要高效的大指数模幂运算:
- RSA 解密:核心公式是 m = c^d mod n,这里 d 是一个几百位的数,如果按朴素循环来算,宇宙毁灭都算不完。
- 概率和组合计数:比如求某事件发生的概率,经常要算 2^n mod p,n 是总人数或总元素个数。
- 斐波那契数列第 n 项:n 取 10^18 时,可以用矩阵快速幂把 O(n) 降到 O(log n),这也是快速幂的一个经典扩展。
- 费马小定理求逆元:要求 a^(p-2) mod p,指数是有 10^9 级别的 p,同样需要快速幂。
这些场景有个共同点:指数不是“几十”而是“巨量”。在它们面前,朴素循环不是慢,是根本跑不完。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 二进制视角:快速幂为什么能降到 O(log n)
2.1 一个手算例子:7 的 10 次方
先别急着看代码,我觉得快速幂最妙的地方在于它的数学视角。你回忆一下小学学的幂运算法则,合并同底数幂是“底数不变,指数相加”。所以 a^(x+y) = a^x * a^y。这个性质看起来平平无奇,但拿它来拆指数,就是快速幂的核心。
举个例子,计算 7^10。10 的二进制是 1010,也就是 10 = 8 + 2。所以:
7^10 = 7^(8+2) = 7^8 * 7^2
如果我手算,会先算:
- 7^2 = 49
- 7^4 = 49^2 = 2401
- 7^8 = 2401^2 = 5764801
然后 7^10 = 7^8 * 7^2 = 5764801 * 49 = 282475249。
注意到没有?我并没有按顺序乘 10 次,而是通过“反复平方”得到了 7^2、7^4、7^8,再挑出二进制中为 1 的位乘起来。整个手算只做了 3 次平方和 1 次最终乘法,比起连续乘 10 次已经少了不少,而且指数越大,这个优势越明显。
2.2 把指数拆成二进制是数学依据
任何一个正整数 b,都可以写成二进制的形式:
b = Σ (b_i * 2^i),其中 b_i 是 0 或 1
那么:
a^b = a^(Σ b_i * 2^i) = Π (a^(2^i)),其中只乘那些 b_i = 1 的项
而 a^(2^i) 这个序列有非常优美的递推关系:
a^(2^(i+1)) = (a^(2^i))^2
换句话说,从 a 开始,每次平方一下,就能得到 a^2、a^4、a^8、a^16……你需要一个,就平方一步,存下来备用。这个过程只需要 log2(b) 次平方操作。
所以快速幂的复杂度是 O(log b)。这里的 log 是以 2 为底的对数。b = 10^18 时,log2(b) 约为 60;哪怕 b 是 10^300,也只是 1000 次左右的平方乘法,依然非常快。这就是快速幂算法“快”的数学本质:通过平方把指数增长的步数吞掉,而不是傻乎乎地一次乘一个 a。
2.3 复杂度对比:一场数量级碾压
我整理了一个简单的对照表,方便直观感受 O(b) 和 O(log b) 的差距:
| 指数 b 的规模 | 朴素循环乘法次数 | 快速幂乘法次数 | 给人什么感觉 |
|---|---|---|---|
| 10^6 | 10^6 | 20 | 朴素还能勉强跑完 |
| 10^9 | 10^9 | 30 | 朴素已经要跑好几秒 |
| 10^18 | 10^18 | 60 | 朴素要跑几十年 |
| 10^300 | 10^300 | 约 1000 | 朴素完全不现实 |
这里的“乘法次数”是理论上的,实际代码里还涉及取模,但量级对比就是这个意思。这也是为什么所有算法教材都说,能把 O(n) 降到 O(log n) 是质变,不是量变。因为 n 一旦大到一定程度,线性复杂度和无理复杂度就是“能不能算出来”和“完全算不出来”的区别。
3. 代码落地:递归、迭代与常见语言写法
3.1 递归实现:先想清楚状态转移
快速幂的实现方式有很多种,我先从递归版本讲,因为它的思路最贴近数学定义。
cpp复制long long modPowRecursive(long long a, long long b, long long mod) {
if (b == 0) {
return 1 % mod;
}
long long half = modPowRecursive(a, b / 2, mod);
half = half * half % mod;
if (b % 2 == 1) {
half = half * a % mod;
}
return half;
}
这段代码看起来短,但每一行都有它的道理:
b == 0是递归出口。a^0 应该等于 1,但为了处理 mod = 1 的情况,我写的是1 % mod,这样在模数为 1 时也能返回正确结果 0。modPowRecursive(a, b / 2, mod)算的是 a^(b/2)。因为递归是整数除法,b/2 是向下取整。half = half * half % mod把 a^(b/2) 自乘,得到 a^b。如果 b 是偶数,这一步就已经完成了。- 如果 b 是奇数,说明 b = 2k + 1,刚才平方得到的是 a^(2k),还差一个 a,所以要再乘一次 a。
递归版本的优点是逻辑清晰,数学对称性好;缺点是每次递归都有函数调用的开销,不过对于 log 级别的深度来说,几十层递归完全没问题,不用担心栈溢出。
3.2 迭代实现:竞赛中最常用的版本
竞赛里我更推荐迭代版本,因为它没有递归开销,写起来也顺手:
cpp复制long long modPow(long long a, long long b, long long mod) {
long long res = 1;
a %= mod;
while (b > 0) {
if (b & 1) {
res = res * a % mod;
}
a = a * a % mod;
b >>= 1;
}
return res;
}
这段代码的核心就是“看 b 的二进制位”。每轮循环,if (b & 1) 判断当前最低位是不是 1;如果是 1,就把当前的 a 乘进结果里。然后不管是不是 1,a 都要自乘一次,相当于把 a^(2^i) 更新为 a^(2^(i+1)),同时 b >>= 1 把 b 的二进制位向右移动一位,准备看下一位。
我用 3^13 来手动跟踪一遍,13 的二进制是 1101:
| 轮次 | b 的二进制 | b 的最低位 | res 变化 | a 变化 |
|---|---|---|---|---|
| 初始 | 1101 | - | res = 1 | a = 3 |
| 1 | 1101 | 1 | res = 3 | a = 9 |
| 2 | 110 | 0 | res 不变,还是 3 | a = 81 |
| 3 | 11 | 1 | res = 3 * 81 = 243 | a = 81^2 = 6561 |
| 4 | 1 | 1 | res = 243 * 6561 = 1594323 | a = 6561^2,不再使用 |
最终 3^13 = 1594323,和计算器按出来的一样。这个表建议自己多推几遍,理解这个过程后,迭代代码就不会写错了。
有一个细节:我第一行就做了 a %= mod。为什么要这样?因为如果 a 本身就很大,比如 a = 10^18、mod = 7,直接在乘法里用原始 a 很容易让中间结果更大。先把底数对模数取余,符合模运算的性质,也能减少溢出风险。
3.3 Python、Java 与内建模幂的差异
很多语言其实已经帮你封装好了快速幂,比如 Python 的 pow(a, b, mod),Java 的 BigInteger.modPow。我自己在做验证、写脚本时经常直接用 pow(2024, 10**18, 1000000007),一行就出结果。
但我的建议是:不管语言有没有内建函数,你都应该自己手写一遍快速幂。原因有几个:
- 竞赛机或者笔试环境不一定允许你用高级 API,或者用了可能因为版本、类型问题出岔子。
- 内建函数是个黑盒,当题目要求你解释复杂度、处理边界,或者扩展成矩阵快速幂时,不懂得原理就无从下手。
- Python 里虽然整形不溢出,但遇到超大底数超大指数时,内建实现也有性能差异,了解原理能帮你判断什么情况下需要自己优化。
Python 版本和 C++ 逻辑几乎一样:
python复制def mod_pow(a: int, b: int, mod: int) -> int:
res = 1
a %= mod
while b > 0:
if b & 1:
res = res * a % mod
a = a * a % mod
b >>= 1
return res
Java 如果要自己写,注意 long 的溢出问题,超过 long 上限时可以用 BigInteger.modPow,或者自己实现快速乘。C++ 和 Python 之间最大的区别就是溢出风险,这是下一章的重头戏。
4. 取模防溢出:最容易被忽视的边界
4.1 为什么每一步取模都不影响最终结果
初学快速幂时我有一个疑问:中途取模会不会把结果算错?后来我才意识到,模运算有两条非常重要的分配律:
- (a + b) mod m = ((a mod m) + (b mod m)) mod m
- (a * b) mod m = ((a mod m) * (b mod m)) mod m
也就是说,乘法取模可以先对乘数分别取模,再乘完取模,结果不变。正因为如此,我才敢大胆地在每一步都用 res * a % mod,而不是等所有乘法都完成后再取模。
但这里藏着一个工程问题:中间乘积会溢出。在 C++ 里,long long 的最大值是 9223372036854775807,大约是 9.22 * 10^18。如果 mod 的级别是 10^9,那么 res * a 最多到 10^18,还在 long long 范围内;如果 mod 的级别到了 10^12,两个接近 10^12 的数相乘,结果就是 10^24,直接爆掉 long long,你会得到一个错误甚至负数的中间结果。
4.2 mod = 1、指数 = 0、底数为负数的边界处理
经验告诉我,边界条件是最容易被真实数据打爆的。下面这几个我全踩过:
- mod = 1:任何数对 1 取模都是 0。如果你的递归出口写的是
return 1;,那当 mod = 1 时会返回 1,正确答案应该是 0。所以我统一写成1 % mod,所有取模操作也都跟着来。 - 指数 b = 0:任何正整数的 0 次方是 1,但题目要是再来个 mod = 1,结果应该是 0。
return 1 % mod能同时处理这两种情况。 - 底数 a 为负数:C++ 里
(-3) % 7的结果是 -3,不是 4。如果你希望结果是数学意义上的非负余数,可以先a = ((a % mod) + mod) % mod;把 a 变成非负的。 - 实际场景中要密切关注 mod 的范围:有的题 mod 故意给到接近 long long 上限,这时候要额外小心乘法溢出。
4.3 乘法溢出与“快速乘”:当 long long 也扛不住
当 mod 接近 10^18 时,两个 long long 相乘是溢出的。解决方案之一是“快速乘”,也叫“龟速乘”,它和快速幂的思路一脉相承:把乘法拆成二进制的加法,让每一步加法结果都不超出范围。
cpp复制long long mulMod(long long a, long long b, long long mod) {
long long res = 0;
a %= mod;
while (b > 0) {
if (b & 1) {
res = (res + a) % mod;
}
a = (a + a) % mod;
b >>= 1;
}
return res;
}
然后用 mulMod(res, a, mod) 替换掉原来的 res * a % mod。因为加法的中间值最大是 2 * mod,不会超过 long long,所以安全。代价是每次乘法会多一个 O(log b) 的循环,所以叫“龟速”。但在这个场景下,正确性优先于那一点常数性能。
还有一种更取巧的方式:在 GNU C++ 编译环境里直接用 (__int128) 类型。把两个 long long 强制转成 __int128 相乘再取模,结果再转回 long long:
cpp复制res = (__int128)res * a % mod;
这是一个很实用的“捷径”,比赛里很多选手会这么干。但要注意,有些评测环境不一定支持 __int128,或者平台有额外的限制。所以我一般会两种都会:默认场景直接用 long long,危急时刻用 __int128,题目明确不允许时才上快速乘。
4.4 边界用例一览表
我整理了一份边界用例表,自己每次写完快速幂都会过一遍,推荐你也这样测:
| 输入 (a, b, mod) | 期望输出 | 说明 |
|---|---|---|
| (7, 0, 10) | 1 | 常规 0 次幂 |
| (7, 0, 1) | 0 | mod = 1 时,0 次幂也是 0 |
| (0, 5, 7) | 0 | 0 的正数次幂为 0 |
| (5, 3, 1) | 0 | 任意数对 1 取模为 0 |
| (-3, 5, 7) | 5 | 负数底数要转成非负余数 |
| (10^18, 10^18, 10^9+7) | 用 Python 验证 | 防止 long long 溢出 |
别嫌这些用例“太极端”,我当年就是没测 mod = 1 的情况,在一个牛客竞赛题里整整卡了半个多小时,最后靠输出调试才发现递归出口返回了 1 而不是 0。
5. 进阶:矩阵快速幂与经典应用
5.1 把快速幂从“数”推广到“矩阵”
快速幂的思想不仅能用在数字上,还能推广到任何满足结合律的运算上。矩阵乘法满足结合律,所以 a^b 里的 a 可以换成矩阵 A,a^b 就是 A^b,也就是 b 个矩阵连续相乘。
为什么矩阵幂有用?因为很多递推关系,比如斐波那契数列、图上两点之间走 k 步的方案数,都可以写成矩阵乘法的形式。一旦写成矩阵,就能用快速幂在 O(k^3 log n) 时间内求出结果。这里的 k 是矩阵边长,通常很小,比如 2x2 或 3x3,所以 log n 才是决定性的因素。
一个 2x2 矩阵乘法的 C++ 实现大概长这样:
cpp复制struct Mat {
long long a[2][2];
};
Mat multiply(const Mat& x, const Mat& y, long long mod) {
Mat z = {0, 0, 0, 0};
for (int i = 0; i < 2; i++) {
for (int j = 0; j < 2; j++) {
for (int k = 0; k < 2; k++) {
z.a[i][j] = (z.a[i][j] + x.a[i][k] * y.a[k][j]) % mod;
}
}
}
return z;
}
这里要特别小心:矩阵乘法不满足交换律,AB 和 BA 通常不一样。所以在实现快速幂时,乘法的顺序一定要保持和数学表达一致。经验是要么严格先 res 后 base,要么先 base 后 res,固定习惯,不要换来换去。
5.2 用矩阵快速幂求斐波那契数列
斐波那契数列是这样定义的:F(0) = 0,F(1) = 1,F(n) = F(n-1) + F(n-2)。
把它写成矩阵形式:
[ F(n+1) F(n) ] = [ 1 1 ]^n
[ F(n) F(n-1) ] [ 1 0 ]
也就是说,只要对矩阵 [1 1; 1 0] 做 n 次方,就能从 F(0)、F(1) 推出 F(n)。普通的递推是 O(n),矩阵快速幂是 O(log n)。n = 10^18 时,前者是天文数字的时间,后者眨眼就好。
完整的求 F(n) 模 p 的代码可以这样写:
cpp复制Mat matPow(Mat base, long long exp, long long mod) {
Mat res = {1, 0, 0, 1}; // 单位矩阵
while (exp > 0) {
if (exp & 1) {
res = multiply(res, base, mod);
}
base = multiply(base, base, mod);
exp >>= 1;
}
return res;
}
long long fib(long long n, long long mod) {
if (n == 0) return 0;
Mat base = {1, 1, 1, 0};
Mat res = matPow(base, n, mod);
return res.a[0][1] % mod;
}
这里的单位矩阵相当于数字快速幂里的 1:任何矩阵乘以单位矩阵都不变。我刚开始写矩阵快速幂时,老忘记给 res 初始化为单位矩阵,结果永远是 0 矩阵,调试了半天。这个坑写出来,希望你能绕开。
5.3 快速幂在工程与竞赛中的常见应用
除了矩阵快速幂和斐波那契,快速幂算法几乎随处可见:
- 模逆元:如果 p 是质数,根据费马小定理,a^(p-1) ≡ 1 (mod p),所以 a 的逆元就是 a^(p-2) mod p。组合数计算、同余方程里都靠它。
- RSA 解密:直接调用快速幂做大数模幂运算,是加解密里绕不开的核心步骤。
- 图的路径计数:邻接矩阵的 k 次幂,(A^k)[i][j] 表示从节点 i 到节点 j 长度恰好为 k 的路径方案数。配合矩阵快速幂,百万级别的 k 也能算。
- 概率和期望问题:有些期望 dp 的状态转移可以表示成矩阵幂形式,快速幂能把“模拟很多次”变成“直接跳到第 n 步”。
看到这里你会发现,快速幂不是一道孤立算法题,而是一个“工具属性”很强的基建类算法。它解决的是“怎么快速做大规模幂运算”这个底层问题,上面可以架设无数应用场景。
6. 调试与验证:从随机对拍到大数自测
6.1 三个最常翻车的点
就算看了再多文章,自己写还是容易踩坑。我总结了自己的“翻车史”,主要有三个地方:
一是忘记开头的 a %= mod,导致中间结果偏大,明明不会溢出的数据也溢出了。二是迭代版里 if (b & 1) 的判断写反,写成 if (b & 1 == 0) 或者把 res = res * a 放进了 else 里。三是取模时机不对,总想在最后一次统一取模。快速幂的正确写法就是“每步都取模”,不要憋到最后。
如果你写完发现结果不对,先按这三条自查,大概率能解决。
6.2 用随机小数据对拍寻找错误
一个非常有效的验证方法是对拍:写一个朴素的 pow 函数,再写一个快速幂函数,随机生成小范围的参数,反复比较输出。
在 C++ 里可以这样:
cpp复制#include <bits/stdc++.h>
using namespace std;
long long naivePow(long long a, long long b, long long mod) {
long long res = 1 % mod;
for (long long i = 0; i < b; i++) {
res = res * a % mod;
}
return res;
}
int main() {
mt19937 rng(20240607);
for (int t = 0; t < 10000; t++) {
long long a = rng() % 1000;
long long b = rng() % 20;
long long mod = rng() % 100 + 1;
long long x = naivePow(a, b, mod);
long long y = modPow(a, b, mod);
if (x != y) {
cout << "Mismatch: " << a << " " << b << " " << mod << endl;
return 0;
}
}
cout << "All OK" << endl;
return 0;
}
对拍的关键是参数要随机但要小,保证朴素算法能快速跑完,同时覆盖足够多的组合。如果对拍通过一万组,正确性基本就有保障了。
6.3 构造极端用例验证边界
小数据对拍只能验证逻辑正确性,边界还得靠手工构造。我每次提交前都会跑下面的用例:
(5, 0, 1):期望 0。这能测出递归出口有没有对 mod 取模。(-3, 5, 7):期望 5。这能测出你对负数底数的处理。(1000000007, 10^18, 1000000007):底数等于模数,结果应该是 0。(2, 10^18, 1000000009):大指数下结果是否稳定。
如果是在 C++ 里,遇到 mod 接近 1e18 时一定要考虑乘法溢出;如果是在 Python 里,大整数不溢出,可以放心验证数学正确性,然后再回到 C++ 调整实现。
6.4 一个真实的调试片段
我印象最深的一次 bug,是在一个需要输出负数取模结果的题里。当时我写的快速幂没错,但对负数底数没有处理,比如 (-3)^5 mod 7,C++ 算出来是负的,我直接输出负数,WA 了。
排查过程很简单:我在 modPow 开头加了 a %= mod,然后输出 a,发现 a 变成了 -3,后续所有运算都带着负号。让我更懵的是,部分用例结果是对的,因为 (-3) * (-3) = 9,负负得正;但只要奇数次幂,中间结果就飘了。解决办法就是开场先转非负:
cpp复制a = ((a % mod) + mod) % mod;
从那以后,我养成了一个习惯:凡是读入的底数和模数,先统一按数学含义处理一次,不要让负数的气味飘进核心运算。
最后一次,把我自己的经验总结成一句话:写快速幂,边界是你的朋友,溢出是你的敌人。把这两件事想清楚,你写的就不是一个“会通过的模板”,而是一个真正理解了的算法。
