1. 算法修炼之路:贪心、二分与背包问题精解
作为一名算法竞赛选手,我深知掌握核心算法思想的重要性。今天我想分享几个经典算法题目及其解法,这些题目来自洛谷平台,涵盖了贪心算法、二分查找、动态规划中的多重背包和完全背包等核心内容。这些题目不仅适合备战蓝桥杯等算法竞赛,也能帮助大家提升算法思维和解题能力。
2. 题目解析与算法应用
2.1 太空电梯问题:贪心+多重背包
问题描述:我们需要用不同种类的方块搭建电梯,每种方块有高度、最大允许使用高度和数量限制,目标是搭建尽可能高的电梯。
解题思路:
- 贪心策略:首先按照方块的最大允许使用高度从小到大排序。这样可以确保我们在使用每个方块时,不会因为后续方块的高度限制而无法使用。
- 多重背包模型:将问题转化为多重背包问题,其中"重量"和"价值"都是方块的高度。
关键实现细节:
cpp复制struct node {
int h, a, c;
}e[N];
bool cmp(node& x, node& y) {
return x.a < y.a;
}
// 多重背包核心代码
for(int i = 1; i <= n; i++) {
int h = e[i].h, a = e[i].a, c = e[i].c;
for(int j = a; j >= 0; j--) {
for(int k = 0; k <= c && k * h <= j; k++) {
f[j] = max(f[j], f[j - k * h] + k * h);
}
ret = max(ret, f[j]);
}
}
注意事项:
- 排序是关键步骤,确保我们按限制高度从小到大处理方块
- 使用滚动数组优化空间复杂度
- 最终结果不是简单的f[n][m],而是整个DP表中的最大值
2.2 语文成绩:差分数组应用
问题描述:需要对一个班级的语文成绩进行多次区间加减操作,最后找出最低分。
解题思路:
使用差分数组可以高效处理区间更新问题。差分数组的核心思想是记录相邻元素的差值,这样区间加减操作可以转化为两个端点的单点操作。
实现代码:
cpp复制// 初始化差分数组
for(int i = 1; i <= n; i++) {
int x; cin >> x;
f[i] += x; f[i + 1] -= x;
}
// 区间更新
while(p--) {
int x, y, z; cin >> x >> y >> z;
f[x] += z;
f[y + 1] -= z;
}
// 还原数组并找最小值
int ret = 1e9;
for(int i = 1; i <= n; i++) {
f[i] += f[i - 1];
ret = min(ret, f[i]);
}
性能分析:
- 初始化:O(n)
- 区间更新:O(1)每次操作
- 查询最小值:O(n)
总时间复杂度O(n+p),远优于暴力方法的O(p*n)
2.3 跳跳游戏:贪心策略
问题描述:在多个高度不同的平台上跳跃,每次消耗能量为高度差的平方,如何安排跳跃顺序使总能量最大?
解题思路:
采用贪心策略,每次选择能产生最大高度差的跳跃。具体实现是将所有平台高度排序,然后采用双指针法,左右交替选择最大高度差。
关键代码:
cpp复制sort(h + 1, h + 1 + n);
int l = 0, r = n;
LL sum = 0;
while(l < r) {
sum += (h[l] - h[r]) * (h[l] - h[r]);
l++;
sum += (h[l] - h[r]) * (h[l] - h[r]);
r--;
}
为什么这样贪心有效:
- 高度差的平方函数是凸函数,大的高度差贡献更多能量
- 交替选择可以确保每次都能利用当前最大的高度差
- 这种策略能保证不会错过任何大的高度差组合
2.4 数列分段:二分答案
问题描述:将数列分成m段,使得各段和的最大值最小。
解题思路:
典型的"最大值最小化"问题,适合用二分答案解决。我们需要找到最小的x,使得数列可以被分成不超过m段,每段和不超过x。
二分判断函数:
cpp复制int calc(LL x) {
int cnt = 0, sum = 0;
for(int i = 1; i <= n; i++) {
sum += a[i];
if(sum > x) {
cnt++;
sum = a[i];
}
}
return cnt + 1; // 最后一段
}
二分过程:
cpp复制LL l = 0, r = 0;
for(int i = 1; i <= n; i++) {
l = max(l, a[i]); // 最小可能值
r += a[i]; // 最大可能值
}
while(l < r) {
LL mid = (l + r) / 2;
if(calc(mid) <= m) r = mid;
else l = mid + 1;
}
注意事项:
- 初始左边界应设为数列中的最大值,右边界为数列总和
- 计算分段数时不要忘记最后一段
- 二分终止条件是l == r
2.5 修理牛棚:正难则反+贪心
问题描述:用最多m块木板修理有c头牛住的牛棚,求使用的木板总长度最小值。
解题思路:
采用逆向思维,先假设用一块大木板覆盖所有牛棚,然后找出m-1个最大的间隔断开,这样可以最小化总长度。
实现步骤:
- 计算所有相邻牛棚之间的间隔
- 按从大到小排序间隔
- 用总长度减去前m-1大的间隔
关键代码:
cpp复制sort(a + 1, a + 1 + c);
for(int i = 1; i < c; i++) {
b[i] = a[i + 1] - a[i] - 1;
}
sort(b + 1, b + 1 + c, cmp);
int ret = a[c] - a[1] + 1;
for(int i = 1; i < m && i < c; i++) {
ret -= b[i];
}
为什么这样有效:
- 初始总长度是a[c] - a[1] + 1
- 每断开一个间隔,相当于减少对应的木板长度
- 选择最大的间隔断开可以最大化减少的总长度
2.6 货币系统:完全背包应用
问题描述:给定一个货币系统,求其最简形式,即删除所有能被其他面值线性组合表示的面值。
解题思路:
将问题转化为完全背包问题,判断每个面值是否能被比它小的面值组合表示。
DP实现:
cpp复制sort(a + 1, a + 1 + n);
memset(f, 0, sizeof f);
f[0] = true;
int ret = 0;
for(int i = 1; i <= n; i++) {
if(!f[a[i]]) ret++;
for(int j = a[i]; j <= a[n]; j++) {
f[j] = f[j] || f[j - a[i]];
}
}
算法分析:
- 排序是为了确保处理顺序从小到大
- f[j]表示面值j是否能被表示
- 如果一个面值a[i]不能被之前的货币表示(f[a[i]]为false),就必须保留
- 完全背包的更新顺序是从小到大
3. 算法思想总结与实战建议
3.1 贪心算法的适用场景
贪心算法通常在问题具有最优子结构且贪心选择性质成立时有效。在解决这些问题时:
- 太空电梯问题:通过合理排序确保后续选择不受限
- 跳跳游戏:每次选择当前最优的跳跃
- 修理牛棚:逆向思考,选择最有价值的断开点
实战建议:
- 尝试证明贪心策略的正确性
- 考虑排序预处理数据
- 思考是否有反例可以推翻你的贪心策略
3.2 动态规划的应用技巧
多重背包和完全背包是动态规划的经典问题:
- 多重背包:太空电梯问题中,每种方块有数量限制
- 完全背包:货币系统问题中,每种面值可以无限使用
优化技巧:
- 使用滚动数组减少空间复杂度
- 完全背包的内层循环从小到大更新
- 多重背包可以考虑二进制优化
3.3 二分答案的解题框架
二分答案适用于"最大值最小化"或"最小值最大化"问题:
- 确定答案的可能范围
- 设计判断函数check(mid)
- 根据check结果调整搜索范围
常见错误:
- 初始范围设置不当
- 终止条件错误
- check函数实现有误
3.4 差分数组的高效处理
差分数组是处理区间更新的有力工具:
- 初始化时构建差分数组
- 区间更新转化为端点操作
- 通过前缀和还原原始数组
优势:
- 将O(n)的区间更新降为O(1)
- 适用于多次更新、最后查询的场景
4. 常见问题与调试技巧
4.1 动态规划初始化问题
问题:DP数组初始化不正确导致结果错误
解决方法:
- 明确边界条件
- 太空电梯问题中,f[0] = 0
- 货币系统问题中,f[0] = true
4.2 二分查找的边界条件
问题:二分陷入死循环或错过正确答案
解决方法:
- 明确循环终止条件 while(l < r)
- 更新时注意 l = mid + 1 还是 l = mid
- 可以打印中间结果调试
4.3 贪心算法的正确性验证
问题:贪心策略看似正确但实际有反例
解决方法:
- 尝试数学证明
- 构造极端测试用例
- 对比动态规划等更可靠算法的结果
4.4 差分数组的常见错误
问题:区间更新后结果不正确
解决方法:
- 检查区间端点处理是否正确
- 确认还原数组时的计算顺序
- 注意数组大小是否足够
5. 个人实战经验分享
在实际比赛中,我有几点深刻体会:
-
排序预处理:很多问题(如贪心、背包)都需要先对数据进行排序,这是解题的关键第一步。
-
逆向思考:像修理牛棚这样的问题,正向思考复杂时,尝试逆向思维往往能简化问题。
-
空间优化:动态规划问题中,能用一维数组就不用二维,但要注意更新顺序。
-
调试技巧:对于二分答案问题,可以在循环中打印中间值,帮助定位问题。
-
时间复杂度估算:实现算法前先估算最坏情况下的运行时间,避免超时。
在解决货币系统问题时,我最初忽略了排序的重要性,导致结果错误。后来发现必须按面值从小到大处理,才能正确应用完全背包的思路。这个教训让我明白,很多算法对处理顺序是有严格要求的。