← 目录 第 40 / 60 节 全部 60 节

40 · 回顾七:DP 与数论

把 31~39 节压成一张判据表:DP 用三句话问出状态,数论用两类边界(取模与端点)自查。以 P1091 合唱队形走完“枚举最高点 + 两遍 LIS”的完整推导,再用 5 道题覆盖背包、区间 DP、筛法、gcd 与组合计数。
回顾训练提高-建议 95 分钟回顾5 题

第 31~39 节是九节:DP 四类(线性、背包、区间/树形、状压/数位)、一场模拟赛,数论四块(质数、gcd 与同余、组合计数、期望)。它们训练的是同一个动作——先把问题压成一张表或一个式子,再动键盘。DP 的表是 dp 数组,数论的式子要取模;错法也一样:不是编译错误,而是答案差 1、差一个模数、差一次初始化。

DP 的分不在转移方程里,在状态定义和初始化里;数论的分不在推导里,在边界和取模里。

先看九节的判据表。复习不是重读,是遮住右边一列,自己先说一遍,说不出来才回去补那一节。

节内容一句话判据(读完题先问自己)
31线性 DP 四步法答案能写成“前 i 个里选、末尾状态是 j”吗?能就设 f[i](或 f[i][j]),从“最后一步”倒推
320/1 与完全背包每种物品只能选一次 → 容量倒序;能选无限次 → 容量正序。记反这条,样例常能过、大数据全错
33区间 DP 与树形 DP答案由“最后一次把区间切成两半”决定 → 枚举断点 k;环形就复制一倍,答案取遍所有长度 n 的区间
34状压 DP 与数位 DP状态要记“哪些已经选过” → 用二进制整数当数组下标;问“1~n 里某数字出现几次” → 按位拆
35模拟赛一(120 分钟 4 题)先暴力拿分再想正解;最后 15 分钟只查数组大小、long long、多测清空
36质数与整除判一堆素数且上界 ≤ 10⁷ → 埃氏筛;单个数分解 → 试除到 √n,剩下的 > 1 必是素数
37gcd、同余与快速幂P·Q = gcd(P,Q)·lcm(P,Q);ab mod p 按二进制拆,每乘一次就取模
38组合计数与容斥C(n,m) 用杨辉三角或阶乘逆元;“至少一个”一律转成“总数 − 一个都不满足”
39概率与期望E(X+Y) = E(X) + E(Y) 恒成立,不需要独立;但每走一步要乘概率,漏乘就是 WA

这一节 95 分钟:前 25 分钟跟着一道题走完 DP 的完整思考链,后 70 分钟做 5 道题,每题 14 分钟。顺序建议就按表里的顺序:背包 → 区间 DP → 筛法 → gcd → 组合计数,前面的题给后面的题热身。

例题 · 合唱队形洛谷 P1091普及/提高-去洛谷做这题 ↗

n 位同学站成一排,第 i 位身高 Ti。音乐老师要请其中的 n − k 位同学出列,使剩下的 k 位同学排成合唱队形:存在一个人,他左边身高严格递增、右边身高严格递减。

输入格式:第一行一个整数 n(2 ≤ n ≤ 100);第二行 n 个整数 Ti(130 ≤ Ti ≤ 230)。

输出格式:一行一个整数,表示最少需要几位同学出列。

输入: 8 186 186 150 200 160 130 197 200 输出: 4
例题怎么想:把“出列最少”换成“留下最多”

题目问“出列最少”,等价于“留下最多”。设最多留下 k 人,答案就是 n − k,于是整题变成求最长的合唱队形。

合唱队形有一个唯一的最高点 i:i 左边严格递增,i 右边严格递减。枚举这个 i,左边的最长上升子序列长度 f[i] 与右边的最长下降子序列长度 g[i] 分别算出来,拼起来是 f[i] + g[i] − 1(i 被算了两次)。

于是问题拆成两个同一个模子的小问题:以 i 结尾的最长上升子序列。f[i] = 1 + max{ f[j] : j < i 且 a[j] < a[i] },没有合法 j 时 f[i] = 1。

右边不用另写一套:把数组从右往左扫,判据仍然是 a[j] < a[i],得到的 g[i] 就是“以 i 开头的最长下降子序列”。方向变了,不等式没变——这是本题最省脑子的地方。

