区间调度问题

1. 相关定义

       在数学里,区间一般是指这样的一类实数集合:若是x和y是两个在集合里的数,那么,任何x和y之间的数也属于该集合。区间有开闭之分,例如(1,2)和[1,2]的表示范围不一样,后者包含整数1和2。c++

       在程序世界,区间的概念和数学里没有区别,可是每每有具体的含义,例如时间区间,工资区间或者音乐中音符的开始结束区间等,图一给出了一个时间区间的例子。区间有了具体的含义以后,开闭的概念就显得很是重要,例如时间区间[8:30,9:30]和[9:30,10:30]两个区间是有重叠的,可是[8:30,9:30)和[9:30,10:30)没有重叠。在不一样的问题中,区间的开闭每每不一样,有时是闭区间,有时是半开半闭区间。时间区间每每是闭区间,可是音符中的开始结束区间则是半开半闭区间,因此在重叠的定义上你们须要具体问题具体分析。稍后你会发现,开闭的区别其实只是差一个等号而已。面试

 

图1 时间区间示例算法

       假设区间是闭合的,并定义为[start,end]。咱们首先看一下区间重叠的定义。给定两个区间[s1,e1]和[s2,e2],它们重叠的可能性有四种:数组

 

能够看出,若是直接考虑区间重叠,判断条件比较复杂,咱们从相反的角度考虑,考虑区间不重叠的状况。区间不重叠时的判断条件为:数据结构

 

也即:(e1<s2|| s1>e2),因此区间重叠的判断条件为:函数

通过化简以后,区间重叠的判断条件只有两个,也很好理解,再也不赘述。若是区间是半开半闭的,则只须要将判断条件中的等号去掉。优化

       如今考虑这样一个问题,如何判断一个区间是否和其余的区间重叠。最坏状况下,咱们可能须要和剩下的全部n-1个区间比较一次才能知道结果,每和一个区间比较都须要两次判断。因此完成n个区间相互之间比较的复杂度为O(n2),常系数为2。为了加快比较的速度,一般会先对区间进行一个排序,能够按照开始时间或者结束时间进行排序,须要根据实际状况选择。排序以后每一个区间再和其余的n-1个区间进行比较。为何要排序,排序以后的比较复杂度不仍是O(n2)吗?缘由在于,区间通过排序以后,其实已经有了一个前后顺序,后续再进行重叠判断的时候只须要比较一次便可,这时的复杂度其实变为O(nlogn+n2),常系数为1,比不排序要快一些。例如,假设全部的区间都按照结束时间进行排序,就会有,这是两个重叠判断条件中的后一个,因此咱们只须要再判断前一个便可。在涉及区间重叠的问题上,通常都会先进行排序。ui

2. 区间调度问题分类

       上面介绍了相关基本概念,这节介绍区间调度问题的两个维度,全部的区间调度问题都是从这两个维度上展开的。给定N个区间,若是咱们在x坐标轴上将它们都画出,则可能因为重叠的缘由而显示很乱。为了不重叠,咱们须要将区间在y轴上进行扩展,将重叠的区间画在纵坐标不一样的行上,如图二。区间在两个维度上的扩展也即在横轴时间和纵轴行数上的扩展。几乎全部的区间调度问题都是从这两个维度上展开的。url

 

图2 区间的两个维度spa

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<int,int> itv[MAX_N];  
  
void solve()  
{  
    //对pair进行的是字典序比较,为了让结束时间早的工做排在前面,把T存入first,//把S存入second  
    for(int i=0;i<N;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<N;i++)  
    {  
        if(t<itv[i].second)//判断区间是否重叠  
        {  
            ans++;  
            t=itv[i].first;  
        }  
    }  
  
    printf(“%d\n”,ans);  
}  

时间复杂度:排序 O(nlogn) +扫描O(n)  =O(nlogn) 。该问题已给出最优解,也即用贪心法能够解决。可是思考的思路如何得来呢?咱们一步步分析,看看能不能最终获得和贪心法同样的结果。

最优化问题均可以经过某种搜索得到最优解,最多区间调度问题也不例外。该问题无非就是选择几个不重叠的区间而已,看看最多能选择多少个,其解空间为一棵二叉子集树,某个区间选或者不选构成了两个分支,如图四所示。咱们的目标就是遍历这棵子集树,而后看从根节点到叶节点的不重叠区间的最大个数为多少。能够看出,该问题的解就是n位二进制的某个0/1组合。子集树共有2 n种组合,每种组合都须要判断是否存在重叠区间,若是不重叠则得到1的个数。
 

