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 字符串题必画数组状态表
我要求我的学生遇到字符串修改类题目,一律在草稿纸上画表。形式很简单:
| 下标 | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| 初始 | a | b | c | a | b | c |
| 第一次修改后 | _ | b | c | _ | b | c |
| 第二次修改后 | _ | _ | c | _ | _ | c |
| 第三次修改后 | _ | _ | _ | _ | _ | _ |
每次外层循环只改一处,就单独写一行。这样不会漏步骤,也方便回头检查。考场上草稿纸不需要工整,但这步绝对不能省。绝大多数"我推错了"的人,都是在脑子里跑程序,把自己绕晕了。
5.3 判断"改动是否影响结果"的三步验证法
初赛特别喜欢考"把某一行改成 XX,结果会怎样"。这类题不要凭感觉,按三步走:
- 看改动影响的是循环边界、判断条件,还是赋值动作;
- 用一个最小例子在草稿上快速跑一遍;
- 和原程序结果对比,不确定再扩大到一个中等例子验证。
比如第 4 章里的判断:改i=0属于循环边界改动,用ab跑一遍就知道不影响;改赋值语句属于动作改动,用abcabc跑一遍就知道影响很大。三步法对付这类题基本不会失手。
5.4 时间分配:阅读程序题每道控制在 12 分钟以内
CSP-J 第一轮的考试时间是 120 分钟。阅读程序三道大题,总时长建议控制在 35 分钟上下,也就是每题 10 到 12 分钟。这道题代码短,正常应该在 8 到 9 分钟内完成全部判断和选择。
如果推演超过 15 分钟还没有完整状态表,建议先跳过,去做后面的完善程序题。初赛不怕"回来再补",最怕在一道题上耗到崩溃。阅读程序的单题分值有限,为它牺牲后面的题目不值得。
5.5 复盘价值:把做错的题变成自己的模板
每一年我都会让学生把这张卷子认真复盘两遍。第一遍按考试状态做,第二遍不看答案重新推演,第三遍只做错题。像这道题的goodgood,如果第一遍推错了,第二遍就重点分析自己是在哪一步把状态搞丢的——是没注意下标 2 已经被改成下划线,还是把连续赋值看成只改一个位置。
错题本上不用抄整道题,只写三行:程序功能一句话、当初的错因一句话、正确推演的关键一步。到复赛前翻一遍,比盲目刷十套新题都管用。
最后再分享一个小技巧:这道题的程序可以随手改造成很多变体,比如"只把后面的重复字符改成下划线"、"从后往前扫描"、"遇到重复就 break",每种变体都能当成新的阅读程序练习题。当年我就是靠这种"一题多变"把字符串处理的基础打得比较扎实,后来刷提高型模拟题时轻松不少。希望这篇文章能帮你看透这道经典题,也祝你下次遇到字符串题时,手不抖、心不慌。