news 2026/9/29 20:57:44

CSP-J 2019阅读程序题解析:字符串处理与字符数组的经典陷阱

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CSP-J 2019阅读程序题解析:字符串处理与字符数组的经典陷阱

CSP-J 2019 入门级第一轮的阅读程序第二题,放到今天看依然是字符串处理题里很经典的一道。整段代码不到20行,考点却踩得相当密集:字符数组、字符串结束符、双层循环的边界,以及st[i] = st[j] = '_'这条赋值语句的求值顺序,一个都不少。很多选手当年在这道题上丢分,不是不会写这段程序,而是不知道在没有编译器的考场上,怎么把程序"跑"明白。

这篇文章不绕弯子,直接把原题摆出来,逐行拆逻辑,再用abcabc和goodgood两个输入把整个执行过程完整推一遍。最后会从这道题里总结一套阅读程序题的通用做法——包括草稿纸怎么用、数组状态表怎么画、遇到"改一处会不会影响结果"时怎么快速验证。不管是在备赛的选手,还是带学生的教练,都能直接拿去用。

适合谁看?初赛分数在五六十分徘徊、阅读程序总靠蒙的选手;刚接手信息学竞赛教学、想给学生把这道真题讲透的教练;还有纯粹想看看CSP-J 初赛题目长什么样的同学。看完你至少会建立一种判断:这种字符串题,真不是靠语感能做对的。

1. 先把原题捞出来:程序长什么样,它到底想干嘛

1.1 原题代码与题型位置

这道题是 2019 年 CSP-J 入门级第一轮试卷里三大阅读程序题的第二题,原题代码长这样(市面扫描版行号可能有细微出入,不影响理解):

#include <cstdio> using namespace std; char st[100]; int main() { scanf("%s", st); for (int i = 1; st[i]; i++) { for (int j = 0; j < i; j++) if (st[i] == st[j]) st[i] = st[j] = '_'; } printf("%s", st); return 0; }

题目结构是三道判断题加两道选择题。判断题集中在三个点上:输入字符串的构成范围、把外层循环的i = 1改成i = 0会不会出错、把赋值语句改成只给st[i]赋值下划线会不会影响输出。选择题给两个明确输入,问你最终输出什么——就是abcabc和goodgood。

题目说明里还有一句很重要的话:"程序输入不超过数组或字符串定义的范围。"这句话不能当摆设。它意味着我们分析时不用考虑数组越界、输入超长这些意外情况,可以放心假设st[100]装得下输入。

1.2 先建立直觉,再谈细节

不做任何推演的情况下,这段程序的意图其实很好概括:它从前到后扫描字符串,每遇到一个字符,就回头看看它之前有没有出现过一模一样的字符;如果出现过,就把"当前这个字符"和"之前那个字符"同时改成下划线_。

也就是说,这是一个"找重复字符并双双标记淘汰"的过程。你可以把它理解成消消乐里两个相同元素碰在一起互相抵消。这个比喻在后面推演时非常有用,尤其是解释为什么某些字符明明重复出现,最后却"逃过一劫"的时候。

1.3 题目在考哪些隐藏知识点

这类题表面是"读代码",实际上考点非常明确:

  • scanf("%s", st)怎么存储字符串,末尾会不会自动补'\0';
  • for (int i = 1; st[i]; i++)把st[i]当成条件用,本质是判断st[i] != '\0';
  • 内层循环j < i决定了它只看当前位置之前的字符;
  • st[i] = st[j] = '_'是右结合的连续赋值,先改st[j],再改st[i]。

这四条里任何一条理解偏了,推演结果都会出问题。下面逐条拆开讲。

2. 逐行拆解:从 scanf 到双层循环的真实执行逻辑

2.1 scanf("%s", st) 读进来的到底是什么

scanf("%s", st)从标准输入读一个以空白字符分隔的字符串,存进st数组。读完后系统会在末尾自动补一个'\0'作为结束标志。比如输入abcabc,数组里的实际状态是:下标 0 到 5 依次是'a' 'b' 'c' 'a' 'b' 'c',下标 6 是'\0'。

