华为OD机试“小猫钓鱼”C++实现:队列与向量模拟游戏逻辑详解
2026/7/21 6:07:57 网站建设 项目流程

1. 项目概述:从一道机试真题看游戏逻辑与C++实现

最近在准备华为OD机试的朋友,应该对“小猫钓鱼”这个题目不陌生。它作为机试真题库里的常客,尤其是新系统下的C卷题目,考察点非常综合,绝不仅仅是“会写代码”那么简单。这道题本质上是一个模拟类纸牌游戏,但内核是对考生数据结构应用、逻辑抽象能力、边界条件处理以及代码健壮性的全面检验。很多人一看到“游戏”二字就觉得简单,上手就写,结果在递归、循环或者状态判断上栽了跟头,导致提交后通过率不高。

我自己也研究过这道题,并且用C++实现了一个相对清晰且高效的版本。今天就来拆解一下“小猫钓鱼”这道题,我会从题目理解、核心逻辑分析、数据结构选型、代码实现细节,再到一些容易踩坑的地方,完整地走一遍。无论你是正在备战华为OD,还是单纯对用C++实现小游戏逻辑感兴趣,相信这篇内容都能给你带来一些直接的参考。我们不光要做出答案,更要理解为什么这么做,以及如何做得更好、更稳。

2. 核心需求与游戏规则解析

2.1 题目场景还原

“小猫钓鱼”是一个经典的两人卡牌游戏。在华为OD的机试题目描述中,通常会给出以下关键信息:

  • 玩家:两名玩家,我们称之为A和B。
  • 牌堆:游戏开始时,每位玩家手中持有若干张牌,构成各自的手牌队列。牌桌上初始为空。
  • 牌面:每张牌上有一个数字(通常题目约定为1-9之间的整数),代表牌的点数。
  • 游戏流程:两位玩家轮流进行自己的回合。
  • 回合行动:在当前回合,玩家从自己手牌的头部取出一张牌,并将其打出到牌桌的尾部(即牌桌形成一个队列)。
  • 胜负判定:打出牌后,需要检查牌桌队列。如果牌桌中存在另一张点数相同的牌,那么当前玩家可以将这两张相同点数的牌,以及它们之间的所有牌,全部赢取回来,放入自己手牌的尾部。然后,将赢取的这些牌从牌桌队列中移除。如果不存在相同的牌,则回合结束,轮到对方玩家。
  • 游戏结束:当任意一名玩家的手牌队列为空时,游戏立即结束。此时手牌不为空的玩家获胜。题目通常要求输出最终胜利者的手牌序列。

简单来说,就是一个“出牌 -> 检查牌桌有无相同牌 -> 有则收牌,无则过”的循环过程。收牌时,收取的是从牌桌上第一张相同牌到刚打出的这张牌之间的所有牌,这是一个关键点。

2.2 输入输出格式与边界条件

机试题目会严格定义输入输出,这是编写代码的契约。

  • 输入:通常是两行字符串。第一行代表玩家A的初始手牌,第二行代表玩家B的初始手牌。牌与牌之间可能用空格分隔,也可能直接是连续的数字字符串。例如:”1 2 3 4“”1234“。需要在代码开始时明确解析方式。
  • 输出:一行字符串,表示游戏结束后,获胜者的手牌序列(从手牌头部到尾部),牌之间通常以空格分隔。如果A赢,则输出A的手牌;如果B赢,则输出B的手牌。
  • 边界条件与特殊场景
    1. 平局考虑:虽然题目描述通常以一方手牌为空为结束,但理论上存在无限循环的可能(例如,牌型导致永远无法收牌,双方手牌不断轮转)。严谨的代码需要考虑这一点,比如设置一个最大回合数上限(如1000回合),超过则判定为平局或游戏无法结束。不过,在OD真题的测试用例中,一般会避免这种情况,但自己实现时考虑到这一点是良好的习惯。
    2. 收牌顺序:从牌桌收牌时,收取的牌需要按它们在牌桌上出现的顺序加入到玩家手牌尾部。这意味着,牌桌队列中较早的牌,在收牌后会位于玩家手牌队列中相对靠前的位置(在同一次收取的牌内部)。
    3. 空牌桌:牌桌为空时,打出任何牌都不会触发收牌。
    4. 多张相同牌:如果牌桌中有多张牌与刚打出的牌点数相同,应该收取从最早出现的那张相同牌开始到当前牌的所有牌。这是模拟“钓鱼”时,钓起第一条鱼以及之后的所有鱼。

理解清楚这些规则,是正确抽象和建模的第一步。很多错误都源于对规则细节的误解。

3. 数据结构选型与设计思路

用C++实现,选择合适的数据结构是高效解题的关键。我们需要模拟两个玩家的手牌队列和一个牌桌队列,并且要频繁地在牌桌队列中查找特定点数的牌。

