题单 · 习题解析

洛谷 P1036 选数

两个坑各在一件事上:用排列枚举会大 k! 倍,isPrime 少一行会把 1 当素数

原题:洛谷 P1036出自 第 3 章 递归 = 决策树:子集、组合、全排列 的题单题面本地存档:2026-08-25
⚠ 先自己写一遍,再往下看

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

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

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

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

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

题目描述

已知 n 个整数 x1, x2, ..., xn,以及 1 个整数 k(k < n)。 从 n 个整数中任选 k 个整数相加,可分别得到一系列的和。 例如当 n = 4k = 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 ≤ 20k < n); 第二行 n 个整数 x1, x2, ..., xn1 ≤ xi ≤ 5 × 10^6)。

输出格式:输出一个整数,表示种类数。

来源:NOIP 2002 普及组第二题。

1先看清楚:这道题是两件事拼起来的

第一件:怎么「任选 k 个」而不重不漏     -> 组合枚举(和 P1157 是同一件事)
第二件:怎么判一个数是不是素数          -> 试除法,而它有一个必写的边界

★ 这道题的两个坑,一个在第一件事上,一个在第二件事上,而且两个都躲得过样例

输入

4 3
3 7 12 19

输出

1

题面里举的那个例子就是它的样例。四个和里只有 29 是素数,所以答案是 1。 上面那段输出是仓库里的 p1036.cpp 真跑出来的。

2第 ① 版:用排列去枚举(答案偏大)

题目说「任选 k 个」,而手边最熟的枚举就是全排列。于是很自然地写出: 每一层挑一个还没用过的数,挑够 k 个就求和判素。

p1036Perm.cpp第 ① 版(答案偏大)
样例应该输出 1,它给 6。6 = 3!,正好是 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 它把同一组数数了 k! 遍

{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
}
p1036One.cpp第 ② 版(边界错)
这组数据:k=1,可选的和只有 1 和 4,都不是素数,答案该是 0。它给 1。
// 洛谷 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 1 不是素数 —— 而这个边界躲得过样例

素数的定义要求大于 1。少写 if (v < 2) return false; 的后果是 1 被判成素数

什么时候会咬到你:k 个数的和等于 1 —— 也就是 k = 1 且那个数是 1。 题面允许 xi ≥ 1k ≥ 1,所以这是完全合法的输入

⚠⚠ 而样例(4 3 / 3 7 12 19)里根本没有这种数据,它一定是过的

★ 这一课比这道题大得多:

判素数函数里 v < 2 那一行是本体,不是补丁。 少写它的代价,是一个只在特定数据上现形的 WA —— 而你多半不会去造那种数据。

⇒ 顺带记住另外两个同类边界:v = 2 必须判成素数(试除循环一次都不进,靠 return true 兜住, 这一点两版都是对的);v 可能很大时,循环条件写 i * i <= v 而不是 i <= sqrt(v) (后者每轮都算一次浮点开方,慢且有精度风险)。

4第 ③ 版:两处都修好(正解)

p1036.cpp第 ③ 版(正解)
样例给 1。再试试上一步那组 2 1 / 1 4 —— 这一版给 0。
// 洛谷 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 往后挑,当前和是 sum
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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

两处关键,各治一个坑:

① 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回头看:这道题在教什么

✓ 三件带得走的东西
  1. 「任选 k 个」是组合,不是排列。 用排列去枚举,答案会正好大 k! 倍 —— 和 P1157 是同一个病、同一个治法(把 for 的起点换成 start)。
  2. isPrimev < 2 是本体。 这类边界的共同特点是:样例躲得过,测试点躲不过。
  3. 动手前估一估工作量。 三秒钟,换掉一整轮返工。

⚠ 还有一件事值得单独说:这道题的两个错都不会让程序变慢或者崩溃, 它们只会给出一个安安静静的错误答案。 ⇒ 别指望「跑起来没事」等于「写对了」。