同一个模子扫两遍
for (int i = 1; i <= n; ++i) {              // f:从左往右扫
    f[i] = 1;                                // 只留自己也算一种队形
    for (int j = 1; j < i; ++j)
        if (a[j] < a[i]) f[i] = max(f[i], f[j] + 1);
}
for (int i = n; i >= 1; --i) {              // g:从右往左扫,判据不变
    g[i] = 1;
    for (int j = n; j > i; --j)
        if (a[j] < a[i]) g[i] = max(g[i], g[j] + 1);
}
完整代码:两遍 LIS,O(n²)
#include <bits/stdc++.h>
using namespace std;

int n, a[105];
int f[105], g[105];        // f[i]:以 i 结尾的最长上升子序列长度
                           // g[i]:以 i 开头的最长下降子序列长度

int main() {
    cin >> n;
    for (int i = 1; i <= n; ++i) cin >> a[i];

    for (int i = 1; i <= n; ++i) {
        f[i] = 1;                          // 自己单独站,长度是 1
        for (int j = 1; j < i; ++j)
            if (a[j] < a[i]) f[i] = max(f[i], f[j] + 1);   // 把 a[i] 接在 a[j] 后面
    }

    for (int i = n; i >= 1; --i) {         // 倒着扫:右边的人先算好
        g[i] = 1;
        for (int j = n; j > i; --j)
            if (a[j] < a[i]) g[i] = max(g[i], g[j] + 1);
    }

    int best = 0;
    for (int i = 1; i <= n; ++i)
        best = max(best, f[i] + g[i] - 1); // i 在 f、g 里各算了一次,减掉一次

    cout << n - best << "\n";              // 题目问的是出列人数
    return 0;
}
逐行拆解
代码片段含义与理由
#include <bits/stdc++.h>一个头文件包含全部标准库。竞赛里省的不是这几个字符,是忘记 include 的那几分钟。
int f[105], g[105];n ≤ 100,开 105。开成 100 而循环写到 i = 100 就越界——习惯上多开 5 个格子。
f[i] = 1;初始化不能省。LIS 的答案至少是 1,漏了这一句 f[i] 从 0 起,best 偏小、输出偏大。
if (a[j] < a[i])严格递增用 <。本题写了“严格”,用 <= 会把 186 186 也算成递增,答案变小。
f[i] = max(f[i], f[j] + 1);枚举“i 的前一个是谁”,取最长的那个接上。f[i] 只表示以 i 结尾,中途不能当答案。
for (int i = n; i >= 1; --i)倒序扫、j 也从 n 往 i 走,算的是“i 往右能下降多长”。依赖的 g[j] 已经算好。
best = max(best, f[i] + g[i] - 1);i 是最高点,左右拼起来。漏减 1 就把最高点算了两遍,答案偏大 1。
cout << n - best << "\n";做一次“留下最多 → 出列最少”的转换。这一步写在纸上,别只在脑子里转。

上面这道题没有新语法,但接下来的 5 道题会反复用到下面两个东西。先看清它们的格式,再往下做。

新函数拆解 · __gcd / std::gcd(最大公约数)
int __gcd(int a, int b); // C++17 也可以写 std::gcd(a, b)
组成含义 / 格式要求
a, b两个整数。类型必须一致:__gcd(3, 4LL) 在老版本 g++ 上编译失败。
返回值最大公约数,正整数。gcd(a, 0) = a,所以“一边是 0”这种边界不用特判。
复杂度欧几里得算法(辗转相除),O(log min(a, b))。10¹⁸ 的数也只要几十步。
lcm 怎么算lcm(a, b) = a / gcd(a, b) * b。先除后乘,先乘就溢出。
需要 includebits/stdc++.h 已经包含,using namespace std 后可直接用。
最小可运行示例
示例
int a = 12, b = 18;
cout << __gcd(a, b) << "\n";             // 6
cout << a / __gcd(a, b) * b << "\n";     // 36:先除后乘,不溢出

long long g = __gcd(6LL, 15LL);          // 3,参数同为 long long
注意

不要写 a * b / gcd(a, b) 求 lcm:a = b = 10⁹ 时 a*b 已经溢出,得到的是垃圾值,而且不会报错。

gcd 为 1 是两个数互质的充要条件,数论题里这句判据出现频率极高。

