引言
在算法竞赛的世界里,每一道题都像是一个等待解开的谜题。今天,我将与大家分享一道关于任务调度问题的完整解题心路历程。这个故事不仅记录了我从暴力搜索到最优算法的探索过程,更展现了在面对复杂问题时,如何通过逐步优化、深入思考最终找到完美解法的思维历程。
问题背景与挑战
问题描述
在蓝桥杯算法训练中,我遇到了这样一个任务调度问题:
有n个任务,每个任务有两个关键属性:
-
消耗体力 ai(完成任务所需的时间或体力)
-
允许的最大累计体力 bi(在开始这个任务之前,已累计消耗的体力不能超过此值)
我们需要从这n个任务中选取一部分,并安排一个合理的完成顺序,使得在满足每个任务的开始条件的前提下,最大化完成的任务数量。
问题特点
-
顺序重要:任务的完成顺序直接影响后续任务的选择
-
相互制约:前面任务的完成会影响后面任务的开始条件
-
选择灵活:可以自由选择要完成的任务,不一定需要完成所有任务
-
优化目标明确:在满足所有限制条件下,完成尽可能多的任务
第一阶段:暴力探索 - DFS深度优先搜索
思路起源
当我第一次看到这个问题时,最直接的反应是:要找到最优顺序,就需要考虑所有可能的顺序。这是一个典型的排列组合问题。每个任务要么被选,要么不被选,而且被选的任务之间还有顺序关系。
代码实现详解
cpp
#include <iostream>
using namespace std;
const int N = 100000; // 定义最大任务数
int visit[N]; // 访问标记数组,visit[i]=1表示第i个任务已被选择
int n; // 实际任务数量
struct Task {
int a; // 消耗体力
int b; // 允许的最大累计体力
} tasks[N]; // 任务数组
int ans = 0; // 全局最优解,记录最多能完成的任务数
int flag = 0; // 标志位,如果完成了所有任务则置为1
// DFS递归函数
// 参数说明:
// count: 当前已完成的任务数量
// last: 当前累计消耗的体力值
void dfs(int count, int last) {
// 每次进入递归,都尝试更新全局最优解
ans = max(ans, count);
// 如果已经完成了所有任务,设置标志并返回
if (count == n) {
flag = 1;
return;
}
// 尝试选择下一个任务
for (int i = 0; i < n; i++) {
if (visit[i] == 0) { // 任务i尚未被选择
if (tasks[i].b >= last) { // 检查是否满足开始条件
// 选择任务i
visit[i] = 1; // 标记为已选择
int start = last + tasks[i].a; // 更新累计体力
// 递归搜索下一层
dfs(count + 1, start);
// 回溯:撤销对任务i的选择
visit[i] = 0;
}
}
}
}
int main() {
cin >> n;
for (int i = 0; i < n; i++) {
cin >> tasks[i].a >> tasks[i].b;
}
// 从初始状态开始搜索:完成了0个任务,累计体力为0
dfs(0, 0);
if (flag == 1) {
cout << "OK" << endl; // 表示可以完成所有任务
}
cout << ans; // 输出最多能完成的任务数
return 0;
}
算法深度解析
状态空间设计
-
显式状态 :
count(已完成任务数)和last(累计体力) -
隐式状态 :
visit数组记录了哪些任务已被选择 -
搜索树:每个节点代表一个部分解,边代表选择一个任务
搜索策略
-
深度优先:沿着一条路径一直搜索到底,然后回溯尝试其他路径
-
剪枝优化 :虽然没有显式剪枝,但通过条件
tasks[i].b >= last自然剪去了不满足条件的路径 -
最优性保证:由于遍历了所有可能的排列,最终一定能找到全局最优解
递归过程示例
以3个任务为例:
cpp
初始状态: (count=0, last=0, 已选: {})
↓ 选择任务1
状态1: (count=1, last=a1, 已选: {1})
↓ 选择任务2(如果满足条件)
状态2: (count=2, last=a1+a2, 已选: {1,2})
↓ 选择任务3(如果满足条件)
状态3: (count=3, last=a1+a2+a3, 已选: {1,2,3})
然后回溯,尝试其他顺序...
复杂度分析
时间复杂度
-
最坏情况下需要遍历所有排列:O(n!)
-
对于每个排列,需要检查每个任务是否满足条件:O(n)
-
总时间复杂度:O(n × n!) ≈ O((n+1)!)
空间复杂度
-
递归深度:O(n),最多递归n层
-
访问数组:O(n)
-
总空间复杂度:O(n)
实际性能
-
n=10时:10! = 3,628,800,还可以接受
-
n=12时:12! = 479,001,600,开始变慢
-
n=15时:15! ≈ 1.3×10¹²,完全不可行
-
题目要求n最大1e5,这个算法完全无法处理
测试结果与反思
-
通过率:6.5%,只能通过最小的测试点
-
反思:
-
暴力搜索能保证正确性,但无法处理大规模数据
-
需要寻找更高效的算法
-
必须利用问题的特殊结构进行优化
-
第二阶段:贪心探索 - 按b_i排序+反悔机制
思路演进
既然暴力搜索不可行,我开始思考贪心策略。最自然的想法是:优先处理那些要求最严格的任务。什么样的任务最严格?显然,b_i最小的任务最"挑剔",必须在累计体力很少的时候就开始。
算法设计思路
-
排序策略:将任务按b_i从小到大排序
-
贪心选择:依次处理任务,如果能直接完成就完成
-
反悔机制:如果不能直接完成,但当前任务的a_i比已选任务中最大的a_i小,就用它替换那个任务
代码实现详解
cpp
#include <bits/stdc++.h>
using namespace std;
using ll = long long; // 使用long long防止溢出
int main() {
// 优化输入输出
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
// 使用vector存储任务,pair的first是a_i,second是b_i
vector<pair<int, int>> tasks(n);
for (int i = 0; i < n; ++i) {
cin >> tasks[i].first >> tasks[i].second;
}
// 关键步骤1:排序
// 使用lambda表达式定义比较函数
sort(tasks.begin(), tasks.end(), [](const pair<int, int>& x, const pair<int, int>& y) {
// 按b_i从小到大排序
return x.second < y.second;
});
// 最大堆(优先队列),存储已选任务的a_i
// 默认是最大堆,堆顶是最大的a_i
priority_queue<int> pq;
ll sum = 0; // 当前累计体力
// 遍历排序后的所有任务
for (int i = 0; i < n; ++i) {
int a = tasks[i].first; // 当前任务的a_i
int b = tasks[i].second; // 当前任务的b_i
if (sum <= b) {
// 情况1:可以直接完成当前任务
// 条件:当前累计体力不超过b
sum += a; // 更新累计体力
pq.push(a); // 将当前任务的a加入堆
} else if (!pq.empty() && pq.top() > a) {
// 情况2:不能直接完成,但可以替换
// 条件1:堆不为空(已有选中的任务)
// 条件2:当前任务的a小于堆顶(已选任务中最大的a)
// 反悔操作:用当前任务替换堆顶任务
sum -= pq.top(); // 从累计体力中减去被替换任务的a
pq.pop(); // 弹出堆顶
sum += a; // 加上当前任务的a
pq.push(a); // 将当前任务的a加入堆
}
}
// 堆的大小就是最多能完成的任务数
cout << pq.size() << '\n';
return 0;
}
算法深度解析
贪心策略的直观理解
-
为什么按b_i排序:b_i小的任务更"挑剔",必须尽早处理
-
为什么使用最大堆:需要快速找到已选任务中a_i最大的,以便在反悔时替换
-
反悔机制的作用:允许我们用更好的任务(a_i更小)替换已选的较差任务
反悔机制的工作原理
反悔机制是一种"延迟决策"的策略:
-
先贪心地选择看起来不错的任务
-
当遇到更好的任务时,可以"反悔"之前的某些选择
-
用更好的任务替换较差的任务
-
保持已选任务数量不变,但优化了整体质量
算法执行示例
假设有任务:任务1(a=5,b=1), 任务2(a=1,b=2)
cpp
初始:sum=0, 堆=[]
按b排序后:[任务1, 任务2]
步骤1:处理任务1
sum=0 ≤ b=1,选择任务1
sum=0+5=5, 堆=[5]
步骤2:处理任务2
sum=5 > b=2,不能直接选择
检查反悔条件:堆顶=5 > a=1,满足
反悔操作:sum=5-5+1=1, 堆=[1]
最终结果:完成1个任务(任务2)
正确性分析
为什么不是100%正确?
这个算法在大多数情况下表现良好,但存在反例:
反例1:
cpp
任务1: a=5, b=1
任务2: a=1, b=2
算法结果:1个任务
最优解:2个任务(先做任务2,再做任务1)
反例2:
cpp
任务1: a=4, b=1
任务2: a=3, b=2
任务3: a=2, b=3
算法可能不是最优
根本问题
-
排序依据不全面:只考虑了b_i,忽略了a_i
-
局部最优不一定全局最优:贪心选择的每一步都是当前最优,但最终结果不一定最优
-
反悔机制有限:反悔只能替换一个任务,不能改变整体顺序
复杂度分析
时间复杂度
-
排序:O(n log n)
-
遍历+堆操作:每个任务最多一次入堆和一次出堆,每次O(log n),总共O(n log n)
-
总时间复杂度:O(n log n)
空间复杂度
-
存储任务:O(n)
-
最大堆:最坏情况O(n)
-
总空间复杂度:O(n)
测试结果
-
通过率:80%,能处理大规模数据,但并非总是最优
-
适用场景:对精度要求不苛刻的场合
-
局限性:竞赛中需要100%正确的算法
第三阶段:理论突破 - 按a_i+b_i排序+反悔机制
思路的质变
经过深入思考,我发现了问题的数学本质。从约束条件出发:
cpp
条件:sum ≤ b_i
两边同时加上a_i:
cpp
sum + a_i ≤ b_i + a_i
这个变形揭示了关键信息:任务的完成时间必须不超过a_i+b_i。
问题转化
令d_i = a_i + b_i,则原问题转化为:
有n个任务,每个任务需要时间a_i,必须在时间d_i之前完成。问最多能完成多少个任务。
这是经典的带截止时间的单机调度问题,有成熟的最优解法。
算法设计思路
-
排序策略:按d_i = a_i + b_i从小到大排序(截止时间早的优先)
-
贪心选择:依次处理任务,如果能完成就直接完成
-
反悔机制:如果不能完成,但当前任务的a_i比已选任务中最大的a_i小,就替换
代码实现详解
cpp
#include<iostream>
#include<bits/stdc++.h>
using namespace std;
typedef long long ll; // 使用长整型,防止整数溢出
// 自定义比较函数,用于对任务进行排序
// 比较规则:按照任务的第一个属性(first)和第二个属性(second)之和进行升序排序
bool compare(const pair<int,int> &a, const pair<int,int> &b)
{
return a.first + a.second < b.first + b.second; // 按a+b从小到大排序
}
int main()
{
int n; // 任务数量
cin >> n; // 输入任务数量
// 使用vector存储任务,每个任务是一个pair,包含两个整数属性
vector<pair<int,int>> tasks(n);
// 输入每个任务的两个属性
for(int i = 0; i < n; i++) // 修正:原代码中for循环缺少i的初始化
{
// 输入第i个任务的两个属性
cin >> tasks[i].first >> tasks[i].second;
}
// 对任务按照自定义规则进行排序
// 排序后,任务按照a+b的和从小到大排列
sort(tasks.begin(), tasks.end(), compare);
// 定义一个最大堆(大顶堆),用于存储已选择任务的第一个属性a
// 堆顶始终是已选择任务中最大的a值
priority_queue<int> maxHeap;
ll sum = 0; // 记录当前已选择任务的所有a值之和
// 遍历排序后的所有任务
for(int i = 0; i < n; i++)
{
// 获取当前任务的两个属性
ll a = tasks[i].first; // 当前任务的第一个属性
ll b = tasks[i].second; // 当前任务的第二个属性
// 条件判断:如果当前任务的b值 >= 当前已选择任务的a值之和
// 说明可以选择当前任务,不会违反某种约束条件
if(b >= sum)
{
// 选择当前任务
maxHeap.push(a); // 将当前任务的a值加入最大堆
sum += a; // 更新已选择任务的a值总和
}
else
{
// 当前任务的b值 < 当前已选择任务的a值之和
// 不能直接选择当前任务,但可以考虑替换
// 如果堆不为空,且当前任务的a值小于堆顶的a值(已选择任务中最大的a)
if(!maxHeap.empty() && maxHeap.top() > a)
{
// 用当前任务替换堆顶任务
sum -= maxHeap.top(); // 从总和中减去堆顶任务的a值
maxHeap.pop(); // 移除堆顶任务(即已选择任务中a值最大的任务)
maxHeap.push(a); // 将当前任务的a值加入堆
sum += a; // 更新总和,加上当前任务的a值
}
// 如果当前任务的a值 >= 堆顶任务的a值,则不进行替换
}
}
// 输出最终选择的任务数量,即堆的大小
cout << maxHeap.size() << endl; // 修正:添加endl确保输出换行
return 0;
}
算法深度解析
为什么按a_i+b_i排序?
-
数学推导 :从
sum ≤ b_i推出sum + a_i ≤ a_i + b_i -
物理意义 :
a_i + b_i是任务的绝对截止时间 -
调度理论:按截止时间排序是单机调度问题的最优策略
反悔机制的正确性
-
保持数量:替换操作不改变已选任务数量
-
优化质量:用耗时短的任务替换耗时长任务,减少总耗时
-
为后续创造机会:总耗时减少后,后续任务更容易满足条件
算法执行示例
以之前的反例为例:
cpp
任务1: a=5, b=1 → d=6
任务2: a=1, b=2 → d=3
按d排序后:[任务2(d=3), 任务1(d=6)]
步骤1:处理任务2
sum=0 ≤ b=2,选择任务2
sum=0+1=1, 堆=[1]
步骤2:处理任务1
sum=1 ≤ b=1,选择任务1
sum=1+5=6, 堆=[5,1]
最终结果:完成2个任务,最优!
正确性证明
数学证明概要
-
排序最优性:可以证明,如果存在最优解,总可以将其调整为按d_i排序的顺序而不减少完成的任务数
-
反悔最优性:在按d_i排序的基础上,反悔机制能保证每一步都是当前最优
-
全局最优:结合以上两点,算法能得到全局最优解
反例验证
所有已知的反例都能被这个算法正确处理,包括:
-
按b_i排序失败的反例
-
按a_i排序失败的反例
-
其他组合排序失败的反例
复杂度分析
时间复杂度
-
排序:O(n log n)
-
遍历+堆操作:每个任务最多一次入堆和一次出堆,每次O(log n)
-
总时间复杂度:O(n log n)
空间复杂度
-
存储任务:O(n)
-
最大堆:最坏情况O(n)
-
总空间复杂度:O(n)
可处理数据规模
-
n=1e5时:log n ≈ 16.6,n log n ≈ 1.66×10⁶,完全可行
-
n=1e6时:n log n ≈ 2×10⁷,依然可行
测试结果
-
通过率:100%,所有测试点通过
-
效率:在最大数据规模下运行时间远小于限制
-
正确性:理论保证总是最优
三种算法的全面对比
性能对比表
| 算法 | 时间复杂度 | 空间复杂度 | 通过率 | 优点 | 缺点 |
|---|---|---|---|---|---|
| DFS暴力搜索 | O(n!) | O(n) | 6.5% | 保证最优解 实现简单 | 只能处理小数据 实际不可用 |
| 按b_i排序+反悔 | O(n log n) | O(n) | 80% | 高效 多数情况正确 | 不是理论最优 存在反例 |
| 按a_i+b_i排序+反悔 | O(n log n) | O(n) | 100% | 高效 总是最优 | 需要数学洞察 |
适用场景分析
-
DFS暴力搜索:
-
仅用于验证小规模数据的正确性
-
作为其他算法的正确性基准
-
教学和理解问题本质
-
-
按b_i排序+反悔:
-
对精度要求不高的实际应用
-
快速得到一个较好的解
-
算法竞赛中的部分分
-
-
按a_i+b_i排序+反悔:
-
算法竞赛的满分解法
-
对精度有严格要求的应用
-
需要理论保证的场景
-
关键测试用例分析
测试用例1:基本功能测试
cpp
输入:
4
3 3
2 2
1 1
10 1
输出:3
解释:最优顺序是任务3(1,1)→任务2(2,2)→任务1(3,3),可以完成3个任务。
测试用例2:关键反例
cpp
输入:
2
5 1
1 2
输出:2
解释:按b排序的算法只能得到1,但按a+b排序的算法能得到最优解2。
测试用例3:复杂情况
cpp
输入:
5
5 1
4 2
3 3
2 4
1 5
输出:5
解释:所有任务都可以按某种顺序完成。
测试用例4:边界情况
cpp
输入:
1
100000 1
输出:1 或 0
解释:只有一个任务,如果能完成就输出1,否则输出0。