图4 区间调度的子集树

假设咱们不对区间进行排序,则每种组合判断是否有重叠区间的复杂度为O(n2),从而整个算法复杂度为O(2n n2)。复杂度至关高!进行各类剪枝也无济于事!下面咱们开始对算法进行优化。

让咱们感到奇怪的是,只是判断n个区间是否存在重叠最坏竟然也须要O(n2)的复杂度。这是由于在区间无序的状况下,每一个区间都要顺次和后面的全部区间进行比较,没有合理利用区间的两个时间点。咱们考虑对区间进行一下排序会有什么不一样。假设咱们按照开始时间进行排序,排序以后有,而后从第一个区间开始判断。第一个区间只须要和第二个区间进行判断便可。若是重叠,则这n个区间存在重叠,后面无需再进行判断;若是不重叠,咱们只须要再将第二个和第三个进行一样的判断便可。因此按照开始时间进行排序以后,判断n个区间是否存在重叠的复杂度将为O(n),因此整个算法复杂度降为O(n2n)。按照结束时间进行排序也会有一样的结论。

虽然排序能够下降复杂度,可是遍历子集树的代价仍是太大。咱们换个角度考虑问题,看能不能避免遍历子集树。突破点在哪呢?咱们不妨从第一个区间是否属于最优解开始。首先假设区间按照开始时间排序,而且已经求出最优解对应的全部区间。若是最优解中开始时间最小的区间不是全部区间中开始时间最小的区间,咱们看看可否进行替换。确定是重叠的,不然就能够将添加到最优解中得到更好的最优解。可否将替换成呢?知足,可是结束时间不肯定,这就可能出现的状况,从而也会出现(i>1)的状况,从而替换可能会引入重叠,最优解变成非最优解。因此在按照开始时间排序的状况下,第一个区间不必定属于最优解。

咱们再考虑一下按照结束时间排序的状况,也已经求出最优解对应的全部区间。若是最优解中结束时间最小的区间 不是全部区间中结束时间最小的区间 ,咱们看看可否进行替换。 确定是重叠的,不然就能够将 添加到最优解中得到更好的最优解。可否将 替换成 呢? 知足 知足 (两个区间不重叠),因此有 ,从而 不重叠。因此咱们能够用 来替换 。这就得出一个结论:在按照结束时间排序的状况下,第一个区间一定属于最优解。按照这个思路继续推导剩下的区间咱们就会发现:每次选结束时间最先的区间就能够得到最优解。这就和咱们一开始给出的结论一致。

通过上面的分析,咱们就明白为啥选择结束时间最先的工做就能够得到最优解。虽然咱们并无遍历子集树,可是它为咱们思考和优化问题给出了一个很好的模型,但愿你们能好好掌握这种构造问题解空间的方法。

下面咱们再换个角度考虑上面的问题。不少最优化深搜问题均可以巧妙地转化成动态规划问题,能够转化的根本缘由在于存在重复子问题,咱们看图四就会发现最多区间调度问题也存在重复子问题,因此能够利用动态规划来解决。假设区间已经排序,能够尝试这样设计递归式:前i个区间的最多不重叠区间个数为dp[i]。dp[i]等于啥呢?咱们须要根据第i个区间是否选择这两种状况来考虑。若是咱们选择第i个区间,它可能和前面的区间重叠,咱们须要找到不重叠的位置k,而后计算最多不重叠区间个数dp[k]+1(若是区间按照开始时间排序,则前i+1个区间没有明确的分界线,咱们必须按照结束时间排序);若是咱们不选择第i个区间,咱们须要从前i-1个结果中选择一个最大的dp[j];最后选择dp[k]+1和dp[j]中较大的。伪代码以下:

void solve()  
{  
    //1. 对全部的区间进行排序  
    sort_all_intervals();  
  
    //2. 按照动态规划求最优解  
    dp[0]=1;  
    for (int i = 1; i < intervals.size(); i++)   
       {  
        //1. 选择第i个区间  
        k=find_nonoverlap_pos();  
        if(k>=0) dp[i]=dp[k]+1;  
        //2. 不选择第i个区间  
        dp[i]=max{dp[i],dp[j]};  
    }  
}  

选择或者不选择第i个区间都须要去查找其余的区间,顺序查找的复杂度为O(n),总共有n个区间,每一个区间都须要查找,因此动态规划部分最初的算法复杂度为O(n2),已经从指数级降到多项式级,可是通过后面的优化还能够降到O(n),咱们一步步来优化。

