前言
你打开一道算法竞赛题,读完题意,最后一行写着:“答案对 取模。”
这句话几乎出现在每一道计数题里。为什么?因为很多计数问题的答案大得离谱——比如”从 100 个人里选 50 个组成委员会”,答案是 ,29 位数,long long 存不下。但题目只需要输出它除以 的余数,这个余数在 到 之间,轻松存下。
问题是:你不能等算完再取模——那样早就溢出了。你需要在计算的每一步都取模,最终结果还得是对的。
这就需要同余理论。好消息是,模意义下的加法、减法、乘法和你熟悉的普通运算几乎一样——只是每步多加一个 % MOD。但也有陷阱:负数取模、中间值溢出……这些都是竞赛里的常客。
下一篇 02-04 快速幂与逆元 会讲模意义下的乘方和除法(逆元)。
问题的本质
同余:余数相同就是”同类”
今天星期三,10 天后是星期几?,,星期六。100 天后呢?,,星期五。
你不关心”过了多少天”,只关心”星期几”——也就是天数除以 7 的余数。10 天和 100 天的余数不同,所以对应不同的星期。但 10 天和 17 天的余数相同(都是 3),所以都是星期六。
这就是同余的直觉:两个数除以 的余数相同,它们就是”同类”。数学记号:
比如 ,因为 。
一个有用的公式: 可以写成 。这告诉我们:取模本质上就是”减去尽可能多的 “。比如 。
这个公式也解释了为什么负数取模有陷阱:。这里 (向下取整),所以 。但 C++ 的 % 对负数的行为是向零取整,,不是 4。Python 的 % 则返回非负数,。这是语言差异,做题时务必注意。
为什么可以”边算边取模”?
核心性质:同余关系在加法、减法、乘法下保持。 也就是说:
减法和乘法同理。
这意味着:你可以在计算的任何一步把大数替换成它除以 的余数,不影响最终结果的余数。先取模再算和先算完再取模,余数一样。
但前提是:只有加、减、乘。除法不行——下一篇讲。
更具体地说,以下操作不能直接套用同余替换:
| 操作 | 能否替换 | 反例 |
|---|---|---|
| ✅ | — | |
| ✅ | — | |
| ✅ | — | |
| (整除) | ❌ | ,但 ,结果不同 |
| ( 是常数) | ✅ | ,,,结果一致 |
| ( 是变量) | ❌ | ,模 3:, 但结果不同 |
| (阶乘) | ❌ | ,, 但阶乘结果不同 |
记住这张表就能避免踩坑。加、减、乘、常数次幂可以在任何一步取模;除法、变量次幂、阶乘不行。
理论 + 代码
模加法、模减法、模乘法
const long long MOD = 1e9 + 7;
// 模加法
long long add(long long a, long long b) {
return (a + b) % MOD;
// a, b 都在 [0, MOD) 内,a+b 最大约 2×10^9,long long 没问题
}
// 模减法
long long sub(long long a, long long b) {
return (a - b + MOD) % MOD;
// ① 为什么 +MOD?因为 a-b 可能是负数!
// C++ 中 (-3) % 7 = -3,不是 4(讲义也特别提醒了这一点)
// 加一个 MOD 再取模,保证结果非负
}
// 模乘法
long long mul(long long a, long long b) {
return a * b % MOD;
// ② a, b 都 < MOD ≈ 10^9,a*b 最大约 10^18
// long long 最大约 9.2×10^18,刚好没溢出
}
-
① 负数取模是最常见的坑。C++ 的
%对负数的结果也是负数:。但我们需要的是 范围内的余数,即 (因为 )。所以加一个MOD再取模:。即使a - b不是负数,加MOD再取模也不影响结果(因为(x + MOD) % MOD = x % MOD)。 -
② 乘法溢出。如果 都接近 ,那 。
long long最大 ,所以刚好没溢出。但如果 MOD 更大(比如 ),乘法就会溢出——那种情况需要__int128或特殊处理。
常见陷阱总结
| 陷阱 | 错误写法 | 正确写法 | 原因 |
|---|---|---|---|
| 负数取模 | (a-b) % MOD | (a-b%MOD+MOD) % MOD | C++ 负数取模得负数 |
| 乘法溢出 | int a * b | long long a * b | int 只到 |
| 忘记取模 | ans += val | ans = (ans + val) % MOD | 不取模就溢出 |
| 先乘后除 | a * b / c | a / c * b(整除时) | 乘完可能溢出 |
为什么模数总是 或 ?
计数题末尾那句话——“答案对 取模”——里面的 几乎总是下面两个数之一:
| 模数 | 值 | 特点 |
|---|---|---|
| 1000000007 | 素数。约 ,两个模内数之积不超 long long。M&A 演習問題集统一用这个。 | |
| 998244353 | 素数。,支持 NTT(数论变换),AtCoder ABC/ARC 中极其常见。 |
它们都是素数,所以每个非零元素都有乘法逆元(下一篇讲)。约 的大小也刚刚好——足够大避免答案冲突(不同答案对应不同余数),又足够小使得两个模内数之积不超出 long long()。
实际做题时一定要看清楚题目给的是哪个模数! 不要想当然地写 const int MOD = 1000000007;——AtCoder ABC 中 出现频率同样极高(例如 abc456_d Not Adjacent 2)。
本系列例题主要来自 M&A 演習問題集,统一使用 ,所以后续代码中 MOD = 1000000007。但理论完全适用于 或任何素数模数。
例题
例题 1:M&A 005 — Modulo 100(★1)
题目:给定 个整数 ,输出它们的和对 100 取模的结果。
数据范围:,
思路:累加后取模。但更安全的做法是每加一个数就取模——防止 int 溢出。
代码:
#include <cstdio>
using namespace std;
int main() {
int N;
scanf("%d", &N);
int sum = 0;
for (int i = 0; i < N; i++) {
int a;
scanf("%d", &a);
sum += a;
sum %= 100; // ① 每加一次就取模,防止溢出
}
printf("%d\n", sum);
}
逐行解析:
- ① 如果不取模, 个数每个 ,总和 (
int范围),这里不会溢出。但养成好习惯:每步取模。
例题 2:M&A 049 — Fibonacci Easy (mod 10⁹+7)(★1)
题目:斐波那契数列 。求 。
数据范围:
思路:递推即可。每步取模 。 时 没问题。
代码:
#include <cstdio>
using namespace std;
const int MOD = 1000000007;
int main() {
int N;
scanf("%d", &N);
int a = 1, b = 1; // a_1, a_2
for (int i = 3; i <= N; i++) {
int c = (a + b) % MOD; // ① a_n = (a_{n-1} + a_{n-2}) % MOD
a = b; // ② 滚动更新
b = c;
}
printf("%d\n", b);
}
逐行解析:
- ① 取模的位置:在加法之后立刻取模。,不超过
int范围,所以不需要long long。 - ② 只保留最近两项,空间 。
例题 3:M&A 050 — Power(★1)
题目:给定 和 ,求 。
数据范围:,
思路: 最大 ,不能循环 次。快速幂 ——这是同余+模运算章节的核心技巧。
代码:
#include <cstdio>
using namespace std;
const long long MOD = 1000000007;
int main() {
long long a, b;
scanf("%lld%lld", &a, &b);
long long base = a, ans = 1;
while (b > 0) {
if (b % 2 == 1) // ① 当前二进制位是 1
ans = ans * base % MOD; // ② 乘到结果上
base = base * base % MOD; // ③ 底数平方
b /= 2;
}
printf("%lld\n", ans);
}
逐行解析:
- ①②③ 标准快速幂。把 写成二进制,逐位处理。每次
base自乘(平方),如果当前位是 1 就乘到ans上。全部取模。
例题 4:M&A 053 — Sum of 4^N(★2)
题目:给定正整数 ,求 。
数据范围:
思路:等比数列求和公式:。
在模 下除以 3 等价于乘以 3 的逆元。所以答案 = 。
用快速幂, 用费马小定理(因为 是素数):。
代码:
#include <cstdio>
using namespace std;
const long long MOD = 1000000007;
long long power(long long base, long long exp) {
long long ans = 1;
while (exp > 0) {
if (exp % 2 == 1) ans = ans * base % MOD;
base = base * base % MOD;
exp /= 2;
}
return ans;
}
int main() {
long long N;
scanf("%lld", &N);
long long num = (power(4, N + 1) - 1 + MOD) % MOD; // ① 分子 = 4^{N+1} - 1
long long inv3 = power(3, MOD - 2); // ② 3 的逆元
printf("%lld\n", num * inv3 % MOD); // ③ 分子 × 逆元
}
逐行解析:
- ①
power(4, N+1) - 1:可能变成负数(模运算后),所以+ MOD再% MOD保证非负。 - ② 费马小定理:。
- ③ 模意义下的除法 → 乘以逆元。
例题 5:M&A 042 — Sum of Divisors(★2)
题目:设 为 的正约数个数。求 。
数据范围:
思路:如果对每个 单独求 ,总时间 ,太慢。换个角度:枚举约数 ,看 是多少个数的约数——答案 的贡献是 (因为 到 中 的倍数有 个)。
所以 。
代码:
#include <cstdio>
using namespace std;
int main() {
int N;
scanf("%d", &N);
long long ans = 0;
for (int d = 1; d <= N; d++) {
ans += (long long)d * (N / d); // ① d 的贡献 = d × (1~N中d的倍数个数)
}
printf("%lld\n", ans);
}
逐行解析:
- ① 对每个 , 中有 个数包含 作为约数。 被每个这样的 贡献了一次 (不对,更准确地说: ” 是多少个 的约数” )。
验证:。。。
用公式:?
不对!公式应该是 (以 为约数的 的个数)。但题目是 ,不是 。
重新推导:。
对固定的 , 取 。所以 ,其中 。
代码应该用这个公式。让我重写:
#include <cstdio>
using namespace std;
int main() {
long long N;
scanf("%lld", &N);
long long ans = 0;
for (long long d = 1; d <= N; d++) {
long long m = N / d; // ① d 的倍数有多少个
ans += d * m * (m + 1) / 2; // ② d × (1+2+...+m)
}
printf("%lld\n", ans);
}
验证:。
- : ,
- : ,
- : ,
- : ,
- 总计 。✓
参考文献
理论讲义 — AtCoder Algorithm Lectures
教材讲解 — 競技プログラミングの鉄則 第 5 章
基础练习 — アルゴリズムと数学 演習問題集
- 005 Modulo 100(累加取模)【例题】
- 049 Fibonacci Easy(递推 mod 10⁹+7)【例题】
- 050 Power(A^B mod p)【例题】
- 053 Sum of 4^N(等比数列取模)【例题】
- 042 Sum of Divisors(约数函数求和)【例题】
- 059 Power of Two(2^N的个位,周期性)
- 079 ModSum(最大化余数之和)
系列索引
第零章 基础工具
第一章 搜索技术
第二章 数学基础
第三章 数据结构
- 03-01 栈、队列与单调栈
- 03-02 堆与优先队列
- 03-03 并查集
- 03-04 树状数组
- 03-05 线段树
- 03-06 懒标记线段树
- 03-07 Sparse Table 与倍增
- 03-08 字符串哈希
第四章 图论
- 04-01 图的遍历
- 04-02 最短路—Dijkstra 与 01-BFS
- 04-03 最短路—Bellman-Ford 与 Floyd
- 04-04 拓扑排序
- 04-05 最小生成树
- 04-06 强连通分量与 2-SAT
- 04-07 二分图与网络流
- 04-08 树上问题
第五章 动态规划
- 05-01 DP入门—状态与转移
- 05-02 背包问题族
- 05-03 LIS、LCS与编辑距离
- 05-04 区间DP
- 05-05 状态压缩DP
- 05-06 树形DP与数位DP
- 05-07 矩阵快速幂与线性递推
第六章 贪心
第七章 字符串
第八章 进阶