今天给大家介绍一下区间调度问题。区间调度是一类难度比较大,但同时应用比较广的问题,经常会在面试中以各种形式出现。本文将会介绍区间调度的各种变形,希望能使大家在面临区间调度问题时得心应手,并可以在实际工作中巧妙应用。
1. 相关定义
在数学里,区间通常是指这样的一类实数集合:如果x和y是两个在集合里的数,那么,任何x和y之间的数也属于该集合。区间有开闭之分,例如(1,2)和[1,2]的表示范围不同,后者包含整数1和2。
在程序世界,区间的概念和数学里没有区别,但是往往有具体的含义,例如时间区间,工资区间或者音乐中音符的开始结束区间等,图一给出了一个时间区间的例子。区间有了具体的含义之后,开闭的概念就显得非常重要,例如时间区间[8:30,9:30]和[9:30,10:30]两个区间是有重叠的,但是[8:30,9:30)和[9:30,10:30)没有重叠。在不同的问题中,区间的开闭往往不同,有时是闭区间,有时是半开半闭区间。时间区间往往是闭区间,但是音符中的开始结束区间则是半开半闭区间,所以在重叠的定义上大家需要具体问题具体分析。稍后你会发现,开闭的区别其实只是差一个等号而已。
图1 时间区间示例
假设区间是闭合的,并定义为[start,end]。我们首先看一下区间重叠的定义。给定两个区间[s1,e1]和[s2,e2],它们重叠的可能性有四种:
可以看出,如果直接考虑区间重叠,判断条件比较复杂,我们从相反的角度考虑,考虑区间不重叠的情况。区间不重叠时的判断条件为:
也即:(e1e2),所以区间重叠的判断条件为:
经过化简之后,区间重叠的判断条件只有两个,也很好理解,不再赘述。如果区间是半开半闭的,则只需要将判断条件中的等号去掉。
现在考虑这样一个问题,如何判断一个区间是否和其他的区间重叠。最坏情况下,我们可能需要和剩下的所有n-1个区间比较一次才能知道结果,每和一个区间比较都需要两次判断。所以完成n个区间相互之间比较的复杂度为O(n2),常系数为2。为了加快比较的速度,通常会先对区间进行一个排序,可以按照开始时间或者结束时间进行排序,需要根据实际情况选择。排序之后每个区间再和其他的n-1个区间进行比较。为什么要排序,排序之后的比较复杂度不还是O(n2)吗?原因在于,区间经过排序之后,其实已经有了一个先后顺序,后续再进行重叠判断的时候只需要比较一次即可,这时的复杂度其实变为O(nlogn+n2),常系数为1,比不排序要快一些。例如,假设所有的区间都按照结束时间进行排序,就会有
,这是两个重叠判断条件中的后一个,所以我们只需要再判断前一个即可。在涉及区间重叠的问题上,一般都会先进行排序。
2. 区间调度问题分类
上面介绍了相关基本概念,这节介绍区间调度问题的两个维度,所有的区间调度问题都是从这两个维度上展开的。给定N个区间,如果我们在x坐标轴上将它们都画出,则可能由于重叠的原因而显示很乱。为了避免重叠,我们需要将区间在y轴上进行扩展,将重叠的区间画在纵坐标不同的行上,如图二。区间在两个维度上的扩展也即在横轴时间和纵轴行数上的扩展。几乎所有的区间调度问题都是从这两个维度上展开的。
图2 区间的两个维度
x轴上的扩展,可能会让我们计算一行中最多可以不重叠地放置多少个区间,或者将区间的时间累加最大可以到多少,或者用最少的区间覆盖一个给定的大区间;y轴上的扩展,可能会让我们计算为了避免区间重叠,最少需要多少行;还可以将y轴的行数固定,然后考虑为了完成n个工作最短需要多少时间,也即机器调度问题。更复杂一些,有时区间还会变成带权的,例如酒店竞标的最大收益等等。区间调度问题的种类非常多,后面会一一展开详细介绍。
3. x轴上的区间调度
x轴上的区间调度主要关注一行中的区间情况,比如最多可以放入多少不重叠的区间,或者最少可以用多少区间覆盖一个大区间等等。该类区间调度问题应用很广,经常会以各种形式出现在笔试面试题中。
3.1 最多区间调度
有n项工作,每项工作分别在
时间开始,在
时间结束。对于每项工作,你都可以选择参与与否。如果选择了参与,那么自始至终都必须全程参与。此外,参与工作的时间段不能重叠(闭区间)。你的目标是参与尽可能多的工作,那么最多能参与多少项工作?其中
并且
。(from《挑战程序设计竞赛
P40》)
图3 最多区间调度
这个区间问题就是大家熟知的区间调度问题或者叫最大区间调度问题。在此我们进行细分,将该问题命名为最多区间调度问题,因为该问题的目标是求不重叠的最多区间个数,而不是最大的区间长度和。
这个问题可以算是最简单的区间调度问题了,可以通过贪心算法求解,贪心策略是:在可选的工作中,每次都选取结束时间最早的工作。其他贪心策略都不是最优的。
下面是一个简单的实现:
const int MAX_N=100000;
//输入
int N,S[MAX_N],T[MAX_N];
//用于对工作排序的pair数组
pair itv[MAX_N];
void solve()
{
//对pair进行的是字典序比较,为了让结束时间早的工作排在前面,把T存入first,//把S存入second
for(int i=0;i
{
itv[i].first=T[i];
itv[i].second=S[i];
}
sort(itv,itv+N);
//t是最后所选工作的结束时间
int ans=0,t=0;
for(int i=0;i
{
if(t
{
ans++;
t=itv[i].first;
}
}
printf(“%d\n”,ans);
}
时间复杂度:排序 O(nlogn) +扫描O(n) =O(nlogn)。该问题已给出最优解,也即用贪心法可以解决。但是思考的思路如何得来呢?我们一步步分析,看看能不能最终得到和贪心法一样的结果。
最优化问题都可以通过某种搜索获得最优解,最多区间调度问题也不例外。该问题无非就是选择几个不重叠的区间而已,看看最多能选择多少个,其解空间为一棵二叉子集树,某个区间选或者不选构成了两个分支,如图四所示。我们的目标就是遍历这棵子集树,然后看从根节点到叶节点的不重叠区间的最大个数为多少。可以看出,该问题的解就是n位二进制的某个0/1组合。子集树共有2n种组合,每种组合都需要判断是否存在重叠区间,如果不重叠则获得1的个数。
图4 区间调度的子集树
假设我们不对区间进行排序,则每种组合判断是否有重叠区间的复杂度为O(n2),从而整个算法复杂度为O(2n n2)。复杂度相当高!进行各种剪枝也无济于事!下面我们开始对算法进行优化。
让我们感到奇怪的是,只是判断n个区间是否存在重叠最坏居然也需要O(n2)的复杂度。这是因为在区间无序的情况下,每个区间都要顺次和后面的所有区间进行比较,没有合理利用区间的两个时间点。我们考虑对区间进行一下排序会有什么不同。假设我们按照开始时间进行排序,排序之后有