十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

NOIP经典模拟题:乒乓球比赛计分算法详解与C++实现

NOIP经典模拟题:乒乓球比赛计分算法详解与C++实现 1. 项目概述从一道经典算法题看模拟与字符串处理最近在整理一些老题目又翻到了2003年NOIP普及组的“乒乓球”这道题。别看它名字听起来像体育项目实际上这是一道非常经典的字符串模拟题考察的是对规则的理解、边界条件的处理以及如何将现实问题转化为清晰的程序逻辑。很多刚接触算法竞赛的同学包括当年的我都在这道题上栽过跟头——不是规则理解错了就是输出格式不对或者漏掉了那些“不起眼”却至关重要的细节。这道题就像乒乓球比赛本身规则简单明了但真正打起来每一个球的处理、每一局的计分都需要绝对的严谨。今天我就结合自己当年踩过的坑和后来辅导学生总结的经验把这道题的解题思路、代码实现尤其是那些容易出错的地方掰开揉碎了讲清楚。无论你是正在备赛的OIer还是对算法感兴趣想找些经典案例练手的开发者相信这篇详尽的拆解都能让你有所收获。这道题的核心就是给你一串由W和L组成的字符序列代表一场乒乓球比赛的得分记录W代表华华得一分L代表对手得一分。你需要根据两种不同的赛制11分制和21分制分别模拟比赛过程并输出每一局的比分。题目输入就是这串字符以一个大写的E结束。输出则是先输出11分制下的比赛结果再输出21分制下的比赛结果。格式要求每一局比分在一行比分之间用冒号分隔每局结束后需要换行并且在所有局数输出完后还需要输出一个空行。规则就是这么简单直接但魔鬼全在细节里。2. 赛制规则深度解析与建模思路2.1 乒乓球比赛的核心计分规则要写好模拟程序首先得吃透规则。乒乓球比赛的计分规则可以提炼为以下几个关键点这直接决定了我们程序的判断逻辑得分来源每个球要么是华华得分输入W要么是对手得分输入L。这是驱动比赛进程的唯一输入。获胜条件一局比赛中先得到11分或21分且领先对手至少2分的选手赢得该局。这是最核心的规则。“且”这个字至关重要。比如11分制下比分10:10时并不会结束必须有一方连续得分到12:10或者交替得分直到分差拉大到2分例如15:13此时得分达到或超过11分且分差≥2的一方获胜。局间逻辑一局结束后下一局从0:0开始。整个比赛就是由一局一局串联起来的。输入终止遇到字符E比赛立即停止无论当前这一局是否打完。此时需要输出当前这一局的即时比分即使它不满足获胜条件。将这些规则转化为程序逻辑我们的模拟器需要持续追踪两个核心变量当前局华华的得分score_w和对手的得分score_l。每读入一个字符W或L就更新对应的分数。然后在每次更新分数后都需要进行一次“局点检查”。2.2 模拟算法的核心状态检查与记录这个“局点检查”是算法的发动机。检查的条件就是上述的获胜条件。用伪代码表示这个检查逻辑对于11分制来说是这样的如果 (score_w 11 或 score_l 11) 并且 abs(score_w - score_l) 2: 那么本局结束 记录本局比分 (score_w : score_l) 将 score_w 和 score_l 重置为 0准备下一局这里有一个非常重要的细节检查必须在每一次得分更新之后立即进行。因为比赛可能在任意一次得分后恰好结束。例如比分从10:10到11:10虽然华华到了11分但分差只有1比赛继续。再得一分变成12:10此时再次检查满足“score_w 11 且 分差2”比赛结束。如果我们把检查放在错误的位置就可能无法及时终止对局。那么如何记录结果呢最自然的方式是使用一个列表或向量来存储每一局的比分。我们可以定义一个结构体或二元组来表示一局比分比如(w, l)。每结束一局就将当前的(score_w, score_l)存入列表然后重置分数。当遇到E时无论是否在检查中触发了结束条件我们都需要将当前进行中的这局比分即重置前的score_w和score_l也存入列表。这是因为E意味着输入结束比赛强行终止我们需要输出这个未完成的局面的比分。2.3 两种赛制的处理策略选择题目要求分别按11分制和21分制输出两次结果。这里有三种实现策略策略一遍历两遍。这是最直观也是初学者最容易想到的方法。将整个输入序列读入到一个字符串中然后对这个字符串分别用11分制和21分制的规则模拟两次。优点是逻辑清晰两套模拟完全独立互不干扰。缺点是需要存储整个输入并且有额外的遍历开销。但对于本题的输入规模理论上可以很长但实际评测数据有限这种方法完全可行且更不容易出错。策略二遍历一遍同时维护两种状态。在单次遍历中同时维护两套计分变量和结果列表。读入一个字符同时更新11分制和21分制下的比分并分别进行局点检查。这种方法效率更高只遍历一次输入。但对状态管理的要求稍高需要小心不要将两种赛制的逻辑混淆。在竞赛中为了编码的稳健和调试的方便我通常更推荐第一种“遍历两遍”的策略除非有严格的性能要求。策略三模块化函数。将模拟比赛的过程写成一个函数接受“获胜分数”11或21和输入字符串作为参数返回一个比分列表。这样主程序只需要调用这个函数两次即可。这是最优雅、复用性最高的方法也体现了良好的编程习惯。在接下来的具体实现中我将采用第三种策略因为它兼具了清晰度和代码的简洁性。3. 代码实现与逐行详解理解了规则和思路我们来看具体的代码实现。我会使用C进行演示因为这是NOIP竞赛的主要语言其思路可以很容易地迁移到其他语言。我们将问题分解为几个关键函数。3.1 核心模拟函数的设计首先我们设计核心的模拟函数simulate。这个函数负责一切。#include iostream #include vector #include string using namespace std; // 定义一个类型别名方便表示一局比分 using GameScore pairint, int; // 核心模拟函数 // 参数input - 包含W,L,E的输入字符串 // win_point - 赛制的获胜分数11或21 // 返回值存储了所有局比分的向量 vectorGameScore simulate(const string input, int win_point) { vectorGameScore result; // 存储所有局的比分 int score_w 0, score_l 0; // 当前局华华和对手的得分 for (char ch : input) { // 遇到E立即终止处理 if (ch E) { break; } // 根据字符更新得分 if (ch W) { score_w; } else if (ch L) { score_l; } // 注意题目保证输入只有W,L,E所以这里不需要else // **关键步骤**每次得分后检查本局是否结束 // 条件任意一方分数达到win_point且分差至少为2 if ((score_w win_point || score_l win_point) abs(score_w - score_l) 2) { // 本局结束记录比分 result.push_back({score_w, score_l}); // 重置开始下一局 score_w 0; score_l 0; } // 如果未结束则继续循环读取下一个字符 } // **另一个关键步骤**循环结束后即遇到E或字符串读完处理未完成的一局 // 只要当前局有得分score_w和score_l不全为0或者结果列表为空一场球都没打完就结束了都需要记录 // 实际上只要score_w和score_l不同时为0就说明有一局未完成的比赛。 // 但更稳妥的判断是无论当前比分是什么只要输入停止了就要输出这个比分。 // 例如输入只有一个E应该输出0:0。我们的逻辑也能覆盖。 result.push_back({score_w, score_l}); return result; }逐行解读与注意事项using GameScore pairint, int; 使用pair来存储一局比分非常合适first代表华华得分second代表对手得分。代码可读性好。函数参数const string input 使用常量引用传递输入字符串避免不必要的拷贝。for (char ch : input) 范围for循环清晰遍历每个字符。if (ch E) { break; } 一旦遇到E立即跳出循环。这是比赛终止的信号。更新得分后紧跟着就是局点检查。这个检查逻辑是本题的灵魂务必确保条件判断的准确性(score_w win_point || score_l win_point) abs(score_w - score_l) 2。两个条件必须同时满足且顺序无关但用连接。result.push_back({score_w, score_l});和重置 当一局结束时将当前比分保存然后双方分数归零。注意保存的是结束时的比分比如12:10。循环外的result.push_back({score_w, score_l}); 这是最容易遗漏的地方跳出循环后因为遇到了E当前这局可能正在进行中比如比分是3:2也可能刚好在一局结束后重置为(0,0)。无论哪种情况我们都必须将此时的(score_w, score_l)作为最后一局的比分输出。如果是在局后重置为(0,0)那么就会输出一个0:0这符合题目要求如果输入只有E就输出0:0。这个处理保证了程序的完备性。3.2 输入处理与主函数逻辑输入并不是规整的多行数据而是一个可能很长的、包含换行的字符序列直到出现E。我们需要正确地读取它。// 读取输入的函数 string readInput() { string input, line; // 不断读取行直到某一行中包含E字符 while (getline(cin, line)) { input line; // 将每一行拼接起来 // 检查这一行中是否包含E if (line.find(E) ! string::npos) { // 找到E停止读取。注意E之后可能还有字符但根据题意E之后不会有有效输入。 // 我们简单地将所有读取到的内容包括E及之前的字符作为输入即可。 // 模拟函数中的循环会在遇到第一个E时停止所以后面的字符不会被处理。 break; } } return input; } int main() { // 1. 读取输入 string input readInput(); // 2. 模拟11分制比赛 vectorGameScore result_11 simulate(input, 11); // 3. 模拟21分制比赛 vectorGameScore result_21 simulate(input, 21); // 4. 输出结果 // 输出11分制结果 for (const auto game : result_11) { cout game.first : game.second endl; } cout endl; // 两组结果之间有一个空行 // 输出21分制结果 for (const auto game : result_21) { cout game.first : game.second endl; } // 注意题目要求输出完21分制结果后也需要一个空行吗 // 严格按题目描述样例输出中最后有一个空行。但通常评测系统会忽略末尾换行。 // 为了严格匹配可以在最后也输出一个换行。这里为了清晰不额外添加。 // 如果担心格式可以输出 cout endl; return 0; }输入处理的陷阱输入可能跨越多行。使用getline(cin, line)逐行读取是最安全的方式。E可能出现在一行的中间、开头或结尾。我们的readInput函数会持续读取直到某一行出现E为止并将已读取的所有内容拼接起来。simulate函数会处理这个长字符串并在遇到第一个E时停止。这里有一个潜在的坑如果E后面还有W或L根据题意它们不应被处理我们的逻辑是正确的。但如果E出现在一行中间该行E后面的字符也会被读入input不过不影响因为模拟函数遇到E就break了。更严谨的做法是在readInput中找到E的位置后只截取E及其之前的部分。但对于本题上述简单写法已足够健壮。输出格式的细节每一局比分单独一行格式为华华得分:对手得分。11分制结果输出完毕后需要输出一个空行再输出21分制结果。这个空行很容易忘记务必注意。关于末尾空行题目样例显示21分制结果后也有一个空行。为了绝对保险可以在21分制结果循环输出后也加一个cout endl;。大多数评测系统对末尾换行符不敏感但养成严格符合样例的习惯总是好的。4. 边界条件与常见错误全解析这道题之所以经典就是因为它的边界情况非常多一不留神就会出错。下面我罗列了几乎所有常见的“坑点”并给出分析和解决方案。4.1 输入终止与未完成对局的处理这是最大的一个坑。很多初学者写完模拟循环输出结果列表就结束了忘记了循环外部、遇到E时可能还有一局未完成的比赛需要输出。错误表现 当输入在某一局中途停止时程序输出的最后一场比赛是上一局结束的比分丢失了最后一局未完成的比分。示例输入WWLWE错误输出11分制0:0因为WWL后比分2:1未达到11分且分差≥2未结束一局。遇到E后程序只输出了之前完成的局数0局然后可能输出一个初始的0:0或者什么都不输出但丢失了2:1这个比分。正确输出11分制2:1解决方法 正如我们在simulate函数中所做在遍历输入的主循环之外无论当前局是否完成都必须将当前的(score_w, score_l)作为一局比分存入结果列表。这就是代码中循环后面那条result.push_back({score_w, score_l});语句的使命。4.2 获胜条件判断的逻辑错误对规则的理解偏差会导致条件判断写错。错误1忽略“领先至少2分”// 错误代码 if (score_w win_point || score_l win_point) { // 错误 // 记录并重置 }这种写法在比分达到11:10或10:11时会错误地结束比赛。必须加上分差判断。错误2判断顺序或逻辑运算符错误// 另一种错误代码 if (score_w win_point abs(score_w - score_l) 2) { // 只判断了华华获胜条件 // ... }这只检查了华华获胜的情况漏掉了对手获胜的情况。应该用||连接两个获胜可能性。// 正确代码 if ((score_w win_point || score_l win_point) abs(score_w - score_l) 2) { // ... }括号是为了确保逻辑清晰的优先级高于||但加上括号是更好的习惯。4.3 比分重置的时机错误重置比分必须在记录当前局比分之后。错误代码示例// 错误的顺序 score_w 0; score_l 0; result.push_back({score_w, score_l}); // 这下push进去的是(0,0)这会导致结果列表里全是0:0。必须确保先保存再清零。4.4 输出格式错误错误1忘记空行。题目明确要求两组结果之间用空行隔开。必须在输出完11分制所有比分后输出一个endl或printf(\n)。错误2末尾空格或空行问题。有些同学会担心输出最后多一个空行会不会错。对于绝大多数OJ在线评测系统输出末尾的多余换行和空格通常会被忽略。但为了严谨应该严格按照样例输出格式来。样例中21分制结果后也有空行所以最好也输出一个。错误3冒号用了中文符号。这是很低级但常见的错误程序中的字符串必须是英文冒号:。4.5 极端输入情况输入为空或只有E 我们的readInput会读到一个可能为空或包含E的字符串。simulate函数中循环会直接跳过或遇到E跳出最后执行result.push_back({0, 0})输出0:0。符合预期。输入非常长 虽然使用string存储整个输入但在内存限制内通常64MB/128MB是足够的。如果实在担心可以采用“在线处理”的方式即读一个字符处理一个字符不存储整个字符串。但对于本题普及组难度存储整个字符串的方案更简单明了。一局比赛极其漫长 例如在11分制下双方从10:10一直打到30:28。我们的判断条件(score win_point) (diff 2)在比分达到11分后会一直检查分差是否达到2。只要分差小于2比赛就会继续。这个逻辑是正确的可以处理任意长度的对局。5. 测试用例与调试技巧自己构造全面的测试用例是保证程序正确的关键。下面提供一组测试用例你可以用来验证自己的程序。输入序列11分制输出21分制输出说明E0:00:0只有终止符W1:01:0只有一个球WWWWWWWWWWW11:011:0华华直接11:0获胜一局WLWLWLWLWLWLWLWLWLWLWL11:911:9交替得分最终11:9结束分差2WWWWWWWWWWLWW11:11:012:111分制第一局11:1结束剩余WW组成新局1:0。21分制未结束比分12:1。WWWWWWWWWWWWWWWWWWWWW11:011:021:021个W。11分制打了两局(11:0, 11:0)21分制一局(21:0)。WLWEE2:12:1中途遇到E输出未完成局比分。WWWWWWWWWWLWWWWWWWWWWLLLE11:111:021:3综合测试。11分制第一局11:1第二局WWWWWWWWWW构成11:0。21分制一局到底21:3。调试技巧打印中间状态 在simulate函数循环内每处理一个字符后打印当前的score_w,score_l以及是否触发局点检查。这能帮你清晰看到程序是如何一步步运行的。单元测试思维 将simulate函数单独拿出来用固定的字符串输入进行测试验证输出是否符合预期。例如写一个小的测试程序直接调用simulate(WWWWWWWWWWW, 11)看输出是不是[(11,0)]。对比输出 对于复杂的输入可以手工模拟计算一遍将每一步的比分记在纸上然后与程序输出的中间状态对比。关注边界 专门测试E出现在各种位置的情况开头、中间、结尾测试恰好赢球的那个球如10:10后连得两分测试大比分差下的赢球如0:11。6. 算法扩展与思维提升虽然这道题作为模拟题已经很经典但我们还可以从几个角度延伸思考提升自己的编程和算法能力。6.1 从模拟到状态机这道题本质上是一个有限状态机。状态就是当前的比分(score_w, score_l)。输入字符W/L是事件驱动状态转移。而“检查是否结束一局”可以看作是在某个状态转移后检查是否进入了“终止状态”即一局结束。一旦进入终止状态就执行“记录比分并重置”的动作然后回到初始状态(0,0)。用状态机的思想来建模会让程序逻辑更加清晰尤其是对于更复杂的规则比如加入暂停、犯规等时状态机的优势会更明显。6.2 性能分析与优化我们采用的是“遍历两遍”的方法时间复杂度是O(2n) O(n)n是输入字符数。空间上我们存储了整个输入字符串和两个比分列表。对于竞赛来说这已经完全足够。但如果我们讨论极端情况比如输入数据流极其庞大GB级别无法全部存入内存那么“在线处理”并同时维护两套状态策略二就是必须的。这时我们需要仔细设计数据结构确保在单次扫描中正确更新和记录两种赛制下的所有对局比分。6.3 通用赛制模拟器我们可以很容易地将程序改造成一个通用的球类比赛模拟器。比如乒乓球旧赛制是21分制排球是25分制但决胜局是15分制羽毛球是21分制30分封顶等等。我们可以将获胜分数、是否需领先2分、是否有最高分限制如羽毛球30分封顶等作为规则参数。这样一个简单的模拟函数就能覆盖多种比赛。这体现了编写通用、可复用代码的价值。// 一个更通用的模拟函数设想 struct Rule { int win_point; // 获胜分数如11、21、25 int lead_point; // 需要领先的分数通常是2 int max_point; // 最高分限制0表示无限制如乒乓球30表示封顶如羽毛球 }; vectorGameScore simulate(const string input, const Rule rule) { vectorGameScore result; int score_w 0, score_l 0; for (char ch : input) { if (ch E) break; // 更新得分... // 根据rule中的规则检查是否结束一局 // 例如是否达到最高分限制是否满足获胜条件 } // 处理未完成局 return result; }6.4 与其他算法题型的联系“乒乓球”这道题训练的是模拟和字符串处理能力。这其实是很多复杂算法的基础。比如编译器解析源代码、处理XML/JSON数据、游戏逻辑引擎其核心都是对输入序列令牌流进行解析并根据一套规则改变内部状态。通过这道题我们练习了如何严谨地定义状态比分、如何定义状态转移规则得分、如何定义终止条件获胜以及如何处理不完整的输入遇到E。这些技能在解决更复杂的模拟类问题如电梯调度、内存管理、CPU指令模拟时都非常有用。最后这道题给我的最大启示是编程的严谨性高于一切。一个条件的疏忽、一个边界情况的遗漏就会导致整个程序的失败。它就像乒乓球比赛每一个球都需要认真对待。在动手编码前花时间把规则、输入输出格式、边界情况彻底想清楚画一画流程图列一列测试用例往往能事半功倍。写完代码后用各种边缘案例去测试它尤其是那些“好像不会发生”的情况往往bug就藏在那里。希望这篇超详细的解析能帮你彻底掌握这道经典题目更重要的是学会这种严谨的、基于规则进行模拟的编程思维方式。
返回列表