1. 题目背景与问题描述
第十四届蓝桥杯省赛C/C++大学B组的"整数删除"问题是一个典型的算法设计题目。这类题目在蓝桥杯竞赛中非常常见,主要考察选手对基础数据结构的掌握程度和算法设计能力。
题目的大致要求是:给定一个由n个整数组成的序列,每次操作需要找到当前序列中最小的元素(如果有多个最小元素,则选择下标最小的那个),将其从序列中删除,并将这个最小元素的值加到它左右相邻的元素上(如果存在相邻元素)。需要重复执行这个操作k次,最后输出剩下的整数序列。
这个问题看似简单,但要在竞赛环境中高效解决,需要考虑以下几个关键点:
- 如何快速找到当前序列中的最小元素
- 如何高效地执行删除和更新操作
- 如何处理边界情况(如删除第一个或最后一个元素)
- 如何保证算法在较大数据规模下的时间效率
2. 数据结构选择与分析
2.1 朴素解法及其局限性
最直观的解法是每次遍历整个数组找到最小元素,然后执行删除和更新操作。这种方法的时间复杂度是O(kn),当k和n都较大时(比如都达到10^5量级),这种解法显然无法在时间限制内完成。
c复制// 伪代码示例
for (i = 0; i < k; i++) {
min_val = INF;
min_index = -1;
// 遍历查找最小值
for (j = 0; j < n; j++) {
if (a[j] < min_val) {
min_val = a[j];
min_index = j;
}
}
// 更新相邻元素
if (min_index > 0) a[min_index-1] += min_val;
if (min_index < n-1) a[min_index+1] += min_val;
// 删除元素
for (j = min_index; j < n-1; j++) {
a[j] = a[j+1];
}
n--;
}
2.2 优先队列优化方案
为了高效处理动态查找最小值的问题,我们可以使用优先队列(堆)数据结构。在C++中,可以使用STL中的priority_queue,但需要注意以下几点:
- 需要能够快速访问堆中的最小元素(默认priority_queue是最大堆)
- 需要能够处理元素值被更新的情况
- 需要能够标记已被删除的元素
一个更合适的解决方案是使用STL的set或者multiset,因为它们支持快速查找、插入和删除操作,且能自动维护元素的有序性。
cpp复制#include <set>
#include <vector>
using namespace std;
struct Node {
int val;
int index;
bool operator<(const Node& other) const {
if (val != other.val) return val < other.val;
return index < other.index;
}
};
void solve(vector<int>& nums, int k) {
set<Node> s;
vector<Node> nodes(nums.size());
// 初始化
for (int i = 0; i < nums.size(); i++) {
nodes[i] = {nums[i], i};
s.insert(nodes[i]);
}
// 处理k次操作
for (int i = 0; i < k; i++) {
auto it = s.begin();
Node min_node = *it;
s.erase(it);
// 更新左右邻居
// ...(详细实现见下文)
}
}
2.3 双向链表维护序列关系
由于删除操作会影响元素的相邻关系,我们需要一种能够高效维护元素前驱后继关系的数据结构。双向链表是一个理想的选择,可以让我们在O(1)时间内访问一个元素的前驱和后继。
我们可以将双向链表与优先队列结合使用:
- 双向链表维护元素的物理顺序关系
- 优先队列用于快速查找当前最小元素
cpp复制struct ListNode {
int val;
int index;
ListNode* prev;
ListNode* next;
ListNode(int v, int i) : val(v), index(i), prev(nullptr), next(nullptr) {}
};
3. 完整算法实现与优化
3.1 数据结构定义与初始化
结合上述分析,我们给出完整的C++实现方案:
cpp复制#include <iostream>
#include <vector>
#include <set>
using namespace std;
struct Node {
int val;
int pos;
bool operator<(const Node& other) const {
if (val != other.val) return val < other.val;
return pos < other.pos;
}
};
int main() {
int n, k;
cin >> n >> k;
vector<int> a(n);
vector<int> prev(n), next(n);
set<Node> s;
// 初始化双向链表和优先队列
for (int i = 0; i < n; i++) {
cin >> a[i];
prev[i] = i - 1;
next[i] = i + 1;
if (next[i] == n) next[i] = -1;
s.insert({a[i], i});
}
// 执行k次操作
while (k--) {
auto it = s.begin();
int val = it->val, pos = it->pos;
s.erase(it);
// 更新前驱节点
if (prev[pos] != -1) {
s.erase({a[prev[pos]], prev[pos]});
a[prev[pos]] += val;
s.insert({a[prev[pos]], prev[pos]});
next[prev[pos]] = next[pos];
}
// 更新后继节点
if (next[pos] != -1) {
s.erase({a[next[pos]], next[pos]});
a[next[pos]] += val;
s.insert({a[next[pos]], next[pos]});
prev[next[pos]] = prev[pos];
}
// 标记当前节点为已删除
prev[pos] = next[pos] = -1;
}
// 输出结果
for (int i = 0; i < n; i++) {
if (prev[i] != -1 || next[i] != -1 ||
(i > 0 && prev[i] == -1 && next[i] == -1 && a[i] != 0)) {
cout << a[i] << " ";
}
}
return 0;
}
3.2 算法复杂度分析
- 初始化阶段:O(n log n),因为向set中插入n个元素
- 每次操作:
- 查找最小值:O(1)
- 删除元素:O(log n)
- 更新相邻元素:每次更新涉及删除和插入操作,各O(log n)
- 总时间复杂度:O(n log n + k log n) = O((n + k) log n)
对于蓝桥杯的测试数据规模(通常n和k在1e5量级),这个复杂度是完全可接受的。
3.3 边界情况处理
在实际编码中,需要特别注意以下几种边界情况:
- 当删除第一个元素时,它没有前驱节点
- 当删除最后一个元素时,它没有后继节点
- 当序列中所有元素值相同时,需要按照位置顺序删除
- 当k >= n时,最终序列为空
4. 竞赛技巧与注意事项
4.1 输入输出优化
在算法竞赛中,特别是C++选手,需要注意输入输出的效率。对于大规模数据,建议使用以下优化:
cpp复制ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
这样可以取消C++标准流与C标准流的同步,显著提高输入输出速度。
4.2 调试技巧
在竞赛环境中调试此类问题时,可以:
- 准备小规模测试用例,手工计算预期结果
- 在关键步骤添加调试输出,如每次操作后的序列状态
- 特别注意指针或索引的越界情况
4.3 常见错误与陷阱
- 未及时更新数据结构:在修改元素值后,必须先从优先队列中删除旧值,再插入新值
- 重复删除问题:需要确保已被删除的元素不会再次被处理
- 相同最小值的处理:题目要求当有多个最小值时,选择位置靠前的那个
- 内存管理:如果使用动态分配的内存(如链表节点),需要注意释放,但在竞赛中通常可以忽略
5. 算法扩展与变种思考
5.1 问题变种
这个问题可以有多种变种形式,例如:
- 每次删除最大的元素而非最小的
- 删除元素后,将其值乘以某个系数再加到相邻元素上
- 在二维矩阵中进行类似的删除操作
5.2 其他解法探讨
除了使用set,还可以考虑以下方法:
- 手写堆:可以更灵活地处理堆中元素的修改
- 线段树:可以支持区间查询和单点修改
- 斐波那契堆:理论上可以提供更好的时间复杂度,但实现复杂
5.3 实际应用场景
这类问题的核心思想(动态维护有序集合并处理相邻关系)可以应用于:
- 数据压缩算法
- 图像处理中的区域合并
- 资源分配与调度问题
6. 竞赛策略与时间管理
在蓝桥杯等编程竞赛中,遇到此类题目时建议采取以下策略:
- 快速理解题意:仔细阅读题目描述,确保理解每一个细节要求
- 设计算法:在纸上画出小规模示例的操作过程,验证思路正确性
- 选择数据结构:根据问题特点选择最合适的数据结构,考虑时间空间复杂度
- 编写代码:先实现核心逻辑,再处理边界情况
- 测试验证:使用样例和自建测试用例验证程序正确性
- 优化调试:如果时间允许,可以进一步优化代码结构或效率
对于这道"整数删除"问题,合理的解题时间分配建议是:
- 理解题目:5分钟
- 算法设计:10分钟
- 编码实现:20分钟
- 测试调试:10分钟
- 边界检查:5分钟
在实际竞赛中,如果遇到卡壳的情况,可以先标记这道题,去做其他题目,等有思路后再回来解决。
