目录
引言
才写完信息学奥赛一本通1422:【例题1】活动安排,详情见https://blog.youkuaiyun.com/2301_80881806/article/details/145433913?spm=1001.2014.3001.5501
感觉自己的方法比较笨拙,比较常规,或者说是比较暴力,所以说上网搜寻了一下一些优秀做法,果不其然,找到了非常简短的解题思路,让我大为赞叹。https://www.cnblogs.com/fashpoint/p/11309412.html
原题
题目描述
某工厂收到了 n
个产品的订单,这些产品需要分别在 A 和 B 两个车间进行加工,并且必须先在 A 车间加工后才可以到 B 车间加工。每个产品在 A 车间和 B 车间的加工时间分别为 Ai
和 Bi
。如何安排这 n
个产品的加工顺序,才能使总的加工时间最短?
这里所说的加工时间是指:从开始加工第一个产品到最后所有的产品都已在 A、B 两车间加工完毕的时间。
输入格式:
- 第一行仅一个数据
n
,表示产品的数量; - 接下来的
n
个数据是表示这n
个产品在 A 车间加工各自所需的时间; - 最后的
n
个数据是表示这n
个产品在 B 车间加工各自所需的时间。
输出格式:
- 第一行一个数据,表示最少的加工时间;
- 第二行是一种最小加工时间的加工顺序。
输入样例:
5
3 5 8 7 10
6 2 1 4 9
输出样例:
34
1 5 4 2 3
我的思路以及代码
思路
参照常规的思路,约翰逊法则,引用一下基本思路,图片取自以下文章https://blog.youkuaiyun.com/weixin_68261415/article/details/129013266
代码
#include <bits/stdc++.h>
using namespace std;
struct Product {
int id;
int timeA;
int timeB;
};
// 比较函数,用于对组 1 按在 A 车间加工时间升序排序
bool cmpA(const Product& p1, const Product& p2) {
return p1.timeA < p2.timeA;
}
// 比较函数,用于对组 2 按在 B 车间加工时间降序排序
bool cmpB(const Product& p1, const Product& p2) {
return p1.timeB > p2.timeB;
}
int main() {
int n;
cin >> n; // 读取产品数量
vector<Product> products(n);
for (int i = 0; i < n; ++i) {
products[i].id = i + 1;
cin >> products[i].timeA; // 读取每个产品在 A 车间的加工时间
}
for (int i = 0; i < n; ++i) {
cin >> products[i].timeB; // 读取每个产品在 B 车间的加工时间
}
vector<Product> group1, group2;
for (const auto& product : products) {
if (product.timeA <= product.timeB) {
group1.push_back(product);
} else {
group2.push_back(product);
}
}
// 对组 1 按在 A 车间加工时间升序排序
sort(group1.begin(), group1.end(), cmpA);
// 对组 2 按在 B 车间加工时间降序排序
sort(group2.begin(), group2.end(), cmpB);
// 合并组 1 和组 2
vector<Product> schedule;
schedule.insert(schedule.end(), group1.begin(), group1.end());
schedule.insert(schedule.end(), group2.begin(), group2.end());
// 计算总的加工时间
int timeA = 0, timeB = 0;
for (const auto& product : schedule) {
timeA += product.timeA;
timeB = max(timeB, timeA) + product.timeB;
}
cout << timeB << endl; // 输出最少的加工时间
for (const auto& product : schedule) {
cout << product.id << " "; // 输出加工顺序
}
cout << endl;
return 0;
}
就是基本的约翰逊法则思路,只不过先分成两组分别排序最后合并,而不是一个个放到最前或是最后。这样实际操作起来更简单,而且时间复杂度更低。
排序方式时间复杂度分析
方法一:分组排序法
这种方法的步骤如下:
-
分组:
- 将所有任务分成两组:组 1 和组 2。
- 组 1:在 A 车间加工时间小于等于在 B 车间加工时间的任务。
- 组 2:在 A 车间加工时间大于在 B 车间加工时间的任务。
-
排序:
- 对组 1 按在 A 车间加工时间升序排序。
- 对组 2 按在 B 车间加工时间降序排序。
-
合并:
- 将组 1 和组 2 合并,组 1 在前,组 2 在后。
方法二:逐个放置法
这种方法的步骤如下:
-
选择最小工时:
- 找到所有任务中最小的加工时间
t
。 - 如果
t
是在第一台机器上的加工时间,则将对应的任务排在最前面。 - 如果
t
是在第二台机器上的加工时间,则将对应的任务排在最后面。
- 找到所有任务中最小的加工时间
-
划去已排序的任务:
- 将已排序的任务从任务列表中移除。
-
重复上述步骤:
- 对剩余的任务重复上述步骤,直到所有任务都排序完毕。
两种方案时间复杂度分析
方法一:分组排序法
-
分组:
- 遍历所有任务,将它们分成两组。时间复杂度为
O(n)
。
- 遍历所有任务,将它们分成两组。时间复杂度为
-
排序:
- 对组 1 进行排序,时间复杂度为
O(n log n)
。 - 对组 2 进行排序,时间复杂度为
O(n log n)
。
- 对组 1 进行排序,时间复杂度为
-
合并:
- 将两个已排序的组合并,时间复杂度为
O(n)
。
- 将两个已排序的组合并,时间复杂度为
总时间复杂度为 O(n) + O(n log n) + O(n log n) + O(n) = O(n log n)
。
方法二:逐个放置法
-
选择最小工时:
- 每次选择最小的加工时间,时间复杂度为
O(n)
。
- 每次选择最小的加工时间,时间复杂度为
-
划去已排序的任务:
- 将已排序的任务从任务列表中移除,时间复杂度为
O(n)
。
- 将已排序的任务从任务列表中移除,时间复杂度为
-
重复上述步骤:
- 对剩余的任务重复上述步骤,直到所有任务都排序完毕。总共需要进行 n 次选择和移除操作。
总时间复杂度为 O(n^2)
。
结论
- 分组排序法:时间复杂度为
O(n log n)
。 - 逐个放置法:时间复杂度为
O(n^2)
。
因此,分组排序法的时间复杂度比逐个放置法低,效率更高。当然,这里没什么好讲的,一般人都会这么做,我相信很少有人纯模拟约翰逊法则一个个取最小来进行排序。
核心原理------计算任意产品加工完的时间
上篇文章我已经讲了,这里简单复制一下
# 关键代码解析
// 计算总的加工时间
int timeA = 0, timeB = 0;
for (const auto& product : schedule) {
timeA += product.timeA;
timeB = max(timeB, timeA) + product.timeB;
}
详细解释
int timeA = 0, timeB = 0;
:初始化两个变量timeA
和timeB
,分别表示当前在 A 车间和 B 车间的总加工时间。for (const auto& product : schedule)
:遍历所有产品,按照之前确定的加工顺序schedule
。timeA += product.timeA;
:将当前产品在 A 车间的加工时间product.timeA
加到timeA
上,表示当前产品在 A 车间加工完毕后的总时间。timeB = max(timeB, timeA) + product.timeB;
:max(timeB, timeA)
:确保 B 车间的加工时间不会早于 A 车间的加工时间,因为产品必须先在 A 车间加工完毕才能进入 B 车间。+ product.timeB
:将当前产品在 B 车间的加工时间product.timeB
加到timeB
上,表示当前产品在 B 车间加工完毕后的总时间。
示例
假设有三个产品,按照 schedule
的顺序如下:
产品 | A 车间时间 | B 车间时间 |
---|---|---|
1 | 3 | 6 |
2 | 5 | 2 |
3 | 8 | 1 |
计算过程如下:
timeA = 0 + 3 = 3
timeB = max(0, 3) + 6 = 9
timeA = 3 + 5 = 8
timeB = max(9, 8) + 2 = 11
timeA = 8 + 8 = 16
timeB = max(11, 16) + 1 = 17
最终,总的加工时间 timeB
为 17。
原理总结
简而言之,对于任意一个产品,先把它在A工厂加工的时间直接加上,算出他在A工厂出来的时间节点。但是,从A工厂出来并不意味着能直接进入B工厂加工,根据上一个产品最终加工完成的时间,也就是B工厂空闲下来的时间,取一个max,从而得到最终进入B工厂加工的时间。再加上在B工厂加工的时间,得到最后的总时间。
大佬的博客代码(添上了点注释)
#include<iostream>
#include<cstdio>
#include<algorithm>
#include<cmath>
using namespace std;
int n, ans; // n 表示任务数量,ans 表示最终的最小加工时间
struct Node {
int a, b, c; // a 表示在 A 机器上的加工时间,b 表示在 B 机器上的加工时间,c 表示任务的原始顺序
} x[1500];
// 比较函数,用于对任务进行排序
bool cmp(Node x, Node y) {
return min(y.b, x.a) < min(x.b, y.a);
}
int main() {
scanf("%d", &n); // 读取任务数量
for (int i = 1; i <= n; i++) scanf("%d", &x[i].a); // 读取每个任务在 A 机器的加工时间
for (int i = 1; i <= n; i++) {
scanf("%d", &x[i].b); // 读取每个任务在 B 机器的加工时间
x[i].c = i; // 记录任务的原始顺序
}
// 使用 sort 函数对任务进行排序,排序依据是 cmp 比较函数
sort(x + 1, x + n + 1, cmp);
int ta = 0, tb = 0; // ta 表示当前在 A 机器的总加工时间,tb 表示当前在 B 机器的总加工时间
for (int i = 1; i <= n; i++) {
ta += x[i].a; // 更新 A 机器的总加工时间
tb = max(ta, tb) + x[i].b; // 更新 B 机器的总加工时间,确保 B 机器的加工时间不会早于 A 机器的加工时间
}
printf("%d\n", tb); // 输出最少的加工时间
for (int i = 1; i <= n; i++) printf("%d ", x[i].c); // 输出加工顺序
return 0;
}
bool cmp(Node x, Node y) {
return min(y.b, x.a) < min(x.b, y.a);
}
其中,这段比较规则是整个代码的关键,它通过一个简洁而高效的表达式,成功地制定了任务的排序规则,从而确保了任务的最优排列。可以说这是整篇代码的精髓所在。之后的内容就是把排完序的任务一个一个取出来进行时间计算,与我代码的后半段基本一致。
证明过程(反证法验证)
详细正向证明见高手的博客,我这里不详细赘述。总而言之,要想正儿八经推出这个简短而精确的式子,可谓是不简单,我看证明都看了老半天,更别提自己证了。所以,我这里提供一下我的反证法,来反向验证这个式子的正确性。
针对甲乙两个产品,我们对其在AB工厂的加工时间分别设为甲:a,b 乙:c,d
针对这行代码
bool cmp(Node x, Node y) {
return min(y.b, x.a) < min(x.b, y.a);
}
翻译结果就是min(d,a)<min(b,c)
这是得出的结论,现在我们将其视作条件来反证
如果甲排在乙产品前面,那么依据核心原理(见上面),两个产品依次完成的时间为a+max(b,c)+d
如果乙排在甲前面,那么总时间为c+max(a,d)+b
要想证明a+max(b,c)+d<c+max(a,d)+b
即证明c+b-(a+d)+max(a,d)-max(b,c)>0
为了分析不等式
c + b - (a + d) + max(a, d) - max(b, c) > 0
对于所有 a, b, c, d 为正整数的情况是否恒成立,我们需要仔细考虑不同情况下 max(a, d) 和 max(b, c) 的取值,并进行详细的数学推导。
分析步骤
-
简化不等式: c + b - a - d + max(a, d) - max(b, c) > 0
-
考虑不同的最大值情况:
情况 1: a ≥ d 且 b ≥ c
- max(a, d) = a
- max(b, c) = b
代入不等式: c + b - a - d + a - b > 0 简化后: c - d > 0 即: c > d
代入条件 min(d, a) < min(b, c): 由于 a ≥ d,则 min(d, a) = d。 因此,条件变为 d < min(b, c)。
因为 c > d 已经满足了条件 d < min(b, c),所以该不等式在这种情况下成立。
情况 2: a ≥ d 且 b < c
- max(a, d) = a
- max(b, c) = c
代入不等式: c + b - a - d + a - c > 0 简化后: b - d > 0 即: b > d
代入条件 min(d, a) < min(b, c): 由于 a ≥ d,则 min(d, a) = d。 因此,条件变为 d < min(b, c)。
因为 b > d 已经满足了条件 d < min(b, c),所以该不等式在这种情况下也成立。
情况 3: a < d 且 b ≥ c
- max(a, d) = d
- max(b, c) = b
代入不等式: c + b - a - d + d - b > 0 简化后: c - a > 0 即: c > a
代入条件 min(d, a) < min(b, c): 由于 a < d,则 min(d, a) = a。 因此,条件变为 a < min(b, c)。
因为 c > a 已经满足了条件 a < min(b, c),所以该不等式在这种情况下成立。
情况 4: a < d 且 b < c
- max(a, d) = d
- max(b, c) = c
代入不等式: c + b - a - d + d - c > 0 简化后: b - a > 0 即: b > a
代入条件 min(d, a) < min(b, c): 由于 a < d,则 min(d, a) = a。 因此,条件变为 a < min(b, c)。
因为 b > a 已经满足了条件 a < min(b, c),所以该不等式在这种情况下也成立。
总结与结论
通过上述四种情况的分析,并代入条件 min(d, a) < min(b, c),我们可以得出以下结论:
- 在情况 1 中,需要满足 c > d,并且该条件在 d < min(b, c) 下成立。
- 在情况 2 中,需要满足 b > d,并且该条件在 d < min(b, c) 下成立。
- 在情况 3 中,需要满足 c > a,并且该条件在 a < min(b, c) 下成立。
- 在情况 4 中,需要满足 b > a,并且该条件在 a < min(b, c) 下成立。
因此,在给定条件 min(d, a) < min(b, c) 下,原不等式对任意正整数 a, b, c, d 都成立。
正常逻辑推理过程:
Johnson's Rule 的数学证明基于交换论证法。交换论证法的核心思想是通过交换两个任务的位置,证明交换后的顺序不会比原来的顺序更差,从而证明原来的顺序是最优的。
交换论证法
假设有两个任务 i
和 j
,它们在 A 机器和 B 机器上的加工时间分别为 a_i, b_i
和 a_j, b_j
。我们需要证明,如果 x.a + max(x.b, y.a) + y.b < y.a + max(x.a, y.b) + x.b,那么任务 i
应该排在任务 j
的前面。
-
假设当前顺序是
i
在前,j
在后:- 总加工时间为
T(i, j) = a_i + max(b_i, a_j) + b_j
。
- 总加工时间为
-
交换顺序,假设
j
在前,i
在后:- 总加工时间为
T(j, i) = a_j + max(b_j, a_i) + b_i
。
- 总加工时间为
-
比较两种顺序的总加工时间:
- 如果 x.a + max(x.b, y.a) + y.b < y.a + max(x.a, y.b) + x.b,则
T(i, j) <= T(j, i)
。
- 如果 x.a + max(x.b, y.a) + y.b < y.a + max(x.a, y.b) + x.b,则
通过交换论证法,可以证明 Johnson's Rule 的比较函数 x.a + max(x.b, y.a) + y.b < y.a + max(x.a, y.b) + x.b 能够确保局部最优,从而推广到全局最优。
思考与改进------直接根据目的构造cmp函数来排序
要想经过正常的证明,得到这样简单的排序规则实在是有些难
bool cmp(Node x, Node y) {
return min(y.b, x.a) < min(x.b, y.a);
}
我们能不能不知道这个公式的情况下,也将这题做出来?
答案是可以的。
推导 cmp 排序逻辑
为了确保总的加工时间最短,我们需要确定任务的最佳顺序。我们通过比较两个任务 x 和 y 的加工时间,来确定它们的相对顺序。
比较两个任务的加工时间
假设有两个任务 x 和 y,它们在 A 机器和 B 机器上的加工时间分别为 x.a, x.b 和 y.a, y.b。我们需要比较以下两种情况:
-
任务 x 在前,任务 y 在后:
- 总加工时间为
T(x, y) = x.a + max(x.b, y.a) + y.b
。
- 总加工时间为
-
任务 y 在前,任务 x 在后:
- 总加工时间为
T(y, x) = y.a + max(y.b, x.a) + x.b
。
- 总加工时间为
比较公式
为了确定任务的最佳顺序,我们比较 T(x, y)
和 T(y, x)
:
bool cmp(Node x, Node y) {
return x.a + max(x.b, y.a) + y.b < y.a + max(x.a, y.b) + x.b;
}
- 如果 x.a + max(x.b, y.a) + y.b < y.a + max(x.a, y.b) + x.b,则任务 x 应该排在任务 y 的前面。
- 否则,任务 y 应该排在任务 x 的前面。
为什么这种排序逻辑能够确保整体最优?
-
贪心选择性质:
- 每一步都选择当前最优的解,这个选择依赖于当前的状态,不考虑未来的后果。
- 通过比较两个任务在不同机器上的加工时间,确定它们的相对顺序,从而确保每一步的选择都是局部最优的。
-
最优子结构性质:
- 一个问题的最优解包含其子问题的最优解。
- 通过对任务进行两两比较,并按照比较结果进行排序,可以确保每个子问题的最优解,从而构建出全局最优解。
因而,我们直接根据我们要实现的目的,推导出我们的cmp排序逻辑,从而简化问题。
与简化后的公式的时间复杂度对比
直接比较函数
bool cmp(Node x, Node y) {
return x.a + max(x.b, y.a) + y.b < y.a + max(x.a, y.b) + x.b;
}
大佬简化后的比较函数
bool cmp(Node x, Node y) {
return min(y.b, x.a) < min(x.b, y.a);
}
运行效率分析
-
原始比较函数:
- x.a + max(x.b, y.a) + y.b 和 y.a + max(x.a, y.b) + x.b 各包含一次 max 操作和三次加法操作。
- max 操作的时间复杂度是常数时间
O(1)
。 - 加法操作的时间复杂度也是常数时间
O(1)
。 - 因此,原始比较函数的时间复杂度是
O(1)
。
-
化简后的比较函数:
min(y.b, x.a)
和min(x.b, y.a)
各包含一次min
操作。min
操作的时间复杂度是常数时间O(1)
。- 因此,化简后的比较函数的时间复杂度是
O(1)
。
运行效率对比
从时间复杂度的角度来看,两个比较函数的时间复杂度都是常数时间 O(1)
。因此,在理论上,它们的运行效率是相同的。
实际运行效率
虽然两个比较函数的时间复杂度相同,但在实际运行中,化简后的比较函数可能会稍微快一些,因为它涉及的操作更少。具体来说:
- 原始比较函数包含两次 max 操作和六次加法操作。
- 化简后的比较函数包含两次
min
操作。
由于 min
和 max 操作的时间复杂度都是常数时间 O(1)
,但加法操作的数量不同,因此化简后的比较函数可能会稍微快一些。
对比总结
尽管化简后的比较函数在实际运行中可能稍微快一些,但由于两者的时间复杂度均为 O(1),这种差异通常可以忽略不计。对于数学方面不是很擅长地coder而言,直接构造原始比较函数可能是更为合理的选择。特别是当性能提升微乎其微时,花费大量精力进行化简并不值得。我们完全可以直接根据问题本身要我们比较的内容,直接制定cmp函数,来实现整体贪心算法的排序逻辑。
最终代码
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <cmath>
using namespace std;
int n, ans;
struct Node {
int a, b, c;
} x[1500];
// 比较函数,用于对任务进行排序
bool cmp(Node x, Node y) {
return x.a + max(x.b, y.a) + y.b < y.a + max(x.a, y.b) + x.b;
}
int main() {
scanf("%d", &n); // 读取任务数量
for (int i = 1; i <= n; i++) {
scanf("%d", &x[i].a); // 读取每个任务在 A 机器的加工时间
}
for (int i = 1; i <= n; i++) {
scanf("%d", &x[i].b); // 读取每个任务在 B 机器的加工时间
x[i].c = i; // 记录任务的原始顺序
}
// 使用 sort 函数对任务进行排序,排序依据是 cmp 比较函数
sort(x + 1, x + n + 1, cmp);
int ta = 0, tb = 0; // ta 表示当前在 A 机器的总加工时间,tb 表示当前在 B 机器的总加工时间
for (int i = 1; i <= n; i++) {
ta += x[i].a; // 更新 A 机器的总加工时间
tb = max(ta, tb) + x[i].b; // 更新 B 机器的总加工时间,确保 B 机器的加工时间不会早于 A 机器的加工时间
}
printf("%d\n", tb); // 输出最少的加工时间
for (int i = 1; i <= n; i++) {
printf("%d ", x[i].c); // 输出加工顺序
}
return 0;
}
一点点心得与体会
在解决调度问题时,贪心算法是一种非常有效的策略。通过贪心算法,我们可以直接根据问题的要求来制定比较函数 cmp,从而简化问题的解决过程。以下是我在使用贪心算法解决问题时的一些心得与体会:
-
直接根据问题要求制定比较函数:
- 在解决调度问题时,我们可以直接根据问题的要求来制定比较函数 cmp。例如,在这个问题中,我们需要比较两个任务在不同机器上的加工时间,从而确定它们的相对顺序。
- 通过比较两个任务的加工时间,我们可以确保每一步的选择都是局部最优的,从而构建出全局最优解。
-
简化问题的解决过程:
- 通过直接制定比较函数,我们可以简化问题的解决过程,而不需要进行复杂的数学化简。
- 这种方法不仅提高了代码的可读性和可维护性,还减少了出错的可能性。
-
数学化简可以省略:
- 虽然数学化简可以提高代码的运行效率,但在实际应用中,这种提升通常是微乎其微的。
- 因此,对于数学方面不是很擅长的开发者而言,直接根据问题要求制定比较函数可能是更为合理的选择。
-
贪心算法的核心思想:
- 贪心算法的核心思想是每一步都选择当前最优的解,这个选择依赖于当前的状态,不考虑未来的后果。
- 通过这种局部最优的选择,我们可以确保整体的最优解。