區間調度問題


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],它們重疊的可能性有四種:

 

可以看出,如果直接考慮區間重疊,判斷條件比較復雜,我們從相反的角度考慮,考慮區間不重疊的情況。區間不重疊時的判斷條件為:

 

也即:(e1<s2|| s1>e2),所以區間重疊的判斷條件為:

經過化簡之后,區間重疊的判斷條件只有兩個,也很好理解,不再贅述。如果區間是半開半閉的,則只需要將判斷條件中的等號去掉。

       現在考慮這樣一個問題,如何判斷一個區間是否和其他的區間重疊。最壞情況下,我們可能需要和剩下的所有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<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;  
        }  
    }  
}  

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM