先说个我自己的经历。几年前我刚开始刷OJ的时候,遇到一道同余类的题目——大概就是让算 (a^{b} \bmod m),其中 (b) 可以大到 (10^{18}) 那种。我当时第一反应是:那不就是个for循环吗?结果一跑,直接超时。后来翻题解,看到“快速幂”三个字,又看到“费马小定理可以求逆元”,整个人是懵的:这些数论概念到底和写代码有什么关系?为什么一个简单求幂问题会牵扯出这么一大片知识?
后来我才慢慢理解,整除、同余、快速幂这三样东西,其实是初等数论在算法领域最常用的三块地基。整除是讨论整数性质的基本语言,同余是描述“余数关系”的简洁工具,而快速幂是让我们真正能在大模数下处理大指数问题的工程实现。把这条线理清楚之后,很多题看起来就不再是孤立的套路,而是同一个底层逻辑的变体。这篇文字写给那些和我当时一样,刚接触数论方向、想系统理解而不是背模板的读者。
1. 初等数论的骨架:为什么这三个概念总被放在一起
很多人学数论是从“质数判定”“最大公约数”这些具体题目入手的,容易把数论学成一堆孤立函数。但实际上,数论的算法化应用有一条非常清晰的递进链:先有整除,再有同余,然后才有快速幂这类计算工具。理解这条链子,比背一百个结论更管用。
1.1 整除:数论的语言系统,而不只是“除得尽”
整除的定义说起来很朴素:如果存在一个整数 (k),使得 (b = a \cdot k),就说 (a) 整除 (b),记作 (a \mid b)。这里最值得注意的不是定义本身,而是它带来的思维方式——在整数范围内讨论问题,不能只看“能不能除尽”,还要看商是否是整数,以及这个整除关系有哪些可传递的性质。
整除有几条最常用的基本性质:
- 如果 (a \mid b) 且 (b \mid c),那么 (a \mid c)。这是传递性,很像不等式的连比,只是对象从数变成了“倍数关系”。
- 如果 (a \mid b) 且 (a \mid c),那么 (a \mid (b + c)),也 (a \mid (mb + nc))。这相当于在说:整除关系对整数线性组合是封闭的。
- 如果 (a \mid b) 且 (b \mid a),那么 (a) 和 (b) 只可能相差一个符号,即 (a = \pm b)。
看到这几条,你可能会觉得这是初中数学内容。确实如此,但关键在于:后面同余里的所有运算规则,本质都是这几条性质的翻译。例如“同余式可以逐项相加”这件事,归根到底是因为整除对加法封闭。
在编程应用里,整除最常见的使用场景是处理周期性问题。比如判断某一天是星期几,本质上就是在做带余除法,把“日期总数”投影到固定长度 (7) 的周期上。另一个典型是约数计数问题,所有质因数分解相关的算法都在用整除的语言描述“某个数由哪些更小的数拼成”。
1.2 带余除法才是真正的逻辑起点
严格来说,数论里更基本的定理是带余除法:对任意整数 (a) 和正整数 (m),总能唯一地写成
[
a = qm + r,\quad 0 \le r < m
]
这里的 (q) 叫商,(r) 叫余数。它的唯一性很重要——正因为余数是唯一确定的,我们才能放心地把“余数”当作一个对象来研究。
这里有第一个容易被忽略的细节:带余除法对 (a) 的范围没有任何限制,(a) 可以是负数、零,可以是巨大的正数。但余数 (r) 永远被限制在半开区间 ([0, m)) 里。这是数学上对“余数归零”的统一约定。可C++里的 % 运算符不是这么干的,C++11 之前取模结果对负数往往是负值,C++11 之后标准规定向零取整,导致 -7 % 3 的结果是 -1,而不是数学上的 2。这个坑,我在后面实际问题排查那一章会专门展开。
有了带余除法,整除的概念可以重新表述成“余数为 (0) 的带余除法”。而这个视角,正是引出同余的跳板。
1.3 同余:把“相等”放宽到“余数相同”
同余的定义可以写成:如果 (m \mid (a - b)),就说 (a) 和 (b) 在模 (m) 的意义下同余,记作
[
a \equiv b \pmod m
]
这句话可以换成一个好记的理解方式:把整数全部除以 (m) 后,(a) 和 (b) 的余数相同。于是同余就把“相等”的概念从元素级放宽到了分类级。你可以把它想象成哈希表:所有整数按模 (m) 的结果被分成了 (m) 个桶,同余就是落在同一个桶里。
这种“放宽相等”的价值在于:很多问题里,我们不关心整数本身是多少,只关心它在某个模数下的分类。最典型的就是计数值取模后输出结果的问题——题目只要求从 (10^9+7) 这个哈希桶里取结果。
同余关系拥有三个基本身份特征:自反性(自己必然和自己同余)、对称性(如果 (a \equiv b),那么 (b \equiv a))、传递性(如果 (a \equiv b) 且 (b \equiv c),那么 (a \equiv c))。有这三个性质,同余就是一个真正的等价关系,可以把整数划分成互不相交的剩余类。
所谓剩余类,就是模 (m) 下余数相同的一组整数,比如模 (3) 的剩余类有:
- 余 (0) 的数:(\dots, -6, -3, 0, 3, 6, \dots)
- 余 (1) 的数:(\dots, -5, -2, 1, 4, 7, \dots)
- 余 (2) 的数:(\dots, -4, -1, 2, 5, 8, \dots)
从每个剩余类里各挑一个代表,就组成完全剩余系。在很多算法里,我们只需要在每个剩余类中挑一个代表来处理,这样就把无穷的整数集压缩成了有限的 (m) 个状态。后面你会看到,很多动态规划和状态机题目就这么诞生。
也因此,同余不是锦上添花的数学游戏,它是压缩计算规模的核心思维。例如在快速幂中,我们面对的指数动辄是 (10^{18}),如果把每一步的中间值都按真实数值存下来,那规模早就上千万亿次了。但同余告诉我们,每一步只需要保留模 (m) 的余数,最终结果不会变——这正是快速幂能够处理大数的底气之一。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 快速幂拆解:从连乘到二进制分治
快速幂这个算法,绝大多数人第一次见到时的感受是“代码好短,但我不知道为什么这么写”。确实,它的代码量只有几行,但背后藏着不止一层设计思路。这一节我会从最笨的求幂方法开始,逐层推演到二进制分治,并且把递归版和迭代版都写清楚,最后给出原因分析。这样你才能面对面试官或者题解评论区时真正有底气。
2.1 朴素求幂的复杂度瓶颈在哪里
假设要计算 (a^b \bmod m),最直观的写法是:
cpp复制long long pow_naive(long long a, long long b, long long m) {
long long result = 1;
for (long long i = 0; i < b; i++) {
result = result * a % m;
}
return result;
}
这段代码逻辑上没错,问题也不在常数大小,而在循环次数与指数规模直接相关。如果 (b) 是 (10^{18}) 级别,这段代码要执行 (10^{18}) 次乘法模运算,按每秒 (10^9) 次运算来算,需要约 (3) 万年。这当然不行。
程序优化的核心思路几乎是通用的:要么减少运算次数,要么让每一步运算执行得更快。在求幂问题里,减少运算次数的钥匙就是“复用中间结果”。
2.2 从平方复用说起:一个关键的中途观察
先看一个简单例子:要算 (a^{16}),朴素做法要乘 (16) 次,但如果我先算 (a^2),再算 ((a^2)^2 = a^4),再算 ((a^4)^2 = a^8),最后算 ((a^8)^2 = a^{16}),只需要 (4) 次乘法,正好是 (16) 的以 (2) 为底的对数。
把指数从 (1) 开始不断翻倍,每次翻倍只需要一次乘法,所以指数达到 (b) 只需要 (\log_2 b) 次操作。这就是分治求幂的思想雏形。
但问题来了:一个任意的指数,比如 (b = 13),并不恰好是 (2) 的幂次。这时应该怎么办?
标准思路不是从 (1) 开始顺着推,而是把指数拆成二进制:
[
13 = 8 + 4 + 1 = 2^3 + 2^2 + 2^0
]
于是
[
a^{13} = a^{8} \cdot a^{4} \cdot a^{1}
]
如果我们能快速得到 (a^1, a^2, a^4, a^8, a^{16}, \dots) 这些“二进制权重幂”,然后把拼入二进制位的那些幂乘起来,就能得到答案。这些权重幂之间有极其方便的关系:
[
a^{2^{k+1}} = \left(a^{2^k}\right)^2
]
换句话说,从 (a^{1}) 开始,每一步把自己的平方作为下一次的基础。这就是快速幂的核心推导逻辑。
为什么能用二进制分治来降低复杂度?因为一个数的二进制展开是唯一且稀疏的,把连续的乘法转化为若干权重幂的乘积,它天然需要的信息量只有 (\log_2 b) 个中间态。
2.3 递归版快速幂:最容易理解的写法
按照分治思路,递归版本的代码几乎可以照着数学公式写出来。核心观察是:
[
a^n = \begin{cases}
(a^{n/2})^2 & n\ \text{为偶数} \
(a^{n/2})^2 \cdot a & n\ \text{为奇数}
\end{cases}
]
对应代码:
cpp复制long long qpow_recursive(long long a, long long b, long long m) {
if (b == 0) return 1 % m;
long long half = qpow_recursive(a, b >> 1, m);
half = half * half % m;
if (b & 1) half = half * a % m;
return half;
}
这种写法很清晰地表达了一个思想:先算出一半幂的结果,再组合。但递归有递归的代价:每次函数调用都有栈帧开销,深度大约 (\log_2 b),对普通题目没问题,但在极限性能场景下,递归常数可能比迭代略大。更重要的是,处理超大指数时,虽然函数深度只有几十层,但如果不小心在递归里重复计算同一个子问题,复杂度就会退化成指数级。上面的写法没有这个问题,因为它每个指数层级只计算一次。
2.4 二进制视角的直接落地:迭代写法
迭代版更适合作为模板代码,因为它避免了递归开销,也更贴近二进制分解的本质。核心逻辑是:遍历指数 (b) 的二进制位,如果当前位是 (1),就乘入对应的底数量级;同时无论当前位是否为 (1),都要把底数量级继续自乘,为下一位做准备。
直接看代码:
cpp复制long long qpow_iterative(long long a, long long b, long long m) {
a %= m;
long long result = 1;
while (b > 0) {
if (b & 1) {
result = result * a % m;
}
a = a * a % m;
b >>= 1;
}
return result;
}
这段代码里最容易被新手忽略的一行是 a = a * a % m。它不只在指数某一位为 (1) 时才发生,而是无论每一位是否被用到都会执行。原因是:二进制权重的幂次序列 (a^{1}, a^{2}, a^{4}, \dots) 必须按顺序生成,后一项永远是前一项的平方。无论这一位是否贡献给最终乘积,这个序列都要继续往后走。如果把它放进 if 里,整个序列就断了。
我见过不少初学者把这个自乘误当作“只在需要的时候才做”,导致结果错误仍不知道哪里出了问题。排查这类问题时,建议打印出每一步 b 的二进制位、当前 a 的值和中间结果,一眼就能看穿。
以 (a=3, b=13, m=100) 为例,手动走一遍:
b=13,二进制是1101,最低位为1,result = 1 * 3 = 3;a = 3^2 = 9;b >>= 1得6。b=6,最低位为0,不乘result;a = 9^2 = 81;b >>= 1得3。b=3,最低位为1,result = 3 * 81 % 100 = 243 % 100 = 43;a = 81^2 = 6561 % 100 = 61;b >>= 1得1。b=1,最低位为1,result = 43 * 61 % 100 = 2623 % 100 = 23;a = 61^2 = 3721 % 100 = 21;b >>= 1得0。
所以 (3^{13} \bmod 100 = 23)。你可以手算或用大数计算工具验证,确实是 (23)。这么走一遍之后,迭代版快速幂的每一步都有了画面感。
3. 模运算与负数取模:实操中躲不开的边界问题
数论定义说余数应当落在 ([0, m)) 区间内,但实际编程语言不一定严格遵守这个约定。这一节总结我在刷题和工程编码里踩过、见别人踩过的高频取模问题,以及对应的防御式写法。这些问题和快速幂、同余直接相关,处理不好会产出各种“诡异”答案。
3.1 C++ 负数取模机制:向零取整还是向负无穷取整
C++ 里 % 运算符作用于整数时,结果符号与被除数完全一致。C++11 标准明确规定了 a % b 的结果满足 ((a / b) \cdot b + a % b = a),且 a / b 向零取整。向零取整就意味着当 (a = -7, b = 3) 时,(-7 / 3 = -2),所以 (-7 % 3 = -1)。而数学上的同余余数定义要求余数是非负的,正确答案应当是 (2),因为 (-7 = (-3)\cdot 3 + 2)。
更直观地说,数学余数是从被除数向下取到 (m) 的最小倍数后得到的差,而C++的%只是直接截断小数部分。两者只有在被除数和模数同号时才一致。很多人在负数参与的同余题里莫名其妙WA,大部分原因就是没有把 % 的结果规整到 ([0, m))。
写一个安全的取模函数就能解决:
cpp复制long long mod_norm(long long x, long long m) {
long long r = x % m;
if (r < 0) r += m;
return r;
}
处理同余问题时,我建议所有进入计算的值都先做一次规整。虽然每次取模多一个条件判断,但能避免后续调试时被负数结果带偏。尤其在做快速幂之前,最好先执行 a %= m,确保底数非负且严格小于 m。
如果有人问:如果不把负余数加回 m,而是直接参与下次乘法,会怎样?在快速幂里通常不会让结果变成特别离谱的值,但最终的余数表示就可能落在 ((-m, m)) 区间而不是 ([0, m)),这样当出题人期待标准答案时,你就会因为余数符号问题被判错。
3.2 模数可能为 1:最短也最容易漏掉的边界
模数为 (1) 时,所有整数除以 (1) 的余数都是 (0)。所以无论底数和指数是什么,答案都应当是 (0)。但在朴素递推写法里,一旦有人把初始 result 设为 1 而不做取模,且循环条件处理不当,就可能返回 1。这是我最常见到的新手边界错误之一。
解决办法很简单:递归版在 b == 0 时需要返回 1 % m 而不是直接返回 1;迭代版虽然通常不会遇到这个问题,但为了统一,最好也在入口处对 a 做一次 a %= m,并考虑如果 m == 1 直接返回 0:
cpp复制long long qpow_safe(long long a, long long b, long long m) {
if (m == 1) return 0;
a = mod_norm(a, m);
long long result = 1;
while (b > 0) {
if (b & 1) result = result * a % m;
a = a * a % m;
b >>= 1;
}
return result % m;
}
这里 return result % m 是双重保险,因为指数为 0 时 result 是 1,而 1 % 1 = 0,正好覆盖了 (a^0 \bmod 1) 这种应该为 0 的情况。
3.3 乘法溢出:long long 也不一定装得下
快速幂的核心操作是“取模乘法”。如果模数 (m) 本身接近 long long 的上限,比如达到 (10^{18}) 级别,那么两个小于 (m) 的数相乘可能达到 (10^{36}),远超 long long 的约 (9.2 \times 10^{18}) 上限。这个时候即使逻辑完全正确,数值也会溢出成错。
应对大模数乘法的办法通常有两种:
一是使用 __int128。很多OJ和现代GCC编译器都支持它,代码改动非常小:
cpp复制long long mul_mod(__int128 a, __int128 b, long long m) {
return (long long)(a * b % m);
}
二是自己实现按二进制展开的加法取模乘法(俄罗斯农夫乘法),用加法替代乘法,因为加法即使溢出也比乘法容易管控,但常数较大,仅在编译器不支持 __int128 时推荐:
cpp复制long long mul_mod_add(long long a, long long b, long long m) {
a = mod_norm(a, m);
b = mod_norm(b, m);
long long result = 0;
while (b > 0) {
if (b & 1) result = (result + a) % m;
a = (a + a) % m;
b >>= 1;
}
return result;
}
需要真正常踩到这类问题的题目,通常是模数为大质数且需要乘法合并的场景,比如某些矩阵快速幂大模数题、RSA里的模幂运算等。一般算法竞赛里模数常是 (10^9+7) 或 (10^9+9),两个数相乘最多约 (10^{18}),long long 勉强能装下,这也是这两个模数出现在竞赛中的原因之一——不是出于巧合,而是因为数值范围方便。
4. 从同余到数论倒数:费马小定理与模逆元
同余理论如果不落回算法应用,很容易给人“纯粹理论”的印象。而它最锋利的应用,就是解决同余方程里的“除法”。为什么除法在同余里特殊?因为模 (m) 意义下,并不是每个数都能像整数除法那样直接“除”。这一节我会讲清楚模逆元为什么存在、怎么用快速幂计算,以及常见的误用方式。
4.1 同余里为什么不能直接“约分”
在实数域中,除以一个数 (a) 等价于乘以它的倒数 (a^{-1})。但在模 (m) 的意义下,我们讨论的“数”是剩余类,未必每个剩余类都有可以相乘得 (1) 的“倒数”。
举个例子:在模 (6) 的意义下,求 (2x \equiv 1 \pmod 6) 是否有解。把 (x) 取遍 (0) 到 (5),会发现 (2x) 的余数只可能是 (0, 2, 4),永远得不到 (1)。所以 (2) 在模 (6) 意义下没有逆元。什么情况下有逆元?关键条件是 (\gcd(a, m) = 1),即 (a) 与模数互质。不互质时,(a) 在模 (m) 下的所有倍数只分布在 (m / \gcd(a,m)) 个剩余类里,不可能覆盖全部类。
这背后其实还是整除那套性质的延伸:如果 (\gcd(a, m) = g > 1),那么 (a x - m y) 一定是 (g) 的倍数,不可能等于 (1)。而当互质时,根据扩展欧几里得算法可以找到一组整数解 (x, y) 使 (ax + my = 1),于是 (x) 就是 (a) 在模 (m) 下的逆元。除了扩展欧几里得,还有一种更契合本文主题的求解方式——用快速幂。
4.2 费马小定理给出的模质数逆元公式
费马小定理说的是:如果 (p) 是一个质数,且整数 (a) 不是 (p) 的倍数,那么有
[
a^{p-1} \equiv 1 \pmod p
]
两边同时除以 (a),立刻得到
[
a^{p-2} \equiv a^{-1} \pmod p
]
也就是说,当模数是质数 (p),并且 (a) 不被 (p) 整除时,模逆元可以直接通过一次快速幂计算出来:
cpp复制long long mod_inverse_fermat(long long a, long long p) {
return qpow_safe(mod_norm(a, p), p - 2, p);
}
这个公式在实际解题中应用极广,最典型的是组合数对 (10^9+7) 取模时的除法计算。大组合数 (C(n,k)) 的值非常大,通常要求对质数 (10^9+7) 取模输出。直接计算分母对应的模逆元 (k!^{-1}),再乘回分子阶乘,就能得到正确结果。
费马小定理这里的幂次 (p-2) 很大,但我们的快速幂只需要 (O(\log p)) 时间,约 (30) 次迭代,几乎是常数时间。
4.3 使用逆元的踩坑点
最常见的误用是模数不是质数时直接套费马小定理。比如模数为 (10^9+7) 时可以放心用 (p-2) 次幂求逆元,但换到模数为 (4) 或 (6) 这种合数时,费马小定理的指数公式不再成立,必须使用扩展欧几里得求逆元:
cpp复制long long exgcd(long long a, long long b, long long &x, long long &y) {
if (b == 0) { x = 1; y = 0; return a; }
long long g = exgcd(b, a % b, y, x);
y -= a / b * x;
return g;
}
long long mod_inverse_exgcd(long long a, long long m) {
long long x, y;
long long g = exgcd(a, m, x, y);
if (g != 1) return -1; // 逆元不存在
return mod_norm(x, m);
}
另一个容易忽视的点是:就算 (\gcd(a, m) = 1),只有当 (a) 与 (m) 互质时,对同余式两边同时“除以 (a)”才等价于乘上 (a^{-1})。如果不互质,原同余式 a*x ≡ b (mod m) 往往也有解,但不能直接乘逆元,要先同除以 (\gcd(a,m)),把模数也一并缩小。我见过不少人把这个步骤漏掉,导致在部分测试数据上错误。
从上面的推导可以看出,快速幂在这里不仅仅是求幂,它实际上是一个计算模逆元的核心引擎。
5. 递进应用:同余性质在矩阵快速幂与状态压缩里的延伸
如果说快速幂解决了“一个数字自身的幂”的计算问题,那么“矩阵快速幂”用完全相同的思路,解决了“一个状态矩阵经过 (n) 步转移后等于什么”的问题。这是数论从数学过渡到算法题最精彩的一段,也是斐波那契数列题、线性递推题中反复出现的模型。
5.1 矩阵快速幂的构造思路
矩阵乘法和标量乘法一样满足结合律,因此我们可以对一个方阵做快速幂。以斐波那契数列为例,斐波那契递推式可以写成矩阵形式:
[
\begin{bmatrix}
F_{n+1} \ F_n
\end
\begin{bmatrix}
1 & 1 \
1 & 0
\end{bmatrix}
\cdot
\begin{bmatrix}
F_n \ F_{n-1}
\end{bmatrix}
]
所以
[
\begin{bmatrix}
F_{n+1} \ F_n
\end
\begin{bmatrix}
1 & 1 \
1 & 0
\end{bmatrix}^{n-1}
\cdot
\begin{bmatrix}
F_2 \ F_1
\end{bmatrix}
]
这样就把“递推 (n) 次”变成“矩阵自乘 (O(\log n)) 次”。这里的计算量之所以能降到对数级,和数字快速幂中“复用中间平方结果”的思想完全一样,只是每次乘法的对象从整数换成了矩阵。
常用于解决递推问题的步骤是:先把递推关系写成向量形式,转移系数填入矩阵,然后用矩阵乘法快速幂。代码和普通快速幂结构几乎一一对应:
cpp复制using Mat = vector<vector<long long>>;
Mat mat_mul(const Mat &A, const Mat &B, long long m) {
int n = A.size();
Mat C(n, vector<long long>(n, 0));
for (int i = 0; i < n; i++) {
for (int k = 0; k < n; k++) {
if (A[i][k] == 0) continue;
for (int j = 0; j < n; j++) {
C[i][j] = (C[i][j] + A[i][k] * B[k][j]) % m;
}
}
}
return C;
}
Mat mat_pow(Mat A, long long b, long long m) {
int n = A.size();
Mat result(n, vector<long long>(n, 0));
for (int i = 0; i < n; i++) result[i][i] = 1;
while (b > 0) {
if (b & 1) result = mat_mul(result, A, m);
A = mat_mul(A, A, m);
b >>= 1;
}
return result;
}
这里需要说明一个和整数快速幂的差异:单位元不再是我们熟悉的 1,而是单位矩阵,它在矩阵乘法里扮演“乘以自己仍不变”的角色。最初 result 要多加一个对角线为 1 的初始化,否则直接乘任何矩阵都会变成零矩阵。
如果把这种思路继续延伸,凡是能抽象成“线性递推”的状态序列,都可以通过构造矩阵加速到对数级复杂度。除了斐波那契数列,像线性常系数递推、某些图上的路径计数问题也都能套用。
5.2 同余在状态压缩题中的角色
除了矩阵快速幂,同余思想在“状态压缩搜索”里也频繁出现。很多计数的搜索题目需要记录已经处理到的个数对某个数取模的结果,这个取模结果本身就是状态的一部分。
举个例子:问长度为 (n)、仅由数字 (1 \sim 6) 构成的字符串中,有多少个能被 (7) 整除?直接从小到大枚举字符串会爆炸,但我们可以把“当前拼接出来的多位数字模 (7) 的余数”作为动态规划状态。每追加一位数字 (d),新的余数等于 (old * 10 + d) % 7。这个过程其实一直在用同余运算规则:加法、乘法对模运算的穿透性。
更本质地说,同余允许我们把一个大数压缩到有限的 (m) 个桶里,并且在整个计算过程中维持这个压缩状态的正确性。这是许多数字相关 DP 题的共同骨架,也是“为什么多数取模题要求 mod 后输出”的根本原因。
5.3 超越快速幂本身:当这三个概念组合出现时
有一类典型的综合题目,同时考察整除、同余和快速幂:给定 (n) 个整数,输出它们的某种高次幂和,并对 (10^9+7) 取模;或者要求计算一个周期序列的某一次幂项。你仔细拆一下,会发现这些题的解题链条往往非常一致:
- 先用整除和同余的性质化简问题规模,把指数或者底数缩到可控范围;
- 再用快速幂处理大指数下的幂值;
- 最后用费马小定理或扩展欧几里得处理除法取模。
比如一道很经典的题:给定 (a, b, c),求 (a^{(b^c)} \bmod m)。难点不在于最外层的快速幂,而在于指数 (b^c) 本身已经是天文数字。如果 (m) 是质数且满足一些条件,可以借助费马小定理先把指数对 (m-1) 取模再求幂。要理解为什么可以这么做,就需要理解“指数处的模运算依赖模数减一”的深层原因,而这又回到同余和费马小定理对指数周期的刻画。
我个人的建议是:遇到这类题不要直接去背公式,先手动算小例子,把每一步“为什么要对指数取模”的枢纽找出来。你自己推导出来一次,之后遇到相关变形就不会慌。
6. 实战路径:从基本性质到组合解决问题的思考框架
前面几节把整除、同余、快速幂各自和彼此的逻辑都串清楚了,但很多人到真正做题时还是不知道从哪里下手。我自己在这个阶段也有过很长时间的迷茫。这一节分享一个后来很受用的分析框架,以及对应的练题路线图。
6.1 遇到数论题时的第一步判断
面对一道题,我习惯先问三个问题:
- 题目涉及的是单个整数的性质,还是一批整数在相同模数下的分类?如果只是检查某数是否是另一个数的倍数,那往往属于整除范畴,可能配合质因数分解解决;如果涉及“余数相同”“周期性”“对某个模数取模后的结果”,那属于同余问题,需要考虑剩余类和相关运算规则。
- 数据规模里有没有大指数?如果有指数或者需要做幂运算,并且模数固定,大概率需要快速幂或预计算幂表;如果数据规模只到几千,朴素连乘也不是绝对不能考虑。
- 是否需要做除法或求逆?如果公式里出现模意义下的除法,先判断模数是否为质数,是则用费马小定理加快速幂,否则用扩展欧几里得,同时确认互质前提是否满足。
这个三步法不能保证立刻解出题,但能帮你快速排除错误算法方向,少走很多弯路。
6.2 一个综合例题的手推流程
不妨把“整除、同余、快速幂”结合起来看一道简化版题目:计算
[
S(n) = \sum_{i=1}^{n} 2^{k_i} \bmod (10^9+7)
]
其中 (k_i) 是第 (i) 个正整数。如果 (k_i) 非常大,直接循环快速幂对每个 (i) 都算一遍成本很高。正确的打开方式是什么?先考虑是否能用同余周期性缩范围。模 (10^9+7) 是质数,根据费马小定理,对于非零底数 (2),有
[
2^{p-1} \equiv 1 \pmod p
]
所以指数可以先用 (k_i \bmod (p-1)) 替换,而不改变结果。假定我们要计算前 (n) 个 (k_i = i) 的和,那实际上是在求
[
\sum_{i=1}^{n} 2^{(i-1) \bmod (p-1)} \pmod p
]
当 (n) 很大的时候,因为周期是 (p-1),可以先算出完整周期的和,再处理剩余部分。这个做法大幅减少了快速幂的调用次数。整个推导链条就是:整除关系给出周期存在性 → 同余性质给出指数化简规则 → 快速幂处理单次幂计算。
6.3 针对不同水平的进阶路线
如果是完全的新手,先把 qpow_iterative 和矩阵快速幂两个模板背熟,然后用几道经典题练手,比如 POJ 1995 Raising Modulo Numbers、洛谷 P1226 快速幂模板题、HDU 2817 的数列求项题。这些题的共同点是只考察单个概念,适合先建立信心。
等到能一眼看出快速幂后,再挑战组合逆元题和“指数循环节类”题目。这类题会逼迫你真正理解费马小定理、扩展欧几里得和同余的深层关系,而不是停留在“快速幂可以加速求幂”的浅层记忆。
更高阶的方向是应用数学建模类问题,比如密码学中 RSA 的加解密核心就是大整数模幂,工程里做哈希分桶、一致性哈希也会用到同余思想。我后来在写分布式任务分片逻辑时,还真的用到过同余来保证某个 key 能稳定落在同一个分片上。
7. 亲手推导一次:用几个小实验巩固数感
学数论的大忌是“看得懂,不会做”。这一节给出三个可以自己动手推的小实验。每个实验不超过十几分钟,但对建立数感很有帮助。
7.1 验证整除性质的传递性
写一个小程序,循环若干组随机整数 (a, b, c),检测如果 (a \mid b) 且 (b \mid c),是否一定有 (a \mid c)。顺便可以加入一些正负混合的值,观察整除关系对符号的处理方式。这个实验的意义在于让你对“整除结论的成立范围”建立直接的感觉,而不是只在数学书上静态地接受。
7.2 画出不同模数下的剩余类
选择一个较小的模数,例如 (6),把所有整数从 (-12) 到 (12) 标在数轴上,并按照余数为 (0, 1, 2, \dots, 5) 分组涂色。观察负数在C++取模下得到的余数如何超出数学定义范围。这个视觉化实验尤其适合理解为什么模运算需要规整。
如果你更偏好写代码,可以写一个简单的输出函数,遍历 -20..20 的每个数字,打印它经过 mod_norm 前后的值:
cpp复制for (int x = -20; x <= 20; x++) {
cout << x << " -> " << mod_norm(x, 6) << "\n";
}
看到 -1 -> 5、-2 -> 4 这些结果后,“负数取模为什么要修正”这个问题就不再抽象了。
7.3 快速幂模板的边界测试
把正确的快速幂模板拿来,跑一组边界数据:
- 指数为 (0),模数任意:期望是
1 % m。 - 模数为 (1):无论任何输入,期望是
0。 - 底数为负数:先取模规整,再跑快速幂。
- 模数很大,接近
1e18:使用__int128乘法版本跑,同时对比朴素循环在小指数下是否一致。
把边界情况挨个跑一遍后,把其中某个环节故意改成容易出错的写法,比如把 a = a * a % m 放进 if (b & 1) 里,观察结果错在哪些数据。这个过程能帮你识别模板中每一行的存在价值,比背诵注释有效得多。
8. 再补一个容易忽略的思考:为什么底数和模数要先取模
快速幂模板里第一行通常写着 a %= m。初学时我总觉得没必要,因为如果底数已经小于模数了,取模等于没变化。但后来才意识到,这条防御式语句的价值在于处理更大的问题。
来看一个场景:如果底数本身是某个表达式的计算结果,比如几百项相乘之后的结果,那么它可能远远超出 long long 的表示范围。此时必须在运算中不断取模,但假如你写的是 a *= a 再去取模,在乘起来之后、取模之前的那一刻,中间结果已经溢出了,后续无论怎么取模都是错的。所以正确的习惯是每一步乘法都用 (long long)a * b % m 这样“先乘再模”的写法,并且如果编译器允许,先对 a 统一进行 a %= m。
这里背后是同余的一个核心性质:模运算对加法和乘法可以提前穿透。
[
(a + b) \bmod m = ((a \bmod m) + (b \bmod m)) \bmod m
]
[
(a \cdot b) \bmod m = ((a \bmod m) \cdot (b \bmod m)) \bmod m
]
正因为这两条穿透性质,我们才可能在算法过程中随时压缩中间值,而不需要等最终一次取模。这也是“为什么计算过程中到处取模答案仍然正确”的数学依据。
快速幂里走一遍:每一步 result * a 后立刻取模,每一步 a * a 后立刻取模,中间每一层的值都被压缩在 ([0, m)) 范围内。这大大避免了中间过程溢出,也让大整数的处理成为可能。可以说,没有同余的穿透性,就没有工程意义上的大整数模幂。
这个思想不止用于快速幂。动态规划里统计某数对 (m) 取模的各个余数出现次数时,也依赖同样的穿透性。所以当你看到公式里到处是 % m,不用觉得啰嗦,这是一套保证“压缩状态不失真”的基本功。
我在实际写代码时,还会刻意避免使用同一个变量名在不同阶段存放不同含义的值,尤其在快速幂这种循环体很短的函数里。虽然模板短小精悍,但可读性和正确性永远比极致压缩更重要。如果别人要维护你的代码,看到一个 a 在循环里不断变化,可能会很困惑。
最后说点个人经验:数论基础这部分,不要试图一次性从定义跳到大招。先把整除训练成直觉,再让同余在自己的思考里变成“余数哈希”,最后让快速幂成为手到擒来的工具。这三层的顺序不能颠倒。当初我跳过同余直接去背快速幂模板,结果连模指数为什么要减一这种关键步骤都理解不了,只能死磕题解。后来重新回去把同余性质逐条推了一遍,很多题突然就变得理所当然。
如果你现在也正卡在“模板全会,一题就废”的阶段,不妨也退回到这三样基本概念的源头,找一个周末,拿一支笔,把每一个式子从定义出发完整推理一遍。磨刀不误砍柴工,这个收益真的是长期的。
