1. 算法竞赛中的二分与贪心策略解析
最近在算法竞赛训练中,我遇到了几道典型的二分查找和贪心算法题目。这些题目看似简单,但想要高效解决却需要深入理解算法原理和灵活运用技巧。下面我将结合具体题目,分享我的解题思路和实现细节。
1.1 二分查找的经典应用
二分查找是算法竞赛中最常用的技巧之一,其核心思想是通过不断缩小搜索范围来快速定位目标值。在解决最优化问题时,二分查找往往能发挥奇效。
1.1.1 平均数最大化问题(P1404)
这道题要求找到一个长度不小于L的子串,使其平均数最大。直接枚举所有可能的子串显然不可行,时间复杂度会达到O(n²)。
解决方案:
- 二分查找平均数mid
- 将原数组每个元素减去mid
- 计算前缀和数组
- 检查是否存在i>j且i-j≥L,使得sum[i]-sum[j]≥0
cpp复制bool check(double mid) {
vector<double> sum(n+1);
for(int i=1; i<=n; i++)
sum[i] = sum[i-1] + a[i] - mid;
double min_val = 0;
for(int i=L; i<=n; i++) {
if(sum[i] - min_val >= 0) return true;
min_val = min(min_val, sum[i-L+1]);
}
return false;
}
时间复杂度分析:
- 二分次数:O(logV),V是数值范围
- 每次检查:O(n)
- 总复杂度:O(nlogV)
1.1.2 部落划分问题(P4047)
这道题要求将n个点分成k个部落,使得不同部落间的最小距离最大化。
解题思路:
- 二分答案,猜测最小距离d
- 对于每个d,用并查集合并距离小于d的点
- 检查最终部落数量是否≤k
cpp复制bool check(double d) {
init_union_find();
int cnt = n;
for(int i=1; i<=n; i++) {
for(int j=i+1; j<=n; j++) {
if(dist(i,j) < d && find(i) != find(j)) {
unite(i,j);
cnt--;
}
}
}
return cnt >= k;
}
优化技巧:
- 预处理所有点对距离并排序,可以优化合并过程
- 使用路径压缩和按秩合并的并查集
1.2 贪心算法的精妙运用
贪心算法通过局部最优选择来达到全局最优,在特定问题中非常高效。
1.2.1 仓库商店问题(P3545)
题目要求在不超支的情况下满足尽可能多的顾客需求。
贪心策略:
- 维护当前剩余资金sum
- 使用最大堆存储已满足的顾客购买金额
- 对于每个新顾客:
- 如果能直接满足,则满足并加入堆
- 否则,比较堆顶元素,若替换后能节省更多资金则替换
cpp复制priority_queue<node> q;
for(int i=1; i<=n; i++) {
sum += a[i];
if(sum < b[i]) {
if(!q.empty() && q.top().mon > b[i]) {
sum += q.top().mon;
q.pop();
ans--;
}
}
if(sum >= b[i]) {
sum -= b[i];
q.push({i, b[i]});
ans++;
}
}
时间复杂度: O(nlogn)
1.2.2 建筑抢修问题(P4053)
需要在有限时间内修复尽可能多的建筑,每个建筑有修复时间和截止时间。
贪心策略:
- 按截止时间排序
- 维护当前总修复时间sum
- 使用最大堆存储已修复建筑的修复时间
- 对于每个新建筑:
- 如果能直接修复,则修复并加入堆
- 否则,比较堆顶元素,若替换后能节省时间则替换
cpp复制sort(a+1, a+n+1, [](Build x, Build y){return x.t2 < y.t2;});
priority_queue<int> q;
for(int i=1; i<=n; i++) {
sum += a[i].t1;
q.push(a[i].t1);
if(sum > a[i].t2) {
sum -= q.top();
q.pop();
}
}
1.3 搜索算法的优化技巧
1.3.1 01BFS优化(P12684)
在网格图中,普通移动消耗1步,魔法移动消耗1次魔法使用次数。
优化思路:
- 普通BFS使用队列会导致状态处理顺序混乱
- 01BFS使用双端队列:
- 普通移动加入队尾
- 魔法移动加入队首
cpp复制deque<Node> q;
q.push_front({x1,y1,0,0});
while(!q.empty()) {
Node u = q.front(); q.pop_front();
// 普通移动
for(int i=0; i<4; i++) {
int nx = u.x + dx1[i], ny = u.y + dy1[i];
if(valid(nx,ny))
q.push_back({nx,ny,u.t1+1,u.t2});
}
// 魔法移动
for(int i=0; i<4; i++) {
int nx = u.x + dx2[i], ny = u.y + dy2[i];
if(valid(nx,ny))
q.push_front({nx,ny,u.t1,u.t2+1});
}
}
时间复杂度: 从O(nmlog(nm))优化到O(nm)
1.4 线段树的高级应用
1.4.1 离线排序问题(P2824)
题目要求对一个序列进行多次区间排序(升序或降序),最后查询某个位置的值。
巧妙解法:
- 二分答案mid
- 将原序列转化为01序列(≥mid为1,否则为0)
- 用线段树维护01序列的区间排序
- 检查最终查询位置是否为1
cpp复制bool chk(int mid) {
build_segment_tree(mid);
for(int i=1; i<=m; i++) {
int cnt = query(L[i], R[i]);
if(op[i] == 0) { // 升序
update(R[i]-cnt+1, R[i], 1);
update(L[i], R[i]-cnt, 0);
} else { // 降序
update(L[i], L[i]+cnt-1, 1);
update(L[i]+cnt, R[i], 0);
}
}
return query_pos(q) == 1;
}
时间复杂度: O(nlog²n)
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 算法竞赛中的实用技巧总结
2.1 二分查找的注意事项
- 终止条件:while(l <= r) vs while(l < r)
- 边界处理:mid = (l+r)/2可能溢出,建议使用mid = l+(r-l)/2
- 浮点数二分:需要设置合理的精度要求,如while(r-l > 1e-6)
2.2 贪心算法的证明技巧
- 交换论证:证明任何非贪心选择都可以通过交换变成贪心选择而不劣化结果
- 归纳法:证明贪心选择在每一步都保持最优子结构
- 拟阵理论:某些贪心问题可以建模为拟阵,从而保证正确性
2.3 数据结构的选择原则
- 查询/更新频率:高频查询选择预处理结构(如前缀和),高频更新选择动态结构(如线段树)
- 维度考虑:一维问题可用树状数组,二维问题考虑二维线段树或KD树
- 离线处理:当操作可批处理时,离线算法往往更高效(如莫队算法)
在实际比赛中,我经常遇到的问题是想到算法但实现不够优化。例如在解决P8817假期计划问题时,最初我尝试完全暴力枚举,结果超时。后来通过预处理每个点可达的前三大权值点,将复杂度从O(n⁴)降到了O(n²)。这提醒我,在算法竞赛中,不仅要想出正确解法,还要不断思考如何优化实现。