这个'\0'很关键,它是下面所有循环的终止线。这也是初赛字符串题特别喜欢用st[i]直接做循环条件的原因——省去显式调用strlen,代码更短,考察点更集中。

注意,scanf的%s不会读入空格。所以如果输入里有空格,程序行为完全不一样。这道题按考试约定,输入就是一个普通字符串,没有空格,可以放心按整串处理。

2.2 外层循环条件 st[i] 是怎么控制边界的

for (int i = 1; st[i]; i++)等价于:

for (int i = 1; st[i] != '\0'; i++)

循环从下标 1 开始,每次检查st[i]是不是'\0',不是就继续,是就停。也就是说,它遍历的是字符串第 2 个字符到最后一个有效字符,不包括最开头的st[0]。

为什么要从 1 开始而不是从 0 开始?因为内层循环要"回头看"当前位置之前的字符。如果从i = 0开始,回头看一个不存在的下标,逻辑上很别扭。实际上代码里j < i会变成j < 0,内层循环一次都不执行,不会越界,但显然没必要多跑这一轮。第 4 章里那道"改成 i=0 会不会出错"的判断题,考的就是这个地方。

2.3 内层循环枚举的是"历史位置"

内层循环:

for (int j = 0; j < i; j++)

枚举从st[0]到st[i-1]的所有字符。它要做的事情只有一件:判断st[i]是否等于历史中的某个字符。一旦相等,就把这一对字符全部变成下划线。

这里有个初学者常忽略的细节:内层循环找到第一个相等的st[j]后并不会break,而是继续扫描完所有j < i。虽然st[i]已经被改成下划线后,后面再对比大概率不相等,但程序确实会把剩余历史位置都检查一遍。这也是整个程序最坏时间复杂度是O(n^2)的原因,n 是字符串长度。

2.4 连续赋值的真正赋值顺序

st[i] = st[j] = '_'是一次连续赋值。C 语言里赋值运算符是右结合,所以执行顺序是:

st[j] = '_'; st[i] = st[j]; // 此时 st[j] 已经是下划线

等价写法就是:

st[j] = '_'; st[i] = '_';

最终效果都是把这两个位置写入'_'。有些初学者担心先改st[j]会不会影响st[i]的结果。不会,因为st[i]的原始值已经在 if 判断时被比较过了,赋值阶段不再需要它。

真正值得注意的是"先改前面的,再改当前的"这个顺序对数组后续状态的影响——它决定了后面字符往回查的时候,看到的是'_'而不是原始字符。这就是下一节要讲的核心陷阱。

2.5 为什么"数组已经变化"会让程序行为变得很关键

这个程序最大的心理陷阱在于:你会以为程序在判断"当前字符是否在原始字符串里出现过",但实际上它判断的是"当前字符是否在已经被修改过的数组里存在"。

拿字符串aaaa举例。按人类直觉,四个 a 两两抵消,应该全部变成下划线。实际推演完全不是这样:

  • i=1:st[1]与st[0]相等,两者变成'_',数组变成__aa;
  • i=2:往回看st[0]、st[1]都是'_',不等,无事发生;
  • i=3:往回看st[0]、st[1]、st[2]分别是'_'、'_'、'a',不等,无事发生。

最终输出是__aa,只有前两个 a 被消掉,后两个 a 留下来了。这个反直觉的结果,恰恰是这道题真正的考点。后面推goodgood时,如果脑子里还保留"对比原始字符串"的错误模型,十有八九会推错。

3. 完整推演两个必考实例:abcabc 与 goodgood

3.1 "abcabc"全过程

把每一步的数组状态写出来。用_表示下划线。

初始状态:a b c a b c

外层i=1,当前字符st[1]='b'

  • j=0:比较'b'与'a',不等。

外层i=2,当前字符st[2]='c'

  • j=0:'c'与'a',不等;
  • j=1:'c'与'b',不等。

