先说个我自己的经历。几年前我刚开始刷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) 万年。这当然不行。
程序优化的核心思路几乎是通用的:要么减少运算次数,要么让每一步运算执行得更快。在求幂问题里,减少运算次数的钥匙就是“复用中间结果”。
