题单 · 习题解析

洛谷 P1082 [NOIP 2012 提高组] 同余方程

★★ 关键一步是读题面那句「保证一定有解」——它等价于 **gcd(a,b) = 1**,**不等价于 b 是质数** ⇒ 费马小定理那条路走不通,只能 exgcd;★★★ 而「顺手挑一个质数当模数」是所有人的肌肉记忆 ⇒ **b 取质数那一档,费马版是精确的 0**(同时也是它的正确性证明),随机档 214、b 合数档 **290 而不是 300**(有 10 轮恰好蒙对 ⇒ [「它算的是另一个量」推不出「它一定不同」](/sol/p3959/));★★★ 全用 int **会挂,但不挂在你以为的那一步**:exgcd 回代的系数满足 |x| ≤ b/2(随机二十万组里 |系数|/max(a,b) 最大 **0.500**,一位不差)⇒ 系数本身塞得进 int(余量 7.4%,和 [P1075](/sol/p1075/) 一字不差),**真正越界的是最后那句 `(x % b + b) % b`**(x%b 可到 b−1 ≈ 2×10⁹,再加 b 就 4×10⁹)—— 顶格触发 28 ≡ 抓获 28;★ 题面三档里**两档是给暴力的**(40 分 / 60 分,b = 5×10⁷ 扫满 115 ms),⚠⚠ 而这张表是第二版:**给「找到就 break」的暴力计时,必须先把答案逼到最远处**(第一版顺手挑的 a 逆元很小,量出 53 ms,量的是运气好的那一次);★★ 顶格没法用暴力当参照物 ⇒ 换**验证器**(a·x ≡ 1 且 1 ≤ x < b,解唯一)

原题:洛谷 P1082出自 第 42 章 快速幂与取模 的题单题面本地存档:2026-09-02
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P1082,日期见页头。两边不一致时信原站。

题目描述

求关于 x 的同余方程 a x ≡ 1 (mod b)最小正整数解

输入格式

一行,包含两个整数 ab,用一个空格隔开。

输出格式

一个整数 x₀,即最小正整数解。输入数据保证一定有解。

数据规模与约定

  • 对于 40% 的数据,2 ≤ b ≤ 10³
  • 对于 60% 的数据,2 ≤ b ≤ 5×10⁷
  • 对于 100% 的数据,2 ≤ a, b ≤ 2×10⁹

时限 1 秒,内存 125 MB(128000 KB)。(题目来源:NOIP 2012 提高组)

输入输出样例

输入

3 10

输出

7

3 × 7 = 21 = 2 × 10 + 13 × 7 ≡ 1 (mod 10),而 7 是最小的那个。

1第 ① 版:从 1 试到 b−1 —— 它值 60 分

p1082Brute.cpp第 ① 版:逐个试 —— 参照物,稳拿 60 分
// 第 ① 版:从 1 试到 b−1,看谁满足 a·x ≡ 1 —— 参照物,而且它值 60 分
//
// ★ 题面三档:
// 40%:b ≤ 10³ ⇒ 一千次,秒过;
// 60%:b ≤ 5×10⁷ ⇒ 五千万次乘法取模,本机几百毫秒 ⇒ **它拿得到 60 分**;
// 100%:b ≤ 2×10⁹ ⇒ 二十亿次 ⇒ 超时。
// ⇒ 又一次「题面的分档是出题人递过来的工具」。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int main() {
ll a, b;
if (scanf("%lld %lld", &a, &b) != 2) return 0;
a %= b;
for (ll x = 1; x < b; x++)
if (a * x % b == 1) { printf("%lld\n", x); return 0; }
printf("-1\n"); // 题面保证有解,走不到这里
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 题面三档,两档是给暴力的(独占实测,扫满整段)
题面的档 暴力顶格 本机 交上去
40%:b ≤ 10³ 一千次 不到 0.01 毫秒 40 分
60%:b ≤ 5×10⁷ 五千万次 约 115 毫秒 60 分
100%:b ≤ 2×10⁹ 二十亿次(外推) 约 4.6 秒 TLE