能够看出dp[i]是非递减的,这能够经过数学概括法证实。也即当咱们已经求得前i个区间的最多不重叠区间个数以后,再求第i+1个区间时,咱们彻底能够不选择第i+1个区间,从而使得前i+1个区间的结果和前i个区间的结果相同;或者咱们选择第i+1个区间,在不重叠的状况下有可能得到更优的结果。dp[i]是非递减的对咱们有什么意义呢?首先,若是咱们在计算dp[i]时不选择第i个区间,则咱们就无需遍历前i-1个区间,直接选择dp[i-1]便可,由于它是前i-1个结果中最大的(虽然不必定是惟一的),此时伪代码中的dp[j]就变成了dp[i-1]。其次,在寻找和第i个区间不重叠的区间时,咱们能够避免顺序遍历。若是咱们将dp[i]的值列出来,确定是这样的:

1,1,…,1,2,2,…,2,3,3,…,3,4……

即dp[i]的值从1开始,顺次递增,每个值的个数不固定。dp[0]确定等于1,后面几个区间若是和第0个区间重叠,则的dp值也为1;当出现一个区间不和第0个区间重叠时,其dp值变为2,依次类推。由此咱们能够获得一个快速得到不重叠位置的方法:从新开辟一个新的数组,用来保存每个不一样dp值的最开始位置,例如pos[1]=0,pos[2]=3,…。这样咱们就能够利用O(1)的时间实现find_nonoverlap_pos函数了,而后整个动态规划算法的复杂度就降为O(n)了。

其实从dp的值咱们已经就能够发现一些端倪了:dp值发生变化的位置恰是出现不重叠的位置!再仔细思考一下就会出现一开始提到的贪心算法了。因此能够说,贪心算法是动态规划算法在某些问题中的一个特例。该问题的特殊性在于只考虑区间的个数,也即每次都是加1的操做,后面会看到,若是变成考虑区间的长度,则贪心算法再也不适用。

3.2 最大区间调度

       该问题和上面最多区间调度问题的区别是不考虑区间个数,而是将区间的长度和做为一个指标,而后求长度和的最大值。咱们将该问题命名为最大区间调度问题。

       WAP某年的笔试题就考察了该问题(下载)。看这样一个例子:如今有n个工做要完成,每项工做分别在 时间开始,在 时间结束。对于每项工做,你均可以选择参与与否。若是选择了参与,那么自始至终都必须全程参与。此外,参与工做的时间段不能重叠(闭区间)。求你参与的全部工做最大须要耗费多少时间。

图5 最大区间调度

       该问题和最多区间调度很类似,一个考虑区间个数的最大值,一个考虑区间长度的最大值,可是该问题的难度要比最多区间调度大些,由于它必需要用动态规划来高效解决。在最多区间调度问题中,咱们用动态规划的方法给你们解释了贪心算法能够解决问题的原因,而最大区间调度问题则是直接利用上面提到的动态规划算法:首先按照结束时间排序区间,而后按照第i个区间选择与否进行动态规划。咱们先给出WAP笔试题的核心代码

public int getMaxWorkingTime(List<Interval> intervals) {  
    /* 
     * 1 check the parameter validity 
     */  
          
    /* 
     * 2 sort the jobs(intervals) based on the end time 
     */  
    Collections.sort(intervals, new EndTimeComparator());  
  
    /* 
     * 3 calculate dp[i] using dp 
     */  
    int[] dp = new int[intervals.size()];  
    dp[0] = intervals.get(0).getIntervalMinute();  
  
    for (int i = 1; i < intervals.size(); i++) {  
        int max;  
  
        //select the ith interval  
        int nonOverlap = below_lower_bound(intervals,   
                intervals.get(i).getBeginMinuteUnit());  
        if (nonOverlap >= 0)  
            max = dp[nonOverlap] + intervals.get(i).getIntervalMinute();  
        else  
            max = intervals.get(i).getIntervalMinute();  
  
        //do not select the ith interval  
        dp[i] = Math.max(max, dp[i-1]);  
    }  
  
    return dp[intervals.size() - 1];  
}  
  
public int below_lower_bound(List<Interval> intervals, int startTime) {  
    int lb = -1, ub = intervals.size();  
  
    while (ub - lb > 1) {  
        int mid = (ub + lb) >> 1;  
        if (intervals.get(mid).getEndMinuteUnit() >= startTime)  
            ub = mid;  
        else  
            lb = mid;  
    }  
    return lb;  
}  

