华为OD机试C卷“记票统计”题解:双数据结构实现高效映射与顺序输出

📅 2026/7/17 6:15:23 👁️ 阅读次数 📝 编程学习
华为OD机试C卷“记票统计”题解:双数据结构实现高效映射与顺序输出

1. 项目概述与核心价值

最近在准备华为OD机试的同学,尤其是瞄准C卷的,应该对“记票统计”这道题不陌生。它频繁出现在各类真题回忆和题库分享里,号称“100%通过率”的标题虽然有点营销味道,但确实反映了这道题在掌握正确思路后,实现起来相对稳定,是冲刺高分的基础题。我结合自己带学生备考和刷题的经验,今天就来彻底拆解这道题。我们不只是写一个能AC(Accept)的代码,更要弄懂它背后的考察意图、数据处理的陷阱,以及如何用C++写出既高效又清晰的解。无论你是刚开始刷题的新手,还是想优化自己代码风格寻求满分的进阶者,这篇从实战出发的深度解析都能给你带来直接的帮助。

这道题本质上是一个“映射统计”问题。给你一组候选人名单和一堆投票,让你统计每个候选人的得票数以及无效票(投给不在名单里的人)的数量。听起来很简单对吧?但机试的考点往往就藏在“简单”里。它考察你对基础数据结构的运用(比如std::mapstd::unordered_map)、对输入输出格式的严格把控、以及边界情况的处理能力。用C++实现,更是考验你对STL容器的熟练程度和编写鲁棒性代码的习惯。接下来,我会从题目解析、思路设计、代码实现到测试技巧,一步步带你吃透它。

2. 题目深度解析与需求拆解

2.1 输入输出格式的精确理解

机试题目,尤其是华为OD的,对输入输出格式的要求近乎苛刻。格式错误,即使逻辑正确也是零分。我们首先必须像编译器一样精确理解题目描述。

根据常见的题目描述(虽然原始描述可能略有差异,但核心一致),输入通常分为四行:

  1. 第一行:一个整数n,表示候选人的人数。
  2. 第二行:n个用空格分隔的字符串,代表候选人的名字。题目明确名字是大写字母字符串。这是一个关键约束,意味着名字不会包含空格或特殊字符,这简化了字符串处理。
  3. 第三行:一个整数m,表示投票人的人数(即投票票数)。
  4. 第四行:m个用空格分隔的字符串,代表每一张票投给的候选人名字。

输出要求是:

  • 按输入的第二行(候选人名单)的顺序,输出每个候选人的名字及其得票数,格式为名字 : 票数
  • 最后一行输出Invalid : 无效票数

这里隐藏着几个至关重要的细节:

  1. 顺序保持:输出必须按照候选人初始输入的顺序。这意味着我们不能简单使用无序的哈希表统计完再输出,必须想办法保留顺序。这是本题第一个核心考点。
  2. 无效票的定义:任何没有出现在初始候选人名单中的名字,都被计为无效票。即使这个名字在投票中出现了多次,只要不在名单内,每次出现都算一张无效票。
  3. 大小写敏感:题目指定是大写字母,但实际输入是否严格?从鲁棒性角度,我们不应做此假设,但可以基于此进行优化。更安全的做法是,在比较时统一处理(如转换为大写),但鉴于题目明确说明,为了效率,我们可以直接比较。

2.2 核心考察点与难点分析

这道题被归为简单题,但想写出一个健壮、高效的满分代码,需要处理好以下几点:

  1. 数据结构的选择:这是本题的灵魂。我们需要一个能快速根据名字查询票数的结构(O(1)或O(log n)复杂度),同时还要能按照初始顺序输出。std::unordered_map(哈希表)查询快,但无序;std::map(红黑树)有序,但它的“序”是键值的字典序,不是我们需要的输入顺序。因此,单纯使用一种映射是不够的。
  2. 输入读取的鲁棒性:如何安全地读取可能带有空格的名字?题目说用空格分隔,所以使用std::cin >>读取字符串是可行的,因为它会在空格处停止。但我们要确保读取完整数后,缓冲区没有残留的换行符影响后续字符串读取。虽然在某些简单场景下cin能自动处理,但养成好习惯很重要。
  3. 无效票的统计效率:最直观的方法是,对于每一张投票,都在候选人列表中线性查找(O(n))。当n和m较大时(虽然机试数据通常不大),这会成为性能瓶颈。我们需要用O(1)的时间来判断一个名字是否为有效候选人。
  4. 输出格式的零误差:冒号前后有空格,每个候选人输出占一行。这种格式必须分毫不差。