3.1 核心数据结构:为什么用queuevector

  1. 玩家手牌 (queue<int>)

    • 操作:玩家总是在头部取牌,在尾部加牌(收牌时)。
    • 选择理由std::queue完美匹配了FIFO(先进先出)的特性。pop()从队头取出,push()向队尾添加,操作都是O(1)复杂度,直观且高效。虽然vectordeque也能模拟,但queue的语义最清晰,能避免误用索引。
  2. 牌桌牌序 (vector<int>deque<int>)

    • 操作:需要在尾部添加牌(出牌);需要根据打出的牌,在序列中从后向前查找最近的一个相同点数的位置;需要删除一段连续的元素(从找到的位置到末尾),并将这些元素按序收集。
    • 选择理由
      • std::vector:支持高效的尾部插入(push_back)。查找操作虽然需要遍历,但牌桌大小在游戏过程中动态变化,遍历查找的复杂度在可接受范围内(题目数据规模通常不大)。最关键的是,一旦找到索引i,我们可以用vector的迭代器范围构造函数或erase配合insert,来相对方便地获取和删除子序列。vector在内存连续,访问速度快。
      • std::deque:同样支持首尾高效插入删除。查找也需要遍历。与vector相比,deque在头部插入删除更优,但本题不需要。deque的非连续内存存储,在中间插入删除时可能比vector稍好,但差异不大。
    • 最终选择:我个人更倾向于使用vector<int> table。因为它语义简单,连续内存访问快,利用find和反向查找rfind(或手动反向遍历)结合迭代器,可以清晰完成“查找-截取-删除”的操作链。

3.2 辅助查找策略:从后向前遍历

牌桌table是一个顺序容器。当一名玩家打出一张牌card后,我们需要知道table里之前有没有card。 最直接的方法是使用std::find,但它是从begin()找到end(),即从前向后找,找到的是第一个出现的card。根据规则,我们需要的是从刚打入的牌之前,向前找最近的一个,也就是最后一个card相同的牌的位置。 因此,我们应该table的末尾向开头进行反向遍历。这可以手动用for循环实现,也可以使用反向迭代器rbegin()rend()

找到这个位置(假设索引为pos)后,我们需要做的是:

  1. table中从posend()的所有元素(包括pos处的牌和刚打出的、在逻辑上即将加入的牌)按顺序取出,放入当前玩家的手牌尾部。
  2. table中从posend()的所有元素删除。

这里有一个细节:刚打出的牌在遍历查找时,实际上还没有被push_backtable。所以我们的查找范围是当前的table。找到后,我们先将被赢取的牌段保存,然后将刚打出的牌也加入这个牌段,最后一起放入玩家手牌尾部,并清空table中对应的部分。

3.3 整体程序流程设计

基于以上分析,主循环的逻辑框架如下:

  1. 初始化:解析输入,初始化玩家A和B的手牌队列queueA,queueB,初始化牌桌vector<int> table。设置当前玩家标志(如currentPlayer = ‘A‘)。
  2. 游戏主循环:循环条件为双方手牌都不为空,且回合数未超过安全上限。 a.确定当前玩家:根据currentPlayer选择从queueA还是queueB取牌。 b.出牌阶段:从当前玩家手牌队列头部取出一张牌curCard。如果取牌后该队列为空,则游戏立即结束,对方获胜。 c.检查与赢牌: i. 在table从后向前查找是否存在与curCard点数相同的牌。 ii. 如果找到: * 记录找到的位置pos。 * 将table中从pos开始到末尾的所有牌,以及curCard这张牌,按顺序添加到当前玩家的手牌队列尾部。 * 将table中从pos开始到末尾的所有牌删除(table.resize(pos)table.erase)。 iii. 如果没找到: * 将curCard放入table的尾部(table.push_back(curCard))。 * 切换当前玩家到对方。
  3. 循环结束与输出:循环退出后,判断获胜方。将获胜方的手牌队列从队头到队尾依次输出。

这个设计清晰地将数据操作(队列、向量)和游戏规则绑定在一起。

4. C++代码实现与逐行解读

接下来,我们按照上述设计,用C++实现代码。我会写出关键代码并附上详细注释。