⚠⚠ 而这张表是第二版:第一版顺手写 a = 999999937 % b,量出 60% 档只要 53 毫秒 —— 可暴力是「找到就 break」的,那个 a 的逆元恰好很小,量的是运气好的那一次。 ⇒ ★★ 给「找到就停」的暴力计时,必须先把答案逼到最远处 (这里的做法是反着造:先定 x₀ = b − 2,再解出以它为逆元的 a)。

2★★ 关键一步:题面那句「不保证是质数」

★★★ 逆元有两条路,而这道题只走得通一条

ax ≡ 1 (mod b) 求的就是 a 在模 b 下的逆元。两条路:

前提 这道题
费马小定理:a⁻¹ = a^(b−2) mod b b 必须是质数 ✗ 题面一个字没提质数
★ 扩展欧几里得:解 a·x + b·y = 1 只要 gcd(a, b) = 1 ★ 题面「保证一定有解」正是这句

⇒ 题面写的是「输入数据保证一定有解」—— 那等价于 gcd(a, b) = 1不等价于 b 是质数。 ⇒ 只能用 exgcd(第 40 章第 13 步那份,原样搬过来)。

⚠ 最后一步不能省:exgcd 给的 x 可能是负数 ⇒ ((x % b) + b) % b 才是最小正整数解

p1082.cpp★ 正解:exgcd —— 顶格约 0.055 微秒
// P1082 同余方程 —— 正解:扩展欧几里得求逆元
//
// ★★ 题面这一句是全部的分水岭:**b 不保证是质数。**
// · b 是质数 ⇒ 费马小定理,逆元 = a^(b−2) mod b,一次快速幂就完了;
// · b 不保证 ⇒ **费马用不了**,只能 exgcd([第 40 章第 13 步](/ch/40-gcd-euclid/)那份)。
//
// ax ≡ 1 (mod b) ⇔ 存在 y 使 a·x + b·y = 1。
// 而 exgcd 解的正是 a·x + b·y = gcd(a, b) —— 题面保证有解 ⇒ gcd(a, b) = 1 ⇒ 直接用。
//
// ⚠ 最后一步不能省:exgcd 给的 x 可能是负数(也可能大于 b),
// 而题目要的是**最小正整数解** ⇒ ((x % b) + b) % b。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
/** 返回 gcd(a,b),并让 a·x + b·y = gcd */
static ll exgcd(ll a, ll b, ll& x, ll& y) {
if (!b) { x = 1; y = 0; return a; }
ll x1, y1;
ll g = exgcd(b, a % b, x1, y1);
x = y1;
y = x1 - a / b * y1;
return g;
}
int main() {
ll a, b, x, y;
if (scanf("%lld %lld", &a, &b) != 2) return 0;
exgcd(a, b, x, y);
printf("%lld\n", (x % b + b) % b);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

3⚠⚠ 费马那条路:它在什么时候对,以及顺手写的生成器怎么把它藏起来

p1082Fermat.cpp✗ 错法一:费马小定理 —— 只在 b 是质数时对
★★★ 「顺手挑一个质数当模数」是所有人的肌肉记忆 —— 而它把这个 bug 打成精确的 0
档位(每档 300 轮) ✗ 费马小定理被抓
0 顺手写的(a, b ≤ 1000 随机) 214
⚠⚠ 1 b 取质数 精确的 0
2 b 取合数 290
3 顶格(10⁹ ~ 2×10⁹ 280

★★ 档 1 那个 0 是能证的(费马小定理在 b 是质数、b ∤ a 时就是对的), 所以它同时是这条路的正确性证明那个 0 的自检和 P1439 那次同形:造一档满足题面之外的额外条件,两件事一起干完)。

⚠ 而档 2 那个 290 而不是 300 也值得停一下:b 是合数时费马不是必错, 它有 10 轮恰好蒙对a^(b−2) 碰巧就等于逆元) ⇒ 「它算的是另一个量」推不出「它一定和正解不同」 的又一次。