代码和最多区间调度最大的不一样在选择第i个区间时。在这里用了一个二分查找来搜索不重叠的位置,而后判断该位置是否存在。若是不重叠位置存在,则算出当前的最大区间长度和;若是不存在,代表第i个区间和前面的全部区间均重叠,但因为咱们还要选择第i个区间,因此暂时的最大区间和也即第i个区间自身的长度。在最多区间调度中,若是该位置不存在,咱们直接将dp[i]赋值成dp[i-1],在这里咱们却要将第i个区间自己的长度做为结果。从图五咱们能够清楚地看到解释,在计算左下角的区间时,它和前面的两个区间都重合,可是它却包含在最优解中,由于它的长度比前面两个的和还要长。

这里求不重叠位置的时候,用了一个和c++中lower_bound函数相似的实现,和lower_bound的惟一差异在于返回的结果位置相差1。因此上述代码若是用C++来实现会更简单:

const int MAX_N=100000;  
//输入  
int N,S[MAX_N],T[MAX_N];  
  
//用于对工做排序的pair数组  
pair<int,int> itv[MAX_N];  
  
void solve()  
{  
    //对pair进行的是字典序比较,为了让结束时间早的工做排在前面,把T存入first,//把S存入second  
    for(int i=0;i<N;i++)  
    {  
        itv[i].first=T[i];  
        itv[i].second=S[i];  
    }  
  
    sort(itv,itv+N);  
  
    dp[0] = itv[0].first-itv[0].second;  
    for (int i = 1; i < N; i++)  
    {  
        int max;  
  
        //select the ith interval  
        int nonOverlap = lower_bound(itv, itv[i].second)-1;  
        if (nonOverlap >= 0)  
            max = dp[nonOverlap] + (itv[i].first-itv[i].second);  
        else  
            max = itv[i].first-itv[i].second;  
  
        //do not select the ith interval  
        dp[i] = max>dp[i-1]?max:dp[i-1];  
    }  
    printf(“%d\n”,dp[N-1]);  
}  

经过上面的分析,咱们能够看出最大区间问题是一个应用范围更广的问题,最多区间调度问题是最大区间调度问题的一个特例。若是区间的长度都同样,则最大区间调度问题就退化为最多区间调度问题,进而能够利用更优的算法解决。通常的最大区间调度问题复杂度为: 排序O(nlogn) +扫描 O(nlogn)=O(nlogn)。

3.3 带权的区间调度

       该问题能够看做最大区间调度问题的通常化,也即咱们不仅是求区间长度和的最大值,而是再在每一个区间上绑定一个权重,求加权以后的区间长度最大值。先看一个例子:某酒店采用竞标式入住,每个竞标是一个三元组(开始,入住时间,天天费用)。如今有N个竞标,选择使酒店效益最大的竞标。(美团2013年)

该问题的目标变成了求收益的最大值,区间不重叠只是伴随必须知足的一个条件。但这不影响算法的适用性,最大区间调度问题的动态规划算法依旧适用于该问题,只不过是目标变了而已:最大区间调度考虑的是区间长度和,而带权区间调度考虑的是区间的权重和,就是在区间的基础上乘以一个权重,就这点差异。因此代码就很简单咯:

const int MAX_N=100000;  
//输入  
int N,S[MAX_N],T[MAX_N];  
  
//用于对工做排序的pair数组  
pair<int,int> itv[MAX_N];  
  
void solve()  
{  
    //对pair进行的是字典序比较,为了让结束时间早的工做排在前面,把T存入first,//把S存入second  
    for(int i=0;i<N;i++)  
    {  
        itv[i].first=T[i];  
        itv[i].second=S[i];  
    }  
  
    sort(itv,itv+N);  
  
    dp[0] = (itv[0].first-itv[0].second)*V[0];  
    for (int i = 1; i < N; i++)  
    {  
        int max;  
  
        //select the ith interval  
        int nonOverlap = lower_bound(itv, itv[i].second)-1;  
        if (nonOverlap >= 0)  
            max = dp[nonOverlap] + (itv[i].first-itv[i].second)*V[i];  
        else  
            max = (itv[i].first-itv[i].second)*V[i];  
  
        //do not select the ith interval  
        dp[i] = max>dp[i-1]?max:dp[i-1];  
    }  
    printf(“%d\n”,dp[N-1]);  
} 

3.4 最小区间覆盖

问题定义以下:有n 个区间,选择尽可能少的区间,使得这些区间彻底覆盖某给定范围[s,t]。

