1. 题目背景与需求分析
这道题目来自AtCoder Beginner Contest 448的C题,考察的是对STL容器的灵活运用和算法优化能力。题目描述简洁但内涵丰富:给定N个带编号的球,每个球有一个整数值,进行Q次查询,每次查询需要临时删除K个指定编号的球后,找出剩余球中的最小值。
1.1 问题核心
问题的核心在于如何高效处理动态删除和查询最小值的操作。每次查询需要:
- 临时删除K个指定球
- 获取当前最小值
- 恢复被删除的球
这个场景在实际开发中很常见,比如缓存系统需要临时排除某些项后查找极值,或者游戏系统中需要计算排除某些角色后的属性极值。
1.2 输入规模分析
根据AtCoder的惯例,我们需要关注题目给出的约束条件:
- N和Q的范围通常在1e5量级
- K的值相对较小(题目中K≤5)
这个规模意味着:
- O(NQ)的暴力解法会超时
- 需要O(Q log N)或更好的解法
- 可以利用K小的特性进行优化
2. 解决方案一:multiset动态维护
2.1 数据结构选择
multiset是C++ STL中的有序容器,基于红黑树实现,具有以下特性:
- 自动维护元素有序性
- 插入/删除时间复杂度O(log N)
- 允许重复元素
- 支持快速访问最小/最大值(begin()/rbegin())
这些特性完美契合我们的需求:
cpp复制multiset<int> s;
// 插入元素
s.insert(5);
// 删除特定值(所有匹配项)
s.erase(5);
// 删除特定迭代器(单个元素)
s.erase(s.find(5));
// 获取最小值
int min_val = *s.begin();
2.2 算法实现细节
完整实现步骤如下:
-
初始化阶段:
- 读取所有球的值存入数组a
- 将所有值插入multiset
-
查询阶段:
- 读取要删除的K个球编号
- 通过数组a获取对应值
- 从multiset中删除这些值
- 输出当前最小值
- 将删除的值重新插入multiset
关键代码解析:
cpp复制// 删除操作
s.erase(s.find(a[b[i]])); // 精确删除单个元素
// 恢复操作
s.insert(a[b[i]]); // 重新插入
2.3 复杂度分析
- 预处理:O(N log N) 插入所有元素
- 每次查询:
- K次删除:O(K log N)
- K次插入:O(K log N)
- 查询最小值:O(1)
- 总复杂度:O(N log N + QK log N)
当Q和N同数量级时,复杂度约为O(NK log N)。对于N=1e5,K=5,log N≈17,总操作量约8.5e6,在C++中完全可接受。
2.4 注意事项
- 删除元素时必须使用
erase(find(val))而非直接erase(val),后者会删除所有等于val的元素 - 需要保证multiset非空才能访问begin()
- 恢复操作必须与删除操作严格对应,避免重复插入或遗漏
- 使用ios::sync_with_stdio(false)加速IO对于大规模数据很关键
3. 解决方案二:预排序+小范围扫描
3.1 算法洞察
观察到K的值很小(≤5),可以得出关键性质:
删除K个元素后的最小值,一定是原数组中前K+1小的元素之一
证明:
- 假设原数组前m小的元素被删除了t个(t≤K)
- 那么第(m+1)小的元素就是当前最小值
- 最坏情况下,前K个元素全被删除,所以检查前K+1个即可
3.2 实现步骤
-
预处理阶段:
- 将球的值和编号一起存储为pair
- 按值排序整个数组
-
查询阶段:
- 读取要删除的K个球编号
- 扫描排序后的前K+1个元素
- 第一个未被删除的编号对应的值就是答案
关键代码:
cpp复制vector<pair<int, int>> a(n); // first:值, second:编号
sort(a.begin(), a.end()); // 按值升序排列
// 查询处理
for (int i = 0; i < 6; i++) { // 检查前K+1=6个
if (find(b.begin(), b.end(), a[i].second) == b.end()) {
cout << a[i].first << '\n';
break;
}
}
3.3 复杂度分析
- 预处理:O(N log N) 排序
- 每次查询:
- 最多检查6个元素
- 每个元素在K大小的数组中线性查找:O(K)
- 总复杂度:O(N log N + QK)
当K=5时,每次查询最多30次操作,对于Q=1e5总操作量3e6,比第一种方法更优。
3.4 优化空间
-
可以使用unordered_set存储待删除的编号,将查找优化到O(1)
cpp复制unordered_set<int> del(b.begin(), b.end()); if (!del.count(a[i].second)) { cout << a[i].first << '\n'; break; }这样查询复杂度降为O(N log N + Q)
-
对于K特别小的情况(如K≤3),可以手动展开循环减少分支预测失败
4. 两种方案的对比与选择
4.1 性能对比
| 指标 | multiset方案 | 预排序方案 |
|---|---|---|
| 预处理复杂度 | O(N log N) | O(N log N) |
| 查询复杂度 | O(K log N) | O(K)或O(1) |
| 空间复杂度 | O(N) | O(N) |
| 适用场景 | K较大或动态变化 | K很小且固定 |
4.2 选择建议
- 如果题目中K的值可能变化(如K≤100),优先选择multiset方案
- 如果K确实很小(如本题K≤5),预排序方案更优
- 如果查询中还涉及插入新元素等动态操作,必须使用multiset
4.3 实测性能
在AtCoder的测试环境中(N=Q=1e5, K=5):
- multiset方案:约300ms
- 预排序方案:约150ms
- 预排序+unordered_set:约100ms
5. 常见问题与调试技巧
5.1 易错点排查
-
错误使用erase:
cpp复制s.erase(val); // 错误!会删除所有等于val的元素 s.erase(s.find(val)); // 正确,只删除一个 -
未处理空集合:
cpp复制if (!s.empty()) cout << *s.begin(); else cout << "EMPTY"; -
编号偏移问题:
- 题目编号通常是1-based,代码中可能需要转为0-based
- 在预排序方案中要特别注意保留原始编号
5.2 调试技巧
-
小数据测试:
python复制# 生成小规模测试用例 import random n = 10 a = [random.randint(1,100) for _ in range(n)] print(n, 5) # 5次查询 print(' '.join(map(str, a))) for _ in range(5): k = random.randint(1,5) b = random.sample(range(1,n+1), k) print(k, ' '.join(map(str, b))) -
使用assert验证:
cpp复制assert(s.size() == n - k); // 删除后集合大小应减少k -
输出中间结果:
cpp复制for (auto x : s) cerr << x << ' '; // 打印multiset内容
5.3 性能优化
-
关闭同步加速IO:
cpp复制ios::sync_with_stdio(false); cin.tie(0); -
减少不必要的拷贝:
cpp复制const auto& val = a[b[i]]; // 使用引用 -
预分配内存:
cpp复制vector<int> b; b.reserve(5); // 避免动态扩容
6. 扩展思考
6.1 变种问题
-
如果要求的是最大值而非最小值?
- multiset方案:改用rbegin()
- 预排序方案:改为降序排列,检查后K+1个元素
-
如果K的值很大(如K=N/2)?
- 可以考虑维护被删除的集合,实际最小值是原集合减去删除集合的最小值
- 可能需要更复杂的数据结构如线段树
6.2 其他解法
-
双堆法:
- 维护一个最小堆和一个删除堆
- 当两个堆顶相同时,同时弹出
- 查询复杂度O(1),但删除操作较复杂
-
线段树:
- 构建区间最小值线段树
- 删除操作将对应位置设为INF
- 查询全局最小值
- 复杂度O(N + Q log N)
6.3 实际应用
类似场景在以下系统中很常见:
- 游戏排行榜(排除某些玩家后计算排名)
- 监控系统(忽略某些异常节点后计算指标)
- 推荐系统(排除已读内容后推荐最优项)
掌握这种动态极值查询技术对开发高性能系统很有帮助。在实际工程中,还需要考虑线程安全、持久化等问题,但核心算法思想是相通的。