★ 顺带量一个能解释「为什么这个坑这么常见」的数: 2 ≤ b ≤ 1000 里有 168 个质数(16.8%) —— 而人挑模数时挑到质数的概率远高于 16.8%, 因为 10⁹+7998244353 这些已经长在肌肉记忆里了

4★★★ 全用 int 会不会挂 —— 会,但不是挂在你以为的那一步

p1082Int.cpp⚠ 全用 int:exgcd 那一段是安全的,挂的是最后一句
★★★ 把 int 版的每一步都用 long long 复算一遍,答案分成两半
档位(每档 300 轮) int 在 exgcd 内部越界 int 在最后那句归正越界 ✗ int 版真被抓
0 顺手写的 0 0 0
1 b 取质数 0 0 0
2 b 取合数 0 0 0
3 顶格 0 28 28

★★★ exgcd 那一段一次都没越界,而且这不是运气 —— 回代出来的系数满足 |x| ≤ b/2|y| ≤ a/2: 随机二十万组顶格数据里,|系数| / max(a,b) 的最大值是 ★ 0.500(正好 1/2,一位不差)。 ⇒ 题面 a, b ≤ 2×10⁹int 上限 2 147 483 647系数本身塞得进 int,余量 7.4% (和 P1075 那条一字不差 —— 那是 int 的性质,不是哪道题的性质)。

★★ 真正挂掉的是最后那句 (x % b + b) % bx % b 最大可以到 b − 1 ≈ 2×10⁹再加一个 b 就是 4×10⁹ —— 而 int 只到 2.1×10⁹。 ⇒ 顶格那一档「会越界的轮数」和「真被抓的轮数」一个不差:28 ≡ 28

⇒ ★★ 这是「说清楚一个 bug 算了什么,比说它错了有用得多」的一个变形: 说清楚它挂在哪一行,比说「它会溢出」有用得多 —— 前者告诉你「把归正那一句写成 ((x % b) + b) % b 并用 long long」就够了。

5⚠ 另外两个错法,以及顶格那一档拿什么当参照物

p1082NoPos.cpp✗ 错法二:exgcd 出来的 x 直接输出(可能是负数)
p1082Swap.cpp✗ 错法三:回代那两行的 x、y 写反了
p1082Gen.cpp生成器(四档:顺手 / b 质数 / b 合数 / 顶格)
★ 「没把负解调正」那一层,四个档都是一个不差
档位(每档 300 轮) exgcd 给的 x < 1 的轮数 ✗ 直接输出 x 被抓
0 顺手写的 144 144
1 b 取质数 144 144
2 b 取合数 141 141
3 顶格 152 152

★ 四格全等 —— 而这次的「≡」是能证的:x < 1 时输出必然不是最小正整数解, x ≥ 1x 本来就已经在 [1, b) 里(|x| ≤ b/2)。

★ 而「回代写反」那一版四个档分别被抓 297 / 298 / 298 / 300 —— 它解的是另一个方程,几乎每一组都错。官方样例 3 10 一测就死(它给 1)。

★★ 顶格那一档没法用暴力当参照物 —— 换验证器

b 到 2×10⁹ 时暴力要跑四秒多,做不了 300 轮的参照物。 ⚠ 但这道题根本不需要参照物

x 是答案 ⟺ a·x ≡ 1 (mod b)1 ≤ x < b,而满足这两条的 x 唯一

⇒ 写一个验证器(一次乘法、一次取模)就够了,比对拍便宜好几个数量级 —— 顶格 300 轮 0 轮不通过。 ⇒ 和第 31 章 B3644 那条是一对:那道题是「答案不唯一 ⇒ 只能写验证器」, 这道题是「答案唯一,但验证比重算便宜」⇒ 两种情形都指向同一个动作。

