洛谷P2365/5785 任務安排 題解 斜率優化DP

quanjun發表於2020-07-16

任務安排1(小資料):https://www.luogu.com.cn/problem/P2365
任務安排2(大資料):https://www.luogu.com.cn/problem/P5785

題目描述

\(N\) 個任務排成一個序列在一臺機器上等待執行,它們的順序不得改變。機器會把這 \(N\) 個任務分成若干批,每一批包含連續的若干個任務。從時刻 \(0\) 開始,任務被分批加工,執行第 \(i\) 個任務所需的時間是 \(T_i\)。另外,在每批任務開始前,機器需要 \(S\) 的啟動時間,故執行一批任務所需的時間是啟動時間 \(S\) 加上每個任務所需時間之和。

一個任務執行後,將在機器中稍作等待,直至該批任務全部執行完畢。也就是說,同一批任務將在同一時刻完成。每個任務的費用是它的完成時刻乘以一個費用係數 \(C_i\)

請為機器規劃一個分組方案,使得費用最小。

輸入格式

第一行是 \(N\) ,第二行是 \(S\)

下面 \(N\) 行每行有一對數,分別為 \(T_i\)\(C_i\),均為不大於 \(100\) 的正整數,表示第 \(i\) 個任務單獨完成所需的時間是 \(T_i\) 機器費用係數 \(C_i\)

輸出格式

輸出一個整數,表示最小的總費用。

樣例輸入

5
1
1 3
3 2
4 3
2 3
1 4

樣例輸出

153

資料規模

50%的資料保證 \(1 \lt N \le 5000, 1 \le S \le 50, 1 \le T_i, C_i \le 100\)
100%的資料保證 \(1 \le N \le 3 \times 10^5, 1 \le S,T_i,C_i \le 512\)

問題分析

解法一:

求出 \(T,C\) 的字首和 \(sumT,sumC\),即

\[sumT[i] = \sum_{j=1}^{i} T_j \]

\[sumC[i] = \sum_{j=1}^{i} C_j \]

\(F[i][j]\) 表示把前 \(i\) 個任務分成 \(j\) 批執行的最小費用,則第 \(j\) 批任務的完成時間就是 \(j \times S + sumT[i]\)

以第 \(j-1\) 批和第 \(j\) 批任務的分界點為DP的“決策”,(設第 \(j-1\) 批的最後一個任務是 \(k\),第 \(j\) 批的最後一個任務是 \(i\))狀態轉移方程為:

\[F[i][j] = \min_{0 \le k \ \lt i} \{ F[k][j-1] + (S \times j + sumT[i]) \times (sumC[i] - sumC[k]) \} \]

實現程式碼如下:

#include <bits/stdc++.h>
using namespace std;
const int maxn = 5000;
int n, S, T, C, sumT[maxn], sumC[maxn], f[maxn][maxn], ans = -1;
int main() {
    cin >> n >> S;
    for (int i = 1; i <= n; i ++) {
        cin >> T >> C;
        sumT[i] = sumT[i-1] + T;
        sumC[i] = sumC[i-1] + C;
    }
    memset(f, -1, sizeof(f));
    for (int j = 1; j <= n; j ++) {
        for (int i = j; i <= n; i ++) {
            if (j == 1) {
                f[i][j] = (S + sumT[i]) * sumC[i];
                continue;
            }
            for (int k = j-1; k < i; k ++) {
                assert(f[k][j-1] != -1);
                int tmp = f[k][j-1] + (S * j + sumT[i]) * (sumC[i] - sumC[k]);
                if (f[i][j] == -1 || f[i][j] > tmp) f[i][j] = tmp;
            }
        }
    }
    for (int i = 1; i <= n; i ++) {
        if (ans == -1 || ans > f[n][i]) ans = f[n][i];
    }
    cout << ans << endl;
    return 0;
}

該解法的時間複雜度是 \(O(n^3)\)

解法二:

本題並沒有規定需要把任務分成多少批,在上一個解法中之所以需要批數 \(j\),是因為我們需要知道機器啟動了多少次(每次啟動都要 \(S\) 單位時間),從而計算出 \(i\) 所在的一批任務的完成時刻。

事實上,在執行一批任務時,我們不容易直接得知在此之前機器啟動過幾次。但我們知道,機器因執行這批任務而花費的啟動時間 \(S\),會累加到在此之後所有任務的完成時刻上。

\(F[i]\) 表示把前 \(i\) 個任務分成若干批執行的最小費用,狀態轉移方程為:

\[F[i] = \min \{ F[j] + sumT[i] \times (sumC[i] - sumC[j]) + S \times (sumC[N] - sumC[j]) \} \]

在上式中,第 \(j+1 \sim i\) 個任務在同一批內完成,\(sumT[i]\) 是忽略機器的啟動時間,這批任務的完成時刻。因為這批任務的執行,機器的啟動時間 \(S\) 會對第 \(j+1\) 個之後的所有任務產生影響,故我們把這部分補充到費用中。