#include <iostream> #include <queue> #include <vector> #include <string> #include <sstream> #include <algorithm> // 用于find using namespace std; int main() { // 读取输入 string lineA, lineB; getline(cin, lineA); getline(cin, lineB); queue<int> handA, handB; // 玩家A和B的手牌队列 vector<int> table; // 牌桌序列 // 辅助lambda函数:将字符串解析为手牌队列 auto initHand = [](queue<int>& hand, const string& line) { // 这里假设输入是用空格分隔的数字字符串,如 "1 2 3 4" // 如果是连续数字字符串如 "1234",则需要修改解析逻辑 istringstream iss(line); int card; while (iss >> card) { hand.push(card); } // 如果是连续字符串,则用以下代码: // for (char ch : line) { // if (ch >= '0' && ch <= '9') { // hand.push(ch - '0'); // 字符转数字 // } // } }; initHand(handA, lineA); initHand(handB, lineB); // 游戏状态 bool isPlayerATurn = true; // true表示A的回合,false表示B的回合 const int MAX_TURNS = 1000; // 防止潜在无限循环的安全上限 int turnCount = 0; // 游戏主循环 while (!handA.empty() && !handB.empty() && turnCount < MAX_TURNS) { turnCount++; int currentCard; queue<int>* currentHand = nullptr; queue<int>* opponentHand = nullptr; // 确定当前玩家和对手 if (isPlayerATurn) { currentHand = &handA; opponentHand = &handB; } else { currentHand = &handB; opponentHand = &handA; } // 1. 出牌:从当前玩家手牌头部取一张牌 currentCard = currentHand->front(); currentHand->pop(); // 2. 检查牌桌并决定操作 // 关键:在table中从后向前查找第一张与currentCard相同的牌 // 使用反向迭代器进行查找 auto rit = find(table.rbegin(), table.rend(), currentCard); if (rit != table.rend()) { // 找到了!计算正向索引位置 // reverse_iterator.base() 返回的是其对应的正向迭代器的下一个位置 // 所以需要调整以获取正确的正向位置 int pos = distance(table.begin(), rit.base()) - 1; // 3. 赢牌阶段 // 3.1 将赢取的牌(从pos到table末尾)按顺序加入当前玩家手牌尾部 for (int i = pos; i < table.size(); ++i) { currentHand->push(table[i]); } // 3.2 将刚打出的这张牌也加入手牌尾部 currentHand->push(currentCard); // 3.3 从牌桌移除这些赢取的牌 table.resize(pos); // 简单高效,直接改变大小丢弃尾部元素 // 注意:当前打出的牌并没有先加入table,所以这里不需要额外处理它 // 赢牌后,当前玩家继续下一个回合,不切换玩家 // isPlayerATurn 保持不变 } else { // 没找到,将牌放入牌桌尾部 table.push_back(currentCard); // 切换回合 isPlayerATurn = !isPlayerATurn; } } // 游戏结束,判断胜负并输出 queue<int> winnerHand; if (handA.empty()) { cout << "B wins. Hand: "; winnerHand = handB; } else if (handB.empty()) { cout << "A wins. Hand: "; winnerHand = handA; } else { // 达到最大回合数,平局或游戏无法正常结束(根据题目要求处理,这里输出剩余牌) cout << "Game over without a clear winner (max turns reached)." << endl; // 可以选择输出当前牌桌或双方手牌,这里按题目要求通常不会走到这一步 // 为演示,输出A的手牌 winnerHand = handA; } // 输出获胜方手牌 while (!winnerHand.empty()) { cout << winnerHand.front(); winnerHand.pop(); if (!winnerHand.empty()) { cout << " "; } } cout << endl; return 0; }

代码关键点解读:

  1. 输入解析:使用了istringstream来处理空格分隔的数字字符串。如果输入是连续数字字符串,注释中提供了另一种解析方法。务必根据题目实际输入格式二选一,这是常见的失分点。
  2. 查找逻辑auto rit = find(table.rbegin(), table.rend(), currentCard);这行代码是核心。它使用标准库的find算法配合反向迭代器,从table的末尾向开头查找第一个等于currentCard的元素。如果找到,rit指向它;如果没找到,rit等于table.rend()
  3. 索引计算int pos = distance(table.begin(), rit.base()) - 1;这是将反向迭代器位置转换为正向索引的关键步骤。rit.base()返回一个指向rit所指元素之后位置的正向迭代器。distance计算从开头到这个正向迭代器的距离,再减1就得到了rit所指元素在vector中的实际索引pos。这个技巧需要理解反向迭代器的底层原理。
  4. 赢牌操作:找到pos后,我们用一个循环将table[pos]table[table.size()-1]的所有牌压入当前玩家手牌队列。然后再将currentCard压入。最后,table.resize(pos)直接将牌桌截断到pos位置(不包含pos),高效地删除了被赢取的牌段。注意,currentCard自始至终没有进入table,所以操作顺序是合理的。
  5. 回合切换:只有未赢牌(即牌放入牌桌)时,才切换玩家(isPlayerATurn = !isPlayerATurn)。赢牌后,当前玩家继续出牌。
  6. 安全上限:引入了MAX_TURNSturnCount,这是一个防御性编程技巧。虽然真题测试用例可能用不到,但防止了因逻辑漏洞或极端输入导致的程序无限循环,确保程序能在规定时间内退出。

