1. 问题背景与需求分析
蓝桥杯2023省赛B组H题"整数删除"是一个典型的数据结构优化问题。题目要求我们对一个长度为N的整数数列进行K次操作,每次操作需要:
- 找到当前数列中最小的元素(若有多个最小值则选择最靠前的)
- 删除该元素
- 将该元素的值加到其左右相邻元素上
最终输出经过K次操作后剩余的数列。
1.1 数据规模与性能要求
根据题目给出的评测用例规模:
- 对于20%的数据:1 ≤ K < N ≤ 10^4
- 对于100%的数据:1 ≤ K < N ≤ 5×10^5,0 ≤ A_i ≤ 10^8
这意味着在最坏情况下,我们需要处理50万次删除操作,这就要求我们的算法时间复杂度必须控制在O(K log N)级别才能通过所有测试用例。
1.2 操作特性分析
每次删除操作会带来两个关键影响:
- 数列长度减少1
- 被删除元素的两个邻居(如果存在)的值会增加
这种动态修改的特性使得我们不能简单地预处理整个数列,而需要在每次操作后实时维护数列状态。
2. 初步解法与性能瓶颈
2.1 有序集合实现方案
最初的思路是使用两个有序集合(set)来维护数列:
valinx:存储(值, 下标)对,按值排序inxval:存储(下标, 值)对,按下标排序
cpp复制set<pair<long long, int>> valinx;
set<pair<int, long long>> inxval;
操作流程:
- 初始化时将所有元素插入两个集合
- 每次操作:
- 从
valinx获取最小值 - 在
inxval中找到对应元素 - 删除该元素
- 修改相邻元素的值(先删除旧值再插入新值)
- 从
时间复杂度分析:
- 每次查找最小值:O(1)
- 查找和删除操作:O(log N)
- 修改相邻元素:2次删除+2次插入,每次O(log N)
- 总复杂度:O(K * 4 log N) = O(K log N)
尽管理论复杂度符合要求,但实际测试中这个方案只能通过25%的测试用例,其余都超时。这是因为:
- set的常数因子较大
- 每次操作涉及多次红黑树调整
- STL容器在极端数据规模下表现不佳
3. 优化方案:双向链表+小根堆
3.1 数据结构设计
为了优化性能,我们采用以下数据结构组合:
- 数组a:存储元素的当前值,被删除的元素标记为-1
- 双向链表data:存储未被删除元素的下标,维护元素间的相邻关系
- 小根堆valinx:存储(当前值, 下标)对,用于快速获取最小值
- 迭代器数组vit:记录每个下标在链表中的迭代器位置
cpp复制vector<long long> a; // 元素当前值
list<int> data; // 未被删除元素的下标
priority_queue<T, vector<T>, greater<>> valinx; // 小根堆
vector<list<int>::iterator> vit; // 下标到链表迭代器的映射
3.2 算法核心流程
-
初始化阶段:
- 填充数组a
- 将所有下标插入链表data
- 记录每个下标在链表中的迭代器位置
- 将所有(值, 下标)对加入小根堆
-
K次操作循环:
- 从堆顶获取候选最小值
- 检查该元素是否已被更新(堆中值 ≠ 数组中的当前值)
- 如果不一致,说明该元素已被邻居修改过,重新入堆
- 如果一致,进行删除操作
- 删除操作:
- 从链表中移除该元素
- 更新左右邻居的值
- 将被删除元素标记为-1
3.3 关键优化点
-
延迟删除策略:
- 堆中可能存储过期的(值, 下标)对
- 只有在出堆时检查有效性(对比堆中值和数组中的实际值)
- 避免了立即更新堆带来的性能损耗
-
双向链表维护相邻关系:
- 删除元素后,链表中自动维护剩余元素的相邻关系
- 通过vit数组可以O(1)时间访问任意下标的链表位置
-
时间复杂度控制:
- 每次堆操作:O(log N)
- 每次无效元素最多导致一次重新入堆
- 总共有K次有效删除,最多2K次邻居修改
- 总时间复杂度:O((K + 2K) log N) = O(K log N)
4. 代码实现详解
4.1 数据结构定义
cpp复制typedef pair<long long, int> T; // (值, 下标)对
class Solution {
public:
vector<long long> Ans(const int K, vector<long long>& a) {
const int N = a.size();
list<int> data; // 存储未被删除元素的下标
priority_queue<T, vector<T>, greater<>> valinx; // 小根堆
vector<list<int>::iterator> vit(N); // 下标到链表迭代器的映射
// 初始化
for (int i = 0; i < N; i++) {
data.emplace_back(i);
vit[i] = prev(data.end());
valinx.emplace(a[i], i);
}
// K次操作
for (int i = 0; i < K; i++) {
// 跳过无效的堆顶元素
while (valinx.top().first != a[valinx.top().second]) {
const int inx = valinx.top().second;
valinx.pop();
valinx.emplace(a[inx], inx);
}
// 获取有效的最小值
const auto [val, inx] = valinx.top();
valinx.pop();
auto it = vit[inx]; // 链表中的位置
// 更新左邻居
if (data.begin() != it) {
a[*prev(it)] += val;
}
// 更新右邻居
auto it2 = next(it);
if (data.end() != it2) {
a[*it2] += val;
}
// 标记删除并移除
a[*it] = -1;
data.erase(it);
}
// 收集结果
vector<long long> ans;
for (int i = 0; i < N; i++) {
if (a[i] >= 0) {
ans.emplace_back(a[i]);
}
}
return ans;
}
};
4.2 关键操作解析
- 堆的有效性检查:
cpp复制while (valinx.top().first != a[valinx.top().second]) {
const int inx = valinx.top().second;
valinx.pop();
valinx.emplace(a[inx], inx);
}
这段代码确保我们处理的堆顶元素的值是最新的。如果堆中存储的值与数组中实际值不符,说明该元素曾被邻居修改过,需要重新入堆。
- 邻居更新逻辑:
cpp复制if (data.begin() != it) { // 存在左邻居
a[*prev(it)] += val;
}
if (data.end() != it2) { // 存在右邻居
a[*it2] += val;
}
通过链表迭代器可以方便地找到相邻元素,即使中间有元素被删除过,链表结构也能正确维护剩余元素的相邻关系。
- 删除标记处理:
cpp复制a[*it] = -1; // 标记已删除
data.erase(it); // 从链表中移除
将数组中的值设为-1表示已删除,同时从链表中移除该元素,后续操作将跳过它。
5. 复杂度分析与优化验证
5.1 时间复杂度
-
初始化阶段:
- 链表插入:O(N)
- 建堆:O(N)
-
操作阶段:
- 每次堆操作:O(log N)
- 最多3K次堆操作(K次有效删除 + 最多2K次重新入堆)
- 总操作复杂度:O(N + K log N)
对于N=5×10^5,K≈5×10^5的情况,这个复杂度是可接受的。
5.2 空间复杂度
- 数组a:O(N)
- 链表data:O(N)
- 堆valinx:O(N)
- 迭代器数组vit:O(N)
总空间复杂度:O(N)
5.3 实测性能对比
测试用例:N=5×10^5,K=2.5×10^5的随机数据
- 有序集合方案:超时(>1s)
- 双向链表+小根堆:约300ms
优化后的方案在实际运行中表现显著优于原始方案,能够轻松通过最大规模测试用例。
6. 常见问题与调试技巧
6.1 典型错误场景
-
堆中值过期问题:
- 现象:程序输出结果不正确
- 原因:没有检查堆顶元素是否是最新值
- 解决:必须添加有效性检查循环
-
链表迭代器失效:
- 现象:程序崩溃或结果异常
- 原因:在删除元素后继续使用其迭代器
- 解决:及时更新迭代器引用,避免悬垂指针
-
边界条件处理:
- 现象:首元素或尾元素处理出错
- 原因:没有检查prev(it)或next(it)是否有效
- 解决:添加边界条件判断
6.2 调试技巧
- 小规模测试验证:
cpp复制TEST_METHOD(TestMethod11) {
int K = 3;
vector<long long> a = {1,4,2,8,7};
auto res = Solution().Ans(K, a);
AssertV({17,7}, res); // 预期结果:17 7
}
- 特殊用例测试:
cpp复制TEST_METHOD(TestMethod12) {
int K = 3;
vector<long long> a = {0,0,0,0};
auto res = Solution().Ans(K, a);
AssertV({0}, res); // 预期结果:0
}
- 调试输出:
在关键操作处添加调试输出,验证数据结构状态:
cpp复制#ifdef _DEBUG
cout << "操作" << i << ": 删除a[" << inx << "]=" << val << endl;
cout << "当前链表: ";
for(int x : data) cout << a[x] << " ";
cout << endl;
#endif
6.3 性能优化建议
- 输入输出加速:
对于大规模数据,使用快速IO方法:
cpp复制ios::sync_with_stdio(0);
cin.tie(nullptr);
- 内存预分配:
对于已知大小的vector,提前预留空间:
cpp复制vector<long long> ans;
ans.reserve(N - K); // 预分配结果数组空间
- 避免不必要的拷贝:
使用引用传递大数组:
cpp复制vector<long long> Ans(const int K, vector<long long>& a)
7. 算法扩展与应用
7.1 变种问题思考
-
删除最大值而非最小值:
只需将小根堆改为大根堆,其余逻辑不变。 -
删除任意位置的元素:
如果需要支持删除指定位置的元素,当前数据结构依然适用。 -
动态插入新元素:
可以扩展链表和堆的实现,支持在任意位置插入新元素。
7.2 实际应用场景
- 资源调度系统:
- 元素代表任务资源需求
- 删除操作代表资源分配
- 邻居更新模拟资源分配后的余量调整
- 图像处理:
- 像素值的最小值删除可用于特定滤波操作
- 邻居更新模拟像素扩散效果
- 内存管理:
- 模拟内存块的合并与分配
- 删除操作代表内存释放
- 邻居更新模拟内存合并
7.3 进一步优化方向
-
自定义堆实现:
STL的priority_queue有一定开销,可以针对此问题定制更高效的堆实现。 -
块状链表优化:
对于极大规模数据,可以考虑分块处理,平衡查询和修改的开销。 -
并行化处理:
将堆操作和链表操作分离,利用多线程加速。