【数学1】基础数学问题
【数学1】基础数学问题
24 道题 | 难度 1-5 | 约 180 分钟 | J 组普及/提高
上节课回顾
📝 本课是「基础篇」数学模块的第一讲。前置知识:C++ 基础语法(循环、数组、函数)。
在正式开始之前,请先完成课前自测,检验以下基础知识:
| 知识点 | 自检问题 |
|---|---|
| 素数判定 | 判断 \(n\) 是否为素数,试除法的时间复杂度是多少? |
| 欧几里得算法 | gcd(a,b) 的核心递推公式是什么? |
| 快速幂 | 计算 \(a^b \bmod p\) 的快速幂时间复杂度是多少? |
| 进制 | 十进制 \(255\) 的十六进制表示是什么? |
| 组合数 | \(C(n,m) = C(n-1,m-1) + C(n-1,m)\) 这个公式叫什么? |
如果上述问题有不确定的,本课将逐一讲解。
课程导入
数学是信息学竞赛的基石。从普及组的进制转换、GCD/LCM,到提高组的组合数取模、素数筛法,数学基础贯穿了整个竞赛学习路径。
本课覆盖 三大模块:
- 位运算与进制转换:理解计算机底层的数据表示,掌握异或、位移等位运算技巧。
- 计数原理与排列组合:从加法/乘法原理出发,建立组合数学的基本框架。
- 整除理论:素数筛法、约数与倍数、GCD/LCM、质因数分解——这是数论最核心的工具箱。
本课学习路线: 进制与位运算 → 计数原理 → 整除理论(素数 → 约数 → GCD/LCM → 质因数分解)
补充知识点:计算机数据表示
⚠️ 以下知识点在思维导图中提及,但没有直接对应的编程题目。在正式讲例题之前,用几分钟把它们讲清楚。
1. 原码、反码、补码
计算机中,整数以补码形式存储。理解补码是理解位运算和负数表示的基础。
| 表示法 | 规则 | 示例(8位,\(+5\) 和 \(-5\)) |
|---|---|---|
| 原码 | 最高位为符号位(0正1负),其余位表示绝对值 | \(+5 = 00000101\),\(-5 = 10000101\) |
| 反码 | 正数反码 = 原码;负数反码 = 符号位不变,其余取反 | \(+5 = 00000101\),\(-5 = 11111010\) |
| 补码 | 正数补码 = 原码;负数补码 = 反码 + 1 | \(+5 = 00000101\),\(-5 = 11111011\) |
为什么用补码? 补码让加减法统一——减法可以转换为加法(\(a - b = a + (-b)\) 的补码)。而且补码只有一个零(
00000000),不会像原码那样出现+0和-0。
速算技巧:对一个数 \(x\),~x + 1 = -x(取反加一得相反数)。例如 \(x=5\):~5 = -6,-6 + 1 = -5。在 C++ 中 int x = 5; cout << -x; 等价于 cout << (~x + 1);。
2. 十六进制(Hexadecimal)
十六进制用 16 个符号表示:0-9 和 A-F(或 a-f)。
| 十进制 | 0 | 1 | ... | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
|---|---|---|---|---|---|---|---|---|---|---|
| 十六进制 | 0 | 1 | ... | 9 | A | B | C | D | E | F |
| 二进制 | 0000 | 0001 | ... | 1001 | 1010 | 1011 | 1100 | 1101 | 1110 | 1111 |
- 为什么用十六进制? 1 个十六进制位恰好对应 4 个二进制位(\(16 = 2^4\))。
0xFF = 1111 1111,一眼就能看出是 8 位全 1。 - C++ 中的表示:
0x前缀表示十六进制字面量,如int x = 0xFF; // x = 255。 - 常见用途:颜色值(
#FF5733)、内存地址、位掩码(如0xFFFF表示低 16 位)。
3. 二进制小数转换
十进制小数转二进制采用乘 2 取整法:
以 \(0.625\) 为例:
步骤 操作 整数部分 小数部分 1 \(0.625 \times 2 = 1.25\) 1 0.25 2 \(0.25 \times 2 = 0.5\) 0 0.5 3 \(0.5 \times 2 = 1.0\) 1 0(结束) 结果:\(0.625_{10} = 0.101_2\)
注意:并非所有有限十进制小数都能用有限二进制小数表示。例如 \(0.1_{10}\) 在二进制中是无限循环小数 \(0.0001100110011..._2\)——这正是
float/double产生精度误差的根源。
4. 数据量单位转换(bit / Byte)
| 单位 | 换算 | 常见场景 |
|---|---|---|
| 1 Byte (B) | = 8 bit | 一个 char 占 1 Byte |
| 1 KB | = \(2^{10}\) B = 1024 B | 一页文本约 2-4 KB |
| 1 MB | = \(2^{20}\) B = 1024 KB | 一首 MP3 约 3-5 MB |
| 1 GB | = \(2^{30}\) B = 1024 MB | 一部高清电影约 1-3 GB |
竞赛相关:洛谷评测机的内存限制通常为 125MB ~ 512MB。一个
int占 4 Byte,long long占 8 Byte。开一个 \(10^7\) 大小的int数组约占用 40MB——在 125MB 限制下开 \(3\times 10^7\) 就危险了。
本课知识地图
每个知识点均依托例题展开,不再单独罗列概念定义。
| 知识模块 | 核心概念 | 对应例题 |
|---|---|---|
| 位运算与进制转换 | 进制互转 · 异或性质 · 位移操作 | 例1 P1143 → 例2 P1469 → 例3 P1100 |
| 计数原理与排列组合 | 乘法原理 · 排列数 · 杨辉三角 · 二维前缀和 | 例4 P1866 → 例5 P1246 → 例6 P2822 |
| 整除理论 — 素数 | 埃氏筛 · 线性筛 · 区间筛 | 例7 P3383 → 例8 P1835 |
| 整除理论 — 约数与倍数 | 倍数枚举法 · 除数函数公式 | 例9 P2926 → 例10 P1403 |
| 整除理论 — GCD/LCM | 核心公式 · 暴力枚举 · 质因数分解综合 | 例11 P1029 → 例12 P1072 |
| 整除理论 — 质因数分解 | 指数比较法 · 综合应用 | 例13 P1069 |
例题精讲
模块一:位运算与进制转换
例题 1:P1143 进制转换
知识点: 进制互转 — 其他进制 → 十进制 → 目标进制
题意
给定 \(n\) 进制数 \(s\),将其转为 \(m\) 进制输出。\(n, m \le 36\),数字用 0-9 和 A-Z 表示。
样例解释
| 输入 | 输出 | 解释 |
|---|---|---|
n=16, s=FF, m=10 |
255 |
十六进制 FF = \(15\times 16 + 15 = 255\) |
n=10, s=255, m=16 |
FF |
十进制 255 转回十六进制 |
思路详解
进制转换的两步走:
十进制作为中转站:任何进制转换都经过十进制,分两步走。
Step 1:任意进制 → 十进制(按权展开)
代码中用 Horner 法(秦九韶算法)逐位累加,一次循环完成。
以 十六进制 A2F → 十进制 为例:
| 步骤 | 当前字符 | 转换值 | ans = ans × 16 + val |
累计 ans |
|---|---|---|---|---|
| 初始 | — | — | — | 0 |
| 1 | A |
\(10\) | 0 × 16 + 10 |
10 |
| 2 | 2 |
\(2\) | 10 × 16 + 2 |
162 |
| 3 | F |
\(15\) | 162 × 16 + 15 |
2607 |
\(A2F_{16} = 10 \times 16^2 + 2 \times 16 + 15 = 2560 + 32 + 15 = 2607_{10}\) ✓
for (auto c : s) {
if ('0' <= c && c <= '9') ans = ans * n + (c - '0');
else ans = ans * n + (c - 'A' + 10);
}
Step 2:十进制 → 任意进制(除基取余,逆序输出)
反复对 \(m\) 取余得到每一位,最后逆序输出。
以 十进制 2607 → 十六进制 为例:
| 步骤 | ans |
ans % 16 |
余数对应字符 | 更新 ans = ans / 16 |
|---|---|---|---|---|
| 1 | 2607 |
2607 % 16 = 15 |
F |
162 |
| 2 | 162 |
162 % 16 = 2 |
2 |
10 |
| 3 | 10 |
10 % 16 = 10 |
A |
0(结束) |
字符记录顺序:F → 2 → A,逆序输出 → A2F ✓
while (ans) {
int t = ans % m;
if (t < 10) a += t + '0';
else a += t - 10 + 'A';
ans /= m;
}
// 逆序输出 a
注意:字母
A表示 10,所以c - 'A' + 10将字母转为数值,t - 10 + 'A'将数值转为字母。除基取余的顺序是低位 → 高位,所以最后必须逆序。
AC 代码
#include<bits/stdc++.h>
using namespace std;
int main()
{
int n, m;
string s;
cin >> n >> s >> m;
// Step 1: n 进制 → 十进制
int ans = 0;
for (auto c : s)
{
if ('0' <= c && c <= '9') ans = ans * n + c - '0';
else ans = ans * n + c - 'A' + 10;
}
// Step 2: 十进制 → m 进制
string a;
while (ans)
{
int t = ans % m;
if (t < 10) a += t + '0';
else a += t - 10 + 'A';
ans /= m;
}
for (int i = a.size() - 1; i >= 0; i--) cout << a[i];
return 0;
}
复杂度:\(O(\log_m \text{ans} + |s|)\)。进制转换的循环次数与数值的对数成正比。
总结与拓展
- 进制转换的核心是十进制作为中转站:任意进制 → 十进制(乘基累加)→ 目标进制(除基取余)。
- 当 \(n=10\) 或 \(m=10\) 时可以把对应步骤省掉,但保留两步走逻辑更清晰。
- 拓展:P1017 引入了负进制的概念,取模运算在负数情况下需要特殊处理。
例题 2:P1469 找筷子
知识点: 位运算 — 异或(XOR)的经典应用
题意
给定 \(n\) 个整数,其中恰好一个数出现了奇数次,其余数都出现了偶数次。找出这个数。
思路详解
本题是异或运算最经典的应用场景。核心性质:
\(a \oplus a = 0\)(一个数与自身异或,结果为 0)
\(a \oplus 0 = a\)(任何数与 0 异或,结果不变)
异或满足交换律和结合律
因此,将所有数全部异或起来:成对出现的数两两抵消归零,最终剩下的就是那个落单的数。
int ans = 0;
for (int i = 1; i <= n; i++) {
cin >> x;
ans ^= x; // ans = ans ^ x
}
// ans 即为答案
教学推演:以 \(n=5\),筷子长度为 [2, 3, 2, 4, 3] 为例:
| 步骤 | 操作 | 当前 ans(二进制) | 说明 |
|---|---|---|---|
| 初始 | — | 000 |
\(0 \oplus a = a\) |
| 1 | ans ^= 2 |
010 |
2 进入 |
| 2 | ans ^= 3 |
010 ^ 011 = 001 |
2 和 3 混在一起 |
| 3 | ans ^= 2 |
001 ^ 010 = 011 = 3 |
🔥 第 2 个 2 把第 1 个 2 抵消了! |
| 4 | ans ^= 4 |
011 ^ 100 = 111 = 7 |
4 进入 |
| 5 | ans ^= 3 |
111 ^ 011 = 100 = 4 |
🔥 第 2 个 3 把第 1 个 3 抵消了! |
关键直觉:每个成对出现的数,第一次异或时"激活",第二次异或时"抵消"——因为 \(a \oplus a = 0\)。最终的
ans就是那个只出现奇数次的落单数 4。
AC 代码
#include<bits/stdc++.h>
using namespace std;
int main()
{
ios::sync_with_stdio(0);
int ans = 0, n, x;
cin >> n;
for (int i = 1; i <= n; i++) cin >> x, ans ^= x;
cout << ans;
return 0;
}
复杂度:\(O(n)\) 时间,\(O(1)\) 额外空间。不需要排序、不需要哈希表,仅用一个
int变量。
总结与拓展
- 异或的"自反性"(\(a \oplus a = 0\))是本题的核心,也是很多位运算技巧的基础。
- 拓展场景:找出数组中两个落单的数(其余成对)→ 先全部异或得到 \(x \oplus y\),再按最低不同位分组分别异或。
- 异或还常用于:交换两个变量(
a^=b; b^=a; a^=b;)、简单加密(明文 \(\oplus\) 密钥 = 密文,密文 \(\oplus\) 密钥 = 明文)。
例题 3:P1100 高低位交换
知识点: 位运算 — 位移(
>>和<<)与位掩码
题意
给定一个 32 位无符号整数 \(n\),将其高 16 位与低 16 位交换后输出。
思路详解
这是位移操作的经典练习题。32 位整数可以看作两段 16 位的数据拼接在一起:
Step 1:分离高低位
x = n & 0xFFFF0000; // 用掩码取出高 16 位(低 16 位清零)
y = n & 0x0000FFFF; // 用掩码取出低 16 位(高 16 位清零)
0xFFFF0000 的二进制是 11111111 11111111 00000000 00000000,& 操作让高 16 位保留、低 16 位清零。
Step 2:移位交换
x >>= 16; // 高 16 位移到低位
y <<= 16; // 低 16 位移到高位
Step 3:合并
x | y // 按位或,将两个错开的部分拼在一起
教学推演:以 \(n = 131073 = 2 \times 2^{16} + 1\) 为例(高 16 位 = 2,低 16 位 = 1):
| 步骤 | 二进制表示 | 十进制 | 说明 |
|---|---|---|---|
| 原始 \(n\) | 00000000 00000010 00000000 00000001 |
131073 | 高 16 位 = 2,低 16 位 = 1 |
x = n & 0xFFFF0000 |
00000000 00000010 00000000 00000000 |
131072 | 掩码保留高 16 位,低 16 位归零 |
y = n & 0x0000FFFF |
00000000 00000000 00000000 00000001 |
1 | 掩码保留低 16 位,高 16 位归零 |
x >>= 16 |
00000000 00000000 00000000 00000010 |
2 | 高位右移到低位 |
y <<= 16 |
00000000 00000001 00000000 00000000 |
65536 | 低位左移到高位 |
x | y |
00000000 00000001 00000000 00000010 |
65538 | 合并:原低位移到高位,原高位移到低位 ✓ |
验证:\(65538 = 1 \times 2^{16} + 2\)——原低 16 位的 1 移到了高位,原高 16 位的 2 移到了低位。交换完成!
AC 代码
#include<bits/stdc++.h>
using namespace std;
int main()
{
unsigned int n, x, y;
cin >> n;
x = n & 0xFFFF0000; // 掩码取高 16 位
y = n & 0x0000FFFF; // 掩码取低 16 位
x >>= 16; // 高位移到低位
y <<= 16; // 低位移到高位
cout << (x | y) << endl;
return 0;
}
复杂度:\(O(1)\)。纯粹的位运算,常数时间。
总结与拓展
- 位掩码(Bit Mask):用
&配合掩码可以提取任意位置的位。0xFFFF是 16 位全 1 的掩码。 unsigned intvsint:右移时,unsigned int高位补 0(逻辑右移),int高位补符号位(算术右移)。处理位运算时建议使用unsigned类型避免符号位干扰。- 拓展:
n = (n >> 16) | (n << 16)一行代码即可完成交换(利用unsigned移位自动补 0 的特性),但分步写更容易理解。
模块二:计数原理与排列组合
例题 4:P1866 编号
知识点: 乘法原理 — 阶梯式乘法
题意
有 \(n\) 个变量,第 \(i\) 个变量的取值范围是 \([1, a_i]\),且所有变量的取值必须互不相同。求方案数对 \(10^9+7\) 取模。
思路详解
这是乘法原理的直接应用:每个位置可选的数量是"当前剩余可用的编号数"。
Step 1:排序
将 \(a_i\) 从小到大排序。排序后,第 \(i\) 个变量可选的编号数为 \(a_i - (i-1)\)(前面的 \(i-1\) 个变量已经用掉了 \(i-1\) 个编号)。
Step 2:累乘
如果某个 \(a_i - (i-1) \le 0\),说明无法分配(可选数量不足),答案为 0。
教学推演:以 \(n=3, a=[4, 2, 5]\) 为例:
| 步骤 | \(a_i\)(排序后) | \(i-1\)(已用) | 可选数 | 累乘 |
|---|---|---|---|---|
| \(i=1\) | 2 | 0 | \(2-0 = 2\) | 2 |
| \(i=2\) | 4 | 1 | \(4-1 = 3\) | 6 |
| \(i=3\) | 5 | 2 | \(5-2 = 3\) | 18 |
答案 = 18 种方案。关键理解:排序保证了小范围的先选,避免了大范围的数"抢占"小范围数的名额。
AC 代码
#include<bits/stdc++.h>
using namespace std;
long long a[55], n, p = 1e9 + 7;
int main()
{
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i];
sort(a + 1, a + n + 1);
long long ans = 1;
for (int i = 1; i <= n; i++)
ans = ans * (a[i] - (i - 1)) % p;
cout << ans << endl;
return 0;
}
复杂度:\(O(n \log n)\),排序是瓶颈。
总结与拓展
- 乘法原理的关键是排序后减去已用数量。排序保证了 \(a_i\) 单调递增,使得"前面用掉 \(i-1\) 个"成为有效的下界。
- 拓展:如果不要求互不相同,则每个位置独立,答案为 \(\prod a_i\)(无排序需要)。
例题 5:P1246 [ECNA 1995] 编码
知识点: 排列数(组合序)— 字典序编码与组合数学
题意
按字典序给所有长度不超过 6 的严格递增字母串(字母从 a 到 z)编号。给定一个合法串,求其编号。
思路详解
本题的核心是:计算比当前串小的所有合法串的数量。
方法一(DFS 打表):数据范围极小(最多 \(C_{26}^1 + C_{26}^2 + \cdots + C_{26}^6 \approx 313911\) 个串),可以直接 DFS 枚举所有合法串并编号。适合初学者理解题意。
void dfs(int u, string s) {
if (u == m) { mp[s] = ++tot; return; }
char i = (u == 0 ? 'a' : s[u-1] + 1);
for (; i <= 'z'; i++) dfs(u + 1, s + i);
}
方法二(组合数公式):对于长度为 \(len\) 的串,可以用组合数直接算:
- 所有长度 \(< len\) 的合法串数量 = \(\sum_{k=1}^{len-1} C_{26}^k\)
- 对于长度等于 \(len\) 且字典序小于当前串的串,逐位枚举比当前位小的可用字母,累加 \(C_{剩余字母数}^{剩余位置数}\)
方法二适合拔高训练,让学生理解组合序的概念——与"下一个排列"计数异曲同工。
AC 代码(DFS 打表法)
#include<bits/stdc++.h>
using namespace std;
int n, m, tot;
map<string, int> mp;
void dfs(int u, string s)
{
if (u == m)
{
mp[s] = ++tot;
return;
}
char i;
if (u == 0) i = 'a';
else i = s[u-1] + 1; // 严格递增:下一位必须比前一位大
for (; i <= 'z'; i++)
dfs(u + 1, s + i);
}
int main()
{
string s;
cin >> s;
for (int i = 1; i <= 6; i++) // 枚举所有长度
{
m = i;
dfs(0, "");
}
cout << mp[s] << endl;
return 0;
}
复杂度:\(O(\sum_{k=1}^6 C_{26}^k)\) ≈ 31 万次 DFS,轻松通过。
总结与拓展
- 字典序编号 = 计数"比它小的"有多少个——这是排列组合中常见的思路。
- 严格递增 = 组合(与顺序无关,一旦选定字母集合,只有一种排列方式符合递增)。
- 拓展:如果字母串不要求严格递增,则问题退化为 26 进制计数。
例题 6:P2822 [NOIP 2016 提高组] 组合数问题
知识点: 杨辉三角递推 + 取模 + 二维前缀和
题意
给定 \(t\) 组询问,每组询问 \(n, m\),求 \(C_i^j\)(\(0 \le i \le n, 0 \le j \le \min(i, m)\))中有多少个是 \(k\) 的倍数。
思路详解
本题是非常典型的预处理 + 查询模式,三步走:
Step 1:杨辉三角递推求组合数(模 \(k\))
初始化:\(C[i][0] = C[i][i] = 1 \bmod k\)。
为什么直接模 \(k\)? 我们只关心 \(C[i][j]\) 是否为 \(k\) 的倍数(即模 \(k\) 余 0),不关心具体值。递推过程中全程模 \(k\) 避免了高精度。
Step 2:二维前缀和统计
以 \(k=2\) 为例,杨辉三角的前 5 行模 2:
\(s[i][j]\) 表示所有满足 \(0 \le x \le i, 0 \le y \le j\) 的 \(C[x][y]\) 中 \(k\) 的倍数的个数。
直观理解:\(s[i-1][j]\) 覆盖了左上矩形,\(s[i][j-1]\) 覆盖了左下矩形,\(s[i-1][j-1]\) 被加了两次所以要减掉一次。
Step 3:O(1) 回答询问
对于询问 \((n, m)\),如果 \(m > n\) 则令 \(m = n\)(因为 \(j\) 最大为 \(i\) 即 \(n\)),输出 \(s[n][m]\)。
AC 代码
#include<bits/stdc++.h>
using namespace std;
const int N = 2e3 + 5;
int t, k, n, m, c[N][N], s[N][N];
int main()
{
cin >> t >> k;
memset(c, -1, sizeof(c));
c[1][1] = 1; c[1][0] = 1;
// Step 1: 杨辉三角递推(模 k)
for (int i = 2; i < N; i++)
for (int j = 0; j <= i; j++)
{
if (j == 0 || j == i) c[i][j] = 1;
else c[i][j] = (c[i-1][j-1] + c[i-1][j]) % k;
}
// Step 2: 二维前缀和
for (int i = 1; i < N; i++)
for (int j = 1; j < N; j++)
s[i][j] = s[i-1][j] + s[i][j-1] - s[i-1][j-1] + (c[i][j] == 0);
// Step 3: O(1) 回答
while (t--)
{
cin >> n >> m;
if (m > n) m = n;
cout << s[n][m] << endl;
}
return 0;
}
复杂度:\(O(N^2)\) 预处理 + \(O(t)\) 查询。\(N=2000\) 时 \(N^2=4\times 10^6\),在可接受范围内。
总结与拓展
- 帕斯卡公式:\(C(n,m) = C(n-1,m-1) + C(n-1,m)\),即杨辉三角的递推关系。
- 模运算与递推兼容:在递推过程中直接模 \(k\),因为加减乘的模运算可交换序。
- 前缀和套路:将"区间内满足某条件的个数"转化为前缀和相减。二维前缀和公式:\(s[x_2][y_2] - s[x_1-1][y_2] - s[x_2][y_1-1] + s[x_1-1][y_1-1]\)。
模块三:整除理论 — 质数与素数筛法
例题 7:P3383 【模板】线性筛素数
知识点: 素数筛法 — 埃氏筛 vs 线性筛
题意
给定 \(n, q\),输出第 \(k\) 小的素数(\(q\) 次询问)。
思路详解
方法一:埃拉托斯特尼筛法(埃氏筛)
基本思想:从 2 开始,遇到一个未标记的数就将其加入素数列表,并将其所有倍数标记为合数。
问题所在:6 被 \(i=2\) 和 \(i=3\) 筛了两次,10 被 \(i=2\) 和 \(i=5\) 筛了两次。每个合数被其所有质因子重复筛——这是 \(O(n \log \log n)\) 而非 \(O(n)\) 的原因。
for (int i = 2; i <= n; i++) {
if (!vis[i]) {
prime[++cnt] = i;
for (int j = i * 2; j <= n; j += i) // 标记 i 的所有倍数
vis[j] = 1;
}
}
时间复杂度:\(O(n \log \log n)\)。每个合数被其所有质因子各筛一次。对于 \(n=10^8\) 勉强可过但常数较大。
方法二:欧拉线性筛
核心优化:每个合数只被它的最小质因子筛掉一次,做到 \(O(n)\)。
关键区别:6 只被 \(i=3\) 用 \(prime[1]=2\) 筛了一次(最小质因子是 2)。而埃氏筛中 6 被筛了两次。每个合数有唯一的最小质因子,线性筛利用这一点做到 \(O(n)\)。
for (int i = 2; i <= n; i++) {
if (!vis[i]) prime[++cnt] = i;
for (int j = 1; j <= cnt && i * prime[j] <= n; j++) {
vis[i * prime[j]] = 1;
if (i % prime[j] == 0) break; // ← 关键!保证 prime[j] 是最小质因子
}
}
if (i % prime[j] == 0) break; 为什么是关键?
以下用 \(n=20\) 演示线性筛的完整执行过程:
线性筛执行追踪(\(n=20\))
| 循环 \(i\) | !vis[i]? |
prime 列表 | 内层: j / p=prime[j] |
i×p |
vis[i×p]=1 |
i%p==0? |
break? |
|---|---|---|---|---|---|---|---|
| 2 | ✅ 素数 | [2] |
j=1, p=2 | 4 | vis[4]=1 | ✅ 2%2=0 | break |
| 3 | ✅ 素数 | [2,3] |
j=1, p=2 | 6 | vis[6]=1 | ❌ 3%2≠0 | 继续 |
| j=2, p=3 | 9 | vis[9]=1 | ✅ 3%3=0 | break | |||
| 4 | ❌ 合数 | [2,3] |
j=1, p=2 | 8 | vis[8]=1 | ✅ 4%2=0 | break |
| 5 | ✅ 素数 | [2,3,5] |
j=1, p=2 | 10 | vis[10]=1 | ❌ | 继续 |
| j=2, p=3 | 15 | vis[15]=1 | ❌ | 继续 | |||
| j=3, p=5 | 25>20 | — | — | 越界停止 |
关键观察:
- \(i=4\) 时,\(4 \% 2 = 0\) 立即 break。如果继续用 \(p=3\) 筛 \(4 \times 3 = 12\),但 12 的最小质因子是 2(不是 3),12 会在后面 \(i=6\) 时被 \(p=2\) 正确筛掉。若这里不 break,12 就被重复筛了两次!
- \(i=3\) 时,\(3 \% 2 \neq 0\) 不 break,继续用 \(p=3\) 筛 \(3 \times 3 = 9\)。因为 9 的最小质因子确实是 3,在这里筛掉是正确且唯一的时机。
每个合数恰好被它的最小质因子筛掉一次——这是 \(O(n)\) 的根本保障。
每个合数被筛的时刻一览
| 合数 | 最小质因子 | 在 \(i=?\) 时被筛 | 筛它的 \(p\) |
|---|---|---|---|
| 4 | 2 | \(i=2\) | \(p=2\) |
| 6 | 2 | \(i=3\) | \(p=2\) |
| 8 | 2 | \(i=4\) | \(p=2\) |
| 9 | 3 | \(i=3\) | \(p=3\) |
| 10 | 2 | \(i=5\) | \(p=2\) |
| 12 | 2 | \(i=6\) | \(p=2\) (不是 \(i=4,p=3\)!) |
| 14 | 2 | \(i=7\) | \(p=2\) |
| 15 | 3 | \(i=5\) | \(p=3\) |
| 16 | 2 | \(i=8\) | \(p=2\) |
| 18 | 2 | \(i=9\) | \(p=2\) |
| 20 | 2 | \(i=10\) | \(p=2\) |
每个合数恰好出现一次——这就是线性复杂度的本质。
| \(i\) | \(prime[j]\) | \(i \times prime[j]\) | \(i \% prime[j] == 0\)? | 是否 break |
|---|---|---|---|---|
| 2 | 2 | 4 | ✅ | break |
| 3 | 2 | 6 | ❌ | 继续 |
| 3 | 3 | 9 | ✅ | break |
| 4 | 2 | 8 | ✅ | break(不会用 3 筛 12) |
| 5 | 2 | 10 | ❌ | 继续 |
| 5 | 3 | 15 | ❌ | 继续 |
| 5 | 5 | 25 | ✅ | break |
埃氏筛 vs 线性筛对比:
| 埃氏筛 | 线性筛 | |
|---|---|---|
| 时间复杂度 | \(O(n \log \log n)\) | \(O(n)\) |
| 空间复杂度 | \(O(n)\) | \(O(n)\) |
| 代码复杂度 | 简单 | 略复杂 |
| 能顺便求最小质因子吗 | ❌ | ✅ |
| \(n=10^7\) 实测 | ~0.3s | ~0.15s |
AC 代码(线性筛)
#include <bits/stdc++.h>
using namespace std;
const int N = 1e8 + 5, M = 6e6 + 5;
bool vis[N];
int a[M], cnt;
void f()
{
vis[1] = 1;
for (int i = 2; i < N; i++) // 保证了每个数是被最小的质数筛掉的
{
if (!vis[i]) a[++cnt] = i;
for (int j = 1; j <= cnt && i * a[j] < N; j++)
{
vis[i * a[j]] = 1;
if (i % a[j] == 0) break; // 保证最小质因子
}
}
}
int main()
{
f();
int n, k, x;
cin >> n >> k;
while (k--) cin >> x, cout << a[x] << endl;
return 0;
}
复杂度:\(O(n)\) 预处理 + \(O(1)\) 查询。
总结与拓展
- 线性筛的核心:
if (i % prime[j] == 0) break;保证了每个合数只被其最小质因子标记。 - 线性筛不仅可以筛素数,还能顺便求出每个数的最小质因子(记录
minp[i]),这是埃氏筛做不到的。 - 拓展:杜教筛、Min_25 筛等可以在亚线性时间内求前缀素数个数(NOI 级内容)。
例题 8:P1835 素数密度
知识点: 区间筛法 — 用小区间素数筛大区间合数
题意
给定区间 \([L, R]\)(\(R-L \le 10^6\),\(L, R \le 2^{31}-1\)),求区间内的素数个数。
思路详解
问题分析:\(R\) 可能高达 \(2\times 10^9\),不能直接开数组到 \(R\)。但区间长度 \(R-L \le 10^6\),可利用区间筛法。
核心思想:
- 任何一个合数 \(x\) 必定有一个 \(\le \sqrt{x}\) 的质因子。
- \(\sqrt{2^{31}} \approx 46340\),只需先生成 \([2, 50000]\) 内的所有素数(埃氏筛即可)。
- 用这些素数去标记 \([L, R]\) 中的合数:对于每个素数 \(p\),从 \(L\) 开始的第一个 \(p\) 的倍数开始,每次加 \(p\) 标记。
区间映射:开一个大小为 \(R-L+1\) 的数组 vis[0..R-L],vis[i] 对应 \(L+i\) 是否为合数。
具体示例:\(L=100, R=120\),用素数 \(p=3\) 筛:
下标 i |
0 | 1 | 2 | 3 | ... | 20 | |
|---|---|---|---|---|---|---|---|
| 实际数值 | \(L+0=100\) | 101 | 102 | 103 | ... | 120 | |
| 筛 3 的倍数 | vis[i] |
❌ | ❌ | ✅ (102=3×34) | ❌ | ... | ❌ (120=3×40 但已过) |
102 是 ≥100 的第一个 3 的倍数 →
vis[2] = 1。继续 +3:105 (vis[5]=1)、108、111、114、117、120 依次标记。
for (int i = 1; i <= tot; i++) { // 遍历每个小素数 p
int p = prime[i];
for (int j = R / p; j >= 2; j--) { // 从大到小找第一个 ≥ L 的 p 的倍数
if (j * p < L) break;
vis[j * p - L] = 1; // 映射到区间下标
}
}
细节:如果 \(L=1\),1 不是素数也不是合数,需要从 \(L=2\) 开始。
AC 代码
#include<bits/stdc++.h>
typedef long long ll;
using namespace std;
const int N = 1e6 + 5;
int f[N], p[N], tot, vis[N];
void init()
{
for (int i = 2; i <= 50000; i++) // 只需要到 sqrt(R_max)
{
if (f[i]) continue;
p[++tot] = i;
for (int j = 2 * i; j <= 50000; j += i) f[j] = 1;
}
}
void solve()
{
int a, b;
cin >> a >> b;
if (a == 1) a = 2; // 1 不是素数
init();
for (int i = 1; i <= tot; i++)
{
for (int j = b / p[i]; j >= 2; j--)
{
if (j * p[i] < a) break;
vis[j * p[i] - a] = 1; // 标记为合数
}
}
int ans = 0;
for (int i = 0; i <= b - a; i++)
if (!vis[i]) ans++;
cout << ans << endl;
}
int main()
{
solve();
return 0;
}
复杂度:\(O(\sqrt{R} \log \log \sqrt{R} + (R-L) \log \log R)\)。\(\sqrt{R} \approx 5\times 10^4\) 部分可忽略,主复杂度 \(O((R-L) \log \log R)\)。
总结与拓展
- 区间筛的核心:大范围 → 小范围映射,用 \(\sqrt{R}\) 内的素数去筛 \([L, R]\)。
- 区间长度 \(10^6\) 是关键限制——使其成为可行的 \(O((R-L) \log \log R)\) 算法。
- 拓展:区间筛也可用于求区间内的欧拉函数值、约数个数等积性函数。
模块三:整除理论 — 约数、倍数与因数
例题 9:P2926 [USACO08DEC] Patting Heads S
知识点: 倍数枚举法 — 类似于埃氏筛的逆过程
题意
给定 \(n\) 个数 \(a_1 \dots a_n\),对于每个 \(i\),求 \(a_1 \dots a_n\) 中(除自身外)能被 \(a_i\) 整除的数的个数。
思路详解
朴素做法:对于每个 \(a_i\),遍历所有 \(a_j\) 判断整除关系 → \(O(n^2)\),\(n \le 10^5\) 会超时。
倍数枚举法:换一个角度——统计每个数出现的次数,然后对于每个数 \(x\),枚举它的所有倍数 \(x, 2x, 3x, \dots\),将 \(cnt[x]\) 加到 \(ans[倍数]\) 上。
关键视角转换:不是枚举 \((i,j)\) 对判整除(\(O(n^2)\)),而是枚举每个值的所有倍数(\(O(N \log N)\))。换个方向,效率天壤之别。
for (int i = 1; i < N; i++) {
if (!cnt[i]) continue;
for (int j = i; j < N; j += i) // 枚举 i 的所有倍数
ans[j] += cnt[i]; // i 的倍数可以被 i 整除
}
直觉:埃氏筛是"遇到素数,标记所有倍数",这里是"对于每个出现过的数,把它的贡献加到所有倍数上"。方向恰好相反。
复杂度分析:内层循环次数 = \(\sum_{i=1}^{N} \frac{N}{i} = N \cdot H_N \approx N \ln N\)(调和级数)。\(N=10^6\) 时约 \(1.4 \times 10^7\) 次,完全可以接受。
AC 代码
#include<bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
const int N = 1e6 + 5;
int cnt[N], a[N], n, m, ans[N];
int main()
{
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i], cnt[a[i]]++;
for (int i = 1; i < N; i++)
{
if (!cnt[i]) continue;
for (int j = i; j < N; j += i) ans[j] += cnt[i];
}
for (int i = 1; i <= n; i++) cout << ans[a[i]] - 1 << endl; // 减掉自身
return 0;
}
复杂度:\(O(N \log N)\),\(N\) 为值域大小。
总结与拓展
- 倍数枚举法是处理"整除关系统计"的通用技巧。当值域不大(\(\le 10^6\))时可用。
- 思路转换:不枚举"每对 \((i,j)\) 判整除",而枚举"每个数的所有倍数"——复杂度从 \(O(n^2)\) 降为 \(O(N \log N)\)。
- 拓展:埃氏筛、倍数枚举、莫比乌斯反演都利用了类似的"调和级数"复杂度结构。
例题 10:P1403 [AHOI2005] 约数研究
知识点: 除数函数 — \(\sum_{i=1}^n d(i)\) 的公式求法
题意
求 \(\sum_{i=1}^{n} d(i)\),其中 \(d(i)\) 表示 \(i\) 的约数个数。\(n \le 10^6\)。
思路详解
方法一(倍数枚举法):用上一题的技巧,枚举每个数并为其倍数贡献约数计数 → \(O(n \log n)\),可以通过。
方法二(公式法):核心观察——
在 \(1 \sim n\) 中,约数 \(i\) 出现了多少次?答:\(i\) 的倍数 \(i, 2i, 3i, \dots\) 都包含 \(i\) 这个约数。所以约数 \(i\) 出现的次数 = \(\lfloor n/i \rfloor\)。
两种视角统计的是同一个东西,但新视角只需一行代码
sum += n/i,把 \(O(n \log n)\) 的分解降为 \(O(n)\) 的除法。
因此:
直接 \(O(n)\) 求和即可。如果想进一步优化到 \(O(\sqrt{n})\),可用整除分块(\(\lfloor n/i \rfloor\) 仅有 \(O(\sqrt{n})\) 个不同取值)。
教学推演:以 \(n=6\) 为例:
| \(i\) | \(\lfloor 6/i \rfloor\) | 含义 |
|---|---|---|
| 1 | 6 | 1 是 1,2,3,4,5,6 的约数 → 出现 6 次 |
| 2 | 3 | 2 是 2,4,6 的约数 → 出现 3 次 |
| 3 | 2 | 3 是 3,6 的约数 → 出现 2 次 |
| 4 | 1 | 4 是 4 的约数 → 出现 1 次 |
| 5 | 1 | 5 是 5 的约数 → 出现 1 次 |
| 6 | 1 | 6 是 6 的约数 → 出现 1 次 |
总和 = \(6+3+2+1+1+1 = 14\)。手动验证:\(d(1)=1, d(2)=2, d(3)=2, d(4)=3, d(5)=2, d(6)=4\),总和 = \(1+2+2+3+2+4 = 14\) ✓
AC 代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int main()
{
ll n, b = 0;
cin >> n;
for (int i = 1; i <= n; i++)
b += n / i;
cout << b << endl;
return 0;
}
复杂度:\(O(n)\)。\(n=10^6\) 秒过。若 \(n\) 达到 \(10^{12}\) 级别,可用整除分块做到 \(O(\sqrt{n})\)。
总结与拓展
- 除数函数前缀和公式:\(\sum_{i=1}^n d(i) = \sum_{i=1}^n \lfloor n/i \rfloor\)。这是数论中的经典结论。
- 直觉:换个角度统计——不统计"每个数有多少约数",而统计"每个约数出现了多少次"。
- 拓展:整除分块可以在 \(O(\sqrt{n})\) 内计算 \(\sum \lfloor n/i \rfloor\)、\(\sum \lfloor n/i \rfloor \times f(i)\) 等和式。
模块三:整除理论 — 最大公约数与最小公倍数(GCD/LCM)
例题 11:P1029 [NOIP 2001 普及组] 最大公约数和最小公倍数问题
知识点: GCD 与 LCM 的核心公式 \(a \times b = \gcd(a,b) \times \operatorname{lcm}(a,b)\)
题意
给定两个自然数 \(x_0\)(GCD)和 \(y_0\)(LCM),求满足 \(\gcd(P, Q) = x_0\) 且 \(\operatorname{lcm}(P, Q) = y_0\) 的有序对 \((P, Q)\) 的数量。
思路详解
核心公式:
直觉:\(\min(a,b) + \max(a,b) = a + b\)。每个质因子的指数也是这样——GCD 取 min,LCM 取 max,两者之和恰好是原数指数之和。乘起来就得到 \(P \times Q = \gcd \times \operatorname{lcm}\)。
有了这个公式,可以枚举 \(P\)(\(P\) 必须是 \(x_0 \times y_0\) 的约数),\(Q = (x_0 \times y_0) / P\),然后验证 \(\gcd(P, Q) = x_0\) 和 \(\operatorname{lcm}(P, Q) = y_0\) 是否成立。
优化枚举:\(P\) 只需枚举到 \(\sqrt{x_0 \times y_0}\),对于每个 \(P\) 可以同时得到 \((P, Q)\) 和 \((Q, P)\) 两对(当 \(P \ne Q\) 时)。
教学推演:以 \(x_0=3, y_0=60\) 为例:
\(P \times Q = 3 \times 60 = 180\)。枚举 180 的约数作为 \(P\):
| \(P\) | \(Q = 180/P\) | \(\gcd(P,Q) = 3\)? | \(\operatorname{lcm}(P,Q) = 60\)? | 合法? |
|---|---|---|---|---|
| 3 | 60 | ✅ | ✅ | ✅ \((3,60)\) |
| 12 | 15 | ✅ | ✅ | ✅ \((12,15)\) |
| 15 | 12 | ✅ | ✅ | ✅ \((15,12)\) |
| 60 | 3 | ✅ | ✅ | ✅ \((60,3)\) |
答案 = 4。有序对意味着 \((3,60)\) 和 \((60,3)\) 算两对。
AC 代码
#include<bits/stdc++.h>
using namespace std;
const int N = 1e5 + 5;
vector<int> g;
int main()
{
int x0, y0;
cin >> x0 >> y0;
// 枚举 y0 的所有约数(也是 x0*y0 的约数)
for (int i = 1; i * i <= y0; i++)
if (y0 % i == 0)
{
g.push_back(i);
if (i * i == y0) continue;
g.push_back(y0 / i);
}
int ans = 0;
for (int i = 0; i < g.size(); i++)
for (int j = 0; j < g.size(); j++)
{
int x = g[i], y = g[j];
if (__gcd(x, y) == x0 && x * y / __gcd(x, y) == y0) ans++;
}
cout << ans << endl;
return 0;
}
复杂度:\(O(d(y_0)^2 \log \max(x,y))\),其中 \(d(y_0)\) 是 \(y_0\) 的约数个数。\(y_0 \le 10^5\) 时约数最多约 128 个,平方后仍可接受。
总结与拓展
- 核心公式:\(a \times b = \gcd(a,b) \times \operatorname{lcm}(a,b)\)。证明思路:分解质因数后,\(\gcd\) 取 \(\min\) 指数,\(\operatorname{lcm}\) 取 \(\max\) 指数,两者相乘刚好是原数指数之和。
- C++17 起
<algorithm>提供了std::gcd和std::lcm。GCC 内置的__gcd也可用(注意两个下划线)。 - 拓展:若有多个数,\(\gcd(a,b,c) = \gcd(\gcd(a,b), c)\),但 \(\operatorname{lcm}\) 无法用 \(\prod / \gcd\) 推广到三个以上。
例题 12:P1072 [NOIP 2009 提高组] Hankson 的趣味题
知识点: GCD/LCM 综合 — 暴力枚举约数 + 质因数分解
题意
给定 \(a_0, a_1, b_0, b_1\),求满足 \(\gcd(x, a_0) = a_1\) 且 \(\operatorname{lcm}(x, b_0) = b_1\) 的 \(x\) 的个数。
思路详解
这是数论综合性较强的经典题。两条约束可以分开利用:
约束 1:\(\gcd(x, a_0) = a_1\) → \(x\) 是 \(a_1\) 的倍数,且 \(\gcd(x/a_1, a_0/a_1) = 1\)。
约束 2:\(\operatorname{lcm}(x, b_0) = b_1\) → \(x\) 是 \(b_1\) 的约数(因为 \(\operatorname{lcm}(x, b_0) = b_1\) 说明 \(x \mid b_1\))。
结合:\(x\) 同时是 \(a_1\) 的倍数和 \(b_1\) 的约数 → \(x\) 是 \(b_1\) 的约数。
算法:
- 枚举 \(b_1\) 的所有约数作为候选 \(x\)。
- 验证两个条件:\(\gcd(x, a_0) = a_1\) 且 \(b_0 / \gcd(x, b_0) = b_1 / x\)(等价于 \(\operatorname{lcm}(x, b_0) = b_1\))。
为什么用 \(b_0 / \gcd(x, b_0) = b_1 / x\) 而不是直接算 LCM? 避免溢出——\(x \times b_0\) 可能超过
int范围,而这种形式只需除法。
AC 代码
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 1e5 + 5;
vector<int> g;
int main()
{
int n, a0, a1, b0, b1;
cin >> n;
while (n--) // 最多 2×10³ 组
{
cin >> a0 >> a1 >> b0 >> b1;
g.clear();
for (int i = 1; i * i <= b1; i++) // 枚举 b1 的约数
if (b1 % i == 0)
{
g.push_back(i);
if (i * i == b1) continue;
g.push_back(b1 / i);
}
int ans = 0;
for (int i = 0; i < g.size(); i++)
{
int x = g[i];
if (__gcd(x, a0) == a1 && b0 / __gcd(x, b0) == b1 / x)
ans++;
}
cout << ans << endl;
}
return 0;
}
复杂度:\(O(n \times d(b_1) \times \log V)\)。\(b_1 \le 2\times 10^9\) 时约数最多约 1536 个,总计算量在可接受范围内。
总结与拓展
- \(\operatorname{lcm}\) 约束可以转化为约数关系 \(x \mid b_1\),缩小枚举范围。
- 避免溢出技巧:\(\operatorname{lcm}(x, b_0) = b_1 \iff b_0 / \gcd(x, b_0) = b_1 / x\)(两边都是整除)。
- 本题也可以从质因数分解角度做:分别考虑每个质因子的指数约束区间。
模块三:整除理论 — 算术基本定理(质因数分解)
例题 13:P1069 [NOIP 2009 普及组] 细胞分裂
知识点: 质因数分解 — 指数比较法
题意
有 \(n\) 种细胞,第 \(i\) 种每秒分裂成 \(a_i\) 倍。试管中需达到 \(m_1^{m_2}\) 个细胞(均匀分装)。求最早在第几秒可以开始实验(即 \(\exists t\) 使得 \(a_i^t\) 是 \(m_1^{m_2}\) 的倍数)。若所有细胞都无法满足,输出 \(-1\)。
思路详解
将 \(a_i^t \mid m_1^{m_2}\) 转化为质因数指数的比较。
核心转化:整除关系 \(A \mid B\) ≈ 每个质因子的指数 \(A\) 都不超过 \(B\)。把"大数能否整除"变成了"指数的不等式比较",清晰且高效。
Step 1:对 \(m_1\) 做质因数分解
Step 2:对于每种细胞 \(a_i\),检查每个质因子 \(p_j\)
- 若 \(a_i\) 不包含 \(p_j\) → 该细胞永远无法满足要求(\(f=0\))。
- 若 \(a_i\) 包含 \(p_j\),设 \(a_i\) 中 \(p_j\) 的指数为 \(e_j\),则 \(a_i^t\) 中 \(p_j\) 的指数为 \(e_j \times t\)。需要 \(e_j \times t \ge c_j \times m_2\),即 \(t \ge \lceil \frac{c_j \times m_2}{e_j} \rceil\)。
对该细胞的所有质因子取 \(\max\) 得到该细胞所需的最小时间。
Step 3:所有细胞中取 \(\min\)
AC 代码
#include<bits/stdc++.h>
using namespace std;
typedef pair<int,int> pa;
const int N = 1e6 + 5;
int n, m1, m2, cnt[N], p[N], tot;
void init()
{
for (int i = 2; i * i <= m1; i++)
{
int s = 0;
if (m1 % i) continue;
while (m1 && m1 % i == 0) m1 /= i, s++;
p[++tot] = i; cnt[tot] = s * m2;
}
if (m1 > 1) p[++tot] = m1, cnt[tot] = m2; // 剩余的大质因子
}
int calc(int x, int a) // 计算 x 中质因子 a 的指数
{
int s = 0;
while (x && x % a == 0) x /= a, s++;
return s;
}
int main()
{
cin >> n >> m1 >> m2;
if (m1 == 1) { cout << 0; return 0; } // 试管只需 1 个细胞,第 0 秒即可
init();
int x, ans = 0x3f3f3f3f;
for (int i = 1; i <= n; i++)
{
cin >> x;
int f = 1, tmp = -1;
for (int j = 1; j <= tot; j++)
{
if (x % p[j]) { f = 0; break; } // 缺少必要质因子 → 不可能
int c = calc(x, p[j]);
tmp = max(tmp, (cnt[j] - 1) / c + 1); // 上取整:ceil(cnt[j] / c)
}
if (f) ans = min(ans, tmp);
}
if (ans == 0x3f3f3f3f) cout << -1 << endl;
else cout << ans << endl;
return 0;
}
复杂度:\(O(\sqrt{m_1} + n \times k \times \log a_i)\),其中 \(k\) 为 \(m_1\) 的不同质因子个数(\(\le 9\) 对 \(m_1 \le 2\times 10^9\))。
总结与拓展
- 整除关系 → 指数比较:\(A \mid B\) 等价于对于每个质因子 \(p\),\(A\) 中 \(p\) 的指数 \(\le B\) 中 \(p\) 的指数。
- 上取整技巧:\(\lceil a / b \rceil = (a - 1) / b + 1\)(整数除法),等价于
(a + b - 1) / b。 - 拓展:质因数分解是数论的"原子操作"。约数个数、约数和、欧拉函数等都可以通过质因数分解得到。
常见错误与坑点
错误 1:线性筛中没有 break 导致复杂度退化
❌ 错误写法:去掉
if (i % prime[j] == 0) break;
for (int i = 2; i <= n; i++) {
if (!vis[i]) prime[++cnt] = i;
for (int j = 1; j <= cnt && i * prime[j] <= n; j++)
vis[i * prime[j]] = 1; // 没有 break!
}
✅ 正确写法:必须在
i % prime[j] == 0时break
for (int i = 2; i <= n; i++) {
if (!vis[i]) prime[++cnt] = i;
for (int j = 1; j <= cnt && i * prime[j] <= n; j++) {
vis[i * prime[j]] = 1;
if (i % prime[j] == 0) break; // 保证每个合数只被最小质因子筛
}
}
没有
break会导致每个合数被所有质因子标记,复杂度退化为 \(O(n \log \log n)\) 甚至更差。
错误 2:GCD/LCM 公式用混
常见错误:把 \(\gcd\) 和 \(\operatorname{lcm}\) 的关系记反了。
✅ 正确公式:\(a \times b = \gcd(a,b) \times \operatorname{lcm}(a,b)\)
❌ 错误写法:
lcm = a * b / gcd(a, b)可能溢出!应先除后乘:lcm = a / gcd(a, b) * b
错误 3:位运算符号位陷阱
int x = -1;
x >>= 1; // 算术右移:高位补符号位 1,结果仍是 -1
unsigned int x = -1;
x >>= 1; // 逻辑右移:高位补 0,结果是 0x7FFFFFFF
做位运算时优先使用
unsigned类型,避免符号位带来的意外。
错误 4:组合数递推溢出
// ❌ 直接存真实值,n=100 时 C(100,50) ≈ 1e29 溢出 64 位
c[i][j] = c[i-1][j-1] + c[i-1][j];
✅ 按需取模:如果只需要判 \(k\) 的倍数,全程模 \(k\);如果需要真实值,使用高精度或 Python。
错误 5:区间筛忘记处理 \(L=1\)
\(L=1\) 时,1 既不是素数也不是合数。如果不处理会导致误统计。
if (L == 1) L = 2; // 或 L++;
错误 6:枚举约数时忘记平方数去重
for (int i = 1; i * i <= n; i++)
if (n % i == 0) {
g.push_back(i);
g.push_back(n / i); // ❌ 当 i*i==n 时会重复加入!
}
✅ 加上判断:
if (i * i != n) g.push_back(n / i);
题单完整题解(24 题)
位运算与进制转换(4 题)
P1143 进制转换 ★★ — 进制互转基础模板
题意:\(n\) 进制 → \(m\) 进制。AC 代码见例题 1。
复杂度:\(O(\log_m \text{ans} + |s|)\)
P1469 找筷子 ★★ — 异或的经典应用
题意:\(n\) 个数中恰好一个出现了奇数次,找出来。AC 代码见例题 2。
复杂度:\(O(n)\),仅需 \(O(1)\) 额外空间。
P1100 高低位交换 ★★ — 位移与位掩码
题意:32 位无符号整数高低 16 位交换。AC 代码见例题 3。
复杂度:\(O(1)\)
P1017 [NOIP 2000 提高组] 进制转换 ★★★ — 负进制
题意:将十进制数转为 \(-R\) 进制(\(2 \le R \le 16\))。
核心技巧:负进制的取模运算。当余数为负数时,商 +1、余数 +R(使余数非负)。
#include<bits/stdc++.h>
using namespace std;
int main()
{
int n, r;
cin >> n >> r;
cout << n << "=";
string s;
while (n) {
int t = n % r;
n /= r;
if (t < 0) t -= r, n += 1; // 负进制关键:使余数非负
s += t < 10 ? t + '0' : t - 10 + 'A';
}
reverse(s.begin(), s.end());
cout << s << "(base" << r << ")" << endl;
return 0;
}
复杂度:\(O(\log_{R} n)\)。关键理解:负进制下
%的结果必须为非负数。
计数原理与排列组合(6 题)
P1866 编号 ★★ — 乘法原理
题意:\(n\) 个变量取值范围 \([1, a_i]\) 且互不相同,求方案数。AC 代码见例题 4。
复杂度:\(O(n \log n)\)
P1246 [ECNA 1995] 编码 ★★★ — 排列序 / 字典序编号
题意:给严格递增字母串按字典序编号。AC 代码见例题 5。
复杂度:\(O(26^6)\) DFS 打表或 \(O(|s| \times 26)\) 组合数计算。
P2822 [NOIP 2016 提高组] 组合数问题 ★★★★ — 杨辉三角 + 二维前缀和
题意:多次询问 \(C_i^j\) 中有多少个是 \(k\) 的倍数。AC 代码见例题 6。
复杂度:\(O(N^2)\) 预处理 + \(O(1)\) 查询
P2789 直线交点数 ★★★ — 枚举与递推计数
题意:\(n\) 条直线(可平行),求可能的不同交点数(方案数,非交点本身)。
核心技巧:设 \(f[i]\) 表示无平行限制时的交点集合,枚举平行线数量 \(k\),剩余 \(i-k\) 条与这 \(k\) 条产生 \(k \times (i-k)\) 个交点。
#include<bits/stdc++.h>
using namespace std;
int n;
bool f[10000];
int main() {
cin >> n;
f[0] = 1;
for (int i = 1; i <= n; i++)
for (int j = n * (n-1) / 2; j >= 0; j--)
if (f[j])
for (int k = 1; k <= i; k++)
f[j + k * (i - k)] = 1;
int ans = 0;
for (int i = 0; i <= n * (n-1) / 2; i++)
if (f[i]) ans++;
cout << ans;
}
复杂度:\(O(n^3)\)
P3913 车的攻击 ★★★ — 容斥原理
题意:\(n \times n\) 棋盘上有 \(k\) 个车,求被攻击到的格子数。
核心技巧:去重。被攻击的行数 + 列数 - 行列交叉处重复计算的格子。
#include<bits/stdc++.h>
using namespace std;
int main() {
long long n, k, r = 0, c = 0;
cin >> n >> k;
map<int,int> R, C;
for (int i = 0; i < k; i++) {
int x, y; cin >> x >> y;
R[x]++; C[y]++;
}
r = R.size(); c = C.size();
cout << r * n + c * n - r * c;
}
复杂度:\(O(k \log k)\)
P2638 安全系统 ★★★★ — 组合数 / 插板法
题意:\(n\) 个存储区存放 \(a\) 个 0 和 \(b\) 个 1,每个区容量无限,求方案数。
核心技巧:\(a\) 个相同球放入 \(n\) 个不同盒子 = \(C(a+n-1, n-1)\)(插板法)。0 和 1 独立,方案数相乘。使用 unsigned long long 防溢出。
复杂度:\(O(n+a+b)\) 递推组合数
整除理论 — 质数与素数筛法(2 题)
P3383 【模板】线性筛素数 ★★★ — 线性筛模板
题意:多次询问第 \(k\) 小素数。AC 代码见例题 7。
复杂度:\(O(n)\) 预处理 + \(O(1)\) 查询
P1835 素数密度 ★★★★ — 区间筛
题意:求 \([L, R]\) 区间素数个数(\(R-L \le 10^6\))。AC 代码见例题 8。
复杂度:\(O((R-L) \log \log R)\)
整除理论 — 约数、倍数与因数(4 题)
P2926 [USACO08DEC] Patting Heads S ★★★ — 倍数枚举法
题意:对每个数,统计能整除它的其他数个数。AC 代码见例题 9。
复杂度:\(O(N \log N)\)
P1403 [AHOI2005] 约数研究 ★★ — 除数函数公式
题意:求 \(\sum_{i=1}^n d(i)\)。AC 代码见例题 10。
复杂度:\(O(n)\) 或 \(O(\sqrt{n})\) 整除分块
P1414 又是毕业季II ★★★ — 寻找最大公约数
题意:从 \(n\) 个数中选 \(k\) 个(\(k=1\dots n\)),求每种 \(k\) 下的最大公约数。
核心技巧:枚举每个可能的公约数 \(d\),统计 \(d\) 的倍数出现次数 \(cnt[d]\)。\(cnt[d] \ge k\) 时 \(d\) 可作为选 \(k\) 个的公约数。
#include<bits/stdc++.h>
using namespace std;
const int N = 1e6 + 5;
int n, cnt[N], ans[N];
int main() {
cin >> n;
int mx = 0;
for (int i = 1; i <= n; i++) {
int x; cin >> x;
cnt[x]++; mx = max(mx, x);
}
for (int i = 1; i <= mx; i++)
for (int j = i * 2; j <= mx; j += i)
cnt[i] += cnt[j]; // cnt[i] = i 的倍数个数
for (int i = 1; i <= mx; i++)
ans[cnt[i]] = max(ans[cnt[i]], i); // 选 cnt[i] 个的最大公约数
for (int i = n; i >= 1; i--)
ans[i] = max(ans[i], ans[i+1]); // 向下传递:选更少个时 gcd 不会更小
for (int i = 1; i <= n; i++)
cout << ans[i] << endl;
}
复杂度:\(O(N \log N)\),\(N\) 为最大值域。
P1593 因子和 ★★★★ — 约数和 + 快速幂
题意:求 \(a^b\) 的所有约数之和 \(\bmod 9901\)。
核心技巧:对 \(a\) 质因数分解:\(a = \prod p_i^{e_i}\),则 \(a^b = \prod p_i^{e_i \times b}\)。约数和公式:
需要快速幂 + 逆元(或分治求和)处理除法取模。
复杂度:\(O(\sqrt{a} \log b)\)
整除理论 — GCD/LCM(6 题)
P1029 [NOIP 2001 普及组] 最大公约数和最小公倍数问题 ★★★ — GCD/LCM 核心公式
题意:已知 \(\gcd(P,Q)=x_0, \operatorname{lcm}(P,Q)=y_0\),求 \((P,Q)\) 有序对数量。AC 代码见例题 11。
复杂度:\(O(d(y_0)^2 \log V)\)
P1072 [NOIP 2009 提高组] Hankson 的趣味题 ★★★★★ — GCD/LCM 综合
题意:\(\gcd(x, a_0) = a_1\) 且 \(\operatorname{lcm}(x, b_0) = b_1\)。AC 代码见例题 12。
复杂度:\(O(n \times d(b_1) \times \log V)\)
P4057 [Code+#1] 晨跑 ★★ — 三数 LCM
题意:求三个数的 LCM。
核心技巧:\(\operatorname{lcm}(a,b,c) = \operatorname{lcm}(\operatorname{lcm}(a,b), c)\)。注意先除后乘防溢出。
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
int main() {
ll a, b, c;
cin >> a >> b >> c;
ll t = a / __gcd(a, b) * b;
cout << t / __gcd(t, c) * c;
}
复杂度:\(O(\log \max(a,b,c))\)
P2660 zzc 种田 ★★★ — GCD 在几何上的应用
题意:\(x \times y\) 的矩形,每次选一个最大的正方形切掉,求总消耗。
核心技巧:每次切掉的正方形边长 = \(\min(x, y)\)。递归/循环交替减,本质是更相减损术。答案为 \(4 \times (x + y - \gcd(x, y))\)。
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
ll ans, x, y;
void dfs(ll a, ll b) {
if (b == 0 || a == 0) return;
ans += 4 * b * (a / b); // 切 a/b 个边长为 b 的正方形
dfs(b, a % b);
}
int main() {
cin >> x >> y;
dfs(x, y);
cout << ans;
}
复杂度:\(O(\log \min(x,y))\),本质是欧几里得算法的过程。
P1572 计算分数 ★★ — 分数四则运算 + GCD 约分
题意:模拟分数加减,输出最简结果。
核心技巧:逐项通分,每次运算后用 GCD 约分。分母取 LCM,分子按比例调整后加减。
复杂度:\(O(n \log V)\)
P2651 添加括号III ★★★ — 分数与 GCD 性质
题意:判断表达式 \(a_1/a_2/a_3/\dots/a_n\) 能否通过加括号变成整数。
核心技巧:\(a_1\) 一定是分子,\(a_2\) 一定是分母中的一部分(在最外层)。其余数可通过加括号调整到分子或分母。实际上只需判断:把 \(a_2\) 中和 \(a_1\) 互质的部分不断约掉后,\(a_2\) 能否变为 1。
复杂度:\(O(n \log V)\)
整除理论 — 算术基本定理(2 题)
P1069 [NOIP 2009 普及组] 细胞分裂 ★★★★ — 质因数分解 + 指数比较
题意:求最小 \(t\) 使 \(a_i^t\) 是 \(m_1^{m_2}\) 的倍数。AC 代码见例题 13。
复杂度:\(O(\sqrt{m_1} + n \times k \times \log a_i)\)
P3601 签到题 ★★★★★ — 综合:质因数分解 + 欧拉函数
题意:求 \(\sum_{i=l}^{r} (i - \varphi(i)) \bmod 66662333\),其中 \(\varphi\) 为欧拉函数。\(r-l \le 10^6\)。
核心技巧:区间筛 + 欧拉函数。每个数 \(i\) 贡献 \(i - \varphi(i)\)。用区间筛法在 \(O((r-l) \log r)\) 内求出每个数的 \(\varphi\) 值。
\(\varphi(n) = n \times \prod_{p \mid n} (1 - \frac{1}{p})\)。区间筛时对每个素数 \(p\),将 \([l, r]\) 中 \(p\) 的倍数乘上 \((p-1)/p\)。
复杂度:\(O((r-l) \log r)\)
课后巩固
| 题目 | 说明 |
|---|---|
| P1017 [NOIP 2000 提高组] 进制转换 | 负进制转换——理解取模运算在负数下的行为 |
| P2638 安全系统 | 插板法应用——相同球放入不同盒子 |
| P1414 又是毕业季II | 倍数枚举法——从另一个角度统计公约数 |
| P1593 因子和 | 约数和公式 + 快速幂 + 等比数列求和 |
| P3601 签到题 | 区间筛 + 欧拉函数——本章节综合大练习 |
完成本课后,你将掌握: 进制转换的标准两步法(按权展开 + 除基取余)、异或的自反性与位移操作、乘法原理的阶梯式应用、杨辉三角递推与二维前缀和优化、埃氏筛与线性筛的区别和实现、倍数枚举法的调和级数复杂度、GCD/LCM 核心公式 \(a \times b = \gcd \times \operatorname{lcm}\)、质因数分解与指数比较法。这些是 NOIP 普及组/提高组数论题的必备基础。
浙公网安备 33010602011771号