摘要 :本文系统整理了2019年至2025年CSP-J(入门级)信息学竞赛的历年真题,包含《数字游戏》、《优秀的拆分》、《分糖果》、《乘方》、《小苹果》、《扑克牌》、《拼数》共七道经典题目。每道题均提供思路要点 、解题步骤 、易错点分析 和C++参考代码,帮助初学者掌握字符串处理、进制转换、数学规律、模拟、贪心等核心算法思想。文章采用"题目列表→详细解析"的结构,适合备赛复习和算法入门学习。
说明 & 备考建议
题目都可以在洛谷上搜名称就会出来,题目名称也都加了链接点击就能跳转到做题页面。
T1 基本都是简单数学模拟不涉及复杂算法,难度在 【入门~普及-】之间。主要考验基本功,基础过关 10 分钟以内 AC 问题都不大。
一些需要注意的点:
- 遇到题目数据量大的不要上来就写暴力,一定要再找找规律进一步想有没有通用数学公式能推导出来。
- 边界情况和特殊 case 一定要考虑周全!越简单的题越容易在这些地方设坑。
- 想清楚输入数据和输出结果包括中间计算过程中可能的数据范围,统一定义成精度范围更大的类型,涉及数组一定要开够大,涉及到计数或累乘不要忘记赋初始值。
Day Day Up!祝大家都能在比赛取得好成绩!✿✿ヽ(°▽°)ノ✿
题目列表
| 年份 | 题目 | 知识点 | 难度 |
|---|---|---|---|
| 2019 | 数字游戏 | 字符串 | 入门 |
| 2020 | 优秀的拆分 | 模拟、进制 | 入门 |
| 2021 | 分糖果 | 数学 | 普及- |
| 2022 | 乘方 | 模拟、基础算法 | 入门 |
| 2023 | 小苹果 | 模拟、数学 | 普及- |
| 2024 | 扑克牌 | 模拟 | 入门 |
| 2025 | 拼数 | 贪心、排序 | 普及- |
题目详解
数字游戏
思路要点
题目要求非常简单:给定一个长度固定为 8 的字符序列(只包含 0 和 1),请你数一数里面到底出现了多少个字符 1。其核心本质是在考察:基础的字符读取、循环结构的运用以及条件判断(计数器思想)。
关键思路
在面对输入的数据时,我们脑海中通常会有以下几种读入方案:
-
当作一个整数读入 (例如
int n):虽然 8 位的由0和1组成的数字不会超出int的范围,但如果用数学方法去读入再按位拆解(不断%10和/10),会略为繁琐。 -
当作字符串读入 (例如
string s或char s[10]):读入后,再使用循环遍历整个字符串进行统计。这种解法很标准,完全正确。 -
当作独立字符逐个读入 (最优解法):既然我们只关心每个独立的字符是不是
1,且长度固定为 8 ,我们完全可以写一个执行 8 次的循环,每次只读入一个字符char c,读完立刻判断并统计。这样连数组和字符串都不用开,极其简洁高效!
解题步骤
我们以样例输入 00010100 为例,模拟代码的执行过程:
-
变量定义与初始化:
-
定义
int cnt = 0;(初始化计数器,准备记录字符1的数量。必须初始化为 0)。 -
定义
char c;(用于每次接收一个输入的字符)。
-
-
循环与数据处理(执行 8 次):
-
第 1 次循环 ( i = 1 i=1 i=1**)** :输入第 1 个字符,此时变量
c = '0'。判断c == '1'不成立,cnt保持为 0。 -
第 2 次循环 ( i = 2 i=2 i=2**)** :输入第 2 个字符,此时变量
c = '0'。判断c == '1'不成立,cnt保持为 0。 -
第 3 次循环 ( i = 3 i=3 i=3**)** :输入第 3 个字符,此时变量
c = '0'。判断c == '1'不成立,cnt保持为 0。 -
第 4 次循环 ( i = 4 i=4 i=4**)** :输入第 4 个字符,此时变量
c = '1'。判断c == '1'成立!执行cnt++,此时cnt= 1。 -
第 5 次循环 ( i = 5 i=5 i=5**)** :输入第 5 个字符,此时变量
c = '0'。判断c == '1'不成立,cnt保持为 1。 -
第 6 次循环 ( i = 6 i=6 i=6**)** :输入第 6 个字符,此时变量
c = '1'。判断c == '1'成立!执行cnt++,此时cnt= 2。 -
第 7 次循环 ( i = 7 i=7 i=7**)** :输入第 7 个字符,此时变量
c = '0'。判断c == '1'不成立,cnt保持为 2。 -
第 8 次循环 ( i = 8 i=8 i=8**)** :输入第 8 个字符,此时变量
c = '0'。判断c == '1'不成立,cnt保持为 2。
-
-
输出结果:
-
循环结束,执行
cout << cnt;。 -
最终输出结果:2。
-
本题易错点
-
坑一:字符
1与 数字1混淆要点提醒 :在
if判断中,必须写成c == '1'(带有单引号,表示字符),绝对不能 写成c == 1(这表示 ASCII 码值为 1 的控制字符,不是我们在屏幕上看到的数字符号)。 -
坑二:计数器未初始化
要点提醒:
int cnt = 0;这里的= 0必不可少。如果在 C++ 中仅仅声明int cnt;,它会被赋予一个随机的垃圾值,导致最终输出的结果完全错误。 -
坑三:当作整型处理
要点提醒: 有同学会尝试
int n; cin >> n;然后通过n % 10来剥离数字。虽然这题数据全是0和1,放在整型里不会越界,但这样做不仅增加了运算量(取模和除法比字符比较慢得多),而且代码逻辑会变得臃肿。我们要记住:处理没有数学运算意义的一长串数字,一律按字符或字符串处理!
参考代码
cpp
#include <iostream>
using namespace std;
int main() {
int cnt = 0; // 计数器,必须初始化为0
char c; // 用于存储每次读取的字符
for(int i = 1; i <= 8; i++) { // 题目明确说明字符串长度固定为 8
cin >> c; // cin 读入 char 类型时,会自动跳过空格和换行,每次只读一个字符
if(c == '1') {
cnt++; // 如果当前字符是 '1',计数器加 1
}
}
cout << cnt; // 输出最终的统计结果
return 0; // 竞赛好习惯,正常结束程序
}
优秀的拆分
思路要点
这道题披着"数字拆分"的外壳,其本质在考察:十进制转二进制(进制转换)与位运算的基础概念。
题目要求我们将一个正整数拆分成多个互不相同 的 2 2 2 的正整数次幂。注意这里的两个关键词:
-
互不相同:意味着每个次幂最多只能出现一次。
-
正整数次幂 :也就是 2 1 , 2 2 , 2 3 ... 2^1, 2^2, 2^3 \dots 21,22,23...,明确排除了 2 0 2^0 20(即 1 1 1)。
综上,题目就是让我们把数字转成二进制形式,看看最低位(代表 2 0 2^0 20)是不是 1 1 1。如果是 1 1 1,说明拆分里必须带上 1 1 1,不符合"正整数次幂"的要求;如果不是 1 1 1,就按从大到小的顺序输出所有二进制位为 1 1 1 对应的数值。
关键思路
当我们看到"拆分为若干个不同的 2 2 2 的次幂"时,应该立刻产生条件反射------这不就是二进制的定义吗?
任何一个十进制正整数,都可以唯一地表示为若干个不同的 2 2 2 的次幂之和。例如:十进制的 10 10 10(二进制为 1010 1010 1010),就等于 2 3 + 2 1 = 8 + 2 2^3 + 2^1 = 8 + 2 23+21=8+2。
解题核心策略:
-
奇偶判断(特判) :由于奇数的二进制最低位一定是 1 1 1(代表包含 2 0 = 1 2^0 = 1 20=1),不满足题目"正整数次幂"的条件,因此所有奇数直接输出
-1。 -
拆解位值 :对于偶数,我们可以通过不断"除以 2 2 2 取余数"的方式(或者使用位运算),将每一位的二进制值存下来。
-
逆序输出 :题目要求从大到小输出,我们只要倒序遍历存下来的每一位,如果该位为 1 1 1,就输出它所代表的十进制数值即可。
解题步骤
我们以样例输入 n = 6 为例,模拟代码的执行过程:
-
变量定义与初始化:
-
定义
int n; -
读入数据:输入
6,此时变量n = 6。 -
奇偶判断:计算
6 % 2结果为0,条件不成立,程序继续。 -
定义数组与计数器:
int a[50], k = 0;(a用来存每一位的二进制值,k记录位数)。
-
-
循环拆解(十进制转二进制):
-
第 1 次循环:
n = 6(满足while(n)),执行a[++k] = n % 2,此时k = 1,a[1] = 0(代表 2 0 2^0 20 位)。n /= 2后n = 3。 -
第 2 次循环:
n = 3,执行a[++k] = n % 2,此时k = 2,a[2] = 1(代表 2 1 2^1 21 位)。n /= 2后n = 1。 -
第 3 次循环:
n = 1,执行a[++k] = n % 2,此时k = 3,a[3] = 1(代表 2 2 2^2 22 位)。n /= 2后n = 0。 -
循环结束,此时数组
a的有效部分为a[1]=0, a[2]=1, a[3]=1。
-
-
倒序格式化输出:
-
for(int i = k; i > 1; i--),循环从最大位i = 3开始,一直到i = 2(巧妙地跳过了代表 2 0 2^0 20 的i = 1)。 -
当
i = 3时 :a[3]为1。计算 2 3 − 1 2^{3-1} 23−1 即 2 2 = 4 2^2 = 4 22=4。输出4。 -
当
i = 2时 :a[2]为1。计算 2 2 − 1 2^{2-1} 22−1 即 2 1 = 2 2^1 = 2 21=2。输出2。 -
输出结果:
4 2。
-
本题易错点
-
坑一:滥用
pow()函数要点提醒 :
pow返回的是浮点数。虽然本题 n ≤ 10 7 n \le 10^7 n≤107(约到 2 23 2^{23} 223),强制转成int不会出错。但如果数据规模扩大到 n ≤ 10 18 n \le 10^{18} n≤1018 级别,pow的精度误差会导致致命的 WA (Wrong Answer)。在位运算能解决的场景,**坚决拥抱左移操作符<<。 -
坑二:忘记排除 2 0 2^0 20
要点提醒:题目特别要求是正整数次幂 ,也就是最小只能是 2 1 2^1 21。如果忽略了这个条件,奇数就不会输出
-1而是输出了结尾带1的序列,导致失分。 -
坑三:数组越界风险
要点提醒: 有同学在拆解二进制时,由于未估算好大小,将存储二进制的数组开得过小(比如
a[10])。 10 7 10^7 107 转换成二进制大概需要 24 24 24 位,所以数组大小a[50]是足够且安全的(数组尽量开大点!)。
参考代码
cpp
#include <iostream>
using namespace std;
int main() {
int n;
cin >> n;
// 如果是奇数,二进制最低位必然是 1(包含 2^0),不符合要求
if (n % 2) {
cout << -1;
return 0;
}
int a[50], k = 0; // a 数组存储二进制每一位,k 为当前位数
// 十进制转二进制,将每一位存入数组 a
while (n) {
a[++k] = n % 2; // a[1] 对应 2^0,a[2] 对应 2^1,以此类推
n /= 2;
}
// 题目要求从大到小输出,因此倒序遍历
for (int i = k; i > 1; i--) { // i > 1 是因为我们不需要判断 a[1]
if (a[i] == 1) {
// 使用位运算 1 << (i - 1) 计算 2^(i - 1)
int t = 1 << (i - 1);
cout << t << " ";
}
}
return 0;
}
分糖果
思路要点
题目大意:有 n n n 个小朋友,你要在 l , r l, r l,r 之间选择一个整数 k k k(代表你拿的糖果总数)。分糖果的规则是每轮每人分 1 1 1 块,直到不够分(少于 n n n 块)为止,剩下的糖果全归你。
用数学语言翻译:当拿了 k k k 块糖时,你最后能得到的奖励就是 k ( m o d n ) k \pmod n k(modn)(即 k k k 除以 n n n 的余数)。题目要求我们找到一个 k ∈ l , r k \in l, r k∈l,r,使得 k ( m o d n ) k \pmod n k(modn)最大,并输出这个最大值。
关键思路
首先来看数据的规律。当我们把一串连续的整数对 n n n 取余时,余数会呈现出周期性 的变化: 0 , 1 , 2 , ... , n − 1 , 0 , 1 , 2 , ... , n − 1 ... 0, 1, 2, \dots, n-1, 0, 1, 2, \dots, n-1 \dots 0,1,2,...,n−1,0,1,2,...,n−1...显然,理论上能得到的最大余数是 n − 1 n - 1 n−1。
我们现在被限制在区间 l , r l, r l,r 之间选数,那么我们能不能拿到这个终极完美的奖励 n − 1 n - 1 n−1 呢?这取决于区间 l , r l, r l,r 是否足够跨越"余数回零"的临界点。
-
情况一(跨越临界点) :如果区间 l , r l, r l,r 足够大,大到里面包含了一个 n n n 的倍数。那么在这个 n n n 的倍数的前一位,其余数一定达到了最大值 n − 1 n - 1 n−1。
- 如何用代码判断?只要 l l l 和 r r r 除以 n n n 的商不同(即
r / n > l / n),就说明中间一定跨过了至少一个 n n n 的倍数,此时最大余数必然是 n − 1 n - 1 n−1。
- 如何用代码判断?只要 l l l 和 r r r 除以 n n n 的商不同(即
-
情况二(未跨越临界点) :如果区间 l , r l, r l,r 很窄,它们都在同一个" n n n 的倍数周期"内(即
r / n == l / n)。在这个单调递增的周期片段里,数字越大,余数就越大。- 因此,为了让余数最大,我们直接选最右端的边界 r r r,此时的最大余数就是 r ( m o d n ) r \pmod n r(modn)。
解题步骤
我们以样例输入 7 16 23(即 n = 7 , l = 16 , r = 23 n = 7, l = 16, r = 23 n=7,l=16,r=23)为例,模拟代码的执行过程:
-
变量定义与初始化: 定义全局变量
int n, l, r;。 -
读入数据: 输入
7 16 23,此时变量n = 7,l = 16,r = 23。 -
条件判断与计算:
-
计算
r / n:即23 / 7,利用 C++ 的整除特性,结果为3。 -
计算
l / n:即16 / 7,结果为2。 -
执行
if(r / n > l / n)判断:由于3 > 2成立,程序进入if分支。 -
这意味着在 16 16 16 到 23 23 23 之间,一定跨过了 7 7 7 的某个倍数(实际上跨过了 7 × 3 = 21 7 \times 3 = 21 7×3=21)。那么在 21 21 21 之前的一个数 20 20 20,其余数就是最大值。
-
-
格式化输出: 执行
cout << n - 1;。- 计算
7 - 1 = 6。输出结果:6。
- 计算
-
我们再以样例输入
10 14 18(即 n = 10 , l = 14 , r = 18 n = 10, l = 14, r = 18 n=10,l=14,r=18)为例,快速手演一遍未跨越临界点的情况:-
r / n变为18 / 10 = 1。 -
l / n变为14 / 10 = 1。 -
判断
1 > 1不成立,程序进入else分支。 -
执行
cout << r % n;,即计算18 % 10 = 8。 -
输出结果:
8。
-
本题易错点
-
坑一:整数除法
/与取余%的混淆要点提醒 :在判断区间是否跨越周期时,使用的是整除商的比较
r / n > l / n;而在计算具体余数时,使用的是r % n。有的同学容易把这两个符号写颠倒。 -
坑二:边界条件的思考
要点提醒: 如果 l l l 和 r r r 刚好一个是周期起点,一个是周期终点,比如 n = 5 , l = 10 , r = 14 n=5, l=10, r=14 n=5,l=10,r=14。
14 / 5 = 2,10 / 5 = 2,此时两者相等会走else分支,输出14 % 5 = 4(即 n − 1 n-1 n−1),逻辑依然完美自洽!这说明代码的边界处理非常鲁棒。 -
坑三:暴力枚举
for循环(导致 TLE 严重超时)要点提醒: 有同学一看到"在 l , r l, r l,r 区间内找最大值",第一反应就是写一个循环:
for(int i = l; i <= r; i++) { max_ans = max(max_ans, i % n); }为什么错? 来看一眼数据范围: 2 ≤ n ≤ L ≤ R ≤ 10 9 2 \le n \le L \le R \le 10^9 2≤n≤L≤R≤109。如果题目给出 L = 1 L = 1 L=1, R = 10 9 R = 10^9 R=109,这个for循环就要执行 10 9 10^9 109 次!计算机 1 秒大概只能运行 10 8 10^8 108 次,这种暴力解法必然会引发 TLE(Time Limit Exceeded,超时) ,只能拿到部分分数。而我们通过数学规律推导出的 O ( 1 ) O(1) O(1) 解法,不管数据多大都是瞬间搞定。
参考代码
cpp
#include <bits/stdc++.h>
using namespace std;
int main(){
int n, l, r; // 定义变量,分别表示小朋友数、糖果下界和上界
cin >> n >> l >> r;
// 核心逻辑:求 l ~ r 中的数对 n 取余的最大值
// 如果 r/n 大于 l/n,说明 [l, r] 区间横跨了至少一个 n 的倍数周期
if(r / n > l / n){
cout << n - 1; // 既然横跨了周期,就一定能取到最大的余数 n - 1
}
else{
// 如果在同一个周期内,余数随数字增大而增大,最大值在右边界 r 处取得
cout << r % n;
}
return 0;
}
乘方
思路要点
题目要求非常直接:输入正整数 a 和 b,计算 a b a^b ab。
-
如果结果不超过 10 9 10^9 109,就输出这个值。
-
如果超过了 10 9 10^9 109,就输出
-1。
这道题表面上是让我们计算 a b a^b ab 的值,但核心本质是在考察:数值溢出防御(防爆栈/防溢出意识)以及循环中的边界拦截。
关键思路
很有同学看到求 a b a^b ab,第一反应是直接用 C++ 自带的 pow(a, b) 函数,或者用一个 for 循环连乘 b 次。
但仔细看题目数据范围: 1 ≤ a , b ≤ 10 9 1 \le a, b \le 10^9 1≤a,b≤109。如果 a = 2, b = 10^9,其结果是一个天文数字,远远超出了 C++ 中任何一种内置整数类型(即使是 long long)的存储极限。直接算完再判断,程序早就因为数据溢出而变成随机的错误数字了。
如何想到正确的解法?
-
在线拦截(防微杜渐) :我们不能等全部乘完再判断,要在每一次相乘的过程之中 去检查。一旦发现当前的乘积已经超过了 10 9 10^9 109,就说明最终结果必然超过 10 9 10^9 109,此时立刻终止循环,直接输出
-1。 -
致命的陷阱(特判 a = 1 a=1 a=1) :如果
a = 1,不管b有多大(比如 10 9 10^9 109), 1 10 9 1^{10^9} 1109 永远等于 1 1 1。如果不做特殊处理,for循环将会老老实实地执行 10 9 10^9 109 次,这会导致程序 TLE(超时) 。因此,我们必须在循环前对a == 1进行特判,直接输出 1 1 1 并结束程序。
解题步骤
我们以输入 a = 100, b = 5 为例(为了让同学们能完整看到被中途拦截的过程),模拟代码的执行过程:
-
变量定义与初始化: 定义
long long a, b, ans = 1;(ans初始化为 1 1 1,作为乘法的累乘器。使用long long是为了防止单次乘法时瞬间爆掉int)。 -
读入数据: 输入
100 5,此时变量a = 100,b = 5。 -
特判阶段: 检查
if(a == 1),此时100 == 1不成立,跳过特判,进入循环。 -
循环计算与拦截:
-
第 1 次循环 ( i = 1 i=1 i=1**)** :执行
ans *= a;→ \rightarrow →ans = 1 * 100 = 100。- 检查
if(ans > 1e9): 100 > 10 9 100 > 10^9 100>109 不成立,继续。
- 检查
-
第 2 次循环 ( i = 2 i=2 i=2**)** :执行
ans *= a;→ \rightarrow →ans = 100 * 100 = 10000。- 检查
if(ans > 1e9): 10000 > 10 9 10000 > 10^9 10000>109 不成立,继续。
- 检查
-
第 3 次循环 ( i = 3 i=3 i=3**)** :执行
ans *= a;→ \rightarrow →ans = 10000 * 100 = 1000000( 10 6 10^6 106)。- 检查
if(ans > 1e9):不成立,继续。
- 检查
-
第 4 次循环 ( i = 4 i=4 i=4**)** :执行
ans *= a;→ \rightarrow →ans = 10^6 * 100 = 100000000( 10 8 10^8 108)。- 检查
if(ans > 1e9):不成立,继续。
- 检查
-
第 5 次循环 ( i = 5 i=5 i=5**)** :执行
ans *= a;→ \rightarrow →ans = 10^8 * 100 = 10000000000( 10 10 10^{10} 1010)。-
检查
if(ans > 1e9):此时 10 10 > 10 9 10^{10} > 10^9 1010>109 成立! -
执行
cout << -1; return 0;。
-
-
-
输出结果: 程序在第 5 次循环时被成功拦截,输出
-1并直接退出。
本题易错点
-
坑一:计数器或乘积未用
long long要点提醒 :如果把
ans定义为int,当ans = 10^8时,再乘以a = 10^5,结果会瞬间超出int的最大范围(约 2 × 10 9 2 \times 10^9 2×109)。这会导致数据发生整数溢出 变成一个负数,那么if(ans > 1e9)的判断就会失效,导致输出错误结果。 -
坑二:忽略
a = 1的超时情况要点提醒: 这是本题最大的陷阱。很多同学想到了在循环内拦截,但卡在了
1 1000000000这组数据上。由于整个算法最坏情况下的循环次数取决于b,不特判a=1就会引发 TLE。 -
坑三:直接使用
pow()函数要点提醒:
pow(a, b)的返回值是浮点型(double),且当结果过大时会变成inf(无穷大)或丢失精度。直接用它去判断是否大于 10 9 10^9 109,在面对大输入时无法保证结果的准确性。
参考代码
cpp
#include <bits/stdc++.h>
using namespace std;
int main(){
long long a, b, ans = 1; // 使用 long long 确保安全存储中间乘积
cin >> a >> b;
// 特判:1 的任何次方都是 1。如果不特判,当 b=10^9 时会 TLE。
if(a == 1){
cout << 1;
return 0;
}
// 模拟连乘过程
for(int i = 1; i <= b; i++){
ans *= a; // 累乘
// 关键:在乘的过程中进行拦截, 1e9 是 1000000000 的浮点数表示法
if(ans > 1e9){
cout << -1; // 一旦发现超过 10^9,立刻输出 -1
return 0; // 直接结束程序,后面的不用再算了
}
}
cout << ans; // 如果顺利走出循环,说明结果没有超限,正常输出
return 0;
}
小苹果
思路要点
把题目规则翻译得更通俗一点:"每 3 个苹果分成一组,每天都拿走每组的第 1 个苹果"。题目要求我们回答两个问题:
-
一共需要多少天才能把所有苹果拿完?
-
那个最初排在最后的第 n n n 个苹果,是在第几天被拿走的?
表面上在玩一个"每隔 2 个苹果拿走 1 个"的淘汰游戏,看起来很像经典的约瑟夫环。但如果我们剥去题目背景的"外壳",它本质上考察的是:数字的按比例缩减、向上取整以及循环控制结构。
关键思路
很多同学看到数据范围 n ≤ 10 9 n \le 10^9 n≤109,心里可能会一惊。如果企图用数组、vector 甚至是 list 去把每个苹果存下来,然后写个循环真的去"删元素",那无论在空间上还是时间上都会彻底爆炸(导致 MLE 或 TLE)。
如何想到高效的解法?我们要学会 "化宏观为微观", 不看单个苹果,只看 "总量的变化":
-
总量的衰减规律 :既然每 3 个苹果一组并拿走 1 个,那么如果当前有 n n n 个苹果,今天就会被拿走 ⌈ n / 3 ⌉ \lceil n / 3 \rceil ⌈n/3⌉(即 n / 3 n/3 n/3 向上取整)个苹果。剩下的苹果数量就是 n − ⌈ n / 3 ⌉ n - \lceil n / 3 \rceil n−⌈n/3⌉。
我们只需要用一个
while(n)循环,让 n n n 不断缩水,直到变为 0 即可。因为每次减少大约 1 3 \frac{1}{3} 31,剩下的 2 3 \frac{2}{3} 32 递减速度极快,大约只需 log 1.5 ( 10 9 ) ≈ 50 \log_{1.5}(10^9) \approx 50 log1.5(109)≈50 次循环就能降为 0! -
锁定第 n n n 个苹果 :这道题最精妙的地方在于,原本排在最后的那个苹果,在它被拿走之前,永远都待在队伍的最后一名!
也就是说,如果某一天队伍的总长度是 n n n,而这一天只要满足 n ( m o d 3 ) = = 1 n \pmod 3 == 1 n(mod3)==1,那么排在队尾的那个苹果就刚好成为了某一组的"第 1 个",从而在今天被无情拿走。因为我们只关心它"第一次"被拿走的日子,所以需要一个布尔标记变量,一旦记录下来,以后就再也不修改它了。
解题步骤
我们以样例输入 n = 8 为例,手演一遍代码的执行全过程:
-
变量定义与初始化:
-
int n, d = 0, x;(n为剩余苹果数,d记录天数,x记录第 n n n 个苹果被拿走的天数)。 -
bool p = 0;(标记变量,p = 0表示第 n n n 个苹果还没被拿走,p = 1表示已经被拿走)。 -
读入数据后,
n = 8。
-
-
进入
while(n)循环模拟:-
☀️ 第 1 天(
d = 1):-
当前
n = 8。 -
检查条件:
8 % 3 == 2,不满足n % 3 == 1。第 n n n 个苹果今天安全。 -
计算今天拿走几个:
int t = 8 / 3;得到2。因为8 % 3 != 0,满足if(n % 3),执行t++;得到t = 3(即向上取整,今天拿走 3 个)。 -
更新剩余苹果:
n -= t;→ \rightarrow →n = 8 - 3 = 5。
-
-
☀️ 第 2 天(
d = 2):-
当前
n = 5。 -
检查条件:
5 % 3 == 2,不满足n % 3 == 1。第 n n n 个苹果继续安全。 -
计算今天拿走几个:
t = 5 / 3 = 1,有余数t++变为2。 -
更新剩余苹果:
n -= t;→ \rightarrow →n = 5 - 2 = 3。
-
-
☀️ 第 3 天(
d = 3):-
当前
n = 3。 -
检查条件:
3 % 3 == 0,不满足n % 3 == 1。第 n n n 个苹果继续安全。 -
计算今天拿走几个:
t = 3 / 3 = 1,没有余数,t保持1。 -
更新剩余苹果:
n -= t;→ \rightarrow →n = 3 - 1 = 2。
-
-
☀️ 第 4 天(
d = 4):-
当前
n = 2。 -
检查条件:
2 % 3 == 2,不满足n % 3 == 1。第 n n n 个苹果继续安全。 -
计算今天拿走几个:
t = 2 / 3 = 0,有余数t++变为1。 -
更新剩余苹果:
n -= t;→ \rightarrow →n = 2 - 1 = 1。
-
-
☀️ 第 5 天(
d = 5):-
当前
n = 1。 -
检查条件:
1 % 3 == 1且!p(即p == 0)完全成立! -
触发锁定:执行
x = d;→ \rightarrow →x = 5;同时更新标记p = 1。这就是原第 n n n 个苹果被拿走的日子。 -
计算今天拿走几个:
t = 1 / 3 = 0,有余数t++变为1。 -
更新剩余苹果:
n -= t;→ \rightarrow →n = 1 - 1 = 0。
-
-
-
退出循环与输出:
-
while(n)条件不再满足,跳出循环。 -
执行
cout << d << " " << x;,即输出5 5。
-
本题易错点
-
坑一:忘记使用标记
p进行锁定要点提醒 :有同学没有开
p这个布尔变量,直接写成if (n % 3 == 1) x = d;。这会导致x的值被不断覆盖,而我们要记录的是 n n n 第一次模 3 余 1 的那天。比如队伍在第 2 天和第 5 天都剩下了模 3 余 1 的长度,但真正的最后一个苹果在第 2 天就已经被拿走了,后面符合条件的是"新任"的最后一个苹果。 -
坑二:向上取整的漏算
要点提醒: 算今天拿走几个苹果时,如果直接用
n / 3,当n = 4时会算出1。但实际上,4 个苹果分两组(3+1),每组拿走第 1 个,应该拿走2个才对。所以必须处理好余数。 -
坑三:盲目使用高维数据结构暴力模拟
要点提醒: 不少同学一看到题目中又是"排成一列"又是"原先顺序重新排",就想用数组移位或者
vector.erase()。面对 10 9 10^9 109 的数据,不仅执行速度慢到让你绝望,光是开辟存储这 10 9 10^9 109 个数字的内存空间就会直接导致 MLE 。记住:数据范围决定算法选择,突破 10 5 10^5 105 的模拟题通常都带有数学规律。
参考代码
cpp
#include <bits/stdc++.h>
using namespace std;
int main(){
int n, d = 0, x;
bool p = 0; // p 用于标记最后一个苹果是否已被拿走
cin >> n;
while(n > 0){
d++; // 天数加 1
// 如果当前总数模 3 余 1,说明最后一个苹果今天刚好排在组内第 1 位
if(n % 3 == 1 && !p){ // !p 确保我们只在它"第一次"符合条件时记录天数
x = d; // 记录该日为第 n 个苹果被拿走的那天
p = 1; // 锁死标记,以后不再进入此分支
}
int t = n / 3; // C++ 默认向下取整
if(n % 3) t++; // 如果有余数,说明最后一组不饱满,但也会被拿走 1 个
n -= t; // 减去今天拿走的苹果数量,更新剩余总量
}
cout << d << " " << x;
return 0;
}
扑克牌
思路要点
题目告诉我们,一副完整的扑克牌(不含大小王)一共有 4 × 13 = 52 4 \times 13 = 52 4×13=52张。现在小 P 手里有 n n n张牌,由于里面可能会有重复的牌,问他还需要补多少张牌才能凑齐完整的 52 张。
用数学语言翻译:给定 n n n个字符串,求这组字符串中互不相同 的字符串个数(设为 k k k),最终的答案就是 52 − k 52 - k 52−k。脱去扑克牌和花色的精美外衣,其核心本质考察的是:字符串去重与计数(数据去重)。
关键思路
如何高效地统计出有多少张不同的牌?可以使用:sort排序 + unique去重。
-
为什么要先排序?:C++ 标准库中的
std::unique函数只能去除连续重复 的元素。如果相同的牌分得很散(比如输入是DQ,H3,DQ),直接使用unique是无法把它删掉的。所以,我们必须先用sort对字符串数组进行字典序排序,让一模一样的扑克牌紧紧挨在一起。 -
unique的精妙之处: 排序后调用unique,它会把所有重复的元素挪到数组的末尾,并返回一个指向"去重后有效数据末尾"的迭代器(指针)。我们将这个指针减去数组的起始指针s + 1,就能瞬间得到去重后不重复的扑克牌张数 k k k。 -
最终计算: 用总数 52 减去不重复的张数 k k k,即为答案。
解题步骤
我们以样例输入
4
DQ
H3
DQ
DT
为例,模拟代码的执行过程:
-
变量定义与初始化: 定义全局变量
int n; string s[55];。 -
读入数据: 输入
n = 4。- 循环读入 4 个字符串,此时:
s[1] = "DQ",s[2] = "H3",s[3] = "DQ",s[4] = "DT"。
- 循环读入 4 个字符串,此时:
-
数组排序: 执行
sort(s + 1, s + 1 + n);-
按照字典序(字母顺序)重新排列字符串数组,排序后的结果为:
-
s[1] = "DQ",s[2] = "DQ",s[3] = "DT",s[4] = "H3"(注意:两张相同的"DQ"此时挨在一起了)。
-
-
去重计数: 执行
int k = unique(s + 1, s + 1 + n) - (s + 1);-
unique函数开始工作,发现s[1]和s[2]都是"DQ",于是把后面的唯一元素往前挪,去重后的有效数组变成了:["DQ", "DT", "H3"]。 -
unique返回的指针指向有效数据的下一位,减去起始地址s + 1后,计算出不重复的牌数k = 3。
-
-
格式化输出: 执行
cout << 52 - k;。-
计算结果: 52 − 3 = 49 52 - 3 = 49 52−3=49。
-
输出结果:
49。
-
本题易错点
-
坑一:孤立使用
unique要点提醒 :很多人记得
unique能去重,却忘了它只能去重相邻 的元素。如果不加sort直接调用unique,面对交替出现的重复数据(如A B A B),去重功能会完全失效。 -
坑二:指针减法的偏移量
要点提醒: 因为输入时我们的数组下标是从
1到n(即s + 1到s + 1 + n),所以在用unique返回的指针计算长度时,必须精准地减去(s + 1),如果错减成了s,算出来的个数就会多 1,导致最终答案错误。 -
坑三:直接拿 52 − n 52 - n 52−n当答案
要点提醒: 有些粗心的同学没有仔细看样例 2 的解释,理所当然地认为拿齐 52 张牌,现在手里有 n n n张,那再借 52 − n 52 - n 52−n张就行了。完全忽略了小 P 手里的牌可能存在一模一样的重复牌这一关键设定。
参考代码
cpp
#include <bits/stdc++.h>
using namespace std;
int n; // 记录小 P 手中现有的牌数
string s[55]; // 字符串数组,用于存储每张牌的花色和点数
int main(){
cin >> n;
for(int i = 1; i <= n; i++){
cin >> s[i]; // 读入每张牌的字符串表示
}
// 第一步:排序。必须先排序,让相同的扑克牌在内存中相邻
sort(s + 1, s + 1 + n);
// 第二步:去重。unique 返回去重后的尾指针,减去首指针 (s + 1) 得到去重后的有效元素个数
int k = unique(s + 1, s + 1 + n) - (s + 1);
// 第三步:输出。完整的 52 张牌减去已经拥有的不同牌数,即为还需要借的牌数
cout << 52 - k << "\n";
return 0;
}
拼数
思路要点
题目要求非常明确:
-
提取出字符串中所有的数字字符(
0到9)。 -
用这些数字组合成一个值最大的正整数(每个数字只能用它在原字符串中出现的次数)。
这道题表面上让我们从一个杂乱的字符串中挑出数字来拼成一个"最大的正整数"。剥去题目背景的"外壳",它本质上考察的是:贪心算法 与 桶排序(计数排序)思想。
关键思路
如何才能拼出最大的正整数?这里蕴含了两个经典的贪心策略:
-
位数越多越好 :在正整数中, 1000 1000 1000 永远比 999 999 999大。既然题目允许我们使用任意多个数字,且目标是最大化这个正整数,那么我们应该无条件把字符串里所有的数字全部用上 。哪怕多一个
0,把它缀在末尾也能让数字的整体数量级扩大 10 倍。 -
高位数字越大越好 :同样是一组数字,拼成 921 921 921显然比 129 129 129大。因此,为了让数值最大,我们应该把最大的数字放在最高位(最左边),次大的数字紧随其后......也就是说,要把所有拿到的数字按从大到小的顺序(降序)排列输出。
如何优化到极致?
-
题目中字符串的长度 ∣ s ∣ |s| ∣s∣ 最高可达 10 6 10^6 106。如果我们把所有的数字字符存进一个标准的数组或
vector里,再用std::sort降序排序,时间复杂度是 O ( ∣ s ∣ log ∣ s ∣ ) O(|s| \log |s|) O(∣s∣log∣s∣)。虽然可以稳稳通过,但并不是最优雅的。 -
仔细想想,数字只有
0~9这 10 种可能。面对这种"数字种类极少、数据总量极大"的排序需求,最强大的武器就是桶排序(计数排序) !我们只需要开一个大小为 10 的数组(桶),统计出0到9每个数字各出现了多少次,最后从9到0倒着把它们打印出来即可。这样时间复杂度能优化到完美的 O ( ∣ s ∣ ) O(|s|) O(∣s∣)。
解题步骤
我们以样例输入 290es1q0 为例,模拟代码的执行过程:
-
变量定义与初始化:
-
定义字符串
string s;。 -
定义长度变量
int len;。 -
定义计数数组
int a[15] = {};(全部初始化为 0,用来作为统计 0~9 频数的"桶")。
-
-
读入数据:
- 输入
290es1q0,此时s = "290es1q0",计算出长度len = 8。
- 输入
-
第一阶段:扫描字符串并计数(
for循环)-
i = 0 i = 0 i=0:
s[0] = '2',满足数字条件,计算t = '2' - '0' = 2,执行a[2]++→ \rightarrow →a[2] = 1。 -
i = 1 i = 1 i=1:
s[1] = '9',满足数字条件,计算t = '9' - '0' = 9,执行a[9]++→ \rightarrow →a[9] = 1。 -
i = 2 i = 2 i=2:
s[2] = '0',满足数字条件,计算t = '0' - '0' = 0,执行a[0]++→ \rightarrow →a[0] = 1。 -
i = 3 i = 3 i=3:
s[3] = 'e',不是数字,跳过。 -
i = 4 i = 4 i=4:
s[4] = 's',不是数字,跳过。 -
i = 5 i = 5 i=5:
s[5] = '1',满足数字条件,计算t = '1' - '0' = 1,执行a[1]++→ \rightarrow →a[1] = 1。 -
i = 6 i = 6 i=6:
s[6] = 'q',不是数字,跳过。 -
i = 7 i = 7 i=7:
s[7] = '0',满足数字条件,计算t = '0' - '0' = 0,执行a[0]++→ \rightarrow →a[0] = 2。
第一阶段结束,此时计数数组
a的状态为:a[9]=1, a[2]=1, a[1]=1, a[0]=2,其余位置均为 0。 -
-
第二阶段:降序贪心输出(嵌套
for循环)我们让控制数字的变量
i从9开始递减到0:-
i = 9 i = 9 i=9:
a[9] = 1,内层循环j执行 1 次,输出:9。 -
i = 8 ∼ 3 i = 8 \sim 3 i=8∼3:对应的
a[i]均为 0,内层循环不执行。 -
i = 2 i = 2 i=2:
a[2] = 1,内层循环j执行 1 次,输出:2。 -
i = 1 i = 1 i=1:
a[1] = 1,内层循环j执行 1 次,输出:1。 -
i = 0 i = 0 i=0:
a[0] = 2,内层循环j执行 2 次,输出:00。
-
-
输出结果: 屏幕上最终拼接并输出的结果为:
92100。
本题易错点
-
坑一:字符转数字的偏移量处理
要点提醒 :在把字符存入计数数组时,必须写成
s[i] - '0'。如果不减去'0',直接使用s[i]作为下标,那么程序会使用字符的 ASCII 码值(例如字符'0'的 ASCII 码是 48,'9'是 57)。这会导致a数组发生严重的越界访问。 -
坑二:试图将拼接结果转换为整数类型(如
long long)再输出要点提醒: 许多初学者有一个习惯:总想用一个变量把答案算出来再输出。比如定义一个
long long ans = 0;,然后在循环里执行ans = ans * 10 + i;。为什么错? 看看数据范围: 1 ≤ ∣ s ∣ ≤ 10 6 1 \leq |s| \leq 10^6 1≤∣s∣≤106。如果输入的字符串里包含了 100 万个数字,那么拼出来的正整数将是一个长达 100 万位的超级大数 !C++ 中哪怕是
long long类型最多也只能存储 19 位左右的整数。一旦你尝试去计算它的数值,变量会在瞬间发生数值溢出 ,最终只能得到一个荒谬的错误答案。直接利用循环顺次输出字符才是处理大数最正确的方法。
参考代码
cpp
#include <bits/stdc++.h>
using namespace std;
int main(){
string s;
cin >> s;
int len = s.size();
int a[15] = {}; // 计数数组(桶),自动初始化为全 0
// 第一步:遍历字符串,统计各个数字字符出现的次数
for(int i = 0; i < len; i++){
// 过滤出数字字符
if(s[i] >= '0' && s[i] <= '9'){
int t = s[i] - '0'; // 将字符转化为对应的整数索引
a[t]++; // 对应的数字桶计数加 1
}
}
// 第二步:从大到小(9 到 0)依次输出,满足贪心策略
for(int i = 9; i >= 0; i--){
// 数字 i 出现了几次,就连续打印几次
for(int j = a[i]; j > 0; j--){
cout << i;
}
}
cout << "\n";
return 0;
}