區間調度問題

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;  
        }  
    }  
}  

本博客詳細介紹了幾類區間調度問題,給出了最優解的思路和代碼。雖然並無徹底覆蓋區間調度問題,可是已足以讓你們應對各類筆試面試。關於還沒有觸及的區間調度問題及相關例題,你們可進一步參考算法合集之《淺談信息學競賽中的區間問題》。下表給出了每一個問題的最優解法以及複雜度(因爲全部的問題都要先進行排序,因此咱們只關注掃描的複雜度)。

相關文章
相關標籤/搜索