新函数拆解 · memset(dp, -1, sizeof dp)
memset(数组名, 填充值, sizeof 数组名);
组成含义 / 格式要求
第 1 个参数数组名(首地址)。二维数组直接写 dp,不要写 dp[0]。
第 2 个参数只有“四个字节都相同”的值才安全:0、-1、0x3f(INF 常用)。它按字节填充,写 1 会得到 0x01010101 = 16843009。
第 3 个参数sizeof dp。写成 sizeof(dp) 等价;写成 n 是经典错误——只清了前 n 个字节。
为什么用 -1int 的 -1 正好是“没算过”的标记:合法答案可以是 0,用 0 当初值就分不清“算过且为 0”和“没算”。
要填别的值用 fill(f, f + n, 值),或者写循环。fill 不要求值是字节可重复的。
最小可运行示例
示例
int dp[15][15];
memset(dp, -1, sizeof dp);              // 全部 -1,当“没算过”

long long f[1005];
fill(f, f + 1005, 1LL << 60);          // 求最小值时的 INF

if (dp[x][y] != -1) return dp[x][y];    // 记忆化搜索的标准判据
注意

多测题每个 case 都要重新 memset,漏一次就把上一组的答案带进这一组。

memset 只对 POD 数组安全;vector 用 assign 或 fill。

DP 四类:三句话问出状态

回顾章的 DP 不该再背模板,而应该能用三句话把状态问出来。问完这三句,转移方程几乎是照着写:

  1. 答案的“最后一步”是什么?最后一步选不选第 i 个物品(背包)、最后一步把区间切成哪两半(区间)、最后一步填哪一位(数位)。
  2. 做完最后一步,剩下的是什么?剩下的必须是同一类问题的更小规模,这就是“无后效性”。
  3. 要记住哪些信息才能接上最后一步?记住的信息就是状态。记多了 MLE,记少了算错。
类型状态写法转移骨架本节对应的题
线性 DPf[i]:前 i 个 / 以 i 结尾f[i] = max(f[j]) + 1(j < i)例题 P1091
背包f[j]:容量 j 的最大价值0/1 倒序 j;完全背包正序 j挑战 1 P1048
区间 DPf[i][j]:区间 [i,j] 的答案枚举断点 k,f[i][k] + f[k+1][j] + 代价挑战 2 P1063
状压 / 数位f[S] 或 f[pos][cnt]枚举这一位填什么,把新状态累加进去下方 30 行的数位统计

表里第四行本节没有配题,但它和第 38 节的组合计数共用一个动作:按位统计贡献。看到“1 到 n 中数字 d 出现了多少次”,先想“固定某一位是 d,这一位贡献几次”,再想“高位有哪些限制”。限制想得清,就是 O(log n) 的数学;想不清,就退回数位 DP 的记忆化框架,别在考场上硬推。下一块把这条路走完。

第 34 节没配题:三十行补完数位统计

数位题的框架只有一句话:把数字一位一位填,记录“这一位能不能随便填”。如果限制只跟某一位有关,不必写记忆化搜索,直接数每一位的贡献更快、更短。

以“1 到 x 中数字 d 出现了几次”为例。固定一个位权 p(1、10、100…),把 x 拆成高位 high、当前位 cur、低位 low。这一位填 d 的取法分三类:

当前位与 d 的关系这一位的贡献理由
cur > d(high + 1) × p高位取 0~high 都能填 d,低位 p 种随便填
cur == dhigh × p + low + 1高位取 0~high−1 时低位随便填;高位取 high 时低位只能 0~low
cur < dhigh × p高位最多取到 high−1,低位随便填

d = 0 要单独处理:最高位不可能是 0,所以 cur == 0 时贡献是 (high − 1) × p + low + 1,其余情况是 high × p;high == 0 说明已经数到最高位,直接退出。这一条漏了,前导零会被当成真的 0 计进去,答案偏大。

P2602 数字计数的 O(log n) 写法
#include <bits/stdc++.h>
using namespace std;

long long countDigit(long long x, int d) {      // 统计 1..x 中数字 d 出现的次数
    long long res = 0;
    for (long long p = 1; p <= x; p *= 10) {    // p 是当前处理的位权
        long long high = x / (p * 10);
        int cur = (int)(x / p % 10);
        long long low = x % p;

        if (d == 0) {                           // 0 不能出现在最高位,单独算
            if (high == 0) break;
            if (cur == 0) res += (high - 1) * p + low + 1;
            else          res += high * p;
        } else {
            if (cur > d)       res += (high + 1) * p;
            else if (cur == d) res += high * p + low + 1;
            else               res += high * p;
        }
    }
    return res;
}

