1 从两台机器的等待说起 2 问题模型 3 完工时刻怎么算 3.1 闭式解:瓶颈转折点 4 邻项交换:推出比较式 4.1 两种顺序的完工时刻 4.2 化简 5 陷阱:这个比较式不能直接 sort 5.1 先看它的「不可比」是什么 5.2 这个断链会真的算错 6 Johnson 算法:集合划分恢复全序 6.1 用键理解它 7 物理直觉:开头抢启动,结尾缩尾巴 8 两种等价写法 9 代码模板 10 复杂度 11 题型信号 12 推荐练习 13 参考资料 14 相关题解 1 从两台机器的等待说起
你已经会用邻项交换论证了:把相邻两个元素换个位置,让「换之前不劣于换之后」的条件化简成一个比较式,再用这个比较式排序。这套方法在国王游戏、给树染色里都用过。
双机流水调度是这条路上最经典的一道坎。它的交换论证只比前面几题多一步代数,但推出来的比较式不能直接丢进 std::sort ——这正是它值得单独用一篇文章讲的原因。
说明 这篇和前面几篇的关系
国王游戏 与 给树染色 都是「排序不等式」模型:邻项交换推出的比较式天然满足严格弱序,可以直接 sort。本文是反例:比较式推对了,却不是 严格弱序,必须再做一次集合划分。建议先读完那两篇再看这里。
2 问题模型
有 n n n 个作业,每个作业 i i i 都要先在机器 A A A 上加工 a i a_i a i 时间,再 到机器 B B B 上加工 b i b_i b i 时间。同一台机器同一时刻只能加工一个作业。
要求给出一个加工顺序 π = ( π 1 , … , π n ) \pi=(\pi_1,\dots,\pi_n) π = ( π 1 , … , π n ) ,使所有作业在机器 B B B 上全部完工的时刻 C max C_{\max} C m a x 最小。
定义 双机流水作业调度 F 2 ∥ C max F2 \parallel C_{\max} F 2 ∥ C m a x
两台机器、所有作业走同一条「先 A A A 后 B B B 」的路线、目标是最小化最大完工时间的调度问题。它是流水作业调度里少数有多项式最优解的情形——机器数增加到三台就是 NP-hard 了。
3 完工时刻怎么算
机器 A A A 没有前置依赖,从 0 0 0 时刻开始连续加工,所以第 i i i 个作业在 A A A 上的完工时间就是前缀和:
A i = ∑ k = 1 i a k
A_i=\sum_{k=1}^{i}a_k
A i = k = 1 ∑ i a k 机器 B B B 上的第 i i i 个作业要开工,必须同时满足两件事:它自己在 A A A 上已经做完(时刻 A i A_i A i ),且前一个作业已经在 B B B 上做完(时刻 C i − 1 C_{i-1} C i − 1 )。所以
C i = max ( C i − 1 , A i ) + b i , C 0 = 0
C_i=\max(C_{i-1},\,A_i)+b_i,\qquad C_0=0
C i = max ( C i − 1 , A i ) + b i , C 0 = 0 这个式子值得记住——后面很多题目(比如皇后游戏)的递推式和它完全同构 ,只是换了故事。
3.1 闭式解:瓶颈转折点
把递推展开可以消掉 max \max max ,得到一个很好看的闭式解:
定理 最大完工时间的闭式解
对任意固定顺序,
C n = max 1 ≤ k ≤ n ( ∑ m = 1 k a m + ∑ m = k n b m )
C_n=\max_{1\le k\le n}\left(\sum_{m=1}^{k}a_m+\sum_{m=k}^{n}b_m\right)
C n = 1 ≤ k ≤ n max ( m = 1 ∑ k a m + m = k ∑ n b m )
证明 闭式解的来历
把 C n C_n C n 一层层展开:
C n = max ( C n − 1 , A n ) + b n = max ( C n − 1 + b n , A n + b n )
C_n=\max\left(C_{n-1},A_n\right)+b_n=\max\left(C_{n-1}+b_n,\;A_n+b_n\right)
C n = max ( C n − 1 , A n ) + b n = max ( C n − 1 + b n , A n + b n ) 继续把 C n − 1 = max ( C n − 2 , A n − 1 ) + b n − 1 C_{n-1}=\max(C_{n-2},A_{n-1})+b_{n-1} C n − 1 = max ( C n − 2 , A n − 1 ) + b n − 1 代入第二项里的 C n − 1 C_{n-1} C n − 1 ,每一项都会「带上」后面所有 b b b 的和。归纳到最后得到:C n C_n C n 是若干个「前缀 a a a 之和 + 从该位置到末尾的 b b b 之和」取最大,即上式。
式中的 k k k 有清楚的物理含义:它是机器 B B B 最后一次等机器 A A A 的那个作业。从第 k k k 个作业开始,机器 B B B 连轴转到底,中间不再空闲。
说明 为什么闭式解值得记
它把「一台机器等另一台」的整个过程摊平成了 n n n 个候选值的最大值。后面判断「哪个作业该放前面」时,用的就是它的相邻两项形式。
4 邻项交换:推出比较式
设 x x x 与 y y y 相邻,在它们之前机器 A A A 已累计 A A A 、机器 B B B 已完工到 B B B 。
4.1 两种顺序的完工时刻
顺序一(x x x 在前) :作业 y y y 完成时
C x y = max ( B + b x + b y , A + a x + b x + b y , A + a x + a y + b y )
C_{xy}=\max\bigl(B+b_x+b_y,\;A+a_x+b_x+b_y,\;A+a_x+a_y+b_y\bigr)
C x y = max ( B + b x + b y , A + a x + b x + b y , A + a x + a y + b y ) 顺序二(y y y 在前) :交换下标即得
C y x = max ( B + b y + b x , A + a y + b y + b x , A + a y + a x + b x )
C_{yx}=\max\bigl(B+b_y+b_x,\;A+a_y+b_y+b_x,\;A+a_y+a_x+b_x\bigr)
C y x = max ( B + b y + b x , A + a y + b y + b x , A + a y + a x + b x ) 4.2 化简
两个展开式里有三件事要利用:
两种顺序下,x , y x,y x , y 都做完时机器 A A A 的累计时间相同,都是 A + a x + a y A+a_x+a_y A + a x + a y ,所以对后续 作业没有影响。只需比较 C x y C_{xy} C x y 与 C y x C_{yx} C y x 。
两者第一项都是 B + b x + b y B+b_x+b_y B + b x + b y ,可一并消去。
剩下的项都含公共和 A + a x + a y + b x + b y A+a_x+a_y+b_x+b_y A + a x + a y + b x + b y ,两边同时减掉。
于是「x x x 在前不劣于 y y y 在前」等价于
max ( − a y , − b x ) ≤ max ( − a x , − b y )
\max(-a_y,-b_x)\le\max(-a_x,-b_y)
max ( − a y , − b x ) ≤ max ( − a x , − b y ) 两边乘 − 1 -1 − 1 (max \max max 变 min \min min ,不等号反向),得到 Johnson 局部条件 :
定理 Johnson 局部条件
min ( a x , b y ) ≤ min ( a y , b x )
\min(a_x,b_y)\le\min(a_y,b_x)
min ( a x , b y ) ≤ min ( a y , b x ) 满足时把 x x x 排在 y y y 前面不会更差。
说明 这一步只用到交换论证
推导里没有任何「贪心直觉」,纯代数。条件成立就说明相邻交换不会变坏,这正是邻项交换法的标准终点。麻烦在于下一步。
5 陷阱:这个比较式不能直接 sort
推到 min ( a x , b y ) ≤ min ( a y , b x ) \min(a_x,b_y)\le\min(a_y,b_x) min ( a x , b y ) ≤ min ( a y , b x ) ,很多人的第一反应是写成比较函数丢进 std::sort。这是错的。
std::sort 的比较器必须是严格弱序 (strict weak ordering):反自反、非对称、传递,外加「不可比」关系的传递性。而 min ( a x , b y ) < min ( a y , b x ) \min(a_x,b_y)<\min(a_y,b_x) min ( a x , b y ) < min ( a y , b x ) 满足前三条,却不满足第四条 。
5.1 先看它的「不可比」是什么
定义 x ∼ y x\sim y x ∼ y 当且仅当 min ( a x , b y ) = min ( a y , b x ) \min(a_x,b_y)=\min(a_y,b_x) min ( a x , b y ) = min ( a y , b x ) 。在这个关系下,∼ \sim ∼ 并不传递。
取三个作业:
x = ( 2 , 10 ) , y = ( 1 , 1 ) , z = ( 3 , 10 )
x=(2,10),\qquad y=(1,1),\qquad z=(3,10)
x = ( 2 , 10 ) , y = ( 1 , 1 ) , z = ( 3 , 10 ) 逐个验证:
比较
左边
右边
结论
x x x vs y y y
min ( 2 , 1 ) = 1 \min(2,1)=1 min ( 2 , 1 ) = 1
min ( 1 , 10 ) = 1 \min(1,10)=1 min ( 1 , 10 ) = 1
x ∼ y x\sim y x ∼ y
y y y vs z z z
min ( 1 , 10 ) = 1 \min(1,10)=1 min ( 1 , 10 ) = 1
min ( 3 , 1 ) = 1 \min(3,1)=1 min ( 3 , 1 ) = 1
y ∼ z y\sim z y ∼ z
x x x vs z z z
min ( 2 , 10 ) = 2 \min(2,10)=2 min ( 2 , 10 ) = 2
min ( 3 , 10 ) = 3 \min(3,10)=3 min ( 3 , 10 ) = 3
x ≺ z x\prec z x ≺ z
x ∼ y x\sim y x ∼ y 、y ∼ z y\sim z y ∼ z ,但 x ≺ z x\prec z x ≺ z ——等价关系断了。
5.2 这个断链会真的算错
排序器遇到 ∼ \sim ∼ 就认为「两者不必交换」,于是初始序列 [ z , y , x ] [z,y,x] [ z , y , x ] 会被判定为已经有序。而实测:
顺序
完工时间
[ z , y , x ] [z,y,x] [ z , y , x ] (错误比较器认为已完成)
24 24 24
[ x , y , z ] [x,y,z] [ x , y , z ] 或 [ y , x , z ] [y,x,z] [ y , x , z ]
23 23 23
差 1 不算大,但方向是错的:算法会稳定地停在次优解上。
我用随机数据验证过:把 min ( a x , b y ) < min ( a y , b x ) \min(a_x,b_y)<\min(a_y,b_x) min ( a x , b y ) < min ( a y , b x ) 直接当 cmp 排序,20000 组随机数据里有 509 组得不到最优解 (约 2.5%)。反例不是精心构造的孤例。
顺带说明:这个比较器还有一个更隐蔽的毛病——它可能是非传递的 ,而给 std::sort 传非传递比较器属于未定义行为,某些实现会直接越界崩溃。所以「碰巧排对了」也不代表能这么写。
6 Johnson 算法:集合划分恢复全序
S. M. Johnson 1954 年的做法是把「两个作业交叉比较」拆成「每个作业自己算一个排序键」:
算法 Johnson 算法
划分:S 1 = { i ∣ a i < b i } S_1=\{i\mid a_i<b_i\} S 1 = { i ∣ a i < b i } ,S 2 = { i ∣ a i ≥ b i } S_2=\{i\mid a_i\ge b_i\} S 2 = { i ∣ a i ≥ b i } 。
组内排序:S 1 S_1 S 1 内按 a i a_i a i 升序 ;S 2 S_2 S 2 内按 b i b_i b i 降序 。
拼接:先 S 1 S_1 S 1 全部,再 S 2 S_2 S 2 全部。
6.1 用键理解它
第 2、3 步等价于给每个作业算一个键:
key ( i ) = { ( 0 , a i ) , a i < b i ( 1 , − b i ) , a i ≥ b i
\text{key}(i)=
\begin{cases}
(0,\;a_i), & a_i<b_i\\[2pt]
(1,\;-b_i), & a_i\ge b_i
\end{cases}
key ( i ) = { ( 0 , a i ) , ( 1 , − b i ) , a i < b i a i ≥ b i 按二元组字典序排序即可。第一维把 S 1 S_1 S 1 全部排在 S 2 S_2 S 2 前面;第二维在 S 1 S_1 S 1 里实现 a i a_i a i 升序、在 S 2 S_2 S 2 里实现 − b i -b_i − b i 升序(即 b i b_i b i 降序)。
定理 划分后的顺序满足 Johnson 局部条件
最终序列中任意 x x x 排在 y y y 前面,都满足 min ( a x , b y ) ≤ min ( a y , b x ) \min(a_x,b_y)\le\min(a_y,b_x) min ( a x , b y ) ≤ min ( a y , b x ) 。
证明 分三种位置关系验证
同属 S 1 S_1 S 1 :a x < b x a_x<b_x a x < b x 、a y < b y a_y<b_y a y < b y ,且 a x ≤ a y a_x\le a_y a x ≤ a y 。左边 min ( a x , b y ) ≤ a x \min(a_x,b_y)\le a_x min ( a x , b y ) ≤ a x ;右边 min ( a y , b x ) ≥ min ( a x , b x ) = a x \min(a_y,b_x)\ge\min(a_x,b_x)=a_x min ( a y , b x ) ≥ min ( a x , b x ) = a x (因为 a x < b x a_x<b_x a x < b x )。故成立。
同属 S 2 S_2 S 2 :a x ≥ b x a_x\ge b_x a x ≥ b x 、a y ≥ b y a_y\ge b_y a y ≥ b y ,且 b x ≥ b y b_x\ge b_y b x ≥ b y 。左边 min ( a x , b y ) = b y \min(a_x,b_y)=b_y min ( a x , b y ) = b y ;右边 min ( a y , b x ) ≥ min ( b y , b x ) = b y \min(a_y,b_x)\ge\min(b_y,b_x)=b_y min ( a y , b x ) ≥ min ( b y , b x ) = b y 。故成立。
x ∈ S 1 x\in S_1 x ∈ S 1 、y ∈ S 2 y\in S_2 y ∈ S 2 :只需证 min ( a x , b y ) ≤ b x \min(a_x,b_y)\le b_x min ( a x , b y ) ≤ b x 且 ≤ a y \le a_y ≤ a y 之类分情况。若 min ( a y , b x ) = a y \min(a_y,b_x)=a_y min ( a y , b x ) = a y ,则 min ( a x , b y ) ≤ b y ≤ a y \min(a_x,b_y)\le b_y\le a_y min ( a x , b y ) ≤ b y ≤ a y ;若 min ( a y , b x ) = b x \min(a_y,b_x)=b_x min ( a y , b x ) = b x ,则 min ( a x , b y ) ≤ a x < b x \min(a_x,b_y)\le a_x<b_x min ( a x , b y ) ≤ a x < b x 。两种情形都成立。
说明 为什么这样就「安全」了
因为键是每个作业独立算出来的 ,排序变成对二元组的字典序,而字典序在自然数二元组上天然是全序——全序当然满足严格弱序。那个「不可比」的等价关系被彻底消掉了,传递性不会再断。
注意这里消掉的是「原始比较式上的 ∼ ∼ ∼ 不传递」这个问题,不是说原始比较式本身错——它作为两两优先关系 是对的,只是不能直接拿来当排序器的键。
7 物理直觉:开头抢启动,结尾缩尾巴
两条规则都能用「谁在等谁」解释:
总结 两条规则的由来
S 1 S_1 S 1 按 a i a_i a i 升序放最前 :开局机器 B B B 是空的,在干等。把 a i a_i a i 最小的放最前,机器 B B B 能最早拿到活。而 S 1 S_1 S 1 里 a i < b i a_i<b_i a i < b i ,机器 B B B 干得慢,机器 A A A 正好趁这段时间攒缓冲。
S 2 S_2 S 2 按 b i b_i b i 降序放最后 :收尾阶段机器 A A A 已经下班,只剩机器 B B B 独自收尾。把 b i b_i b i 最小的留在最后,机器 B B B 的尾巴最短。
8 两种等价写法
「按 min ( a i , b i ) \min(a_i,b_i) min ( a i , b i ) 升序扫,min \min min 取到 a i a_i a i 的往答案头部追加、取到 b i b_i b i 的往尾部插入」是 Johnson 算法更常见的教材写法。
说明 这和集合划分等价
实测两种写法在 4000 组随机数据上给出相同的最优值。等价原因:min ( a i , b i ) \min(a_i,b_i) min ( a i , b i ) 升序扫描时,前缀恰好就是 S 1 S_1 S 1 按 a a a 升序(a i < b i a_i<b_i a i < b i 时 min = a i \min=a_i min = a i ),后缀恰好是 S 2 S_2 S 2 按 b b b 降序(a i ≥ b i a_i\ge b_i a i ≥ b i 时 min = b i \min=b_i min = b i ,从后往前插入等于 b b b 递减)。
9 代码模板
以 为例,要求输出最小时间和 对应顺序。
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
# include <bits/stdc++.h>
using namespace std;
struct Job {
int id, a, b;
pair< int , long long > key ( ) const {
if ( a < b) return { 0 , a} ;
return { 1 , - ( long long ) b} ;
}
} ;
int main ( ) {
int n;
scanf ( "%d" , & n) ;
vector< Job> v ( n) ;
for ( int i = 0 ; i < n; i++ ) scanf ( "%d" , & v[ i] . a) ;
for ( int i = 0 ; i < n; i++ ) { scanf ( "%d" , & v[ i] . b) ; v[ i] . id = i + 1 ; }
sort ( v. begin ( ) , v. end ( ) , [ ] ( const Job& x, const Job& y) {
auto kx = x. key ( ) , ky = y. key ( ) ;
if ( kx != ky) return kx < ky;
return x. id < y. id;
} ) ;
long long A = 0 , B = 0 ;
for ( auto & j : v) {
A += j. a;
B = max ( B, A) + j. b;
}
printf ( "%lld\n" , B) ;
for ( int i = 0 ; i < n; i++ ) printf ( "%d%c" , v[ i] . id, i + 1 == n ? '\n' : ' ' ) ;
return 0 ;
}
复制
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
import sys
def main ( ) :
data = sys. stdin. read( ) . split( )
if not data:
return
n = int ( data[ 0 ] )
a = list ( map ( int , data[ 1 : 1 + n] ) )
b = list ( map ( int , data[ 1 + n: 1 + 2 * n] ) )
def key ( i) :
return ( 0 , a[ i] ) if a[ i] < b[ i] else ( 1 , - b[ i] )
order = sorted ( range ( n) , key= lambda i: ( key( i) , i) )
cur_a = cur_b = 0
for i in order:
cur_a += a[ i]
cur_b = max ( cur_b, cur_a) + b[ i]
print ( cur_b)
print ( ' ' . join( str ( i + 1 ) for i in order) )
main( )
复制
上面的代码用的是 key() 的字典序 ,不是 min ( a x , b y ) < min ( a y , b x ) \min(a_x,b_y)<\min(a_y,b_x) min ( a x , b y ) < min ( a y , b x ) 。这两者不是一回事,后者会破坏严格弱序(见前文反例)。
10 复杂度
排序 O ( n log n ) O(n\log n) O ( n log n ) ,扫描一遍 O ( n ) O(n) O ( n ) ,总计 O ( n log n ) O(n\log n) O ( n log n ) ,空间 O ( n ) O(n) O ( n ) 。
说明 为什么不用 DP
状态要记「哪些作业已做 + 两台机器各自进度」,是指数级的。而这个问题有交换论证给出的多项式最优解,直接排序即可。作为交换,机器数一旦增到三台就没有这样的结论了,F 3 ∥ C max F3\parallel C_{\max} F 3 ∥ C m a x 是 NP-hard。
11 题型信号
总结 什么题该想到 Johnson
两个工序串行 :每个任务包含有序的两个阶段 ( a i , b i ) (a_i,b_i) ( a i , b i ) ,第二阶段必须在该任务第一阶段完成后、且前一个任务第二阶段完成后才开始。
递推形如 f i = max ( f i − 1 , ∑ k ≤ i a k ) + b i f_i=\max(f_{i-1},\sum_{k\le i}a_k)+b_i f i = max ( f i − 1 , ∑ k ≤ i a k ) + b i ,或等价地出现「前缀 + 后缀」取 max \max max 的闭式结构。
收益有正有负、且带前置门槛 :这类题常能拆成「净赚组」和「净亏组」两组分别贪心,本质是同一个集合划分。
总结 三步走
化简 :写出相邻两项交换前后的 max \max max 展开式,两边同减公共和,提炼出局部条件。
验序 :检查这个条件是否满足不可比性的传递性。含 min / max \min/\max min / max 交叉项的式子经常不满足。
解耦 :按每个元素自身属性把集合一分为二,把「双变量交叉比较」降为「单变量独立排序」,恢复全序。
12 推荐练习
:母题 。Johnson 算法直接套用,要求同时输出最小时间与顺序。
:USACO12JAN Mountain Climbing。上山下山对应两台机器,U i U_i U i 是 a i a_i a i 、D i D_i D i 是 b i b_i b i ,模型完全一致。
:皇后游戏 。递推式 c i = max ( c i − 1 , ∑ j ≤ i a j ) + b i c_i=\max\left(c_{i-1},\sum_{j\le i}a_j\right)+b_i c i = max ( c i − 1 , ∑ j ≤ i a j ) + b i 与本文完全同构。它的正解也要做集合划分,许多直接写 min 比较器的代码会 WA——是检验有没有读懂本文的最佳题目。
:IOI96 / USACO4.2 工序安排。M 1 M_1 M 1 台 A 型机、M 2 M_2 M 2 台 B 型机,属于多机变体:先用优先队列分别求出各工件在 A 完成的时间序列与 B 的倒推时间序列,再前后配对取 max \max max 。
:AtCoder DP 专题 X - Tower。邻项交换推出按 w i + s i w_i+s_i w i + s i 升序,再跑一遍 DP,用来体会「先交换论证定序、再按顺序处理」的套路。
:魔法。b i b_i b i 有正有负,按符号分成两组:正组按门槛 t i t_i t i 升序,负组按 t i + b i t_i+b_i t i + b i 降序。与本文同一套「二元集合划分」思想。
:Complete the Projects (hard)。同样按 b i b_i b i 正负分组,负组内按 a i + b i a_i+b_i a i + b i 降序后再套 01 背包。
说明 一点提醒
与 的递推式一模一样,但数据范围差很远 :P1248 满足 a i , b i ≤ 1000 a_i,b_i\le 1000 a i , b i ≤ 1000 、n ≤ 1000 n\le 1000 n ≤ 1000 ,∑ a i \sum a_i ∑ a i 在 int 里;P2123 是 a i , b i ≤ 1 0 9 a_i,b_i\le 10^9 a i , b i ≤ 1 0 9 、n ≤ 2 × 1 0 4 n\le 2\times10^4 n ≤ 2 × 1 0 4 ,前缀和会冲到 2 × 1 0 13 2\times10^{13} 2 × 1 0 13 ,必须全程 long long。
另外 a i = b i a_i=b_i a i = b i 的作业归哪一组都不影响 最大完工时间(这一条我穷举验证过),两种归法可能给出不同的合法顺序、但值相同。真正容易踩的是溢出,不是归组。
13 参考资料
14 相关题解