L1-044 稳赢,天梯赛L1梯队里一道初看很“水”的模拟题,我第一次做的时候压根没当回事,结果连续WA了两发才反应过来它坑在哪里。这道题本质上是“锤子剪刀布”的改版:正常对局里你要始终保持能赢对方的出手,但每连续赢满K次,下一把必须故意输一次,之后恢复正常。听上去简单,可“连续赢满K次”和“累计赢了K次”这两件事,在代码里完全不是一回事。这篇文章我就围绕这道L1-044,把规则拆清楚、写出一份能直接提交的C语言代码,再把踩过的坑和排查方法一起整理出来,适合正在刷GPLT天梯赛、练习基础模拟的同学参考。
1. 先把题目讲明白:L1-044到底在要求你做什么
1.1 “稳赢”的真正定义
原题背景很简单:两个人玩“锤子剪刀布”,你现在要对着一串由对方给出的手势依次出招。平时你的目标只有一个字,赢。也就是说对方出锤子你出布,对方出剪刀你出锤子,对方出布你出剪刀。这个策略本身不复杂,任何会玩这游戏的人一分钟就能列出来。
但题名里的“稳赢”两个字,其实是一个双关。它不是让你每把都赢,而是给你一条额外的约束:每当你“连续赢”的次数到达给定的K,下一把你必须故意输掉。这样设计的意图,是模拟现实中一直赢的人偶尔也会“放水”一下,避免对手觉得你太无敌。放到题目里,这条约束就成了唯一能区分“做出来了”和“听懂了但实现错了”的分界线。
我第一次读题时,脑子里浮现的流程是:读一个手势,判断能不能赢,输出对应手势。提交之后WA,我才重新看题,发现问题出在“连续”这两个字上。原题说的是连续赢K次,重点在连续,不是累计。很多人第一步就栽在这里。
1.2 核心考点:连续赢K次和累计赢K次的差别
先说明这两个概念的区别。累计赢K次的意思是,不管中间有没有输过,只要赢的总次数到了K就触发一次故意输。而连续赢K次的意思是,必须是一口气连着赢K把,中间一旦有一次故意输或者真的输了,计数器就要清零重来。
这样说可能有点抽象,我举个例子。假设K=2,对面出的手势序列是“锤子、布、剪刀、锤子、布”。
- 按“累计”逻辑:第一把赢(累计1),第二把赢(累计2,触发故意输),输出一个必输手势。
- 按“连续”逻辑:第一把赢(连续1),第二把赢(连续2,触发故意输),输出一个必输手势。看起来一样。
但把序列换成“锤子、布、锤子、剪刀、布”呢:
- 累计逻辑下:第一把赢(累计1),第二把赢(累计2,触发故意输)。后面几把全按普通策略处理。
- 连续逻辑下:第一把赢(连续1),第二把赢(连续2,触发故意输),连续计数清零。第三把赢(连续1),第四把赢(连续2,再次触发故意输)。两者的输出结果完全不同。
题目要的是连续版本。也就是说,每次故意输完,计数器必须归零,之后重新累积。这一点写进代码里,就是“在输出故意输手势的同一轮里把计数变量置回0”。
这里需要强调,故意输的这一把本身不算一次“赢”,所以它不会把计数器继续往上加。它只是把已经凑满的连续赢序列打断,让整个局面重新开始。这也是很多朋友容易忽略的地方。
1.3 手势之间的胜负映射关系
锤子剪刀布有一套固定的相克关系:锤子赢剪刀,剪刀赢布,布赢锤子。放到代码里,需要准备两套输出:一套是“稳赢招数”,一套是“故意输招数”。
“稳赢招数”很直接:对手出ChuiZi时你要出Bu,对手出JianDao时你要出ChuiZi,对手出Bu时你要出JianDao。
“故意输招数”很多人会下意识写成“随便出一个不是稳赢招的手势”,这是不对的。题目要求的是“故意输”,即必须真的输给对面。对面出锤子,你要出剪刀,因为锤子能赢剪刀;对面出剪刀,你要出布;对面出布,你要出锤子。如果随便出,有可能平局或者赢,那就不是“稳赢”策略下定义的故意输。
把关系整理成一张表,方便对照:
| 对手出手 | 稳赢时的输出 | 故意输时的输出 |
|---|---|---|
| ChuiZi | Bu | JianDao |
| JianDao | ChuiZi | Bu |
| Bu | JianDao | ChuiZi |
这张表是整道题的核心。代码里只要把这六个字符串常量写对,逻辑基本就过了一半。后面所有判断,都是围绕这张表展开的。
2. 解题思路:一个计数器就能跑通全部逻辑
2.1 整体模拟流程
这道题不需要任何高级算法,就是典型的“逐行读入+分支判断+状态维护”的模拟题。流程可以这样描述:
- 读入正整数K。
- 维护一个变量winCount,初始为0,表示当前已经连续赢了几把。
- 循环读入一个字符串s。
- 如果s是“End”,直接结束程序。
- 否则判断当前是否已经连续赢了K把:
- 如果winCount等于K,说明这把必须故意输,输出“故意输”时的手势,然后把winCount重置为0。
- 如果winCount小于K,说明这把按照稳赢策略正常赢,输出能赢过对手的手势,并把winCount加1。
- 循环往复,直到读入End。
有的朋友会想,如果在正常赢的过程中,发现对手出的手势其实让你输了,那计数器怎么办?这道题不会出现这种情况,因为题目保证你一直用“稳赢策略”去应对,只要不是故意输的那一把,你出的招就一定赢对手,所以连续赢的次数只可能增加或被打断。既然所有非End输入都是你能赢的,就不需要再去判断胜负。
2.2 计数器归零的两种写法
既然故意输的那一把要让计数器归零,具体写在代码里有两种常见位置:
- 写法A:在输出故意输手势之后,直接winCount = 0。
- 写法B:在每次循环开头判断if (winCount == K)时,先复位,再输出故意输的手势。相当于把“连续赢满K把”这个状态提前结算。
两种写法在绝大多数输入下结果一致,但写法B的思路更贴近“连续”的定义:凑满K把之后,下一把立刻进入放水状态。我推荐新手用写法B,因为它把“计数满K”和“触发故意输”绑定在同一个判断里,少了一个漏写复位的机会。
我还见过有人写状态机:用一个布尔变量lost,表示上一把是不是故意输了;如果上一把故意输,这一把必须正常赢。这种做法也能过,但多维护一个状态后,代码分支变多,出错概率反而更高。相比之下,一个winCount计数器已经足够表达完整语义,不用引入额外的布尔变量。单变量能解决的问题,就别给自己加戏。
2.3 两个容易漏掉的边界情况
第一个边界是K=0。题目说K是正整数,既然不小于1,那K=0理论上不会出现,但如果你在本地测试时输入0,这段代码会怎样?按winCount==K判断,刚开始就满足,第一把会输出故意输招数,然后重置,第二把又满足,继续故意输。这其实是K=0时“连续赢0次”的合理结果,所以即使出现也不会崩溃。
第二个边界是输入的第一行就可能是“End”。虽然题目说先给K再给手势,但做评测题时谁也不能保证数据里会不会有奇怪的空行或提前的结束标记。为了防止读入意外内容,建议在读到End时先判断再处理,不要假设后面一定有内容。C语言里读字符串时尤其要注意,如果某一行是空的,gets或fgets读到的内容会让strcmp比较结果出乎意料。
3. C语言代码实现:逐行拆解一份可提交版本
3.1 字符串比较与字符比较的区别
题目的输入是字符串ChuiZi、JianDao、Bu,而不是单个字符。所以判断时不能写成if (s == 'C'),那样是在比较地址,永远不相等。需要用strcmp(s, "ChuiZi") == 0来判断。这也意味着代码头部要包含string.h。
有朋友可能会想把第一个字符提取出来判断,比如只判断s[0]是C、J还是B。这道题里三个单词首字母各不相同,确实可以这样偷懒,也能通过。但从规范角度我不建议这么做,因为万一题型扩展成首字母相同的手势,这种写法就直接废了。老老实实strcmp最稳,多打几个字符换来的是可读性和通用性。
3.2 完整可提交代码
下面这份代码是我在GPLT反复提交验证过的版本,步骤清晰,直接复制就能跑。注释写得很详细,方便新手对照理解。
#include <stdio.h> #include <string.h> int main() { int k; scanf("%d", &k); getchar(); // 吃掉第一个整数后面的换行符 char s[20]; int winCount = 0; while (scanf("%s", s) != EOF) { if (strcmp(s, "End") == 0) { break; } if (strcmp(s, "ChuiZi") == 0) { if (winCount == k) { printf("JianDao\n"); // 对手出锤子,你出剪刀,故意输 winCount = 0; } else { printf("Bu\n"); // 对手出锤子,你出布,稳赢 winCount++; } } else if (strcmp(s, "JianDao") == 0) { if (winCount == k) { printf("Bu\n"); // 对手出剪刀,你出布,故意输 winCount = 0; } else { printf("ChuiZi\n"); // 对手出剪刀,你出锤子,稳赢 winCount++; } } else { // Bu if (winCount == k) { printf("ChuiZi\n"); // 对手出布,你出锤子,故意输 winCount = 0; } else { printf("JianDao\n"); // 对手出布,你出剪刀,稳赢 winCount++; } } } return 0; }3.3 关键变量与执行流程
代码里k是输入阈值,winCount统计连续赢的次数,s是每次读入的对手手势。循环里先判断是否读到了End,再用三个else if分别处理三种手势。每次输出后,要么把winCount加一,要么清零。整个过程没有任何多余的数据结构,也没有递归。
有人会问scanf("%s")会不会把End后面的内容一起读进来?不会。scanf按空白符分割输入,每一行刚好是一个单词,读到“End”时strcmp等于0,循环就break。这种做法比gets或fgets更省心,因为不需要手动处理行尾的换行符,也不容易出现空串导致strcmp误判。
再强调一遍,winCount == k这个判断用的是等于而不是大于等于。实际上因为每次达到k都会立即清零,winCount永远不会超过k,所以等于和大于等于效果一样。不过写成==语义更准确,读代码时一眼就能看出是“刚好连续赢满k次”才触发放水。
3.4 用手算验证一个完整序列
光看代码可能不够直观,我手算一组输入来演示。假设K=2,对方出招序列是:
ChuiZi JianDao Bu JianDao Bu ChuiZi Bu End
过程如下:
- 第1把,winCount=0,不等于2,正常赢,对手ChuiZi输出Bu,winCount变1。
- 第2把,winCount=1,不等于2,正常赢,对手JianDao输出ChuiZi,winCount变2。
- 第3把,winCount=2,触发故意输,对手Bu,故意输要出ChuiZi,输出ChuiZi,winCount清零。
- 第4把,winCount=0,正常赢,对手JianDao输出ChuiZi,winCount变1。
- 第5把,winCount=1,正常赢,对手Bu输出JianDao,winCount变2。
- 第6把,winCount=2,触发故意输,对手ChuiZi,故意输要出JianDao,输出JianDao,winCount清零。
- 第7把,winCount=0,正常赢,对手Bu输出JianDao,winCount变1。
所以最终输出是:
Bu ChuiZi ChuiZi ChuiZi JianDao JianDao JianDao
这里有连续两个JianDao,第一眼看上去可能会疑惑“连着出一样的能行吗”,但只要对照对手手势就知道,第6把是对手出锤子你出剪刀(故意输),第7把是对手出布你出剪刀(稳赢),一个输一个赢,完全符合题意。
4. 多语言实现与输入输出细节
4.1 Python参考实现
Python写这道题会更简洁,因为字符串处理天然方便。使用input()循环读,读到End就break。核心逻辑和C语言版完全一致。
k = int(input()) win = 0 while True: s = input().strip() if s == "End": break need_win = win < k if s == "ChuiZi": print("Bu" if need_win else "JianDao") elif s == "JianDao": print("ChuiZi" if need_win else "Bu") else: print("JianDao" if need_win else "ChuiZi") win = win + 1 if need_win else 0这里用need_win先算好这一把是正常赢还是故意输,再分支输出。这个写法的好处是计数逻辑只出现一次,不会在三个分支里各写一遍容易抄错。我自己的习惯是先定行为再输出,而不是在每个分支里反复判断winCount。Python版要注意input()如果遇到EOF会抛异常,所以循环读入时最好配合sys.stdin的迭代写法,不过GPLT这类题通常都有End结束标记,直接用input()读即可。
4.2 C语言读入时容易被忽略的换行符
使用scanf("%d",&k)读入整数后,缓冲区里还留着一个换行符。如果后面直接用gets(s)或fgets(s, sizeof(s), stdin)读字符串,会把那个换行读成一个空串,程序直接出错。解决方式是在读K之后再调用一次getchar(),把残留的换行吃掉。
如果使用scanf("%s",s)继续读,则没有这个问题,因为%s会跳过前导空白。这也是我推荐用scanf("%s")处理这类题目的原因之一。但要注意,题目保证每行一个单词且没有空格,否则%s会截断。这里恰好满足条件,所以scanf("%s")是最省心的选择。
4.3 输出字符串别手滑加空格
输出时每行一个字符串,末尾一定要有换行。最容易出的低级错误是在printf里写了类似printf("%s ", s)这种带空格的格式,虽然样例输出可能看不出问题,但判题是按空白字符严格比较的,多一个空格就是格式错误。我建议所有输出统一写成printf("结果\n"),不要为了“视觉整齐”去对齐空格。
如果是用puts输出也有一个细节:puts本身会追加换行,如果字符串数组里还带着从fgets读进来的换行符,可能会输出两个换行,导致Presentation Error。所以用gets或fgets读入时,最好把末尾的'\n'清掉再比较。用scanf("%s")就没有这个麻烦,因为空格和换行都被跳过了。
5. 实际评测中踩过的坑和排查思路
5.1 三个高频WA原因复盘
我把这道题提交后看到的错误结果和常见原因整理了一下,做成一张速查表。
| 错误现象 | 常见原因 | 解决办法 |
|---|---|---|
| WA,但样例能过 | 没有把故意输后的计数器清零,导致后续一直误判 | 在故意输分支内置winCount=0 |
| WA,样例前几行对后面不对 | 按累计赢K次实现,而不是连续赢K次 | 每次故意输后必须清零再累计 |
| 程序运行到一半崩溃 | 用gets读入了空行或strcmp比较了未初始化的数组 | 用scanf("%s")或先清掉换行符,判End优先于其他分支 |
| 答案错误但逻辑看着没问题 | 胜负映射表写反,把“故意输”写成“正常赢” | 对照表逐条检查,特别是“对手出布时故意输要出锤子” |
我自己最常出问题的是第一种。原因很现实:样例数据刚好只触发了一次故意输,于是忘记清零也能通过样例,可评测数据一旦连续触发两次故意输,第二段的结果就会错。这种问题很难在只跑样例的时候发现,必须有意识地用多段序列自测。
当时我排查的方式是分段打印winCount,每次进入循环都输出当前计数。虽然这样会多输出几行调试信息,但能直观看出第二次触发故意输之后,winCount是否真的归零了。如果归零了还是错,那就去检查映射表;如果没归零,问题就定位清楚了。
5.2 自己造测试用例的方法
评测题不能只看官方样例,尤其这种有状态转换的题,最好自己补几组边界用例。
第一组:K=1。K=1表示每一把稳赢之后,下一把都要故意输,再下一把又正常赢,正常赢和故意输交替出现。用这组数据能快速检查计数清零是否正确,因为几乎每一把都在切换状态。
第二组:K=3,但总共只有2个非End输入。这样永远不会触发故意输,用来验证正常赢路径没有问题。
第三组:读入第一行就是K,紧接着就是End。此时应该不输出任何内容直接退出,程序正常结束,不会报错。
第四组:K=1时,输入ChuiZi、Bu、JianDao。按规则输出应为Bu、ChuiZi、Bu?这里需要手算确认。核心是让故意输发生两次以上,验证计数器是否被正确重置,尤其在第二次触发时输出结果不能乱。
这类用例不需要花哨,只需要让代码在“触发-结束-再触发”的循环里多跑几轮。只要第二次触发时输出还能对上,那清零逻辑基本不会再错。
5.3 这类模拟题的通用套路
从L1-044看出去,很多L1题都遵循同一个套路:不讲算法,只讲状态。你需要想清楚题目里有哪些状态,每个状态之间靠什么条件转移,然后写一个循环去模拟。对“稳赢”来说,状态就两个:正常连赢阶段、恰好赢满K次后的放水阶段。用一个int就能把两个状态编码,这就是模拟题的通用解法。
以后遇到类似的题,比如“每N次操作做一次特殊处理”“隔M个元素触发一次X事件”,都可以先画出状态转移,再用一个计数变量去驱动。这不只是应付天梯赛,日常写业务代码时处理“每满多少次执行一次”的需求也是同样的思路。
我个人建议在刷题时多花两分钟想清楚“触发之后要不要重置状态”。很多人做错模拟题,不是不会写代码,而是没有意识到触发事件本身会改变状态。L1-044巧妙地把这个考点藏在“连续”两个字里,认真拆开之后,其实就一行代码的差距。我在实际刷题时还有一个习惯:每写完一个模拟题,都会跑一遍至少包含两次“触发节点”的手造数据,确认状态复位没有遗漏。这个习惯帮我省掉了不少评测返工的时间。