int main() {
    long long a, b;
    cin >> a >> b;                              // 输出 [a,b] 中 0~9 各出现几次
    for (int d = 0; d <= 9; ++d)
        cout << countDigit(b, d) - countDigit(a - 1, d) << (d == 9 ? "\n" : " ");
    return 0;
}

输入 1 99,输出 9 20 20 20 20 20 20 20 20 20 20:0 出现 9 次(10、20、…、90),1~9 各 20 次(个位 10 次 + 十位 10 次)。这份输出就是自测数据;数位 DP 版和这一版都过,才算真懂。

数论那四块的知识点都是单行公式,考前扫一遍比读十页推导有用:

考点必背式子用错会怎样
gcd / lcmgcd(a,b) · lcm(a,b) = a · b直接写 a*b/g 求 lcm,10⁹ 就溢出成负数
快速幂ab = (ab/2)² · ab mod 2,每步 % p循环 b 次,b = 10⁹ 时 TLE
组合数C(n,m) = C(n−1,m) + C(n−1,m−1)用阶乘硬算,n = 2000 就溢出
容斥|A∪B∪C| = Σ|A| − Σ|A∩B| + |A∩B∩C|符号不交替,答案偏大
期望E(X+Y) = E(X) + E(Y)以为必须独立才能相加,白推一小时
DP 的 4 个高频失分点(按症状找病因)

WA,答案恰好差 1:初始化漏了。LIS 的 f[i] 忘了赋 1、背包忘了 f[0] = 0、区间 DP 忘了 f[i][i] = 0。

WA,小数据对、大数据偏大:0/1 背包把容量写成正序。正序时同一件物品被反复选,样例常常看不出来。

RE(段错误):区间 DP 断环复制后数组只开了 n,实际要访问到 2n;或第二维写成了 n 而不是 2n。

TLE:区间 DP 写成了 O(n⁴)(枚举 i、j、k 之外又套一层扫描),或者把 10⁷ 的筛法写成对每个数试除到 √n。

数论的 4 个高频失分点

WA,只有大样例错:乘法没开 long long。a、b 都是 10⁹ 时 a*b 已经溢出,取模后是负数。写法固定成 1LL * a * b % p。

WA,答案差一个:区间端点。求 [a,b] 用 solve(b) − solve(a−1),写成 solve(b) − solve(a) 会少算 a 本身。

WA,n = 1 或全是 0 时错:筛法边界。i 从 2 开始、comp[1] 当非素数;分解时试除到 i*i ≤ n,结束后若 n > 1,它本身就是一个素数因子。

RE / MLE:筛到 10⁸ 还开 int 数组(400 MB)。布尔筛用 bool,10⁸ 只要 100 MB;能筛到 10⁷ 就别筛到 10⁸。

自己动手复现

关掉这一页,把 P1091 的代码手敲一遍提交。卡住的那一行,就是你没真懂的地方。

然后把 a[j] < a[i] 改成 a[j] <= a[i],用样例再交一次。答案从 4 变成了几?想清楚为什么是变小而不是变大。

最后把 f、g 两遍扫描塞进同一个 for:第 t 次迭代用 i = t 算 f,再用 i = n − t + 1 算 g。为什么这样两个方向都能满足?

从暴力到正解:固定五步
  1. 写暴力:先写 O(2ⁿ) 或 O(n³) 的枚举,确保小样例过。暴力是正解的对照组,也是部分分的保险。
  2. 数状态:暴力里哪些参数在变?把它们写成数组下标。参数超过两个,先想能不能砍掉一个。
  3. 定转移:从“最后一步”倒推,写出 f[...] 由哪些更小的 f[...] 拼成。写完先手算一组小数据。
  4. 定顺序:转移依赖的下标必须先算。背包靠倒序保证“只用一次”,区间 DP 靠长度从小到大保证子区间已算。
  5. 对拍:暴力 + 正解 + 随机数据,跑 100 组。这一步能抓住九成的初始化与边界错误。

数论题的版本是:先枚举(O(n) 或 O(√n))→ 找等价式(gcd·lcm = P·Q、C(n,m) 的递推)→ 算复杂度(能不能过 10⁷)→ 查 long long 与取模。四步都在纸上完成,键盘只负责翻译。

本次训练 · 共 5 题

做题方式

限时 100 分钟。每题先自己写 20~25 分钟;写不出来先看提示,再给自己 10 分钟;还不行才点开思路。每题都独立限时,不要一题耗死。

大题 挑战题 1 · 采药
洛谷 P1048普及-去洛谷做这题 ↗

