双机器调度问题(贪心)
代码模板先把“第一台快、第二台慢”的作业放前面,再把“第一台慢、第二台快”的作业放后面,让第二台机器尽量少等待。
一句话算法
先把“第一台快、第二台慢”的作业放前面,再把“第一台慢、第二台快”的作业放后面,让第二台机器尽量少等待。
问题模型
有
第
:在 上的加工时间; :在 上的加工时间。
现在可以重新排列作业顺序。目标是最小化最后一个作业在
设某个固定顺序下:
表示第
其中
核心直觉
第二台机器只有在两个条件都满足时才能加工当前作业:
- 当前作业已经从
上出来; 已经完成前一个作业。
所以真正要避免的是:
对于一个作业
- 若
,它能较快通过 ,但会在 上占较久,适合放前面,让 尽早忙起来。 - 若
,它在 上拖得久,在 上很快结束,适合放后面,避免一开始就让 等太久。
于是 Johnson 法则是:
算法步骤
- 将所有作业分成两类:
- 第一类:
; - 第二类:
。
- 第一类:
- 第一类按
从小到大排序。 - 第二类按
从大到小排序。 - 将第一类放在前面,第二类放在后面。
- 按这个顺序扫描,使用递推式计算最短完成时间:
等价写法
也可以每次在未安排作业中找最小的 $\min(a_i,b_i)$。如果最小值来自 $a_i$,就放到序列最前面的空位;如果来自 $b_i$,就放到最后面的空位。这个写法和上面的分类排序等价。
算法证明
证明使用相邻交换法:如果两个相邻作业不符合 Johnson 顺序,交换它们不会使答案变差。不断交换相邻逆序对后,就能得到 Johnson 顺序,因此 Johnson 顺序最优。
难点在于,直接交换相邻两个作业时,后面的
目标函数变形
展开递推式:
所以:
对固定的
记:
则:
于是:
为什么这一步重要
原来的 $C_i$ 在交换后可能影响一长段后缀;换成 $A_k-B_{k-1}$ 后,交换相邻两项只影响两个位置。这样相邻交换证明才变成局部问题。
相邻交换只影响两个候选值
考虑相邻两个作业:
它们位于第
对于
对于
所以只需要比较第
若顺序为
若顺序为
因此
推出 Johnson 顺序
下面验证 Johnson 法则中的三种情况。
情况一:两个作业都满足
若
又有
而:
故
情况二:两个作业都满足
若
又因为
右边最大值中的第二项同时不小于左边最大值中的两项:
因此:
第二类内部应按
情况三:
若:
则:
因此:
另一方面,右边:
显然不小于
所以右边也不小于
于是
三种情况合起来,正好得到 Johnson 法则。证明完成。
复杂度分析
排序需要
- 时间复杂度:
。 - 空间复杂度:
,用于保存作业。
如果只需要答案,不需要输出顺序,仍然需要保存作业用于排序。
代码实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;
struct Job {
int id;
long long a;
long long b;
};
bool johnson_cmp(const Job& x, const Job& y) {
const bool x_front = x.a <= x.b;
const bool y_front = y.a <= y.b;
if (x_front != y_front) return x_front > y_front;
if (x_front) return x.a < y.a;
return x.b > y.b;
}
long long finish_time(const vector<Job>& jobs) {
long long finish_m1 = 0;
long long finish_m2 = 0;
for (const Job& job : jobs) {
finish_m1 += job.a;
finish_m2 = max(finish_m2, finish_m1) + job.b;
}
return finish_m2;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<Job> jobs(n);
for (int i = 0; i < n; ++i) {
cin >> jobs[i].a >> jobs[i].b;
jobs[i].id = i + 1;
}
sort(jobs.begin(), jobs.end(), johnson_cmp);
cout << finish_time(jobs) << '\n';
for (int i = 0; i < n; ++i) {
if (i) cout << ' ';
cout << jobs[i].id;
}
cout << '\n';
return 0;
}
并列作业(比较器判等)的先后不是契约:std::sort 与 Timsort 的稳定性不同,输出顺序可能不同但都合法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# 两台机器流水作业调度(Johnson 法):按 min(a, b) 排序后顺序通过两台机器。
# 排序规则:a <= b 的"前段作业"在前(按 a 升序),a > b 的"后段作业"在后(按 b 降序)。
# C++ 用 long long 防止 finish_m1 累加溢出 int;Python int 任意精度,该坑不存在。
from functools import cmp_to_key
type Jobs = list[Job]
class Job:
"""id 是输入顺序(1 开始),仅用于输出加工顺序;a、b 是两台机器的加工时长。"""
def __init__(self, id: int, a: int, b: int) -> None:
self.id = id
self.a = a
self.b = b
def johnson_cmp(x: Job, y: Job) -> bool:
# 对应 C++ 的严格弱序比较器:返回 True 表示 x 应排在 y 前面。
x_front = x.a <= x.b
y_front = y.a <= y.b
if x_front != y_front:
return x_front and not y_front
if x_front:
return x.a < y.a
return x.b > y.b
def sort_jobs(jobs: Jobs) -> None:
# C++ 的 sort 直接吃“是否更靠前”的严格弱序比较器;cmp_to_key 需要三向比较,
# 这里用 johnson_cmp(x, y) / johnson_cmp(y, x) 拼出 -1/0/1,保持 johnson_cmp 本身与 C++ 同签名。
# 并列作业(比较器判等)的先后不是契约:std::sort 不稳定、Timsort 稳定,两边可能不同但都合法。
def three_way(x: Job, y: Job) -> int:
if johnson_cmp(x, y):
return -1
if johnson_cmp(y, x):
return 1
return 0
jobs.sort(key=cmp_to_key(three_way))
def finish_time(jobs: Jobs) -> int:
finish_m1 = 0
finish_m2 = 0
for job in jobs:
finish_m1 += job.a
# 机器 2 必须等:作业上机时刻是 max(finish_m2, finish_m1),再加工 job.b。
finish_m2 = max(finish_m2, finish_m1) + job.b
return finish_m2
测试用例
输入:
5
2 5
4 2
3 3
6 1
1 7
Johnson 分类:
:作业 、 、 ,按 升序得到 5 1 3;:作业 、 ,按 降序得到 2 4。
最终顺序:
5 1 3 2 4
逐步计算:
| 位置 | 作业 | ||
|---|---|---|---|
| 1 | 5 | 1 | 8 |
| 2 | 1 | 3 | 13 |
| 3 | 3 | 6 | 16 |
| 4 | 2 | 10 | 18 |
| 5 | 4 | 16 | 19 |
输出:
19
5 1 3 2 4
与皇后游戏的关系
luogu-P2123 “皇后游戏”虽然题面不是机器调度,但它的递推式是:
这和两台机器流水作业调度完全一致:
对应第 个作业在 上完成的时间; 对应第 个作业在 上完成的时间; 对应第 个作业在 上的加工时间。
所以皇后游戏的排序规则也是:
a <= b 的放前面,按 a 升序
a > b 的放后面,按 b 降序
多组数据时,只需要对每组分别排序并扫描。答案可能很大,使用 long long。
常见误区
一、按 升序排序
这个规则只看单个作业总时间,没有考虑两台机器之间的衔接。流水作业调度的关键不是单个作业短不短,而是
二、按 排序
先按 a <= b 分前后两类;
前一类按 a 升序;
后一类按 b 降序。
三、套到三台及以上机器
Johnson 法则直接解决的是两台机器流水作业调度。三台及以上机器的调度问题更复杂,不能直接套这个比较器。
应用分类详解
Johnson 法则的本质是:当每个任务都必须经过两个连续阶段时,用相邻交换推出一个全局最优排序。
一、两机流水作业调度
典型模式: 每个任务必须先经过阶段一,再经过阶段二,两个阶段各有一个耗时。
识别信号: 题面出现“两台机器”“先 A 后 B”“流水线”“最后完成时间最小”。
核心建模: 第一阶段的前缀和是到达第二阶段的时间,第二阶段用 max(上一任务完成时间, 当前任务到达时间) 递推。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 两台机器加工 | 通用 Johnson 模型 |
二、伪装成递推的最值问题
典型模式: 题面给出两个数 max(前一个状态, 前缀和)+b_i。
识别信号: 出现类似
核心建模: 把
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 皇后游戏 | luogu-P2123 | 把奖金递推转成两机流水作业调度 |
三、相邻交换证明排序规则
典型模式: 需要安排一个顺序,直接看全局很难,但可以比较相邻两项交换前后的最坏值。
识别信号: “任意排列”“最小化最大值”“相邻交换后前缀和后缀结构可化简”。
核心建模: 先把目标函数化成只受相邻两项影响的局部候选值,再推出比较器。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| Johnson 证明 | 本文模型 | 将目标化成 |
| 排序贪心证明 | 排序不等式 | 用交换前后的差值推出排序方向 |
经典例题
-
luogu-P2123 皇后游戏。题面是奖金递推,本质是两机流水作业调度。重点是识别
。 -
两台机器流水作业调度 给出
个作业的 ,要求最小化最后完成时间。直接套 Johnson 法则。 -
排序贪心证明题 如果题目允许重排,并且相邻交换能推出一个比较规则,可以优先尝试相邻交换法。排序不等式是最基础的例子,Johnson 法则是“先变形目标函数再交换”的例子。
参考
- Johnson, S. M. “Optimal two- and three-stage production schedules with setup times included.” Naval Research Logistics Quarterly, 1954.
- luogu-P2123
- 排序不等式