也就是說,我們沒有直接求出每批任務的完成時間,而是在一批任務“開始”對後續任務產生影響時,就先把費用累加到結果中。這是一種名為 “費用提前計算” 的經典思想。

實現程式碼如下:

#include <bits/stdc++.h>
using namespace std;
const int maxn = 5000;
int n, S, T, C, sumT[maxn], sumC[maxn], f[maxn];
int main() {
    cin >> n >> S;
    for (int i = 1; i <= n; i ++) {
        cin >> T >> C;
        sumT[i] = sumT[i-1] + T;
        sumC[i] = sumC[i-1] + C;
    }
    memset(f, -1, sizeof(f));
    f[0] = 0;
    for (int i = 1; i <= n; i ++) {
        for (int j = 0; j < i; j ++) {
            int tmp = f[j] + sumT[i] * (sumC[i] - sumC[j]) + S * (sumC[n] - sumC[j]);
            if (f[i] == -1 || f[i] > tmp) f[i] = tmp;
        }
    }
    cout << f[n] << endl;
    return 0;
}

該解法的時間複雜度為 \(O(N^2)\)

解法三:

對上一題的演算法二進行優化,先對狀態轉移方程稍作變形,把常數、僅與 \(i\) 有關的項、僅與 \(j\) 有關的項 以及 \(i,j\) 的乘積項分開。

\[F[i] = \min_{0 \le j \lt i} \{ F[j] - (S + sumT[i]) \times sumC[j] \} + sumT[i] \times sumC[i] + S \times sumC[N] \]

\(\min\) 函式去掉,把關於 \(j\) 的值 \(F[j]\)\(sumC[j]\) 看做變數,其餘部分看做常數,得到:

\[F[j] = (S + sumT[i]) \times sumC[j] + F[i] - sumT[i] \times sumC[i] - S \times sumC[N] \]

\(sumC[j]\) 為橫座標, \(F[j]\) 為縱座標的平面直角座標系中,這是一條以 \(S + sumT[i]\) 為斜率,\(F[i] - sumT[i] \times sumC[i] - S \times sumC[N]\) 為截距的直線。也就是說,決策候選集合是座標系中的一個點集,每個決策 \(j\) 都對應著座標系中的一個點 \((sumC[j], F[j])\)。每個待求解的狀態 \(F[i]\) 都對應著一條直線的截距,直線的斜率是一個固定的值 \(S + sumT[i]\),截距未知。當截距最小化時,\(F[i]\) 也取到最小值。

該問題實際上是一個線性規劃問題,高中數學有所涉及。令直線過每個決策點 \((sumC[j], F[j])\),都可以求得一個截距,其中使截距最小的一個就是最優決策。體現在座標系中,就是用一條斜率為固定正整數的直線自下而上平移,第一次接觸到某個決策點時,就得到了最小截距。如圖所示:

對於任意三個決策點 \((sumC[j_1], F[j_1])\)\((sumC[j_2], F[j_2])\)\((sumC[j_3], F[j_3])\),不妨設 \(j_1 \lt j_2 \lt j_3\),因為 \(T,C\) 均為正整數,亦有 \(sumC[j_1] \lt sumC[j_2] \lt sumC[j_3]\)。根據及時排除無用決策的思想,我們考慮 \(j_2\) 可能成為最優決策的條件。

從上圖中我們發現,\(j_2\) 有可能成為最優決策,當且僅當 \(j_1\)\(j_2\) 的斜率小於 \(j_2\)\(j_3\) 的斜率,即:

\[\frac{F[j_2] - F[j_1]}{sumC[j_2] - sumC[j_1]} \lt \frac{F[j_3] - F[j_2]}{sumC[j_3] - sumC[j_2]} \]

小於號兩側實際上都是連線兩個決策點的線段的斜率。通俗地講,我們應該維護“連線相鄰兩點的線段斜率”單調遞增的一個“下凸殼”,只有這個“下凸殼”的頂點才有可能成為最優決策。實際上,對於一條斜率為 \(k\) 的直線,若某個頂點左側線段線段的斜率比 \(k\) 小,右側線段的斜率比 \(k\) 大,則該頂點就是最優決策。換言之,如果把這條直線和所有線段組成一個序列,那麼令直線截距最小化的頂點就出現在按照斜率大小排序時,直線應該排在的位置上。如圖所示:

在本題中,\(j\) 的取值範圍是 \(0 \le j \lt i\),隨著 \(i\) 的增大,每次會有一個新的決策進入候選集合。因為 \(sumC\) 的單調性,新決策在座標系中的橫座標一定大於之前的所有決策,出現在凸殼的最右端。另外,因為 \(sumT\) 的單調性,每次求解“最小截距”的直線斜率 \(S+sumT[i]\) 也單調遞增,如果我們只保留凸殼上“連線相鄰兩點的線段斜率”大於 \(S+sumT[i]\) 的部分,那麼凸殼的最左端點就一定是最優決策。