5. 常见问题排查与实战心得

在实际编写和调试过程中,会遇到一些典型问题。这里我结合自己的经验,总结了一个排查表。

问题现象可能原因解决方案与检查点
程序输出结果与示例完全不符1. 输入解析错误。
2. 游戏核心规则理解错误,特别是收牌规则。
1.首先检查输入:打印初始化后的handAhandB,确认读入的牌序正确。使用题目给的样例输入测试。
2.单步调试收牌逻辑:用一个简单用例(如A:1, B:1)手动模拟,看tablehand的变化是否符合预期。重点检查找到牌后,收取的牌段是否正确(是否包含找到的那张牌及之后所有牌)。
程序在某些测试用例下陷入死循环1. 未处理“赢牌后继续出牌”的规则,导致回合切换逻辑错误。
2. 存在极端的牌型组合导致游戏无法终止(虽少见,但需防护)。
1.仔细检查玩家切换标志isPlayerATurn的更新时机:只有在打出牌且未触发收牌时,才切换玩家。一旦触发收牌,当前玩家立即获得下一次出牌权,标志位不应改变。
2.加入回合数上限:如代码所示,设置一个MAX_TURNS(例如1000),超过则强制退出循环,并输出当前状态或平局。
收牌后牌桌状态错误,或收取的牌顺序不对1. 计算赢取牌段的起始索引pos错误。
2. 向手牌队列添加牌的顺序错误。
3. 从牌桌删除牌段的方式错误。
1.验证pos的计算:使用cout在找到牌后打印postable的内容和大小,确认pos指向的是否是最早的那张相同牌。理解rit.base()distance的用法。
2.确认添加顺序:赢取的牌必须保持它们在牌桌上的原有顺序。用for (int i = pos; i < table.size(); ++i)循环可以保证顺序。先加table中的牌,再加currentCard
3.确认删除操作table.resize(pos)是最清晰的方法,它将table的大小设置为pos,丢弃pos之后的所有元素。确保pos是有效的索引(0 <= pos < table.size())。
输出格式错误(多空格、少空格、换行)输出逻辑不严谨。在输出手牌的循环中,常用技巧是:先输出第一张牌(front()pop()),然后在每次输出后续牌之前输出一个空格。就像代码中if (!winnerHand.empty()) { cout << “ “; }的位置,它确保了最后一个数字后面没有多余空格。
内存访问越界或段错误1. 在table为空时进行查找或访问。
2. 计算出的pos索引非法(如为-1或大于size)。
1.查找前检查:虽然find在空容器上调用是安全的(会直接返回rend()),但在后续计算pos前,必须确保rit != table.rend()
2.索引安全:在pos = distance(...) - 1后,可以添加断言或检查:assert(pos >= 0 && pos < table.size())。在竞赛或机试中,如果非常确定逻辑,可以不写断言,但心里要清楚其有效性。

几点个人实操心得:

  1. 先画图,再编码:对于这种状态转移清晰的模拟题,在纸上画一下几个回合的流程,标出手牌队列、牌桌队列的变化,比直接敲代码有效得多。它能帮你厘清“收牌时到底收哪些牌”、“牌的顺序如何保持”这些关键细节。
  2. 善用STL,但需知其所以然queuevector的选择让代码简洁。但像reverse_iteratorbase()这种操作,如果不理解,很容易用错。写代码时,如果对某个STL操作的结果不确定,立刻写个小测试程序验证,不要想当然。
  3. 防御性编程:像MAX_TURNS这样的安全措施,在机试中可能不是必须的,但体现了编程的严谨性。在更复杂的工程或面试中,考虑边界和异常情况是加分项。
  4. 测试用例要全面:不要只满足于题目给的样例。自己设计几个边缘用例:
    • 初始一方手牌为空(题目应避免,但可测)。
    • 永远无法收牌的牌型(如A:1 2, B:3 4,牌桌会一直增长)。
    • 第一张牌就触发收牌(牌桌初始为空,不会触发)。
    • 收牌后,收取的牌中包含可以再次触发收牌的牌(这是允许的,但逻辑要正确,本次收牌只基于刚打出的那张牌判断)。
  5. 模块化与可读性:虽然机试代码往往一气呵成,但将初始化、出牌回合、赢牌判断等逻辑用函数或清晰的代码块分隔,并加上注释,不仅方便自己调试,也便于阅卷人(或面试官)理解你的思路。清晰的逻辑胜过炫技的代码。

这道“小猫钓鱼”题,很好地考察了在压力下对基础数据结构的组合运用和细致逻辑的实现能力。把它吃透,对于应对华为OD机试中类似的模拟题、队列栈应用题,会有很大的帮助。核心就是保持头脑清晰,一步一步地将自然语言描述的游戏规则,精确地翻译成数据结构的操作语句。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询