外层i=3,当前字符st[3]='a'

  • j=0:'a'与st[0]='a',相等。执行赋值,st[0]='_',st[3]='_',数组变为_ b c _ b c;
  • j=1:此时st[3]已经是'_',不可能再等于'b';
  • j=2:同理,不等。

外层i=4,当前字符st[4]='b'

  • j=0:'b'与'_',不等;
  • j=1:'b'与st[1]='b',相等。执行赋值,st[1]='_',st[4]='_',数组变为_ _ c _ _ c;
  • j=2:此时st[4]是'_',不等。

外层i=5,当前字符st[5]='c'

  • j=0:'c'与'_',不等;
  • j=1:'c'与'_',不等;
  • j=2:'c'与st[2]='c',相等。执行赋值,st[2]='_',st[5]='_',数组变为_ _ _ _ _ _。

结束。printf("%s", st)输出六个下划线。注意下标 6 的位置还是'\0',它从头到尾没有被覆盖,所以输出到第六个下划线后正常停止。

3.2 "goodgood"全过程

初始状态:g o o d g o o d

外层i=1,当前字符st[1]='o'

  • j=0:'o'与'g',不等。

外层i=2,当前字符st[2]='o'

  • j=0:'o'与'g',不等;
  • j=1:'o'与st[1]='o',相等。执行赋值,st[1]='_',st[2]='_',数组变为g _ _ d g o o d;
  • j的范围是j<2,只有 0 和 1,内层结束。

外层i=3,当前字符st[3]='d'

  • j=0:'d'与'g',不等;
  • j=1:'d'与'_',不等;
  • j=2:'d'与'_',不等。

外层i=4,当前字符st[4]='g'

  • j=0:'g'与st[0]='g',相等。执行赋值,st[0]='_',st[4]='_',数组变为_ _ _ d _ o o d;
  • j=1、j=2、j=3:此时st[4]已经变成'_',比较都不等。

外层i=5,当前字符st[5]='o'

  • j=0:'o'与'_',不等;
  • j=1:'o'与'_',不等;
  • j=2:'o'与'_',不等;
  • j=3:'o'与'd',不等;
  • j=4:'o'与'_',不等。

注意,这里'o'原本在下标 1 出现过,但下标 1 早已变成'_',所以这个'o'没能配对,被放过去了。

外层i=6,当前字符st[6]='o'

  • j=0:'o'与'_',不等;
  • j=1:'o'与'_',不等;
  • j=2:'o'与'_',不等;
  • j=3:'o'与'd',不等;
  • j=4:'o'与'_',不等;
  • j=5:'o'与st[5]='o',相等。执行赋值,st[5]='_',st[6]='_',数组变为_ _ _ d _ _ _ d。

外层i=7,当前字符st[7]='d'

  • j=0:'d'与'_',不等;
  • j=1:'d'与'_',不等;
  • j=2:'d'与'_',不等;
  • j=3:'d'与st[3]='d',相等。执行赋值,st[3]='_',st[7]='_',数组变为_ _ _ _ _ _ _ _。

结束。输出八个下划线。

3.3 推演里最容易看走神的三个位置

推goodgood时,大多数人翻车在三个地方。

第一,下标 5 的'o'。它和下标 2 的'o'本来是一对,但下标 2 早就被改成了'_',所以它匹配不到,白白被放过去。这就对应前面强调的"数组状态会变"。

第二,下标 7 的'd'。它会和下标 3 的'd'配对,因为下标 3 的'd'从头到尾没被动过。如果你推演过程中看到下标 3 周围全是下划线,就以为它也已经消失了,那这一步就会漏掉。

第三,printf("%s", st)输出的是字符数组的当前内容,不是"修改了几次",也不是"剩下几个非下划线字符"。题目问的是最终字符串长什么样,别答成计数题。

4. 题目逐条拆解:判断题和选择题的答案为什么是这些

4.1 判断:输入的字符串只能由小写字母或大写字母组成