6★ 哪一版就已经能过了

p1082Count.cpp本页所有数字的出处
// P1082 的度量程序:./p1082Count csv (本页的数字都出自它)
//
// int : ★★★ 全用 int 的那一版**到底哪一步溢出** —— 把它的每一步都拿 long long 复算一遍。
// ⚠ 结论和直觉不一样:**exgcd 的中间系数是安全的,挂的是最后那句归正。**
// coef : ★ 上一条的证据:exgcd 一路回代出来的系数,绝对值最大是多少(拿 max(a,b) 比一比)。
// prime : ⚠ 随机挑一个 b,它是质数的概率 —— 这就是「费马版被顺手写的生成器盖住」的量。
// verify: ★ 顶格那一档没法用暴力当参照物 ⇒ 改用**验证器**:a·x ≡ 1 (mod b) 且 1 ≤ x < b。
// (满足这两条的 x 唯一,所以验证器和对拍一样强,而且便宜得多。)
// ms : 题面三档 × 两种写法的秒表。
//
// ⚠ 这里**复刻**了 p1082Gen.cpp 的档位逻辑(同一个 mt19937、同一个种子公式)。
// ⚠ 秒表用 steady_clock 在进程内量;模数取运行期的值([P1965 那一跤](/sol/p1965/):
// 编译期常量会让 -O2 把取模换成乘法)。
#include <bits/stdc++.h>
#include <chrono>
using namespace std;
using namespace std::chrono;
typedef long long ll;
static const ll INT_TOP = 2147483647LL;
static ll gcdll(ll a, ll b) { while (b) { ll t = a % b; a = b; b = t; } return a; }
static bool isPrime(ll x) { if (x < 2) return false; for (ll i = 2; i * i <= x; i++) if (x % i == 0) return false; return true; }
/** 标准 exgcd(long long),顺手记下这一路系数的最大绝对值 */
static ll maxCoef;
static ll exgcd(ll a, ll b, ll& x, ll& y) {
if (!b) { x = 1; y = 0; return a; }
ll x1, y1;
ll g = exgcd(b, a % b, x1, y1);
x = y1; y = x1 - a / b * y1;
maxCoef = max(maxCoef, max(llabs(x), llabs(y)));
return g;
}
/** 把 int 版的每一步都用 long long 复算:分别看 exgcd 内部和最后那句会不会越界 */
static bool exgcdIntBad(ll a, ll b, ll& x, ll& y) {
if (!b) { x = 1; y = 0; return false; }
ll x1, y1;
bool bad = exgcdIntBad(b, a % b, x1, y1);
ll q = a / b;
if (llabs(q * y1) > INT_TOP) bad = true;
ll ny = x1 - q * y1;
if (llabs(ny) > INT_TOP) bad = true;
x = y1; y = ny;
return bad;
}
static void gen(unsigned seed, int mode, ll& a, ll& b) {
mt19937 rng(seed * 1000003u + 20260902u);
while (true) {
if (mode == 0) { a = 2 + (ll)(rng() % 999u); b = 2 + (ll)(rng() % 999u); }
else if (mode == 1) { b = 2 + (ll)(rng() % 999u); if (!isPrime(b)) continue; a = 2 + (ll)(rng() % 999u); }
else if (mode == 2) { b = 4 + (ll)(rng() % 997u); if (isPrime(b)) continue; a = 2 + (ll)(rng() % 999u); }
else { a = 1000000000LL + (ll)(rng() % 1000000000u); b = 1000000000LL + (ll)(rng() % 1000000000u); }
if (b >= 2 && gcdll(a, b) == 1) break;
}
}
static double med3(double a, double b, double c) { return max(min(a, b), min(max(a, b), c)); }
int main(int argc, char** argv) {
bool csv = (argc > 1 && string(argv[1]) == "csv");
/* ① 四个档:int 在 exgcd 内部越界的轮数 / 在最后那句越界的轮数 / x 是负数的轮数 / 验证器 */
int badIn[4] = {0, 0, 0, 0}, badTail[4] = {0, 0, 0, 0}, negX[4] = {0, 0, 0, 0}, verifyBad[4] = {0, 0, 0, 0};
ll coefOverMax[4] = {0, 0, 0, 0};
for (int mode = 0; mode < 4; mode++)
for (int s = 1; s <= 300; s++) {
ll a, b, x, y; gen((unsigned)s, mode, a, b);
maxCoef = 0;
exgcd(a, b, x, y);
if (maxCoef > coefOverMax[mode]) coefOverMax[mode] = maxCoef;
if (x < 1) negX[mode]++;
ll xi, yi;
if (exgcdIntBad(a, b, xi, yi)) badIn[mode]++;
ll r = xi % b; // int 版最后那句:(x % b + b) % b
if (llabs(r) > INT_TOP || llabs(r + b) > INT_TOP) badTail[mode]++;
ll ans = (x % b + b) % b; // 正解
if (!(ans >= 1 && ans < b && (__int128)a % b * ans % b == 1)) verifyBad[mode]++;
}
/* ② exgcd 的系数到底有多大:拿 max(a,b) 当尺子,看比值 */
double coefRatio = 0;
{
mt19937 rng(20260902u);
for (int i = 0; i < 200000; i++) {
ll a = 1 + (ll)(rng() % 2000000000u), b = 1 + (ll)(rng() % 2000000000u);
if (b < 2 || gcdll(a, b) != 1) continue;
ll x, y; maxCoef = 0; exgcd(a, b, x, y);
coefRatio = max(coefRatio, (double)maxCoef / (double)max(a, b));
}
}
/* ③ 随机 b ≤ 1000 里质数占多少 */
int primeCnt = 0;
for (int b = 2; b <= 1000; b++) if (isPrime(b)) primeCnt++;
/* ④ 秒表:暴力在三档顶格 / 正解 */
// ⚠⚠ 暴力是「找到就 break」的 ⇒ **必须让它扫满整段才算最坏**。
// 第一版顺手写 a = 999999937 % b,那个 a 的逆元恰好很小,量出来 53 毫秒 ——
// 量的是「运气好的那一次」。这里改成:先挑一个很大的目标 x₀ = b − 2,
// 再反解出以它为逆元的 a(a ≡ x₀⁻¹),于是循环一定要走到 b − 2。
static volatile ll VB1 = 1000, VB2 = 50000000LL;
double msB40, msB60, projB100, usFast;
auto worstA = [](ll b) { // 返回一个 a,使 a 的逆元 = b − 2
ll x, y; exgcd(b - 2, b, x, y);
return (x % b + b) % b;
};
{
double t[3];
for (int r = 0; r < 3; r++) {
ll b = VB1, a = worstA(b);
auto t0 = steady_clock::now();
volatile ll got = 0;
for (ll x = 1; x < b; x++) if (a * x % b == 1) { got = x; break; }
(void)got;
t[r] = duration<double, milli>(steady_clock::now() - t0).count();
}
msB40 = med3(t[0], t[1], t[2]);
}
{
ll b = VB2, a = worstA(b); // 让它扫满整段
auto t0 = steady_clock::now();
volatile ll got = 0;
for (ll x = 1; x < b; x++) if (a * x % b == 1) { got = x; break; }
(void)got;
double ms = duration<double, milli>(steady_clock::now() - t0).count();
msB60 = ms;
projB100 = ms * (2000000000.0 / 50000000.0);
}
{
auto t0 = steady_clock::now();
// ⚠ a 和 b 都得随循环变,否则 -O2 会把整次调用提到循环外([P1965 那一跤](/sol/p1965/))
static volatile ll VA0 = 1999999973LL, VB0 = 1999999943LL;
volatile ll acc = 0;
for (int r = 0; r < 20000; r++) {
ll a = VA0 - r, b = VB0 - (r & 1), x, y;
exgcd(a, b, x, y);
acc = acc + ((x % b + b) % b);
}
usFast = duration<double, micro>(steady_clock::now() - t0).count() / 20000.0;
}
if (csv) {
for (int i = 0; i < 4; i++) printf("badIn%d,%d\n", i, badIn[i]);
for (int i = 0; i < 4; i++) printf("badTail%d,%d\n", i, badTail[i]);
for (int i = 0; i < 4; i++) printf("negX%d,%d\n", i, negX[i]);
for (int i = 0; i < 4; i++) printf("verifyBad%d,%d\n", i, verifyBad[i]);
printf("coefRatio,%.3f\n", coefRatio);
printf("primeCnt,%d\n", primeCnt);
printf("primePct,%.1f\n", primeCnt * 100.0 / 999.0);
printf("msB40,%.2f\n", msB40);
printf("msB60,%.0f\n", msB60);
printf("projB100,%.0f\n", projB100);
printf("usFast,%.3f\n", usFast);
return 0;
}
const char* NAME[4] = {"顺手写的(≤1000)", "b 取质数", "b 取合数", "顶格(10⁹~2×10⁹)"};
printf("① 四个档各 300 轮\n");
for (int i = 0; i < 4; i++)
printf(" 档 %d %-20s int 在 exgcd 内部越界 %3d 轮 / **在最后那句归正越界 %3d 轮** / x < 1 的 %3d 轮 / 验证器不通过 %d 轮\n",
i, NAME[i], badIn[i], badTail[i], negX[i], verifyBad[i]);
printf(" ⇒ ★★★ int 挂的地方**不在 exgcd 里**,在 `(x %% b + b) %% b` 那一句:\n");
printf(" x %% b 最大可到 b−1 ≈ 2×10⁹,再加一个 b 就是 4×10⁹ —— 而 int 只到 2.1×10⁹\n");
printf("\n② exgcd 的系数有多大:随机二十万组里,|系数| / max(a,b) 的最大值 = %.3f\n", coefRatio);
printf(" ⇒ 系数被 max(a, b) 界住 ⇒ 题面 a, b ≤ 2×10⁹ 时它们**本身**是塞得进 int 的(余量 7.4%%)\n");
printf("\n③ 2 ≤ b ≤ 1000 里质数有 %d 个(%.1f%%)—— 顺手挑一个 b,它是质数的概率\n",
primeCnt, primeCnt * 100.0 / 999.0);
printf("\n④ 秒表(独占实测;时限 1000 ms)\n");
printf(" 暴力 b = 10³(40%% 档顶格,扫满) %.2f ms ⇒ 稳拿 40 分\n", msB40);
printf(" 暴力 b = 5×10⁷(60%% 档顶格,扫满)%.0f ms ⇒ 稳拿 60 分\n", msB60);
printf(" 暴力 b = 2×10⁹(100%% 档,外推) %.0f ms ⇒ ✗ 超时\n", projB100);
printf(" 正解 exgcd(顶格,两万次取平均) %.2f 微秒 ⇒ ★ AC\n", usFast);
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
只有 exgcd 那一版能拿满分;而官方样例把三个错法全挡住了
版本 顶格 交上去
① 逐个试 外推 约 4.6 秒 60 分
✗ 费马小定理 快,但 b 是合数时答案错 WA
✗ 不调正负解 / 回代写反 —— WA(样例就死)
⚠ 全用 int 挂在 (x % b + b) % b 那一句 WA(顶格 28/300)
★ exgcd + long long 约 0.055 微秒 AC

★ 样例 3 10三个错法一测就死(费马给 1、不调正给 −3、回代写反给 1)—— ⚠ 而唯一放过的正是 int 那一版(b = 10,离 2³¹ 差八个数量级)。 ⇒ 又一次「官方样例是个『一测就死』的过滤器」它筛掉的是「每一组都错」的,放过的是「只在顶格才错」的。