3. 解决方案设计与数据结构选型

3.1 双数据结构配合策略

针对“快速查询”和“保持顺序”这一对矛盾需求,最经典且高效的解决方案是双数据结构配合

  1. std::vector<std::string>:用于存储候选人名单,完美保持初始输入顺序。我们可以通过下标索引来关联候选人。
  2. std::unordered_map<std::string, int>:用于快速统计票数。键(Key)是候选人名字,值(Value)是该候选人的当前得票数。哈希表提供了平均O(1)时间复杂度的查找和插入操作。

工作流程如下:

  • 初始化阶段:读取候选人名单,存入vector<string> candidates。同时,初始化一个unordered_map<string, int> voteCount,将每个候选人的票数初始化为0。这里有一个小技巧:可以在读取候选人名字时,直接向voteCount中插入一个键值对{name, 0}。这为后续的快速查找建立了索引。
  • 统计阶段:读取每一张投票。对于每个投票名字voteName
    • voteCount中查找voteName。如果找到(find()返回的迭代器不等于end()),说明是有效票,将对应的值加1。
    • 如果没找到,则说明是无效票,无效票计数器invalidCount加1。
  • 输出阶段:遍历candidates向量(保证了原始顺序),对于每个候选人name,从voteCount中取出其票数并输出。最后输出无效票数。

这个方案的时间复杂度是 O(n + m),空间复杂度是 O(n),完全满足题目要求。

为什么不用std::map有同学可能会想,std::map内部有序,是不是可以按输入顺序插入,然后按迭代顺序输出?不行。std::map的有序性是按照键(名字)的严格弱序(通常是字典序)排列的,与我们插入的顺序无关。要保证输入顺序,必须依赖外部结构如vector

3.2 边界情况与防御性编程

在真实机试环境中,我们需要考虑一些边界情况,确保程序不会崩溃或产生意外结果。

  1. 候选人名字重复:题目通常保证候选人名字不重复。但如果作为一个通用程序,我们应该考虑这一点。如果名字重复,我们的unordered_map会覆盖,统计就会出错。可以在插入voteCount前检查,或者使用unordered_map<string, vector<int>>来存储对应多个候选人的索引?但这复杂化了。鉴于题目明确,我们按不重复处理。
  2. 空输入:如果候选人人数n为0怎么办?程序应该能处理,输出可能只有Invalid : m
  3. 超大输入:虽然机试有约束,但良好的习惯是选择时间复杂度最优的算法。我们的双结构方案是最优的。
  4. 输入缓冲区问题:在混合使用cin >> intgetline(cin, string)时,容易因为换行符残留导致getline读到空行。虽然本题用cin >> string可以避免,但知道这个坑很重要。一个清除缓冲区的小技巧是:cin.ignore(numeric_limits<streamsize>::max(), '\n');

4. C++代码实现与逐行精讲

下面给出一个完整、健壮且注释详细的C++实现代码。我将分模块讲解关键部分。

#include <iostream> #include <vector> #include <string> #include <unordered_map> using namespace std; int main() { int n, m; // 读取候选人数量 cin >> n; vector<string> candidates(n); // 使用unordered_map作为快速查询的票数统计表 // key: 候选人名字, value: 得票数 unordered_map<string, int> voteMap; // 读取候选人名单,并初始化票数统计表 for (int i = 0; i < n; ++i) { cin >> candidates[i]; // 在map中插入候选人,初始票数为0 // 使用emplace或insert,避免重复插入(虽然题目假设名字不重复) voteMap[candidates[i]] = 0; } // 读取投票人数 cin >> m; int invalidVotes = 0; // 无效票计数器 string voteName; // 统计每一张投票 for (int i = 0; i < m; ++i) { cin >> voteName; // 在voteMap中查找该名字 auto it = voteMap.find(voteName); if (it != voteMap.end()) { // 找到,有效票,票数+1 it->second++; } else { // 未找到,无效票 invalidVotes++; } } // 输出结果 // 按照初始候选人顺序输出 for (const auto& name : candidates) { // 直接从voteMap中取出该候选人的票数 cout << name << " : " << voteMap[name] << endl; } // 输出无效票数 cout << "Invalid : " << invalidVotes << endl; return 0; }