山洞里有 M 株草药,采第 i 株需要 ti 的时间,能得到 vi 的价值。总时间只有 T,每株草药最多采一次,求能采到的最大总价值。

输入格式:第一行两个整数 T、M(1 ≤ T ≤ 1000,1 ≤ M ≤ 100);接下来 M 行,每行两个整数 ti、vi(1 ≤ ti ≤ T,1 ≤ vi ≤ 100)。

输出格式:一行一个整数,表示最大总价值。

引导性提示(先只看这里)

每株草药只有“采”和“不采”两种选择,而且每株最多一次——这是哪种背包?

把“时间”当容量,“价值”当物品价值,dp 数组只要一维就够。

一维数组的关键是循环方向:想清楚更新 f[j] 时,f[j − t] 是本轮的还是上一轮的。

想看思路了?点开完整思路

设 f[j] = 用不超过 j 的时间能拿到的最大价值,初值全 0(什么都不采,价值 0)。

对每株草药 i:不采它,f[j] 不变;采它,f[j] = f[j − t[i]] + v[i]。两者取大。

容量必须倒序枚举。正序时 f[j − t[i]] 已经被第 i 株更新过,等于允许同一株采两次,答案偏大。

答案就是 f[T],复杂度 O(M·T) = 10⁵,时间与空间都宽松。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int T, M;
int t[105], v[105];
int f[1005];                        // f[j]:时间不超过 j 时的最大价值

int main() {
    cin >> T >> M;
    for (int i = 1; i <= M; ++i) cin >> t[i] >> v[i];

    for (int i = 1; i <= M; ++i)
        for (int j = T; j >= t[i]; --j)          // 倒序:保证每株草药只用一次
            f[j] = max(f[j], f[j - t[i]] + v[i]);

    cout << f[T] << "\n";
    return 0;
}
关键片段拆解
片段作用
int f[1005];T ≤ 1000,开 1005。一维数组是“滚动掉物品维”的结果:第 i 轮覆盖第 i−1 轮。
for (int j = T; j >= t[i]; --j)倒序是 0/1 背包的全部秘密。j 从大到小,f[j − t[i]] 还没被本轮改过。
f[j] = max(f[j], f[j - t[i]] + v[i]);不采(f[j])与采(f[j−t[i]] + v[i])取大。
cout << f[T];f[T] 是“时间不超过 T”的最优值,不必再扫一遍 max。
这题最容易错的地方
  • 容量写成正序 → 变成完全背包,样本能过、大数据偏大。
  • 把 t[i] 和 v[i] 读反(题目先时间后价值)。
  • f 数组按物品数开成 105,越界或 RE。
大题 挑战题 2 · 能量项链
洛谷 P1063提高-去洛谷做这题 ↗

项链由 n 颗珠子串成一圈。第 i 颗珠子有头标记 ai、尾标记 ai+1(第 n 颗的尾标记是 a1)。相邻两颗可以合并:若前一颗是 (a, b)、后一颗是 (b, c),合并后得到 (a, c),同时释放 a × b × c 点能量。

求把整圈合并成一颗珠子,最多能释放多少能量。

输入格式:第一行一个整数 n(4 ≤ n ≤ 100);第二行 n 个正整数 ai(1 ≤ ai ≤ 10⁴)。

输出格式:一行一个整数,表示最大总能量。

引导性提示(先只看这里)

合并只能在相邻的两颗之间发生,而且最后一次合并一定把“环上的某一段”切成两半。

把环拉直的做法是把数组复制一份接在后面,然后只看长度为 n 的区间。

区间 DP 的枚举顺序是:长度从小到大 → 区间起点 → 断点。想清楚为什么长度必须在最外层。

想看思路了?点开完整思路

断环成链:把 a 复制一份,a[i + n] = a[i]。于是任何长度为 n 的连续区间都对应环上的一种合并顺序。

设 f[i][j] 表示把第 i..j 颗珠子合并成一颗的最大能量。最后一次合并把区间分成 [i,k] 与 [k+1,j],合并后的左块是 (a[i], a[k+1]),右块是 (a[k+1], a[j+1]),释放 a[i] × a[k+1] × a[j+1]。

转移:枚举断点 k,f[i][j] = max( f[i][k] + f[k+1][j] + a[i]·a[k+1]·a[j+1] )。

答案 = max f[i][i+n−1](i = 1…n),因为环有 n 种断法。复杂度 O(n³) = 10⁶。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int n, a[205];
long long f[205][205];      // f[i][j]:把第 i..j 颗珠子合成一颗的最大能量

