CSP-J 历年复赛 T1 及解析(2019~2025)

摘要 :本文系统整理了2019年至2025年CSP-J(入门级)信息学竞赛的历年真题,包含《数字游戏》、《优秀的拆分》、《分糖果》、《乘方》、《小苹果》、《扑克牌》、《拼数》共七道经典题目。每道题均提供思路要点解题步骤易错点分析C++参考代码,帮助初学者掌握字符串处理、进制转换、数学规律、模拟、贪心等核心算法思想。文章采用"题目列表→详细解析"的结构,适合备赛复习和算法入门学习。

说明 & 备考建议

题目都可以在洛谷上搜名称就会出来,题目名称也都加了链接点击就能跳转到做题页面。

T1 基本都是简单数学模拟不涉及复杂算法,难度在 【入门~普及-】之间。主要考验基本功,基础过关 10 分钟以内 AC 问题都不大。

一些需要注意的点:

  1. 遇到题目数据量大的不要上来就写暴力,一定要再找找规律进一步想有没有通用数学公式能推导出来。
  2. 边界情况和特殊 case 一定要考虑周全!越简单的题越容易在这些地方设坑。
  3. 想清楚输入数据和输出结果包括中间计算过程中可能的数据范围,统一定义成精度范围更大的类型,涉及数组一定要开够大,涉及到计数或累乘不要忘记赋初始值。

Day Day Up!祝大家都能在比赛取得好成绩!✿✿ヽ(°▽°)ノ✿

题目列表

年份 题目 知识点 难度
2019 数字游戏 字符串 入门
2020 优秀的拆分 模拟、进制 入门
2021 分糖果 数学 普及-
2022 乘方 模拟、基础算法 入门
2023 小苹果 模拟、数学 普及-
2024 扑克牌 模拟 入门
2025 拼数 贪心、排序 普及-

题目详解

数字游戏

思路要点

题目要求非常简单:给定一个长度固定为 8 的字符序列(只包含 01),请你数一数里面到底出现了多少个字符 1。其核心本质是在考察:基础的字符读取、循环结构的运用以及条件判断(计数器思想)。

关键思路

在面对输入的数据时,我们脑海中通常会有以下几种读入方案:

  • 当作一个整数读入 (例如 int n):虽然 8 位的由 01 组成的数字不会超出 int 的范围,但如果用数学方法去读入再按位拆解(不断 %10/10),会略为繁琐。

  • 当作字符串读入 (例如 string schar s[10]):读入后,再使用循环遍历整个字符串进行统计。这种解法很标准,完全正确。

  • 当作独立字符逐个读入 (最优解法):既然我们只关心每个独立的字符是不是 1,且长度固定为 8 ,我们完全可以写一个执行 8 次的循环,每次只读入一个字符 char c,读完立刻判断并统计。这样连数组和字符串都不用开,极其简洁高效!

解题步骤

我们以样例输入 00010100 为例,模拟代码的执行过程:

  1. 变量定义与初始化:

    • 定义 int cnt = 0;(初始化计数器,准备记录字符 1 的数量。必须初始化为 0)。

    • 定义 char c;(用于每次接收一个输入的字符)。

  2. 循环与数据处理(执行 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

  3. 输出结果:

    • 循环结束,执行 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 来剥离数字。虽然这题数据全是 01,放在整型里不会越界,但这样做不仅增加了运算量(取模和除法比字符比较慢得多),而且代码逻辑会变得臃肿。我们要记住:处理没有数学运算意义的一长串数字,一律按字符或字符串处理!

参考代码

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 为例,模拟代码的执行过程:

  1. 变量定义与初始化:

    • 定义 int n;

    • 读入数据:输入 6,此时变量 n = 6

    • 奇偶判断:计算 6 % 2 结果为 0,条件不成立,程序继续。

    • 定义数组与计数器:int a[50], k = 0;a 用来存每一位的二进制值,k 记录位数)。

  2. 循环拆解(十进制转二进制):

    • 第 1 次循环: n = 6(满足 while(n)),执行 a[++k] = n % 2,此时 k = 1, a[1] = 0(代表 2 0 2^0 20 位)。n /= 2n = 3

    • 第 2 次循环: n = 3,执行 a[++k] = n % 2,此时 k = 2, a[2] = 1(代表 2 1 2^1 21 位)。n /= 2n = 1

    • 第 3 次循环: n = 1,执行 a[++k] = n % 2,此时 k = 3, a[3] = 1(代表 2 2 2^2 22 位)。n /= 2n = 0

    • 循环结束,此时数组 a 的有效部分为 a[1]=0, a[2]=1, a[3]=1

  3. 倒序格式化输出:

    • for(int i = k; i > 1; i--),循环从最大位 i = 3 开始,一直到 i = 2(巧妙地跳过了代表 2 0 2^0 20 的 i = 1)。

    • i = 3a[3]1。计算 2 3 − 1 2^{3-1} 23−1 即 2 2 = 4 2^2 = 4 22=4。输出 4

    • i = 2a[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 , 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)为例,模拟代码的执行过程:

  1. 变量定义与初始化: 定义全局变量 int n, l, r;

  2. 读入数据: 输入 7 16 23,此时变量 n = 7, l = 16, r = 23

  3. 条件判断与计算:

    • 计算 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,其余数就是最大值。

  4. 格式化输出: 执行 cout << n - 1;

    • 计算 7 - 1 = 6。输出结果:6
  5. 我们再以样例输入 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 = 210 / 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;
}