4.1 关键代码段解析

  1. 数据结构初始化

    vector<string> candidates(n); unordered_map<string, int> voteMap;

    在读取n后,直接初始化candidates向量的大小为n,这样可以避免动态push_back可能带来的多次内存分配(虽然对性能影响微乎其微,但是个好习惯)。voteMap初始为空。

  2. 候选人录入与映射建立

    for (int i = 0; i < n; ++i) { cin >> candidates[i]; voteMap[candidates[i]] = 0; }

    这是核心预处理步骤。在读取候选人名字到candidates[i]的同时,我们向voteMap中插入一个键值对。voteMap[name] = 0这个操作非常巧妙:如果name不存在,它会自动插入并赋值为0;如果已存在(理论上不会),则会覆盖其值。这行代码同时完成了“插入”和“初始化”两个动作。

  3. 投票统计逻辑

    auto it = voteMap.find(voteName); if (it != voteMap.end()) { it->second++; } else { invalidVotes++; }
    • voteMap.find(key)是STL映射容器的标准查找操作,返回一个迭代器。如果找到,迭代器指向该元素;如果没找到,返回voteMap.end()
    • it->second访问找到的元素的“值”(即票数),然后自增。
    • 使用find后再操作,比直接使用voteMap[voteName]++要好。因为voteMap[voteName]voteName不存在时,会自动插入一个新的键值对(票数为0),这会导致voteMap中多出无效的候选人条目,虽然不影响最终输出(因为我们按candidates输出),但污染了数据,且可能影响内存和查找效率(尽管很小)。
  4. 顺序输出

    for (const auto& name : candidates) { cout << name << " : " << voteMap[name] << endl; }

    遍历candidates向量,保证了输出顺序与输入顺序严格一致。这里使用voteMap[name]是安全的,因为我们在初始化阶段已经确保了所有候选人都在voteMap中有对应的键。

4.2 代码优化与变体探讨

  1. 使用reserve提升性能:如果知道大概的规模,可以为unordered_map预留桶空间,减少哈希冲突和重哈希。

    voteMap.reserve(n * 2); // 预留大约2倍空间
  2. 使用范围for循环(C++11):代码中已经使用了现代C++的写法,更简洁。

  3. 考虑大小写转换:如果题目没有明确说明名字是大写,为了鲁棒性,可以在插入voteMap和查找时,将名字统一转换为大写或小写。

    #include <cctype> #include <algorithm> string toUpper(string s) { transform(s.begin(), s.end(), s.begin(), ::toupper); return s; } // 在插入和查找时使用 toUpper(name)

    但这会引入额外的字符串拷贝和遍历开销,在明确为大写时不必使用。

5. 测试用例设计与调试技巧

写完代码不代表万事大吉,设计全面的测试用例是保证通过率的关键。

5.1 标准测试用例

输入: 4 A B C D 8 A B A C D E B F 输出: A : 2 B : 2 C : 1 D : 1 Invalid : 2

解析:候选人A、B、C、D。投票中A、B、C、D有效,E和F无效。

5.2 边界与特殊测试用例

  1. 无效票居多

    输入: 2 Tom Jerry 5 Spike Tyke Butch Nibbles Tuffy 输出: Tom : 0 Jerry : 0 Invalid : 5

    测试所有票都无效时,有效候选人票数是否为0。

  2. 单候选人,大量投票

    输入: 1 ONE 10000 (这里输入10000个"ONE"和随机无效名)

    测试程序处理大量输入时的效率和稳定性。

  3. 空候选人名单

    输入: 0 5 A A B C D 输出: Invalid : 5

    测试n=0时,程序是否能正常跳过候选人读取和输出。注意第二行是空行。

  4. 名字带数字或特殊字符(如果题目允许,但本题不允许):用于测试字符串读取的鲁棒性。

5.3 本地调试技巧

  1. 使用文件重定向:将测试用例保存到input.txt,运行程序时使用./your_program < input.txt,方便多次测试。
  2. 打印中间变量:在统计投票的循环后,可以临时打印voteMap的内容,检查统计是否正确。
  3. 使用Valgrind检查内存:对于C++程序,使用valgrind ./your_program < input.txt检查是否有内存泄漏。虽然本题代码简单,但养成习惯很重要。
  4. 对比输出:将你的程序输出与预期输出保存到文件,用diff命令比较,确保格式完全一致,包括末尾换行符。

6. 常见错误与避坑指南

