「建 aᵢ 个棚剩 bᵢ 头牛」即同余式 x≡bᵢ (mod aᵢ);维护不变量 x≡v (mod m) 逐条合并,由 x=v+mt 解出 t≡(b−v)·m⁻¹ (mod a),用 pow 求模逆元,答案取最小正剩余。
OJ: roj
题目 ID: 3100
难度:普及+/提高-
标签:数论中国剩余定理同余方程模逆元python
创建: 2026-10-01 17:02
更新: 2026-10-07 13:50
形式化题目
给定
其中
题面没有保证
样例的三条条件为
正解
思路
朴素做法与它的瓶颈。 最直接的写法是从
| 数据 | 答案 | ||
|---|---|---|---|
input1 |
1 | ||
input3 |
3 | ||
input10 |
10 |
光是 input3 就要循环两千亿次。更要紧的是,枚举出来的候选几乎全是废信息:
问题的本质在于,候选虽然有
关键观察:若干条同余式的解集仍然是一个剩余类。 换句话说,
「同时满足
怎么合并:把不变量展开代入。 设已合并的部分等价于
互质性让这一步一步就能解出。
取
这样取代表元不丢解。
为什么求逆元这一步能直接调用库函数。 解 pow(m, -1, a) 就是它的封装,直接把 t = (b - v) * pow(m, -1, a) % a。
这里的 % a 还顺手解决了一个边界:input10 就是这种情形——
条件从 % 会得出负数、把 ((..)%a+a)%a;
Python 的 % 总是返回非负余数,所以代码里不需要额外分支。
样例逐步核对。 三条条件
| 步 | 新条件 |
合并前 |
要解的方程 | 解出 |
合并后 |
|---|---|---|---|---|---|
| 1 | |||||
| 2 | |||||
| 3 |
读表时注意第 2 步:解出
三个容易踩的边界。 其一,合并出来的是最小非负解,若所有条件在 print(v or m)。
其二,input10 的每条都满足 % 直接返回 ((..)%a+a)%a。
其三,input9 第一条就是 1 0):
pow(m, -1, 1) 定义为
代码
Python 版:
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-01 17:03
# update_at: 2026-10-01 17:10
import sys
def merge(v: int, m: int, a: int, b: int) -> tuple[int, int]:
"""并入条件 x ≡ b (mod a):由 x = v + m*t 解出 t,返回新剩余类的 (余数, 模数)。"""
t = (b - v) * pow(m, -1, a) % a # 解 m*t ≡ b-v (mod a);a_i 两两互质,故 m 可逆
return (v + m * t) % (m * a), m * a
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n = next(data)
v, m = 0, 1 # 空约束:x ≡ 0 (mod 1) 对一切 x 成立,是合并的单位元
for _ in range(n):
a, b = next(data), next(data) # 建 a 个棚剩 b 头牛,即 x ≡ b (mod a)
v, m = merge(v, m, a, b)
print(v or m) # v 是 [0, m) 内的解,题目要正整数:v = 0 时取周期 m
if __name__ == "__main__":
solve()C++ 版(同一算法):
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-06 18:43
* update_at: 2026-10-06 18:43
*/
#include <cstdio>
typedef long long ll;
// 模数 a_i 两两互质,最终模数 M 最大约 6.2e60(61 位十进制),
// 超过 64 位范围,所以自己写一个 1e9 进制的简单大整数。
const int MAXL = 12; // 12 个 1e9 进制位可存 108 位十进制,足够放下 M
const ll BASE = 1000000000LL;
struct Big {
int len; // 有效位数
ll d[MAXL]; // 低位在前,每位的基是 BASE
};
Big g_v; // 当前不变量 x ≡ g_v (mod g_m) 的余数
Big g_m; // 当前不变量的模数
// 大整数乘小数 k:把 x 就地乘上 k
void mul_small(Big &x, ll k) {
ll carry = 0;
for (int i = 0; i < x.len; i++) {
ll cur = x.d[i] * k + carry;
x.d[i] = cur % BASE;
carry = cur / BASE;
}
while (carry > 0 && x.len < MAXL) {
x.d[x.len] = carry % BASE;
carry /= BASE;
x.len++;
}
while (x.len > 1 && x.d[x.len - 1] == 0) x.len--; // 去掉高位 0,保证表示唯一
}
// 大整数加法:x += y
void add_big(Big &x, const Big &y) {
ll carry = 0;
int n = x.len > y.len ? x.len : y.len;
for (int i = 0; i < n; i++) {
ll cur = carry;
if (i < x.len) cur += x.d[i];
if (i < y.len) cur += y.d[i];
x.d[i] = cur % BASE;
carry = cur / BASE;
}
x.len = n;
if (carry > 0 && x.len < MAXL) {
x.d[x.len] = carry;
x.len++;
}
while (x.len > 1 && x.d[x.len - 1] == 0) x.len--; // 去掉高位 0,保证表示唯一
}
// 大整数对小数 a 取模,返回 [0, a) 内的余数
ll mod_small(const Big &x, ll a) {
if (a == 1) return 0;
ll rem = 0;
for (int i = x.len - 1; i >= 0; i--) {
rem = (rem * BASE + x.d[i]) % a;
}
return rem;
}
// 扩展欧几里得:求 gcd(a, b) 以及一组解 a*x + b*y = gcd
ll exgcd(ll a, ll b, ll &x, ll &y) {
if (b == 0) {
x = 1;
y = 0;
return a;
}
ll g = exgcd(b, a % b, x, y);
ll nx = y;
ll ny = x - (a / b) * y;
x = nx;
y = ny;
return g;
}
// 求 a 在模 mod 下的逆元;mod = 1 时约定逆元为 0
ll mod_inverse(ll a, ll mod) {
if (mod == 1) return 0;
ll x, y;
exgcd(a, mod, x, y);
return (x % mod + mod) % mod;
}
// 输出大整数(不带前导零)
void print_big(const Big &x) {
printf("%lld", x.d[x.len - 1]);
for (int i = x.len - 2; i >= 0; i--) {
printf("%09lld", x.d[i]);
}
printf("\n");
}
int main() {
int n;
scanf("%d", &n);
// 空约束的单位元:x ≡ 0 (mod 1) 对一切 x 成立
g_v.len = 1;
g_v.d[0] = 0;
g_m.len = 1;
g_m.d[0] = 1;
for (int i = 1; i <= n; i++) {
ll a, b;
scanf("%lld%lld", &a, &b);
// 已合并部分等价于 x = g_v + g_m * t,代入 x ≡ b (mod a)
// 得 g_m * t ≡ b - g_v (mod a),a_i 两两互质故 g_m 可逆
ll v_mod_a = mod_small(g_v, a);
ll m_mod_a = mod_small(g_m, a);
ll inv = mod_inverse(m_mod_a, a);
ll diff = ((b - v_mod_a) % a + a) % a; // 归一化到 [0, a)
ll t = diff * inv % a;
// 合并:新模数 m*a,新余数 v + m*t(一定落在 [0, m*a) 内)
Big mt = g_m;
mul_small(mt, t);
add_big(g_v, mt);
mul_small(g_m, a);
}
// g_v 是 [0, g_m) 内的解;题目要求正整数,g_v = 0 时取最小正解 g_m
if (g_v.len == 1 && g_v.d[0] == 0) {
print_big(g_m);
} else {
print_big(g_v);
}
return 0;
}代码只有三层,与上面的推导一一对应:
merge(v, m, a, b)就是「合并一条条件」:t = (b - v) * pow(m, -1, a) % a对应,返回值 (v + m*t) % (m*a), m*a对应。 末尾的 % (m*a)不是必需的(结果本来就在内),写出来是为了让 「返回值永远是最小非负代表元」这个约定在函数边界上自明。 solve()里的v, m = 0, 1是合并的单位元:对一切 成立。 循环恰好执行 次,每次消费一对 ,循环体内没有任何分支。 print(v or m)处理「答案必须是正整数」这一条。
复杂度
-
时间复杂度:合并
次,每次一次扩展欧几里得求逆元 , 加上次数很少的大整数运算。设最终模数的位数为 , 则总时间 也就是「
次 exgcd 加少量 200 位大数运算」的量级。实测真实数据最慢一组 s, 手工构造的 、每个模数都在 附近、答案长达 61 位的极限用例 s, 远低于 1000 ms 限时。 -
空间复杂度:只保存当前
与输入缓冲, ,实测峰值 RSS 14.6 MB。 -
与朴素做法对比:朴素枚举是
,其中 可达 ; 这里是 。差别不是常数,而是「枚举剩余类里的每个数」被换成了「直接算出这个剩余类」。
总结
- 「建
个牛棚剩 头牛」就是同余式 ,题目求同余方程组的最小正解。 - 朴素枚举的瓶颈不是常数,而是在一个剩余类里逐个试数;
有 量级, 但它只携带 比特信息。 - 逐条合并的核心是维护不变量
:把旧解写成 代入新条件, 用 更新成 。 两两互质 逆元总存在, 合并永远有解,不需要写一般 CRT 的无解分支; pow(m, -1, a)直接给出逆元。- 三个实现细节:
% a自动把负的归一到 ; v or m保证输出是正整数;时 pow(m, -1, 1) = 0,状态自动不变,不必特判。
图示解析
先用一张图对比「朴素枚举」与「逐条合并」两条路线,说明优化究竟消除了什么:
flowchart TD
P["n 条同余式 x ≡ b_i (mod a_i)"] --> B1["朴素:x = 1, 2, 3, ... 逐个检验"]
B1 --> B2["每个候选都要花 O(n) 验证"]
B2 --> B3["步数 = 答案本身,可达 10^60 量级 · 超时"]
P --> C1["关键观察:解集仍是单个剩余类"]
C1 --> C2["维护不变量 x ≡ v (mod m),初值 (0, 1)"]
C2 --> C3["代入 x = v + mt 解出 t ≡ (b-v)·m⁻¹ (mod a)"]
C3 --> C4["更新 (v, m) ← (v + mt, m·a),共 n 轮"]
C4 --> C5["输出 [0, m) 内最小正解:v or m"]
图中上半支是朴素路线:它的每一步只回答「这个数行不行」,信息量为 1 比特,
却要重复
再看合并过程中「解在数轴上的位置」如何被一步步锁定:
第 1 条 x ≡ 1 (mod 3) 候选:1, 4, 7, 10, 13, 16, 19, 22, ... -> v=1, m=3
第 2 条 x ≡ 1 (mod 5) 取交集:1, 16, 31, 46, 61, ... -> v=1, m=15
第 3 条 x ≡ 2 (mod 7) 取交集:16, 121, 226, ... -> v=16, m=105
^^ 第一个正解就是 16三行的模数依次是