这个说法是错的。scanf("%s", st)没有任何字符集限制,它只是读到空白字符为止。小写字母、大写字母、数字、下划线、标点都能读进来。程序逻辑只做字符相等比较,'_'只是替换用的字符,不参与输入限制。

即便从运行角度想,输入123123一样能跑,输出还是六个下划线;输入a1b2a1也能正常出结果。所以这道判断题的真正考点是:你知不知道%s的读入规则,以及char类型几乎能存任意 ASCII 字符。

4.2 判断:把 i=1 改成 i=0 会不会运行出错

答案是不会发生错误。把外层循环改成:

for (int i = 0; st[i]; i++)

第一次进入内层循环时,j < 0不成立,内层一次都不执行,相当于白白扫了一次st[0]。st[0]在有输入的情况下不是'\0',所以循环正常进入,内层空转一轮,然后i自增,从i=1开始行为和原程序完全一样。

唯一要抬杠的极端情况是输入空串。但scanf("%s", st)遇到 EOF 时根本不会给st写入内容,数组内容是未定义的,这属于题目约定外的输入。原题明确约定输入合法,所以标准结论就是:不会发生错误,输出结果也不变。

4.3 判断:把 if 条件改成 st[i]==st[j] && i!=j 会影响结果吗

不会影响。因为内层循环边界是j < i,j永远不可能等于i。加上i != j相当于加了一个恒真条件,整个 if 是否成立仍然完全由st[i] == st[j]决定。输出结果和原程序一模一样。

这道题在考你算不清j的取值范围时会不会慌。只要确认j < i,就说明当前字符不会和自己比较,追加i != j是纯冗余。

4.4 选择:输入 abcabc 输出什么

选项里必然有一个是"六个下划线"的形态。根据第 3 章的完整推演,最终数组是______,所以选输出六个下划线的那一项。

很多同学在考场上推到_ b c _ b c就以为结束了,丢分非常可惜。他们少做了i=4和i=5两步。其实只要耐心补完,结论水到渠成。

这里再额外说一句,常见错误选项会围绕"原样输出 abcabc"和"abc___"来设计。abcabc是完全没有理解程序的人选的;abc___是把"只改当前位置"和"原文改动"混在一起的人选的。如果你想检验自己的理解,可以试着解释这两个选项为什么错,比直接背答案有用得多。

4.5 选择:输入 goodgood 输出什么

同理,根据第 3 章推演,最终是八个下划线,选对应项。

这道题比abcabc更考验理解,因为good里有连续两个o,推演到i=5时会出现"明明 o 出现过,却配不上对"的错觉。能把这道题推对,说明你真的理解了"数组会持续变化",而不是只会机械套步骤。

4.6 扩展:把赋值改成 st[i]='_' 会怎样

原题有一道判断专门问这个:把st[i] = st[j] = '_'改成st[i] = '_'后,输出结果还一样吗?答案是不一样。

原因很直接:原代码把"历史相同字符"和"当前字符"同时抹掉;改完之后只抹掉当前字符,历史字符还保留原样。拿abcabc验证:

  • i=3时,st[3]变成'_',st[0]仍然是'a';
  • i=4时,st[4]变成'_',st[1]仍然是'b';
  • i=5时,st[5]变成'_',st[2]仍然是'c'。

最终输出变成abc___。历史字符不再被标记,输出自然不同。

5. 从这道题提炼出的阅读程序题做题方法论

5.1 先给程序"起名字",再看代码

拿到任何阅读程序题,别急着逐行读。花十几秒做两件事:看输入输出,猜程序目的;给程序功能起一个直观的名字。这道题如果你先概括出"相同字符互相消除",后面所有推演都会顺很多。反过来,一上来就死盯 for 边界,很容易被细节淹没。

这个习惯对所有阅读程序题都适用。CSP-J 试卷里的阅读程序无非考递推、模拟、字符串处理、简单数据结构。先定方向,再动手,效率完全不同。

5.2 字符串题必画数组状态表

我要求我的学生遇到字符串修改类题目,一律在草稿纸上画表。形式很简单:

下标012345
初始abcabc
第一次修改后_bc_bc
第二次修改后__c__c
第三次修改后______

每次外层循环只改一处,就单独写一行。这样不会漏步骤,也方便回头检查。考场上草稿纸不需要工整,但这步绝对不能省。绝大多数"我推错了"的人,都是在脑子里跑程序,把自己绕晕了。

5.3 判断"改动是否影响结果"的三步验证法

初赛特别喜欢考"把某一行改成 XX,结果会怎样"。这类题不要凭感觉,按三步走:

  1. 看改动影响的是循环边界、判断条件,还是赋值动作;
  2. 用一个最小例子在草稿上快速跑一遍;
  3. 和原程序结果对比,不确定再扩大到一个中等例子验证。

比如第 4 章里的判断:改i=0属于循环边界改动,用ab跑一遍就知道不影响;改赋值语句属于动作改动,用abcabc跑一遍就知道影响很大。三步法对付这类题基本不会失手。

5.4 时间分配:阅读程序题每道控制在 12 分钟以内

CSP-J 第一轮的考试时间是 120 分钟。阅读程序三道大题,总时长建议控制在 35 分钟上下,也就是每题 10 到 12 分钟。这道题代码短,正常应该在 8 到 9 分钟内完成全部判断和选择。

如果推演超过 15 分钟还没有完整状态表,建议先跳过,去做后面的完善程序题。初赛不怕"回来再补",最怕在一道题上耗到崩溃。阅读程序的单题分值有限,为它牺牲后面的题目不值得。

5.5 复盘价值:把做错的题变成自己的模板

每一年我都会让学生把这张卷子认真复盘两遍。第一遍按考试状态做,第二遍不看答案重新推演,第三遍只做错题。像这道题的goodgood,如果第一遍推错了,第二遍就重点分析自己是在哪一步把状态搞丢的——是没注意下标 2 已经被改成下划线,还是把连续赋值看成只改一个位置。

错题本上不用抄整道题,只写三行:程序功能一句话、当初的错因一句话、正确推演的关键一步。到复赛前翻一遍,比盲目刷十套新题都管用。

最后再分享一个小技巧:这道题的程序可以随手改造成很多变体,比如"只把后面的重复字符改成下划线"、"从后往前扫描"、"遇到重复就 break",每种变体都能当成新的阅读程序练习题。当年我就是靠这种"一题多变"把字符串处理的基础打得比较扎实,后来刷提高型模拟题时轻松不少。希望这篇文章能帮你看透这道经典题,也祝你下次遇到字符串题时,手不抖、心不慌。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 20:57:38

中频采样与数字下变频:FPGA实现带通采样及抽取滤波链路设计

1. 从射频到基带&#xff1a;中频采样到底在解决什么问题搞过软件无线电或者雷达接收链路的人&#xff0c;大概率都绕不开一个经典架构选择&#xff1a;射频信号下来之后&#xff0c;到底是在射频直接采样&#xff0c;还是先搬到中频再采样&#xff1f;这个问题我在不同项目里反…

作者头像 李华
网站建设 2026/9/29 20:56:06

博客1_九个坑实录

手写 FASTA 解析器踩了 9 个坑:从 0 条记录到 3 条的完整调试实录本文是《手写 FASTA 解析器》系列第 1 篇我是生信方向研一新手,Python 零基础起步,研究方向是小鼠 VNTR(可变数目串联重复)变异分析。 为了搞懂序列文件是怎么被读进程序的,我从零手写了一个 FASTA 解析器 —— …

作者头像 李华
网站建设 2026/9/29 20:55:09

射频PA模块选型:五大关键参数权衡与实战指南

做射频的人都知道&#xff0c;PA模块选型看起来是个“按参数挑器件”的活儿&#xff0c;实际上更像是在做一次系统级的权衡博弈。我见过不少项目&#xff0c;前期讨论方案时大家把注意力全放在输出功率上&#xff0c;觉得“功率够了就行”&#xff0c;结果样机一测&#xff0c;…

作者头像 李华