乘方

思路要点

题目要求非常直接:输入正整数 ab,计算 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 为例(为了让同学们能完整看到被中途拦截的过程),模拟代码的执行过程:

  1. 变量定义与初始化: 定义 long long a, b, ans = 1;ans 初始化为 1 1 1,作为乘法的累乘器。使用 long long 是为了防止单次乘法时瞬间爆掉 int)。

  2. 读入数据: 输入 100 5,此时变量 a = 100b = 5

  3. 特判阶段: 检查 if(a == 1),此时 100 == 1 不成立,跳过特判,进入循环。

  4. 循环计算与拦截:

    • 第 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. 输出结果: 程序在第 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 为例,手演一遍代码的执行全过程:

  1. 变量定义与初始化:

    • int n, d = 0, x;n 为剩余苹果数,d 记录天数,x 记录第 n n n 个苹果被拿走的天数)。

    • bool p = 0; (标记变量,p = 0 表示第 n n n 个苹果还没被拿走,p = 1 表示已经被拿走)。

    • 读入数据后,n = 8

  2. 进入 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

  3. 退出循环与输出:

    • 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

为例,模拟代码的执行过程:

  1. 变量定义与初始化: 定义全局变量 int n; string s[55];

  2. 读入数据: 输入 n = 4

    • 循环读入 4 个字符串,此时:s[1] = "DQ", s[2] = "H3", s[3] = "DQ", s[4] = "DT"
  3. 数组排序: 执行 sort(s + 1, s + 1 + n);

    • 按照字典序(字母顺序)重新排列字符串数组,排序后的结果为:

    • s[1] = "DQ", s[2] = "DQ", s[3] = "DT", s[4] = "H3"(注意:两张相同的 "DQ" 此时挨在一起了)。

  4. 去重计数: 执行 int k = unique(s + 1, s + 1 + n) - (s + 1);

    • unique 函数开始工作,发现 s[1]s[2] 都是 "DQ",于是把后面的唯一元素往前挪,去重后的有效数组变成了:["DQ", "DT", "H3"]

    • unique 返回的指针指向有效数据的下一位,减去起始地址 s + 1 后,计算出不重复的牌数 k = 3

  5. 格式化输出: 执行 cout << 52 - k;

    • 计算结果: 52 − 3 = 49 52 - 3 = 49 52−3=49。

    • 输出结果:49

本题易错点
  • 坑一:孤立使用 unique

    要点提醒 :很多人记得 unique 能去重,却忘了它只能去重相邻 的元素。如果不加 sort 直接调用 unique,面对交替出现的重复数据(如 A B A B),去重功能会完全失效。

  • 坑二:指针减法的偏移量

    要点提醒: 因为输入时我们的数组下标是从 1n(即 s + 1s + 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;
}

拼数

思路要点

题目要求非常明确:

  • 提取出字符串中所有的数字字符(09)。

  • 用这些数字组合成一个值最大的正整数(每个数字只能用它在原字符串中出现的次数)。

这道题表面上让我们从一个杂乱的字符串中挑出数字来拼成一个"最大的正整数"。剥去题目背景的"外壳",它本质上考察的是:贪心算法 与 桶排序(计数排序)思想。

关键思路

如何才能拼出最大的正整数?这里蕴含了两个经典的贪心策略

  • 位数越多越好 :在正整数中, 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 的数组(桶),统计出 09 每个数字各出现了多少次,最后从 90 倒着把它们打印出来即可。这样时间复杂度能优化到完美的 O ( ∣ s ∣ ) O(|s|) O(∣s∣)。

解题步骤

我们以样例输入 290es1q0 为例,模拟代码的执行过程:

  1. 变量定义与初始化:

    • 定义字符串 string s;

    • 定义长度变量 int len;

    • 定义计数数组 int a[15] = {};(全部初始化为 0,用来作为统计 0~9 频数的"桶")。

  2. 读入数据:

    • 输入 290es1q0,此时 s = "290es1q0",计算出长度 len = 8
  3. 第一阶段:扫描字符串并计数(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。

  4. 第二阶段:降序贪心输出(嵌套 for 循环)

    我们让控制数字的变量 i9 开始递减到 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

  5. 输出结果: 屏幕上最终拼接并输出的结果为: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;
}

相关推荐
basketball6161 小时前
C++ 高级编程:2. 基本线程池实现
java·开发语言·c++
珊瑚里的鱼1 小时前
【动态规划】打家劫舍Ⅱ
算法·动态规划
chao1898441 小时前
SGM(Semi-Global Matching)立体匹配算法 — C++ 实现
开发语言·c++·算法
黎阳之光2 小时前
数智赋能水厂全链路安全|黎阳之光以视频孪生技术落地供水精细化管控
人工智能·物联网·算法·安全·数字孪生
10岁的博客2 小时前
IOI 2018 高速公路收费(Highway)题解:二分与树的巧妙结合
开发语言·c++
不知名的老吴2 小时前
C++运算符重载的常见注意点
开发语言·c++
喜欢打篮球的普通人2 小时前
LLVM 后端流程与关键数据结构:从 IR 到机器码的入门笔记
java·数据结构·笔记
NOVAnet20232 小时前
AI 全球化部署网络瓶颈:算法模型跨地域、跨云互联核心痛点解析
算法·ai·sd-wan·专线·跨区域
wuminyu2 小时前
Java锁机制之轻量级锁判断与尝试逻辑源码剖析
java·linux·c语言·jvm·c++