初次遇到该问题,你们可能会把该问题想得很复杂,是否是须要用最长的区间去覆盖给定的范围,而后将给定范围分割成两个更小的子问题,用递归去解决。这时咱们就须要得到在给定范围内的最长区间,可是如何判断最长区间却有太多的麻烦,并且即便选择了在给定范围内的最长区间,也不见得能得到最优值。其实该问题根本就没有想象中麻烦,可能很容易地解决。

解决问题的关键在于,咱们不要一开始就考虑整个范围,而是从给定范围的左端点入手。咱们选择一个能够覆盖左端点的区间以后,就能够将左端点往右移动获得一个新的左端点。只要咱们不停地选择能够覆盖左端点的区间就必定能够到达右端点,除非问题无解。关键是咱们应该选择什么样的区间来覆盖左端点。因为咱们要用选择区间的右端点和给定范围的左端点比较,因此第一想法会是先对全部的区间按照结束时间排序,而后按照结束时间从小到大和左端点比较。啥时候中止比较而后修改左端点呢?确定是到了某个区间的开始时间大于给定范围的左端点的时候。这是由于若是咱们继续遍历,可能就会不能彻底覆盖给定范围。可是这样也可能会得不到最优解,如图七所示。

图7 按照结束时间排序的最小区间覆盖错误示意图

       在上图中,三个区间按照结束时间排序,第一个区间和给定范围的左端点相交,接着遍历第二个区间。这时发现第二个区间的左端点大于给定范围的左端点,这时咱们就须要中止继续比较,修改给定范围新的左端点为end1。接着遍历第三个区间,按照上述规则咱们就会将第三个区间也保留下来,但其实只须要第三个区间就知足要求了,第一个区间没有保留的意义,也即咱们得到不了最优解。

       既然按照结束时间得到不了最优解,咱们再尝试按照开始时间排序看看。区间按照开始时间排序以后,咱们从最小开始时间的区间开始遍历,每次选择覆盖左端点的区间中右端点坐标最大的一个,并将左端点更新为该区间的右端点坐标,直到选择的区间已包含右端点。按照这种方法咱们就能够得到最优解,可是为何呢?算法其实根据区间开始时间的值将区间进行了分组:在给定范围左端点左侧的和在左端点右侧的。因为咱们按照开始时间排序,因此这两组区间的分界线很明确。而为了覆盖给定的范围,咱们必需要从分界线左侧的区间中选一个(不然就不能覆盖整个范围)。上述算法选择了能覆盖给定范围左端点中右端点最大的区间,这是一个最优的选择。对剩余的区间都执行这样的选择显然能够得到最优解。

图8 按照开始时间排序的最小区间覆盖示意图

       图八给出一个示例。四个区间已经按照开始时间排序,咱们从I1开始遍历。I1和I2都覆盖左端点,I3不覆盖,选择右端点最大的一个end1做为新的左端点,而且将I1添加到最小覆盖区间中。而后重复上述步骤,将剩余的区间和新的左端点比较并选择右端点最大的区间,修改左端点,这时左端点就会变为end4,I4添加到最小覆盖区间中。依次处理剩余的区间,咱们就得到了最优解。代码实现以下:

const int MAX_N=100000;  
//输入  
int N,S[MAX_N],T[MAX_N];  
  
//用于对工做排序的pair数组  
pair<int,int> itv[MAX_N];  
  
int solve(int s,int t)  
{  
    for(int i=0;i<N;i++)  
    {  
        itv[i].first=S[i];  
        itv[i].second=T[i];  
    }  
  
    //按照开始时间排序  
    sort(itv,itv+N);  
  
    int ans=0,max_right=0;  
    for (int i = 0; i < N; )  
    {  
        //从开始时间在s左侧的区间中挑出最大的结束时间  
        while(itv[i].first<=s)  
        {  
            if(max_right<itv[i].end) max_right=itv[i].end;  
            i++;  
        }     
  
        if(max_right>s)   
        {  
             s=max_right;  
             ans++;  
            if(s>=t) return ans;  
        }  
        else //若是分界线左侧的区间不能覆盖s,则不可能有区间组合覆盖给定范围  
        {  
                return -1;  
        }  
    }  
}  

本博客详细介绍了几类区间调度问题,给出了最优解的思路和代码。虽然并无彻底覆盖区间调度问题,可是已足以让你们应对各类笔试面试。关于还没有触及的区间调度问题及相关例题,你们可进一步参考算法合集之《浅谈信息学竞赛中的区间问题》。下表给出了每一个问题的最优解法以及复杂度(因为全部的问题都要先进行排序,因此咱们只关注扫描的复杂度)。

相关文章
相关标签/搜索