C++算法精解:树状数组与映射优化排队模拟题P11396

发布时间:2026/7/16 4:52:18
C++算法精解:树状数组与映射优化排队模拟题P11396 1. 项目概述与核心需求解析最近在带学生刷信息学奥赛信奥的题目遇到一道非常经典的模拟题——P11396 排队。这道题本身逻辑不复杂但非常考验选手对问题场景的抽象能力、边界条件的处理以及C基础数据结构和输入输出的熟练运用。很多初学者一看到题目描述里“排队”、“插队”、“离开”这些字眼第一反应可能是去写一个复杂的链表或者队列模拟结果代码写得又长又容易出错。其实这道题有一个非常巧妙的“逆向思维”解法用C的vector容器配合查找和删除操作就能以清晰、高效的代码实现。今天我就来详细拆解这道题不仅给出AC代码更重要的是分享从读题到AC的完整思考路径以及如何利用C标准库的特性来简化代码逻辑。无论你是正在备赛的信奥选手还是想巩固C算法基础的开发者相信这篇深度解析都能给你带来启发。P11396 题目的核心是模拟一个动态的排队过程。通常排队系统会有人员加入排到队尾、特定人员离开从队伍中任意位置离开、以及查询某人的位置。如果直接模拟一个动态链表每次有人离开都需要遍历查找并调整指针对于大数据量题目未明确给出但信奥题常设上限到10^5效率堪忧且代码容易写错。因此我们的核心需求是设计一个数据结构能够高效地支持“按人名查找位置”、“从任意位置删除人员”以及“在队尾添加人员”这三种操作。这直接引导我们去评估C中vector,list,map等容器的特性。2. 解题思路与算法设计2.1 问题重述与输入输出分析首先我们得把题目“翻译”成程序员能理解的语言。题目会给出若干行操作每行操作属于以下三种类型之一Arrive Name: 名为Name的人到达并排到队伍的最后。Leave Name: 名为Name的人离开队伍。题目保证此人当前一定在队伍中。Queue Name: 查询名为Name的人当前在队伍中的位置从1开始计数并输出。我们需要根据这些操作序列模拟整个排队过程并对每一个Queue操作给出正确的输出。输入格式通常是先给出操作次数n然后n行每行一个操作。输出则是对于每个Queue操作输出一行结果。关键点与难点动态性队伍随时可能有人加入或离开队伍长度和内部顺序是变化的。按名操作所有操作都基于“人名”这个字符串关键字我们需要快速找到这个人当前在队伍中的索引下标。位置查询Queue操作需要输出当前的位置这个位置会随着前面人的离开而改变。例如队伍是[A, B, C, D]B离开后C的位置就从3变成了2。2.2 数据结构选型与逆向思维最直观的想法是用一个vectorstring按顺序存储队伍中的人。Arrive操作直接push_back即可是O(1)复杂度。麻烦在于Leave和Queue。Queue Name: 需要在vector中查找Name。使用std::find是O(n)的线性查找。Leave Name: 先要O(n)找到这个人再用vector::erase删除这个删除操作本身在平均情况下也是O(n)因为需要移动后续所有元素。如果操作次数n很大比如10^5最坏情况下每次都是Leave或Queue队首的人那么总复杂度可能接近O(n^2)有超时风险。一个优化思路是引入一个mapstring, int将人名映射到他在vector中的下标。这样Queue操作就变成了O(log n)的查找。但Leave操作依然棘手从vector中间删除一个元素后后面所有人的下标都变了我们需要更新map中所有这些人的映射关系这又变成了O(n)的操作。这里就是体现“逆向思维”的地方。我们为什么要维护一个“绝对正确”的下标呢对于Queue操作我们需要的“位置”其实就是在这个人之前有多少个还没有离开的人。我们可以换一种存储方式用一个vectorstring按顺序记录所有曾经到达过的人包括已经离开的。我们称之为allPersons。Arrive操作只需将人名追加到这个列表末尾。这个列表一旦加入顺序就不再改变它的索引可以看作每个人的“时间戳”或“唯一ID”。用一个setint或unordered_set来记录当前已经离开的人在allPersons中的索引。当进行Queue Name操作时我们 a. 在allPersons中找到Name对应的索引id这里可以用一个mapstring, int来建立人名到id的映射实现O(log n)查找。 b. 计算位置位置 id- (在allPersons中索引小于id且已经离开的人数)。因为id是从0开始的而位置是从1开始的所以最终位置 (id - 已离开人数) 1。如何快速计算“索引小于id且已经离开的人数”这正是set的用武之地。set是有序的我们可以用std::set::lower_bound或std::set::count在某个范围内的特性。更简单直接的方法是因为set里存储的是离开者的id且id是递增加入allPersons的所以“小于id的离开者数量”就等于在set中值小于id的元素个数。我们可以用std::distance(set.begin(), set.lower_bound(id))来计算复杂度是O(log n)。算法流程梳理初始化vectorstring allPersons;记录所有人mapstring, int idMap;记录人名到allPersons索引的映射setint leftPersons;记录离开者的索引。Arrive Name:将Name加入allPersons。在idMap中记录Name对应的索引id allPersons.size() - 1。Leave Name:通过idMap找到Name对应的索引id。将id插入leftPersons集合。Queue Name:通过idMap找到Name对应的索引id。计算left_count distance(leftPersons.begin(), leftPersons.lower_bound(id))。这一步计算了在id之前有多少人已经离开。计算当前位置pos id - left_count 1。输出pos。这个算法的巧妙之处在于它将动态的删除操作转化为了静态的集合标记操作。allPersons和idMap在人员到达后就固定不变变化的只有leftPersons这个集合。所有操作的时间复杂度都降为了O(log n)set和map的操作完全能够应对大数据量。2.3 复杂度分析假设总操作次数为n人员到达Arrive操作次数为m。空间复杂度allPersons和idMap存储了所有到达过的人O(m)。leftPersons最多存储O(m)。总体O(m)。时间复杂度Arrive: O(log m)用于向map中插入。Leave: O(log m)用于向set中插入。Queue: O(log m)包括map查找和set的lower_bound与distance计算对于setdistance在给定迭代器范围内是常数时间这里需要注意std::distance在两个随机访问迭代器间是O(1)但set的迭代器是双向迭代器distance是O(k)其中k是距离。但在我们计算left_count时distance是从set.begin()到lower_bound(id)这个距离最大为set.size()因此最坏是O(n)。为了优化我们可以用set的order_of_key功能但C标准库的set没有直接提供。我们可以改用pb_ds库中的tree数据结构或者用一个Fenwick Tree树状数组来维护离开标记将复杂度稳定在O(log n)。对于信奥比赛环境通常支持pb_ds。为了代码清晰和普适性下文将先给出用set的直观版本再讨论优化方案。3. 代码实现与逐行解析3.1 基于set的直观实现可能非最优我们先实现思路最直接的版本帮助理解算法核心。#include iostream #include vector #include string #include map #include set #include algorithm // for std::distance using namespace std; int main() { int n; cin n; vectorstring allPersons; // 所有到达过的人按到达顺序存储 mapstring, int idMap; // 人名 - 在allPersons中的索引 setint leftIndexSet; // 已经离开的人的索引集合 for (int i 0; i n; i) { string op, name; cin op name; if (op Arrive) { // 新到达分配id int newId allPersons.size(); allPersons.push_back(name); idMap[name] newId; // 记录映射 } else if (op Leave) { // 离开标记索引 int id idMap[name]; // 假设name一定存在 leftIndexSet.insert(id); } else if (op Queue) { // 查询位置 int id idMap[name]; // 计算在id之前有多少人已经离开 auto it leftIndexSet.lower_bound(id); int leftCount distance(leftIndexSet.begin(), it); // 计算当前位置id - 前面离开的人数 1 (因为位置从1开始) int position id - leftCount 1; cout position endl; } } return 0; }代码解析与注意事项输入处理使用cin op name;简单处理。注意题目中操作和名字间有空格。Arrive操作newId就是当前allPersons的大小因为索引从0开始。先存名字再更新映射。Leave操作直接从idMap中取得id然后插入leftIndexSet。这里没有检查name是否存在因为题目保证合法。Queue操作这是核心。lower_bound(id)找到set中第一个大于等于id的迭代器。所有小于id的离开者都在[begin(), it)这个区间。distance(begin(), it)计算这个区间的元素个数即id之前离开的人数。位置计算逻辑id表示这个人是第(id1)个到达的从1开始计。在他之前到达的人有id个。这id个人里有一部分已经离开了(leftCount)。那么还留在他前面的人就是id - leftCount个。所以他的当前位置就是(id - leftCount) 1。潜在性能问题std::distance对于set这样的双向迭代器其复杂度是线性时间与区间内元素个数成正比。在最坏情况下比如几乎所有人都没离开查询队尾的人leftCount可能接近set.size()导致单次Queue操作复杂度为O(n)总复杂度可能退化到O(n^2)。3.2 优化方案使用树状数组Fenwick Tree为了将Queue操作稳定在O(log n)我们需要一种能快速计算“前缀和”即小于某个值的元素个数的数据结构。树状数组Binary Indexed Tree, BIT正是为此而生。我们可以维护一个树状数组bit数组大小为maxn最大可能人数5。初始时所有位置为0。当一个人索引为id离开时我们执行bit.add(id, 1)表示这个位置有一个人离开。那么查询“小于id的离开人数”就变成了查询前缀和bit.sum(id)严格来说是bit.sum(id-1)因为我们要求小于id的。树状数组的add和sum操作都是O(log n)。优化后的数据结构allPersons,idMap保持不变。将setint leftIndexSet替换为FenwickTree bit(maxn)。Fenwick Tree 实现class FenwickTree { private: vectorint tree; int n; public: FenwickTree(int size) : n(size), tree(size 1, 0) {} // 获取最低位1的值标准lowbit操作 int lowbit(int x) { return x -x; } // 在位置idx增加val void add(int idx, int val) { // 树状数组下标从1开始而我们的id从0开始需要1转换 for (int i idx 1; i n; i lowbit(i)) { tree[i] val; } } // 求前缀和 [0, idx] 的和 int sum(int idx) { int s 0; for (int i idx 1; i 0; i - lowbit(i)) { s tree[i]; } return s; } };优化后的主逻辑修改int main() { int n; cin n; // 估计最大人数最坏情况是所有操作都是Arrive所以最大人数不超过n const int MAXN n 5; FenwickTree bit(MAXN); vectorstring allPersons; mapstring, int idMap; for (int i 0; i n; i) { string op, name; cin op name; if (op Arrive) { int newId allPersons.size(); allPersons.push_back(name); idMap[name] newId; } else if (op Leave) { int id idMap[name]; bit.add(id, 1); // 标记该id的人已离开 } else if (op Queue) { int id idMap[name]; // 查询在id之前索引小于id有多少人离开 int leftCount bit.sum(id - 1); // 注意是id-1因为我们要的是严格小于id的 int position id - leftCount 1; cout position endl; } } return 0; }关键改动与优势Leave操作bit.add(id, 1)O(log n)。Queue操作leftCount bit.sum(id - 1)O(log n)。这里sum(id-1)计算的是索引在[0, id-1]区间内离开的人数即严格在id之前离开的人数。复杂度所有操作都稳定在O(log n)总复杂度O(n log n)可以轻松处理n10^5甚至更大的数据。空间树状数组大小O(n)。注意树状数组的下标通常从1开始因为lowbit(0)0会导致死循环。所以我们内部存储时将外部索引id从0开始转换为id1进行存储和计算。在sum函数中我们查询的是[0, idx]的和转换后就是[1, idx1]的和逻辑是一致的。3.3 边界条件与测试用例编写完代码必须用多种情况测试。测试用例1基本功能输入 6 Arrive Alice Arrive Bob Queue Alice Queue Bob Leave Alice Queue Bob 输出 1 2 1解析Bob前面的人Alice离开后Bob的位置从2变成1。测试用例2连续离开输入 7 Arrive A Arrive B Arrive C Leave B Queue C Leave A Queue C 输出 2 1解析初始队伍[A, B, C]。B离开后队伍为[A, C]C是第2个。A再离开后队伍为[C]C是第1个。测试用例3大数据量压力测试可在本地生成 生成10^5条Arrive操作然后间隔进行Leave和Queue。使用优化后的树状数组版本应该能快速运行。4. 常见问题与调试技巧4.1 为什么不用vector直接模拟这是初学者最容易陷入的误区。直接模拟的代码可能长这样vectorstring queue; if (op Arrive) queue.push_back(name); else if (op Leave) { auto it find(queue.begin(), queue.end(), name); if (it ! queue.end()) queue.erase(it); } else if (op Queue) { auto it find(queue.begin(), queue.end(), name); cout (it - queue.begin() 1) endl; }问题find是O(n)的线性查找。vector::erase在中间删除元素需要移动后面所有元素也是O(n)。 在频繁操作下算法很容易超时TLE。4.2map和set的使用注意事项键的存在性题目保证了Leave和Queue操作中的name一定存在于当前队伍中所以我们可以放心地用idMap[name]获取值。但在更通用的场景下应该先使用find或count检查键是否存在避免未定义行为。set的有序性我们利用set的有序性来使用lower_bound。如果使用unordered_set哈希集合虽然插入删除是O(1)平均但无法进行范围查询计算小于id的元素个数会失去我们算法的核心优势。4.3 树状数组下标处理易错点这是实现树状数组时最常见的坑。内部下标转换我们的id是外部索引从0开始树状数组内部索引必须从1开始。所以在add和sum函数内部我们统一进行i idx 1的转换。前缀和查询在Queue操作中我们要的是严格小于id的离开人数所以查询的是bit.sum(id - 1)。如果查询bit.sum(id)则把id本人是否离开也计入了会导致位置计算错误如果本人已离开理论上不应该被查询但题目保证了Queue时人一定在队伍中所以查id本人也不会离开。但逻辑上更严谨的应该是查id-1。4.4 如何验证算法正确性对于算法题尤其是信奥题目我强烈建议养成以下习惯小数据手工模拟像上面给出的测试用例在纸上或心里走一遍流程和程序输出对比。对拍写一个“暴力但正确”的程序比如直接用vector模拟确保逻辑正确和你的“高效算法”程序用同一个随机数据生成器产生大量随机测试数据对比两者的输出。这是发现边界条件和隐藏bug的终极利器。对于本题可以随机生成操作序列确保Arrive的名字不重复或处理重复然后两个程序同时运行比对结果。输出中间变量在调试时可以打印出allPersons,idMap,leftIndexSet或树状数组的状态帮助理解程序运行过程。4.5 性能优化延伸对于这道题我们给出的树状数组解法已经是时间最优之一。空间上如果名字字符串很长且数量巨大mapstring, int和vectorstring可能占用较多内存。一个优化是使用字符串哈希如std::unordered_map代替map但需要注意哈希冲突。在信奥竞赛中通常给定的数据规模下map足以应对。还有一种思路是如果名字是连续的整数ID或者可以映射为整数那么可以进一步简化。但本题中名字是字符串所以我们的解法是通用的。最后再分享一个我个人的调试心得在写这类涉及下标计算的题目时我习惯在关键步骤后添加assert断言。例如在计算position后可以断言position 1 position currentQueueSize。虽然正式提交时需要去掉但在本地调试时能快速定位计算错误。这道P11396排队题核心在于思维的转换——从维护一个动态序列转换为维护一个静态序列加一个离开标记集合。掌握了这种“逆向”或“差分”的思想很多类似的动态查询问题都能迎刃而解。