綜上所述,我們可以建立單調佇列 \(q\),維護這個下凸殼。佇列中儲存若干個決策變數,它們對應凸殼上的頂點,且滿足橫座標 \(sumC\) 遞增、連線相鄰兩點的線段斜率也遞增。需要支援的操作與一般的單調佇列題目類似,對於每個狀態變數 \(i\)

  1. 檢查隊首的兩個決策變數 \(Q_l\)\(Q_{l+1}\),若斜率 \(\frac{F[Q_{l+1}] - F[Q_l]}{sumC[Q_{l+1}] - sumC[Q_l]} \le S + sumT[i]\),則讓 \(Q_l\) 出隊,繼續檢查新的隊首。
  2. 直接取隊首 \(j = Q_l\) 為最優決策,執行狀態轉移,計算出 \(F[i]\)
  3. 把新決策 \(i\) 從隊尾插入,在插入之前,若三個決策點 \(j_1 = Q_{r-1}, j_2 = Q_r, j_3 = i\) 不滿足斜率單調遞增(不滿足下凸性,即 \(j_2\) 是無用決策),則直接從隊尾讓 \(Q_r\) 出隊,繼續檢查新的隊尾。

實現程式碼如下:

#include <bits/stdc++.h>
using namespace std;
const int maxn = 300030;
int n, q[maxn], l, r;
long long S, T, C, sumT[maxn], sumC[maxn], f[maxn];
int main() {
    cin >> n >> S;
    for (int i = 1; i <= n; i ++) {
        cin >> T >> C;
        sumT[i] = sumT[i-1] + T;
        sumC[i] = sumC[i-1] + C;
    }
    memset(f, -1, sizeof(f));
    f[0] = 0;
    q[l = r = 1] = 0;
    for (int i = 1; i <= n; i ++) {
        while (l < r && f[q[l+1]] - f[q[l]] <= (S + sumT[i]) * (sumC[q[l+1]] - sumC[q[l]])) l ++;
        f[i] = f[q[l]] - (S + sumT[i]) * sumC[q[l]] + sumT[i] * sumC[i] + S * sumC[n];
        while (l < r && (f[q[r]]-f[q[r-1]]) * (sumC[i]-sumC[q[r]]) >= (f[i]-f[q[r]]) * (sumC[q[r]]-sumC[q[r-1]])) r --;
        q[++r] = i;
    }
    cout << f[n] << endl;
    return 0;
}

整個演算法的時間複雜度為 \(O(N)\)

與一般的單調佇列優化DP的模型相比,本題維護的“單調性”依賴於佇列中相鄰兩個元素之間的某種“比值”。因為這個值對應線性規劃的座標系中的斜率,所以我們在本題中使用的優化方法稱為“斜率優化”。


以上分析針對 \(T_i\) 為正數的情況,接下來我們來考慮 \(T_i\) 為負數的情況。


與任務安排1不同的是,任務安排2中任務的執行時間 \(T\) 可能是負數。這意味著 \(sumT\) 不具有單調性,從而需要最小化截距的直線的斜率 \(S + sumT[i]\) 不具有單調性。所以,我們不能在單調佇列中只保留凸殼上“連線相鄰兩點的線段斜率”大於 \(S + sumT[i]\) 的部分,而是必須維護整個凸殼。這樣一來,我們就不需要在隊首把斜率與 \(S + sumT[i]\) 比較。

隊首也不一定是最優決策,我們可以在單調佇列中二分查詢,求出一個位置 \(p\)\(p\) 左側線段的斜率比 \(S + sumT[i]\) 小,右側線段的斜率比 \(S+sumT[i]\) 大,\(p\) 就是最優決策。

實現程式碼如下:

#include <bits/stdc++.h>
using namespace std;
const int maxn = 300030;
int n, q[maxn], l, r;
long long S, T, C, sumT[maxn], sumC[maxn], f[maxn];
int my_binary_search(int k) {
    if (l == r) return q[l];
    int L = l, R = r;
    while (L < R) {
        int mid = (L + R) / 2;
        if (f[q[mid+1]] - f[q[mid]] <= k * (sumC[q[mid+1]] - sumC[q[mid]])) L = mid + 1;
        else R = mid;
    }
    return q[L];
}
int main() {
    cin >> n >> S;
    for (int i = 1; i <= n; i ++) {
        cin >> T >> C;
        sumT[i] = sumT[i-1] + T;
        sumC[i] = sumC[i-1] + C;
    }
    memset(f, -1, sizeof(f));
    f[0] = 0;
    q[l = r = 1] = 0;
    for (int i = 1; i <= n; i ++) {
        int p = my_binary_search(S + sumT[i]);
        f[i] = f[p] - (S + sumT[i]) * sumC[p] + sumT[i] * sumC[i] + S * sumC[n];
        while (l < r && (f[q[r]]-f[q[r-1]]) * (sumC[i]-sumC[q[r]]) >= (f[i]-f[q[r]]) * (sumC[q[r]]-sumC[q[r-1]])) r --;
        q[++r] = i;
    }
    cout << f[n] << endl;
    return 0;
}

相關文章