0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P1036,日期见页头。两边不一致时信原站。
题目描述
已知 n 个整数 x1, x2, ..., xn,以及 1 个整数 k(k < n)。
从 n 个整数中任选 k 个整数相加,可分别得到一系列的和。
例如当 n = 4、k = 3,4 个整数分别为 3, 7, 12, 19 时,可得全部的组合与它们的和为:
3 + 7 + 12 = 22
3 + 7 + 19 = 29
7 + 12 + 19 = 38
3 + 12 + 19 = 34
现在,要求你计算出和为素数共有多少种。例如上例,只有一种的和为素数:3 + 7 + 19 = 29。
输入格式:第一行两个空格隔开的整数 n、k(1 ≤ n ≤ 20,k < n);
第二行 n 个整数 x1, x2, ..., xn(1 ≤ xi ≤ 5 × 10^6)。
输出格式:输出一个整数,表示种类数。
来源:NOIP 2002 普及组第二题。
1先看清楚:这道题是两件事拼起来的
第一件:怎么「任选 k 个」而不重不漏 -> 组合枚举(和 P1157 是同一件事)
第二件:怎么判一个数是不是素数 -> 试除法,而它有一个必写的边界
★ 这道题的两个坑,一个在第一件事上,一个在第二件事上,而且两个都躲得过样例。
输入
4 3 3 7 12 19
输出
1
题面里举的那个例子就是它的样例。四个和里只有 29 是素数,所以答案是 1。
上面那段输出是仓库里的 p1036.cpp 真跑出来的。
2第 ① 版:用排列去枚举(答案偏大)
题目说「任选 k 个」,而手边最熟的枚举就是全排列。于是很自然地写出: 每一层挑一个还没用过的数,挑够 k 个就求和判素。
// 洛谷 P1036 选数 —— 第 ① 版:用「排列」去枚举(**答案偏大**)//// 输入:第一行 n k;第二行 n 个整数// 输出:本该是「和为素数」的选法种数//// 这份为什么存在:这道题要「任选 k 个相加」,而手边最熟的枚举就是全排列 ——// 于是很多人写出了一份**枚举排列**的代码:每一层挑一个还没用过的数,挑够 k 个就求和判素。//// ⚠ 它把同一组数**数了 k! 遍**:{3,7,19} 和 {7,3,19} 在它眼里是两回事,// 可题目说的是「任选 k 个」—— 顺序根本不算数。// 样例 n=4 k=3 的正确答案是 1,这一版会给 6(正好 3! = 6 倍)。//// ★★ 这个病和 [P1157 组合的输出] 是**同一个**:排列树天生会把同一个组合数很多遍。// 治法也一样:**换成组合的枚举方式 —— 下一个数只从「上一个之后」挑**。// ⇒ 见第 ② 版那一行 for 的起点。
#include <bits/stdc++.h>using namespace std;
int n, k;int x[25];bool used[25];long long ans = 0;
bool isPrime(long long v) { if (v < 2) return false; for (long long i = 2; i * i <= v; i++) if (v % i == 0) return false; return true;}
void dfs(int step, long long sum) { if (step == k) { if (isPrime(sum)) ans++; return; } for (int i = 0; i < n; i++) { if (used[i]) continue; used[i] = true; dfs(step + 1, sum + x[i]); used[i] = false; }}
int main() { if (!(cin >> n >> k)) return 0; for (int i = 0; i < n; i++) cin >> x[i]; dfs(0, 0); cout << ans << "\n"; return 0;}点「运行 ▶」看结果
{3,7,19} 和 {7,3,19} 在它眼里是两回事,可题目说的是「任选 k 个」—— 顺序根本不算数。
正确答案 1
它给的 6 = 1 × 3!★★ 这个病和 P1157 组合的输出 是同一个:
排列树天生会把同一个组合数很多遍,而多出来的倍数正好是 k!。
⇒ 治法也一样:换成组合的枚举方式 —— 下一个数只从「上一个之后」挑:
// 排列:每层从头挑,靠 used[] 挡住用过的
for (int i = 0; i < n; i++) { if (used[i]) continue; ... }
// 组合:每层只从 start 往后挑,天生递增、天生不重复
for (int i = start; i < n; i++) dfs(step + 1, i + 1, sum + x[i]);⚠ 顺带一提:这一版只是答案错,速度没问题。
A(20,10) 虽然是六千多亿,但这道题 k < n ≤ 20 且它是边选边加的……
别指望速度会替你暴露这个错。 它就是个安静的 WA。
3第 ② 版:组合修好了,但 1 被当成了素数
重复的问题治好了,这一版把注意力挪到第二件事上 —— 然后在那儿踩了坑:
bool isPrime(long long v) {
for (long long i = 2; i * i <= v; i++) if (v % i == 0) return false;
return true; // ⚠ v = 1 时循环一次都不进,直接 return true
}
// 洛谷 P1036 选数 —— 第 ② 版:组合枚举修好了,但 isPrime 把 1 当成了素数//// 输入 / 输出:同第 ① 版//// 这份为什么存在:第 ① 版的重复问题已经治好了(for 的起点改成 start),// **但素数判断写错了一个边界**,而这个边界极容易漏。//// bool isPrime(long long v) {// for (long long i = 2; i * i <= v; i++) if (v % i == 0) return false;// return true; // ⚠ v = 1 时循环一次都不进,直接返回 true// }//// ⇒ **1 被判成了素数。** 而 1 不是素数(素数的定义要求大于 1)。//// ⚠ 什么时候会咬到你:k 个数的和等于 1 —— 也就是 k = 1 且那个数是 1。// 题面允许 x[i] >= 1、k >= 1,所以这是**合法输入**。// ⚠⚠ 但样例里没有这种数据,**样例一定是过的**。//// ★ 这一课比这道题大得多:// **判素数的函数,v < 2 那一行是本体,不是补丁。** 少写它的代价是一个只在特定数据上现形的 WA。
#include <bits/stdc++.h>using namespace std;
int n, k;int x[25];long long ans = 0;
bool isPrime(long long v) { // ⚠ 少了一行 if (v < 2) return false; for (long long i = 2; i * i <= v; i++) if (v % i == 0) return false; return true;}
void dfs(int step, int start, long long sum) { if (step == k) { if (isPrime(sum)) ans++; return; } for (int i = start; i < n; i++) dfs(step + 1, i + 1, sum + x[i]);}
int main() { if (!(cin >> n >> k)) return 0; for (int i = 0; i < n; i++) cin >> x[i]; dfs(0, 0, 0); cout << ans << "\n"; return 0;}点「运行 ▶」看结果
素数的定义要求大于 1。少写 if (v < 2) return false; 的后果是 1 被判成素数。
什么时候会咬到你:k 个数的和等于 1 —— 也就是 k = 1 且那个数是 1。
题面允许 xi ≥ 1、k ≥ 1,所以这是完全合法的输入。
⚠⚠ 而样例(4 3 / 3 7 12 19)里根本没有这种数据,它一定是过的。
★ 这一课比这道题大得多:
判素数函数里 v < 2 那一行是本体,不是补丁。
少写它的代价,是一个只在特定数据上现形的 WA —— 而你多半不会去造那种数据。
⇒ 顺带记住另外两个同类边界:v = 2 必须判成素数(试除循环一次都不进,靠 return true 兜住,
这一点两版都是对的);v 可能很大时,循环条件写 i * i <= v 而不是 i <= sqrt(v)
(后者每轮都算一次浮点开方,慢且有精度风险)。
4第 ③ 版:两处都修好(正解)
// 洛谷 P1036 选数 —— 第 ③ 版:组合枚举 + 正确的素数判断(正解)//// 输入:第一行 n k(1 <= n <= 20,k < n);第二行 n 个整数(1 <= x[i] <= 5e6)// 输出:和为素数的选法种数//// 两处关键,各治一个前面踩过的坑://// ① for 的起点是 start,不是 0 —— 选出来的下标天生递增 ⇒ 每个组合只数一次// (和 P1157 是同一招)// ② isPrime 的第一行是 if (v < 2) —— 1 不是素数//// 工作量估一估(这一步应该在动手前做):// 最多 C(20,10) = 184 756 个组合,每个判一次素数最多 sqrt(20 * 5e6) ≈ 10^4 次试除。// ⇒ 最坏一亿多次除法,而绝大多数合数会被 2、3 这种小因子当场筛掉 ——// 真正跑满 10^4 的只有那些真素数。1.5 秒的时限够用。//// ★ 「估一估再动手」这一步只要三秒钟,却能省掉「写完才发现要重写」的整轮返工。
#include <bits/stdc++.h>using namespace std;
int n, k;int x[25];long long ans = 0;
bool isPrime(long long v) { if (v < 2) return false; // ★ 本体,不是补丁 for (long long i = 2; i * i <= v; i++) if (v % i == 0) return false; return true;}
// 已经选了 step 个,下一个只能从下标 start 往后挑,当前和是 sumvoid dfs(int step, int start, long long sum) { if (step == k) { if (isPrime(sum)) ans++; return; } for (int i = start; i < n; i++) dfs(step + 1, i + 1, sum + x[i]);}
int main() { if (!(cin >> n >> k)) return 0; for (int i = 0; i < n; i++) cin >> x[i]; dfs(0, 0, 0); cout << ans << "\n"; return 0;}点「运行 ▶」看结果
两处关键,各治一个坑:
① for 的起点是 start,不是 0 选出来的下标天生递增 => 每个组合只数一次
② isPrime 的第一行是 if (v < 2) 1 不是素数
最多的组合数 C(20,10) = 184 756
每个和最大 20 × 5×10^6 = 10^8
判一次素数最多 sqrt(10^8) = 10^4 次试除最坏情况看着是一亿多次除法,但实际远没那么糟: 绝大多数合数会被 2、3 这种小因子当场筛掉,真正跑满 10^4 的只有那些真素数 (10^8 附近素数密度约 1/18)。1.5 秒的时限够用。
★ 这一步的价值不在于算得多准,而在于它让你在动手前就知道「这条路走不走得通」。 估完发现不行,就该去想别的做法,而不是写完了才发现要重写。
5回头看:这道题在教什么
- 「任选 k 个」是组合,不是排列。 用排列去枚举,答案会正好大
k!倍 —— 和 P1157 是同一个病、同一个治法(把for的起点换成start)。 isPrime的v < 2是本体。 这类边界的共同特点是:样例躲得过,测试点躲不过。- 动手前估一估工作量。 三秒钟,换掉一整轮返工。
⚠ 还有一件事值得单独说:这道题的两个错都不会让程序变慢或者崩溃, 它们只会给出一个安安静静的错误答案。 ⇒ 别指望「跑起来没事」等于「写对了」。