算法设计的心得体会
1. 从简单开始
不要一开始就追求最优解法。从最简单的暴力搜索开始,可以帮助我们:
-
理解问题本质
-
验证后续算法的正确性
-
建立对问题的直观感受
2. 寻找模式
观察暴力解法的执行过程,寻找可以优化的模式:
-
哪些计算是重复的?
-
有没有明显的贪心选择?
-
能否利用问题的特殊结构?
3. 大胆猜想,小心验证
对于贪心策略:
-
先提出一个直观的猜想
-
实现并测试
-
寻找反例验证
-
如果发现反例,分析原因并改进
4. 数学是关键
很多算法问题的突破来自于数学洞察:
-
将问题形式化
-
进行数学推导
-
寻找等价问题
-
借鉴已知理论
5. 反悔机制的威力
反悔机制是一种强大的优化技术:
-
允许在贪心的基础上进行优化
-
保持当前解的质量
-
为未来留下优化空间
-
在很多调度、选择问题中有效
6. 理论与实现的结合
-
理论指导实现方向
-
实现验证理论正确性
-
在理论和实践之间反复迭代
对算法学习者的建议
1. 打好基础
-
熟练掌握基本数据结构和算法
-
理解常见算法设计范式
-
培养分析问题复杂度的能力
2. 多做多练
-
从简单题目开始,逐步提高难度
-
每道题尝试多种解法
-
比较不同解法的优劣
3. 深入思考
-
不仅要做出题目,更要理解为什么
-
探索不同的解题思路
-
总结同类问题的解题模式
4. 学会调试
-
构造测试用例验证算法
-
使用调试工具分析程序行为
-
学会通过日志输出理解程序执行
5. 注重理论
-
学习算法分析的基本方法
-
理解常见问题的理论背景
-
掌握基本的证明技巧
6. 保持耐心
-
算法学习是一个长期过程
-
遇到困难是正常的
-
每个难题的解决都是成长的机会
扩展思考
1. 问题变体
如果问题条件变化,算法如何调整?
-
每个任务有不同权重,最大化权重和
-
任务有依赖关系,必须先完成某些任务
-
有多个处理器可以并行处理任务
2. 算法优化
现有算法还有优化空间吗?
-
能否进一步降低时间复杂度?
-
能否减少空间使用?
-
能否并行化处理?
3. 实际应用
这个问题在实际中有什么应用?
-
项目调度与资源分配
-
处理器任务调度
-
生产计划安排
总结
这道任务调度问题的解题历程,完美展现了算法设计的完整思考过程:
-
从暴力开始:理解问题,建立基准
-
贪心尝试:寻找启发式策略,提高效率
-
发现反例:意识到贪心的局限性
-
数学突破:通过数学推导发现本质
-
理论应用:借鉴经典问题的解法
-
实现优化:加入反悔机制进一步提高
-
最终成功:得到高效且正确的算法
这个过程告诉我们:
-
没有一步到位的完美解法
-
每个失败都是通向成功的阶梯
-
深入思考比盲目尝试更重要
-
理论基础指导实践方向
在算法的世界里,最宝贵的不是记住多少解法,而是培养出解决问题的思维能力。希望这道题的解题历程,能为你提供一些启发和帮助,让你在算法学习的道路上走得更远、更稳。