洛谷P2365/5785 任務安排 題解 斜率優化DP
阿新 • • 發佈:2020-07-17
任務安排1(小資料):[https://www.luogu.com.cn/problem/P2365](https://www.luogu.com.cn/problem/P2365)
任務安排2(大資料):[https://www.luogu.com.cn/problem/P5785](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]) \}$$
實現程式碼如下:
```c++
#include
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$ 個之後的所有任務產生影響,故我們把這部分補充到費用中。
也就是說,我們沒有直接求出每批任務的完成時間,而是在一批任務“開始”對後續任務產生影響時,就先把費用累加到結果中。這是一種名為 __“費用提前計算”__ 的經典思想。
實現程式碼如下:
```c++
#include
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])$,都可以求得一個截距,其中使截距最小的一個就是最優決策。體現在座標系中,就是用一條斜率為固定正整數的直線自下而上平移,第一次接觸到某個決策點時,就得到了最小截距。如圖所示:
![](https://img2020.cnblogs.com/blog/1878967/202007/1878967-20200716192351077-1783068206.png)
對於任意三個決策點 $(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$ 可能成為最優決策的條件。
![](https://img2020.cnblogs.com/blog/1878967/202007/1878967-20200716194148338-890994987.png)
從上圖中我們發現,$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$ 大,則該頂點就是最優決策。換言之,如果把這條直線和所有線段組成一個序列,那麼令直線截距最小化的頂點就出現在按照斜率大小排序時,直線應該排在的位置上。如圖所示:
![](https://img2020.cnblogs.com/blog/1878967/202007/1878967-20200716195919825-989340491.png)
在本題中,$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$ 出隊,繼續檢查新的隊尾。
實現程式碼如下:
```c++
#include
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$ 就是最優決策。
實現程式碼如下:
```c++
#include
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