int main() {
    cin >> n;
    for (int i = 1; i <= n; ++i) { cin >> a[i]; a[i + n] = a[i]; }  // 断环成链

    for (int len = 2; len <= n; ++len)                  // 长度从小到大,保证小区间先算
        for (int i = 1; i + len - 1 < 2 * n; ++i) {     // 右端点 j <= 2n-1:能量要用 a[j+1],j = 2n 就越界
            int j = i + len - 1;
            for (int k = i; k < j; ++k)                 // 最后一次合并的断点
                f[i][j] = max(f[i][j],
                              f[i][k] + f[k + 1][j] + 1LL * a[i] * a[k + 1] * a[j + 1]);
        }

    long long ans = 0;
    for (int i = 1; i <= n; ++i) ans = max(ans, f[i][i + n - 1]);   // n 种断法
    cout << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
a[i + n] = a[i];环形问题第一步:复制一份接在后面,任何长度 n 的区间都对应环上的一种合并顺序。
long long f[205][205];一次合并最多释放 10¹²(三个 10⁴ 相乘),n−1 次累加可到 10¹⁴,int 装不下。
for (int len = 2; len <= n; ++len)按长度从小到大:算长度 len 时,所有更短的子区间已经算好。
for (int k = i; k < j; ++k)枚举最后一次合并的分界:左边合成 (a[i], a[k+1]),右边合成 (a[k+1], a[j+1])。
1LL * a[i] * a[k + 1] * a[j + 1]合并释放的能量。三个 10⁴ 相乘是 10¹²,int 直接溢出。
ans = max(ans, f[i][i + n - 1]);长度为 n 的区间有 n 个起点,正好对应环的 n 种断法,取最大。
这题最容易错的地方
  • 数组只开 n 而不是 2n,复制后越界,RE。
  • 循环写成 i + len - 1 <= 2 * n,右端点取到 2n 时 a[j+1] 读到空白值,答案偏小且不报错。
  • len 从 1 开始:长度为 1 不需要合并,硬算会读到 f[i][i−1] 这种空白区域。
  • 能量用 int 存,ai = 10⁴ 时乘积 10¹² 溢出成负数。
  • 答案只取 f[1][n],漏掉“从别处断开”的更优解。
大题 挑战题 3 · 回文质数
洛谷 P1217普及-去洛谷做这题 ↗

回文质数是指既是质数、又是回文数的整数(回文数指正着读反着读一样)。给定 a、b,按从小到大输出 [a, b] 内所有回文质数,每行一个。

输入格式:一行两个整数 a、b(5 ≤ a ≤ b ≤ 10⁸)。

输出格式:每行一个回文质数,按升序输出。

引导性提示(先只看这里)

要输出区间内所有满足条件的数,意味着你得先有一个“谁是质数”的表,而不是对每个数单独判。

回文数有一个性质:偶数位的回文数一定能被 11 整除。想一想这对 10⁸ 的范围意味着什么。

标记倍数时从 i × i 开始——更小的倍数已经被更小的质数标记过了。

想看思路了?点开完整思路

先筛出 b 以内的所有质数(埃氏筛),再对每个质数判回文。

关键剪枝:偶数位的回文数一定能被 11 整除,所以 8 位数的回文数全都不是质数;b ≤ 10⁸ 时 9 位数只有 100000000,它不是回文。于是 b 可以直接截到 9999999,1 亿的筛法变成 1 千万,时间空间都省一个量级。

筛法:comp[i] 表示 i 被标记为合数;i 未标记就是素数,把 i²、i²+i、… 全部标记。内层循环的起点必须是 i²,否则 10⁷ 的筛法会退化成 O(n log n) 且常数很大。

判回文用整数翻转:把 x 的十进制位翻过来,和原数比较,空间 O(1),比转字符串快。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int N = 10000005;
bool comp[N];                       // comp[i] = true 表示 i 是合数

bool pal(int x) {                   // 判回文:翻过来看是否相等
    int t = x, r = 0;
    while (t) { r = r * 10 + t % 10; t /= 10; }
    return r == x;
}

int main() {
    int a, b;
    cin >> a >> b;
    b = min(b, 9999999);            // 8 位回文数必被 11 整除,截掉它们不丢答案

    for (int i = 2; i <= b; ++i) {
        if (!comp[i]) {             // i 没被标记 → i 是素数
            if (i >= a && pal(i)) cout << i << "\n";
            for (long long j = 1LL * i * i; j <= b; j += i)
                comp[j] = true;     // 从 i*i 开始标记,更小的倍数早被筛过
        }
    }
    return 0;
}
关键片段拆解
片段作用
b = min(b, 9999999);偶数位回文数必被 11 整除,8 位数的回文数都不是质数,所以截到 9999999 不丢答案。
if (!comp[i])埃氏筛的核心:从小到大,没被标记过的就是素数。
for (long long j = 1LL * i * i; ...)j 必须是 long long:i 接近 10⁷ 时 i*i 溢出 int,变成负数后死循环。
if (i >= a && pal(i))先筛出素数再判回文,比“枚举回文再判素数”省事:素数表已经给全了。
r = r * 10 + t % 10;整数翻转。回文判据是 r == x,不需要字符串,也没有前导零问题。
这题最容易错的地方
  • 写成 for (int j = i * i; ...),i > 46340 后 i*i 溢出,RE 或死循环。
  • 筛到 10⁸ 还用 int 数组,400 MB 直接 MLE;bool 只要 100 MB,能筛到 10⁷ 就别筛 10⁸。
  • 忘记 b 可能大于 10⁷,不做截断,时间翻几倍。
  • 把 1 当成素数输出。i 从 2 开始就不会有这个问题。
大题 挑战题 4 · 最大公约数和最小公倍数问题
洛谷 P1029普及-去洛谷做这题 ↗

已知两个正整数 P、Q 的最大公约数是 x0,最小公倍数是 y0。求满足条件的 (P, Q) 有多少对,其中 (P, Q) 与 (Q, P) 算两对。

输入格式:一行两个正整数 x0、y0。

输出格式:一行一个整数,表示满足条件的对数;不存在时输出 0。

引导性提示(先只看这里)

先用一个等式把 P、Q 的关系压缩成两个数之间的关系,再想枚举什么。

P 和 Q 都是 x0 的倍数,把它们写成 x0·i 和 x0·j,代进等式看看。

枚举 n 的因子只需要到 √n,再检查这两个因子是否互质。

想看思路了?点开完整思路

由 gcd(P,Q) · lcm(P,Q) = P · Q 得 P·Q = x0·y0。x0 不整除 y0 时无解。

令 P = x0·i、Q = x0·j,代入 P·Q = x0·y0 得 i·j = y0/x0 = n。

gcd(P,Q) = x0·gcd(i,j),要求等于 x0,所以 gcd(i,j) = 1。于是问题变成:枚举 n 的因子对 (i, j),数其中互质的有几对。

枚举到 √n 即可:每找到一个因子 i 就得到一对 (i, n/i),互质则答案加 2((i,j) 和 (j,i));i == n/i 时只加 1,避免重复。复杂度 O(√n)。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    long long x, y;
    cin >> x >> y;

    if (y % x) { cout << 0 << "\n"; return 0; }   // x 不整除 y,无解
    long long n = y / x, ans = 0;

    for (long long i = 1; i * i <= n; ++i) {
        if (n % i) continue;                     // i 不是 n 的因子
        long long j = n / i;
        if (__gcd(i, j) == 1)                    // i、j 互质,还原出的 P、Q 的 gcd 才恰好是 x
            ans += (i == j) ? 1 : 2;             // 有序对:(i,j) 与 (j,i) 各算一次
    }

    cout << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
if (y % x)P、Q 都是 x 的倍数,所以 x 必须整除 y;不整除时答案直接是 0。
n = y / x;把 x 从两个数里约掉:i、j 满足 i·j = n 且 gcd(i,j) = 1。枚举范围从 y 降到 √n。
for (i = 1; i * i <= n; ++i)枚举因子只到 √n,n 再大也只要几万次。i 用 long long 防 i*i 溢出。
__gcd(i, j) == 1互质是“gcd(P,Q) 恰好为 x”的充要条件。不互质说明还有公因子,gcd 会更大。
ans += (i == j) ? 1 : 2;(i,j) 与 (j,i) 是两个不同的有序解,各算一次;i == j 时只有一个解,只算一次。
这题最容易错的地方
  • 漏了 i == j 的特判,把 (i,i) 算成两对。
  • 用 int 存 P·Q:x·y 一超过 2×10⁹ 就溢出,直接 long long 省心。
  • 从 1 枚举到 y 逐个检查 gcd,O(y) 超时。
  • 忘记 gcd·lcm = P·Q 这条关系,直接两重循环。
大题 挑战题 5 · 组合数问题
洛谷 P2822普及/提高-去洛谷做这题 ↗

组合数 C(i,j) 表示从 i 个不同物品中选出 j 个的方案数,规定 j > i 时 C(i,j) = 0。给定 k 和 t 组询问,每组给出 n、m,求有多少对 (i, j) 满足 0 ≤ i ≤ n、0 ≤ j ≤ min(i, m),且 C(i,j) 是 k 的倍数。

输入格式:第一行两个整数 t、k(1 ≤ t ≤ 10⁴,2 ≤ k ≤ 10⁹);接下来 t 行,每行两个整数 n、m(0 ≤ n, m ≤ 2000)。

输出格式:t 行,每行一个整数,表示该组询问的答案。

引导性提示(先只看这里)

C(i,j) 可以用杨辉三角 O(n²) 全算出来,但真值有几百位,存不下——你只需要知道它是不是 k 的倍数。

每个询问问的是一块区域里有多少个满足条件的位置,位置总数是 n² 级别,逐组统计一定超时。

把每个位置标成 0/1 之后,询问就变成了区域求和。第 18 节学过一个工具,能让区域求和做到 O(1)。

想看思路了?点开完整思路

杨辉三角只保留模 k 的余数:C[i][j] = (C[i−1][j−1] + C[i−1][j]) % k。余数为 0 就是 k 的倍数。

把“C[i][j] 是 k 的倍数”记成 0/1 矩阵 bad[i][j],并把 j > i 的格子强制置 0。这样矩形 [0..n] × [0..min(n,m)] 的和就恰好是答案——多出来的 j > i 格子贡献为 0。

对 bad 做二维前缀和:s[i][j] = bad[i][j] + s[i−1][j] + s[i][j−1] − s[i−1][j−1]。每次询问 O(1)。

预处理 O(N²) ≈ 4×10⁶,询问 O(1),总复杂度由预处理决定,完全够用。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int N = 2005;
int t, k;
int C[N][N];        // 杨辉三角模 k 的余数
int s[N][N];        // bad 的二维前缀和

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> t >> k;

    for (int i = 0; i < N; ++i) {
        C[i][0] = C[i][i] = 1 % k;              // 两条边界:C(i,0) = C(i,i) = 1
        for (int j = 1; j < i; ++j)
            C[i][j] = (C[i - 1][j - 1] + C[i - 1][j]) % k;   // 只要余数
    }

    for (int i = 0; i < N; ++i)
        for (int j = 0; j < N; ++j) {
            int bad = (j <= i && C[i][j] == 0) ? 1 : 0;      // j > i 不是合法组合数
            s[i][j] = bad;
            if (i) s[i][j] += s[i - 1][j];
            if (j) s[i][j] += s[i][j - 1];
            if (i && j) s[i][j] -= s[i - 1][j - 1];          // 二维容斥
        }

    while (t--) {
        int n, m;
        cin >> n >> m;
        if (m > n) m = n;                       // 题目要求 j <= min(i, m)
        cout << s[n][m] << "\n";
    }
    return 0;
}
关键片段拆解
片段作用
C[i][0] = C[i][i] = 1 % k;杨辉三角的两条边界都是 1。写 1 % k 是防御写法:k 整除 1 时也不会错。
C[i][j] = (C[i-1][j-1] + C[i-1][j]) % k;只要余数:C(2000,1000) 有 600 位,存不下;判断倍数只看余数。
int bad = (j <= i && C[i][j] == 0);j > i 的格子不是合法组合数,必须置 0,否则前缀和会把空白区算进去。
s[i][j] = bad + s[i-1][j] + s[i][j-1] - s[i-1][j-1];二维前缀和:以 (i,j) 为右下角的矩形里有多少个 bad。
if (m > n) m = n;要求 j ≤ min(i, m)。m 超过 n 时直接截到 n,否则会查到无意义的区域。
cout << s[n][m];查询 O(1)。预处理 4×10⁶ 次,1 万组询问也不会超时。
这题最容易错的地方
  • 用 int 存真实组合数,n = 2000 就溢出,答案全错。
  • 忘了把 j > i 的格子置 0,前缀和把下三角以外算进去,答案偏大。
  • 每组询问都重新递推杨辉三角,O(t·n²) 超时。
  • 数组只开 2000 而 n 能取到 2000:下标 2000 越界,必须开 2005。
做完之后自问
延伸阅读