根据过往经验,很多同学在这道题上失分,不是因为算法难,而是掉进了以下几个坑:

  1. 输出顺序错误:这是最常见的错误。没有使用额外的顺序容器(如vector)来记录初始顺序,而是尝试在统计后对map进行排序输出。记住,map按名字排序,不是输入顺序。

  2. 无效票统计逻辑错误

    • 错误方法1:对于每张票,遍历候选人列表查找。当数据量大时超时。
    • 错误方法2:先统计所有票到map,然后输出时,如果map中的键不在初始列表中就算无效。这会导致无效票被错误地归到某个不存在的“候选人”名下,且无法正确统计无效票总数。无效票的判断必须在统计时实时进行。
  3. 输入读取问题

    • 在读取完整数nm后,没有正确处理换行符,导致后续的getline读取到空字符串。本题使用cin >> string通常能避免,但混合输入时需警惕。
    • 假设名字中无空格而使用getline,导致读取整行。
  4. 容器选择不当

    • 使用vector<pair<string, int>>存储,统计时每次查找都是O(n)。
    • 使用map<string, int>,输出时误以为迭代顺序是插入顺序。
  5. 格式错误

    • 输出时冒号后面少了空格或多了空格。
    • 最后一行输出Invalid后忘了换行。
    • 输出大小写错误(如invalid)。

避坑心法:机试题目,尤其是格式题,一定要像对待数学证明题一样严谨。写完代码后,用眼睛逐字逐句对比输出样例,或者写一个简单的脚本进行比对。对于输入处理,最稳妥的方式是,在写完代码后,用本节提到的边界用例全部测试一遍。

7. 性能分析与扩展思考

7.1 时间复杂度与空间复杂度分析

  • 时间复杂度:O(n + m)。
    • 初始化候选人列表和哈希表:O(n)。
    • 统计m张选票:每次查找和插入(仅针对无效票,有效票是查找和修改)在unordered_map中平均为O(1),所以是O(m)。
    • 输出n个候选人:O(n)。
    • 总计 O(n + m + n) = O(n + m)。
  • 空间复杂度:O(n)。
    • candidates向量存储n个字符串:O(n)。
    • voteMap哈希表存储n个键值对:O(n)。
    • 总计 O(n)。

这是一个非常高效的算法,可以处理百万级的数据量(如果机试允许的话)。

7.2 题目可能的变体与扩展

这道题的基本模型可以衍生出很多变体,考察不同的知识点:

  1. 变体一:输出按票数排序。如果题目要求按票数从高到低输出,票数相同按名字字典序。这就需要我们在统计完成后,将结果从voteMap转移到vector<pair<string, int>>中,然后使用自定义比较函数进行排序。

    vector<pair<string, int>> result(voteMap.begin(), voteMap.end()); sort(result.begin(), result.end(), [](const pair<string, int>& a, const pair<string, int>& b) { if (a.second != b.second) return a.second > b.second; // 票数降序 return a.first < b.first; // 名字升序 });
  2. 变体二:统计投票人而非票数。假设每个投票人只能投一票,但一个人可能投多个候选人?或者需要去重?这就会涉及到setmap的更复杂嵌套使用。

  3. 变体三:实时统计/流式处理。如果投票数据不是一次性给出,而是源源不断的流,需要实时输出当前排名。这就涉及到数据结构(如堆)和算法的动态维护。

掌握基础版本后,思考这些变体有助于深化对数据结构应用场景的理解。

8. 华为OD机试实战建议

最后,结合这道“记票统计”,给准备华为OD机试的同学几点实战建议:

  1. 熟悉环境:提前在牛客、力扣等平台的模拟环境练习,熟悉在线IDE的调试功能(如打印日志)。华为OD通常使用牛客网系统。
  2. 审题第一:花2-3分钟仔细阅读题目,用笔或注释标出输入输出格式、数据范围、特殊要求(如顺序、大小写)。像“按输入顺序输出”这种关键要求,一旦漏看,全盘皆输。
  3. 先写思路,再写代码:在编码前,在注释里或用草稿写下核心数据结构、算法步骤。这能帮你理清逻辑,避免边写边改。
  4. 模块化与测试驱动:像本题一样,将代码分为“输入读取”、“数据处理”、“结果输出”几个清晰的部分。写完一个部分,就构思一个简单的测试用例验证它。
  5. 时间管理:简单题(如本题)建议在15-20分钟内完成,包括编码和基本测试。留出时间检查格式和边界。
  6. 心态平稳:遇到编译错误或样例不过别慌。仔细阅读错误信息,从最简单的用例开始调试。机试也考察debug能力。

这道“记票统计”题,就像一面镜子,能很好地反映出一个考生对基础数据结构的掌握程度、代码实现的严谨性,以及解决实际问题的结构化思维能力。把它吃透,不仅是为了通过这一道题,更是为了建立起解决一类问题的可靠方法论。在实际的开发工作中,这种“快速查找+保持顺序”的需求也非常常见,比如缓存管理、订单处理等场景。希望这篇超详细的解析能帮你夯实基础,在机试中游刃有余。