华为OD机试必刷题:银行插队问题与多级队列C++实现

发布时间:2026/7/13 9:23:33
华为OD机试必刷题:银行插队问题与多级队列C++实现 1. 项目概述从一道题看华为OD机试的考核逻辑最近在帮几个朋友准备华为OD的机试发现“银行插队”这道题出现的频率相当高几乎成了必刷的经典题目之一。很多刚接触的朋友看到题目描述第一反应是“这不就是个简单的优先级队列吗”但实际动手一写才发现里面有不少细节坑比如如何处理同优先级客户的到达顺序、如何高效模拟动态插队过程、以及如何用C标准库优雅地实现。这道题之所以经典是因为它完美地融合了数据结构基础、逻辑思维严谨性以及C STL的实战应用非常能体现一个候选人的基本功和编码习惯。简单来说这道题模拟了一个银行叫号插队的场景银行客户被分为1到5级优先级1级最高5级最低。当新客户到来时如果他的优先级比正在排队中的某些客户高他就可以直接插队到那些低优先级客户的前面但必须排在所有与他同优先级或更高优先级的客户之后。最终我们需要输出客户离开银行的顺序。题目输入是一系列客户的到达事件每个事件包含客户ID和优先级。这听起来规则很简单对吧但魔鬼藏在细节里。如果你直接用一个简单的vector或者list去模拟在数据量大的情况下插入操作的时间复杂度会让你直接超时。这正是考察你是否能第一时间想到合适的数据结构——一个能根据优先级自动排序同时又能稳定处理同优先级客户先来后到顺序的容器。我之所以花时间详细拆解这道题是因为它不仅仅是一道“算法题”。在真实的软件开发尤其是涉及任务调度、消息队列、资源分配的系统里这种带优先级的排队逻辑无处不在。理解并熟练实现它是后端开发、嵌入式系统开发等岗位的一项核心能力。接下来我会用C带你从暴力解法开始一步步优化到最优解并分享我在调试过程中踩过的坑和总结的技巧。无论你是正在备战华为OD还是想巩固数据结构与算法这篇文章都能给你带来直接的帮助。2. 问题核心与数据结构选型分析2.1 题目规则深度解读与边界条件梳理我们先抛开代码把题目规则用人话彻底捋清楚。假设银行窗口只有一个办理业务是串行的一次只能服务一个人。客户们不是一次性全部排好队而是随着时间依次“到达”。每个客户有两个属性唯一的id和优先级priority1-5。关键的插队规则是高优先级客户可以插队到任何低优先级客户前面但必须排在所有优先级大于等于他的客户之后。这里有几个极其重要的细节点也是容易出错的地方“大于等于”的含意一个新来的优先级为2的客户他可以插到所有优先级为3、4、5的客户前面但他必须排在所有优先级为1和2的已有客户后面。也就是说插队不能插到同优先级或更高优先级的人前面。同优先级的顺序对于相同优先级的客户必须严格遵守先来后到的原则。先到的同优先级客户永远排在后到的同优先级客户前面。这是保证顺序稳定性的关键。动态插入与稳定排序队列随着新客户到来在不断变化。我们需要一个数据结构能随时接受新元素并自动将其放在“所有优先级大于等于它的客户”之后“所有优先级小于它的客户”之前。同时这个数据结构内部同优先级的客户还要保持其到达的先后顺序。如果理解有偏差很容易写出错误的逻辑。比如错误地认为“高优先级可以插到最前面”而忽略了前面可能还有更高优先级的人或者在处理同优先级时没有维护稳定性导致输出顺序混乱。2.2 从暴力模拟到最优解的数据结构演进思路最直观的暴力解法是怎样的我们可以用一个数组或链表vectorCustomer来代表当前排队队列。每当一个新客户到来我们就从队列头部开始遍历找到第一个优先级小于新客户优先级的客户然后在这个位置之前插入新客户。如果遍历完都没找到说明新客户优先级最低就插在队尾。// 伪代码暴力遍历插入法 vectorpairint, int queue; // priority, id for (auto [id, priority] : arrivals) { auto it queue.begin(); while (it ! queue.end() it-first priority) { it; } queue.insert(it, {priority, id}); } // 最后按队列顺序输出id这个算法逻辑正确但性能是灾难性的。vector的insert操作在中间位置插入时需要移动后续所有元素平均时间复杂度是O(n)。对于n个客户最坏情况下总时间复杂度是O(n²)在华为OD机试的大数据量用例下必然超时。那么如何优化核心在于减少查找插入位置和插入本身的开销。我们需要一个能快速定位“所有优先级大于等于p的客户之后”这个位置的数据结构。既然优先级只有1-5这固定的5个级别一个非常自然的想法是为每个优先级单独维护一个队列。因为同优先级客户必须按到达顺序服务所以每个优先级队列本身就是一个FIFO先进先出的普通队列。整个数据结构的蓝图就清晰了我们维护5个队列queue[1]到queue[5]分别对应优先级1到5。当一个优先级为p的新客户到达时他应该被放入queue[p]的队尾。这自动保证了同优先级客户的顺序。那么如何保证高优先级客户排在低优先级客户之前呢这体现在“从队列中取人办理业务”即输出顺序的逻辑上。我们不需要在插入时做复杂的查找和移动只需要在输出时总是从当前非空的、优先级最高的那个队列里取出队首的客户。这本质上就是一个**多优先级队列Multi-level Queue**的模型。插入操作是O(1)的直接入队而获取下一个要服务的客户在最坏情况下需要从1到5遍历一下找到第一个非空队列由于优先级数量是常数5所以这也是O(1)的操作。整体效率非常高。2.3 C STL容器queue与vector的实战抉择确定了使用5个队列的思路后在C里如何实现std::queue是一个适配器容器默认基于deque实现它提供了完美的FIFO操作push入队、front查看队首、pop出队。对于本题每个优先级的子队列queue非常合适。但这里有一个小技巧我们如何方便地管理这5个队列一种方法是声明一个数组std::queueint queues[6];使用下标1-5忽略0。另一种是使用vectorstd::vectorstd::queueint queues(6);。两者都可以数组访问速度稍快代码也更简洁。我个人的习惯是使用vector因为其大小可以在运行时确定虽然这里不需要但写法上更现代和统一。那么为什么不直接用vector模拟每个优先级的队列呢因为queue的pop操作不返回元素需要先front再pop这虽然多了一行代码但语义更清晰明确表明了这是一个队列数据结构。用vector则需要自己维护一个头指针或使用erase反而更麻烦。注意在输出最终顺序时我们需要不断从队列中取客户直到所有队列为空。这个过程就是模拟银行依次叫号。由于优先级高的队列数字小优先被检查所以自然实现了高优先级客户先被服务同优先级客户按到达顺序服务的规则。3. C详细实现与代码逐行解析3.1 输入处理与数据结构初始化华为OD机试的输入通常是字符串格式需要我们自己解析。本题的输入假设为第一行是一个整数N表示客户数量。接下来N行每行两个整数分别代表客户ID和优先级。首先我们定义核心数据结构并处理输入#include iostream #include vector #include queue using namespace std; int main() { int n; cin n; // 读取客户总数 // 创建6个队列下标1-5对应优先级1-5下标0不用 vectorqueueint queues(6); for (int i 0; i n; i) { int id, priority; cin id priority; // 将客户id放入对应优先级的队列尾部 queues[priority].push(id); } // ... 后续处理 }这里有几个细节vectorqueueint queues(6);这一行初始化了一个包含6个queueint的向量。构造函数里的6是指定初始大小这6个队列会被默认构造即为空。输入循环中我们直接queues[priority].push(id);。这行代码是核心它利用了优先级priority直接作为索引O(1)时间复杂度完成客户分类入队。客户ID被存入队列其到达顺序由push操作自动维护。这种设计简洁高效但前提是我们确信输入的优先级一定在1-5之间。虽然题目已明确但在实际编码中如果输入范围不确定加入一个合法性检查是良好的习惯if(priority 1 priority 5) queues[priority].push(id);。3.2 模拟叫号与顺序输出的核心算法所有客户都按优先级进入各自的队列后我们就开始模拟银行窗口的叫号过程。我们需要一个结果列表来存储客户离开的顺序。vectorint result; // 存储最终离店顺序 // 模拟银行依次叫号直到所有队列为空 while (true) { bool allEmpty true; // 从优先级最高的队列1级开始检查 for (int p 1; p 5; p) { if (!queues[p].empty()) { // 找到当前优先级最高的非空队列 int customerId queues[p].front(); // 获取队首客户ID queues[p].pop(); // 该客户离队 result.push_back(customerId); // 加入结果序列 allEmpty false; // 标记还有客户 break; // 本次只叫一个号所以跳出优先级循环 } } if (allEmpty) { break; // 所有队列都空了过程结束 } } // 输出结果 for (int i 0; i result.size(); i) { cout result[i]; if (i ! result.size() - 1) cout ; // 最后一个数字后不加空格 } cout endl;这段代码的逻辑需要仔细理解while大循环只要还有客户在排队就持续叫号。内层for循环从p1最高优先级开始向下检查到p5。一旦发现某个优先级的队列非空!queues[p].empty()就处理该队列的队首客户。break的作用这是关键处理完一个客户后我们立即break跳出内层for循环。这意味着每次while循环只叫一个号。然后重新开始下一轮while循环再次从优先级1开始检查。这保证了永远都是当前在场客户里优先级最高的人被优先服务完全符合“高优先级可插队”的动态语义。如果没有这个break一次while循环就会把某个优先级的所有客户都处理完那就变成了“按优先级批次处理”违反了可能随时有更高优先级客户插队进来的动态场景。allEmpty标志位用于判断是否所有队列都已清空。每次进入while循环先设为true只要处理过一个客户就设为false。如果一轮优先级检查下来1到5allEmpty仍为true说明所有队列都空了模拟结束。实操心得这里的双重循环whilefor是模拟动态过程的标准写法。for循环负责“选择”while循环负责“推进”。一定要理解break的位置它确保了每次只服务一个人然后重新评估当前排队情况。这是本题从“静态排序”思维转向“动态调度”思维的关键。3.3 完整代码整合与时间复杂度分析将输入处理和模拟叫号两部分组合起来就得到了完整的解决方案#include iostream #include vector #include queue using namespace std; int main() { int n; cin n; vectorqueueint queues(6); // 索引1-5有效 // 阶段一接收所有客户按优先级分入不同队列 for (int i 0; i n; i) { int id, priority; cin id priority; // 假设输入优先级合法1-5 queues[priority].push(id); } vectorint result; // 阶段二模拟叫号过程 while (true) { bool allEmpty true; for (int p 1; p 5; p) { if (!queues[p].empty()) { result.push_back(queues[p].front()); queues[p].pop(); allEmpty false; break; // 关键一次只服务一个人 } } if (allEmpty) { break; } } // 输出 for (size_t i 0; i result.size(); i) { if (i 0) cout ; cout result[i]; } cout endl; return 0; }时间复杂度分析插入阶段对于N个客户每个客户执行一次queues[priority].push(id)这是O(1)操作。总时间复杂度为O(N)。叫号阶段while循环最多执行N次因为每次叫走一个人。在每次while循环中for循环最多检查5个队列常数次。因此叫号阶段的总时间复杂度是O(5N) O(N)。整体复杂度O(N) O(N) O(N)。这是一个非常高效的线性算法完美通过了大数据量的要求。空间复杂度分析我们使用了5个队列来存储客户ID总共存储N个元素因此空间复杂度为O(N)。result向量也存储了N个元素但属于输出必要空间通常不计入额外空间复杂度或者说是O(N)。这个解法的优势在于它完全贴合了题目的物理意义多个优先级队列代码直观易懂且效率极高。在华为OD机试的环境下这种清晰、高效、bug-free的代码正是考官希望看到的。4. 常见陷阱、调试技巧与扩展思考4.1 新手极易踩坑的三大典型错误即使理解了算法在实现时也容易掉进以下几个坑里同优先级顺序错乱这是最常见的错误。如果你使用了一个单一的数据结构如vector或list并在插入时使用stable_sort或类似排序但排序的比较函数只比较了优先级没有考虑到达顺序即输入顺序就会导致同优先级客户的顺序被打乱。正确的做法是在插入时除了优先级还必须携带一个递增的序号如时间戳来稳定排序。而我们的“多队列”方案天然避免了这个问题因为每个队列自己就是FIFO的。动态处理理解偏差错误地认为可以先对所有客户按优先级排序然后直接输出。这忽略了“插队”的动态性。举个例子客户到达顺序是 (A,1), (B,3), (C,2)。如果简单排序输出是A, C, B。但根据规则C到达时前面只有A优先级1C的优先级2比A低所以不能插到A前面只能排在A后面、B前面。所以实际排队是A, C, B。最终输出也是A, C, B。虽然这个例子结果巧合但换一个顺序就能测出错误。我们的模拟叫号过程本质上是严格按照事件客户到达即入队和规则从高优先级非空队列取人来推进的保证了动态正确性。输出格式错误华为OD机试对输出格式要求严格。常见的错误包括行末多了一个空格或者没有换行符。上面的代码示例中使用if(i0) cout ;的技巧可以避免行尾多余空格。另一种更简洁的写法是for (size_t i 0; i result.size(); i) { cout result[i] (i result.size() - 1 ? \n : ); }4.2 调试方法与测试用例设计在本地或线上调试时设计有效的测试用例至关重要。不要只依赖题目给的样例。基础测试输入 3 101 1 102 3 103 2 预期输出101 103 102解释103不能插101但可以插102前面。同优先级测试输入 4 1 3 2 3 3 1 4 2 预期输出3 4 1 2解释同优先级(1,2)保持到达顺序。边界测试只有一个客户。所有客户同一优先级。客户按优先级从低到高到达应无插队输出即输入顺序。客户按优先级从高到低到达每次新来的都是最高级应逆序输出。较大数据量测试可以写个脚本生成1000个随机客户数据检查程序是否运行流畅结果是否符合规则可以写一个简单的验证程序用O(n²)的暴力算法对比结果。在调试时可以在关键步骤添加打印语句。例如在每次客户入队后打印所有队列的状态或者在每次叫号后打印被叫到的客户ID和当前结果列表。这能帮你直观地跟踪程序逻辑。4.3 算法扩展与变种思路探讨“银行插队”问题是一个很好的起点可以引申出更多有趣的变种和扩展这些也常出现在其他面试或竞赛中多窗口服务如果银行有K个窗口同时服务规则变为“每个窗口总是呼叫当前排队中优先级最高的客户”。这变成了一个多消费者优先级队列问题可以使用一个全局的优先级队列如priority_queue但需要注意同优先级客户的顺序。通常需要为每个客户附加一个全局递增的序号在优先级相同时比较序号。优先级数值范围很大如果优先级不是固定的1-5而是一个很大范围比如1-100000再用数组管理队列就不太合适了空间浪费。此时可以用mapint, queueint键是优先级值是对应的队列。在叫号时需要快速找到当前键值最小的非空队列即最高优先级map本身是按键排序的我们可以用begin()迭代器开始查找。办理业务耗时不同如果每个客户办理业务的时间不同题目要求模拟整个时间线计算平均等待时间等。这就变成了一个离散事件模拟DES问题需要引入时间轴和事件调度复杂度会大大增加。使用priority_queue实现能否用一个priority_queue堆直接解决可以但需要自定义比较器。比较器需要先比较优先级数值小的优先如果优先级相同则比较到达序号小的优先代表先到。这要求我们在输入时给每个客户分配一个自增的seq号。代码如下struct Customer { int id; int priority; int seq; // 到达序号 // 重载小于运算符用于priority_queue默认为大顶堆所以逻辑要反着写 bool operator(const Customer other) const { if (priority ! other.priority) { return priority other.priority; // 优先级数字越小反而越大 } return seq other.seq; // 同优先级先到的seq小反而“大” } }; priority_queueCustomer pq; // 按到达顺序pushpop时自然按优先级和到达顺序弹出这种方法的代码更简短但理解起来需要绕清堆的比较逻辑。两种方法多队列 vs 优先级堆在时间复杂度上都是O(N log N)堆的插入是O(log N)但多队列方法在优先级范围小时是O(N)常数更优且更直观对应问题模型。掌握基础解法后思考这些变种能极大地锻炼你的算法设计能力。在华为OD或其他面试中面试官也可能会在你写出基础解后追问这些扩展场景考察你的思维灵活性和知识迁移能力。