任務安排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\),即
設 \(F[i][j]\) 表示把前 \(i\) 個任務分成 \(j\) 批執行的最小費用,則第 \(j\) 批任務的完成時間就是 \(j \times S + sumT[i]\)。
以第 \(j-1\) 批和第 \(j\) 批任務的分界點為DP的“決策”,(設第 \(j-1\) 批的最後一個任務是 \(k\),第 \(j\) 批的最後一個任務是 \(i\))狀態轉移方程為:
實現程式碼如下:
#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\) 個任務分成若干批執行的最小費用,狀態轉移方程為:
在上式中,第 \(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\) 的乘積項分開。
把 \(\min\) 函式去掉,把關於 \(j\) 的值 \(F[j]\) 和 \(sumC[j]\) 看做變數,其餘部分看做常數,得到:
在 \(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\) 的斜率,即:
小於號兩側實際上都是連線兩個決策點的線段的斜率。通俗地講,我們應該維護“連線相鄰兩點的線段斜率”單調遞增的一個“下凸殼”,只有這個“下凸殼”的頂點才有可能成為最優決策。實際上,對於一條斜率為 \(k\) 的直線,若某個頂點左側線段線段的斜率比 \(k\) 小,右側線段的斜率比 \(k\) 大,則該頂點就是最優決策。換言之,如果把這條直線和所有線段組成一個序列,那麼令直線截距最小化的頂點就出現在按照斜率大小排序時,直線應該排在的位置上。如圖所示:
在本題中,\(j\) 的取值範圍是 \(0 \le j \lt i\),隨著 \(i\) 的增大,每次會有一個新的決策進入候選集合。因為 \(sumC\) 的單調性,新決策在座標系中的橫座標一定大於之前的所有決策,出現在凸殼的最右端。另外,因為 \(sumT\) 的單調性,每次求解“最小截距”的直線斜率 \(S+sumT[i]\) 也單調遞增,如果我們只保留凸殼上“連線相鄰兩點的線段斜率”大於 \(S+sumT[i]\) 的部分,那麼凸殼的最左端點就一定是最優決策。
綜上所述,我們可以建立單調佇列 \(q\),維護這個下凸殼。佇列中儲存若干個決策變數,它們對應凸殼上的頂點,且滿足橫座標 \(sumC\) 遞增、連線相鄰兩點的線段斜率也遞增。需要支援的操作與一般的單調佇列題目類似,對於每個狀態變數 \(i\):
- 檢查隊首的兩個決策變數 \(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\) 出隊,繼續檢查新的隊首。
- 直接取隊首 \(j = Q_l\) 為最優決策,執行狀態轉移,計算出 \(F[i]\)。
- 把新決策 \(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;
}