第二週教材中分治法,提到了每個子問題都需互相獨立,就是每分割都將產生全新的問題
而本週介紹的動態規劃則適合在大部分的問題互相重疊
回憶一下第二週教材的那段話: "從邊界遞推地紀錄所有問題的解,且一個項用到前一項的最佳結果,就是動態規劃的精神。"
這裡給出正式的定義:
一個問題若能用 Dynamic Programming (以下簡稱 DP) 求解,其該問題含有以下三個性質:
DP 不滿足第一週教材中提到的演算法三步驟,它僅是個方法
考慮一個問題
要買價值
日常生活中有個貪心策略是,每次用額面較大的硬幣去付
這樣就能使用較少的硬幣數,也就是
同樣
顯然上述貪心策略就不是最佳策略了。
設
若第一次:
將問題以
也就是說,求解
以及邊界:
最少即
若求出了
若求
也就是說無需再為
有非常多實用的演算法是使用 DP 方法所設計的,
比起學習這些演算法的實作,更重要的是要學習如何應用 DP 去設計出演算法
Fibonacci 數列由
也就是
求數列第
考慮用遞迴寫:
int solve(int n) {
if (n == 1 || n == 2) return 1;
return solve(n-1) + solve(n-2);
}
每個子問題都將分為兩個子問題,複雜度為
而實際上此演算法將同樣的子問題計算過兩次以上
例如在 solve(3)
兩次:
也就是說,只要在每次解出子問題後將結果記錄,下一次遇到重複的子問題就無需計算
複雜度改進為
int solve(int n) {
if (fib[n]) return fib[n];
return fib[n] = solve(n-1) + solve(n-2);
}
由原問題開始遞歸逐步計算子問題的解
優點:不會計算太多子問題
缺點:呼叫函式的成本頗高
fib[1] = fib[2] = 1;
for (int i = 3; i <= n; i++) fib[i] = fib[i-1] + fib[i-2];
由邊界(最小子問題)逐步計算到原問題的解
優點:若適當的設計迭代順序則能很好的壓縮空間
缺點:可能會多計算無用的子問題
CODEFORCES 327A Flipping Game
CODEFORCES 1033C Permutation Game
* CODEFORCES 1133E K Balanced Teams
在繼續往下讀前,先複習第四週教材中關於搜尋的術語
給固定體積的背包,以及各種體積及價值不盡相同的物品
在背包容量不超過體積上限的前提,使總價值最大。
經典的背包問題有以下幾類:
先用一個比較生活化的例子來說,假設有一個古代的東方商人想沿著絲路走至西方販賣珍奇古物,但是他當然沒辦法將所有店內的商品帶去,所以為了最大化此趟旅程所能賺到的錢,他必須在他的大後背包內放入總價值最多的商品
對沒錯,他要用走的
假設他的大背包容量為
Index | 價值 | 體積 |
---|---|---|
1 | ||
2 | ||
3 | ||
4 | ||
5 |
以商品都能賣出去為前提,請問該怎麼帶才能最大化收益呢?
在進入正題前,讀者可以先思考一下這個看起來似乎蠻簡單的問題
要使得總價值最大不就是先算好各物品的 C/P 值,然後從最高的開始挑起嗎?
這樣做會發生什麼事? 最佳解又是多少?
背包問題的術語約定:
例如:
價值 | 體積 |
---|---|
假設剩下 |
|
但在 01 背包問題中,只能選擇 |
先設計出狀態的定義,以及狀態轉移方程:
狀態
這個最大值,是下列兩項其中之一
也就是狀態轉移方程:
而當背包體積小於
以及邊界 (當沒有挑任何物品時):
假設有
for (int n = 0; n <= maxn; n++) S[0][n] = 0;
for (int i = 1; i < C; i++)
for (int n = 0; n <= maxn; n++) {
if (n >= V[i])
S[i][n] = max(S[i-1][n], S[i-1][n-V[i]] + P[i]);
else
S[i][n] = S[i-1][n];
}
狀態定義
然後面對第
for (int i = 1; i < C; i++)
for (int n = maxn; n >= V[i]; n--)
S[n] = max(S[n], S[n-V[i]] + P[i]);
這裡有個重點:
for (int n = maxn; n >= V[i]; n--)
此順序必須為從大到小,
舉個反例,若當
由於若
這個優化叫做滾動陣列
辛普森先生吃 Krusty 漢堡需要
給定
如果一定得浪費時間,辛普森先生可以喝啤酒(?)
要求輸出辛普森先生在時限內可以吃進最多的漢堡數,若可以喝啤酒,則需要再輸出浪費多少時間。
因為每種漢堡可以吃無限多個,但 01 背包問題限制每種只能選一次
也就是說,只要創造更多種漢堡就好!
對於原本的漢堡
創造出個數(價值)為
不過各位學過二進制都知道,只需要
int C = 1;
for (int i = 0; m * pow(2, i) <= t; i++) {
V[C] = m * pow(2, i);
P[C] = pow(2, i);
C++;
}
for (int i = 0; n * pow(2, i) <= t; i++) {
V[C] = n * pow(2, i);
P[C] = pow(2, i);
C++;
}
轉成背包問題的術語後,
定義狀態
則狀態轉移方程:
但當
而邊界為 (當背包體積為
上段創造了
for (int i = 1; i < C; i++)
for (int n = 0; n <= t; n++)
S[i][n] = (n==0)? 0 : -1;
for (int i = 1; i < C; i++)
for (int n = 0; n <= t; n++) {
if (n >= V[i] && S[i-1][n-V[i]] != -1)
S[i][n] = max(S[i-1][n], S[i-1][n-V[i]] + P[i]);
else
S[i][n] = S[i-1][n];
}
其中 -1
代表無解的狀態。
接著看浪費多少體積:
for (int n = t; n >= 0; n--) if (S[C-1][n]) {
printf("%d", S[C-1][n]);
if (n < t) printf(" %d", t-n);
putchar('\n');
break;
}
UVa OJ 624 CD
UVa OJ 10130 SuperSale
UVa OJ 10819 Trouble of 13-Dots
首先設計出此問題的狀態定義:
最好盡可能簡單直覺
假設背包體積為
所以,狀態轉移方程為:
對於解
,每次討論 物品時,子問題都需先得到最優解
也就是要先得到最優解,才能求解
以及邊界 (還未挑任何物品):
假設有
memset(S, 0, sizeof S); // 還未挑任何東西的初始邊界
for (int i = 1; i <= C; i++)
for (int n = V[i]; n <= maxn; n++)
S[n] = max(S[n], S[n-V[i]] + P[i]);
與 01 背包的空間優化版本相反,這裡
理解這段程式碼得先看懂 01 背包的空間優化
與 01 背包問題的範例一樣:
memset(S, -1, sizeof S);
S[0] = 0;
for (int j = m; j <= t; j++) if (S[j-m] != -1) S[j] = max(S[j], S[j-m] + 1);
for (int j = n; j <= t; j++) if (S[j-n] != -1) S[j] = max(S[j], S[j-n] + 1);
接著看浪費多少時間:
for (int i = t; i >= 0; i--) if (S[i]) {
printf("%d", S[i]);
if (i < t) printf(" %d", t-i);
putchar('\n');
break;
}
UVa OJ 10980 Lowest Price in Town
POJ 2063 Investment
Longest Increasing Subsequence 中譯為 最長遞增子序列
在給定
例如
則 LIS 為
仔細考慮,若某數字在某遞增子序列後出現,並且它比此序列的末項還大,那麼加入它就能形成更長的遞增子序列!
不過在那之前,這個遞增序列是如何求得的?
通過上述,定義狀態
意思是在
之間找個一定要包含 的 LIS
且狀態轉移方程為,對於所有
也就是找出所有在
之前的遞增子序列,選出最長的
若找不到
for (int n = 1; n <= N; n++) {
S[n] = 1;
f[n] = n;
}
for (int n = 2; n <= N; n++) // N 為 a 的總長度
for (int i = 1; i <= n; i++)
if (a[n] > a[i] && S[n] < S[i] + 1) {
S[n] = S[i] + 1;
f[n] = i; // 紀錄遞增子序列
}
複雜度為
其中 f[n]
代表遞增子序列中 a[f[n]]
下個接 a[n]
例如
得出
所以利用 f
就能將其中一個 LIS 輸出!
表示 為欲輸出的 LIS 首項
直覺的,當前 LIS 末項數字越小,那麼越有可能使得 LIS 繼續變長
根據這樣的貪心策略,
定義狀態
狀態轉移方程:
有可能無解, 或許找不到長度為 的 LIS
邊界:
for (int l = 1; l <= N; l++) S[1][l] = INF;
S[1][1] = a[1];
int t = 1; // LIS 初始長度
for (int i = 2; i <= N; i++) {
for (int l = 1; l <= t; l++) S[i][l] = min(a[i], S[i-1][l]);
if (a[i] > S[i-1][t]) S[i][++t] = a[i];
}
這裡尚未用
f
去紀錄 LIS
由於狀態的解是
同學自行研究,這很簡單的
且這裡
for (int i = 1; i <= N; i++) S[i] = INF, f[i] = i;
S[1] = a[1];
int t = 1; // LIS 初始長度
for (int i = 1; i <= N; i++) {
int l = lower_bound(S+1, S+t+1, a[i]) - S;
if (l == t+1) t++;
S[l] = a[i];
p[l] = i;
f[i] = (l==1)? i : p[l-1];
}
其中 p[l]
為 S[l]
在原本
且由於長度是從
複雜度為
UVa OJ 10131 Is Bigger Smarter?
UVa OJ 10534 Wavio Sequence
UVa OJ 437 The Tower of Babylon
CODEFORCES 1257E The Contest
Longest Common Subsequence 中譯為 最長公共子序列
在兩個序列
例如:
則 LCS 為
與 LIS 類似,
若某數字在某公共子序列後出現,並且它為兩序列的公共數字,那麼加入它就能形成更長的公共子序列!
不過在那之前,這個公共序列是如何求得的?
通過上述,定義狀態
則狀態轉移方程為:
也就是找出在
假設
for (int n = 1; n <= AN; n++)
for (int m = 1; m <= BN; m++) {
if (A[n] == B[m])
S[n][m] = S[n-1][m-1] + 1;
if (A[n] != B[m])
S[n][m] = max(S[n-1][m], S[n][m-1]);
}
一樣的,假設
則輸出
0 | 1 |
3 |
7 | 7 | 4 |
1 |
9 | 9 |
|
1 |
0 | 1 |
1 | 1 | 1 | 1 | 1 | 1 | 1 |
4 | 0 | 1 | 1 | 1 | 1 | 2 | 2 | 2 | 2 |
2 | 0 | 1 | 1 | 1 | 1 | 2 | 2 | 2 | 2 |
3 |
0 | 1 | 2 |
2 | 2 | 2 | 2 | 2 | 2 |
8 | 0 | 1 | 2 | 2 | 2 | 2 | 2 | 2 | 2 |
3 | 0 | 1 | 2 | 2 | 2 | 2 | 2 | 2 | 2 |
4 |
0 | 1 | 2 | 2 | 2 | 3 |
3 | 3 | 3 |
1 |
0 | 1 | 2 | 2 | 2 | 3 | 4 |
4 | 4 |
9 |
0 | 1 | 2 | 2 | 2 | 3 | 4 | 5 | 5 |
標上紫色的為 LCS 中的數,而粉紅則為此時的長度 | |||||||||
也就是說,只要順著狀態轉移方程以及 |
int n = AN, m = BN;
while(n && m) {
if (S[n][m] != max(S[n-1][m], S[n][m-1])) {
LCS.push_back(A[n]);
n--; m--;
}
else if (S[n][m] == S[n-1][m]) n--;
else if (S[n][m] == S[n][m-1]) m--;
}
reverse(LCS.begin(), LCS.end());
UVa OJ 10405 Longest Common Subsequence
UVa OJ 10066 The Twin Towers
UVa OJ 10192 Vacation
UVa OJ 531 Compromise
貪心法是 DP 的一種特例
回憶一下第二週教材的那段話:
"每次做一個在當下看起來最佳的決策,進而漸漸求出全局最佳解"
一旦證明問題能被貪心法解決,通常貪心法是問題的最佳策略,因為放棄搜索所有解答空間,效率會提高不少。
通常觀察出一個問題是否有最優子結構時
就可假設看看:下某個決策能漸漸朝全局最佳解的方向走
接著證明每次做這個決策一定不會更差
證明(或直覺?)很重要,有些看似美好的假設,不見得能求得問題的解
直覺是先讓吃最慢的人先上菜
比較看看,若
將總時間的
所以先讓
#include<bits/stdc++.h>
using namespace std;
int const maxn = 1e4 + 10;
int N, C[maxn], E[maxn];
int main()
{
while(scanf("%d", &N) && N) {
vector<int> idx;
for(int i = 0; i < N; i++) {
scanf("%d%d", &C[i], &E[i]);
idx.push_back(i);
}
sort(idx.begin(), idx.end(), [&](int x, int y) { return E[x] > E[y]; });
int cook = 0, time = 0;
for(int i: idx) {
cook += C[i];
time = max(time, cook + E[i]);
}
printf("%d\n", time);
}
return 0;
}
是第一場比賽的題目呢
模擬切格子的步驟就行了
從最高的地方 (maxh
) 一路一層層往最低處 (1
) 看
for(int i = maxh; i >= 1; i--) {
:
.
}
對於每一層,收集該層的格子們 sum
中
sum += s;
但是當該層的格子加進 sum
中會超過
此時這個位置就是要 slice 的地方
if(sum + s > k) slice++, sum = 0;
而當
if(s == n) {
if(sum) slice++;
break;
}
這裡特別注意 sum
如果還有東西,表示結束前還要切一刀 (slice)
在以上步驟之前,我們還得問,如何知道某層的
for(int i = 0; i < n; i++) scanf("%d", &h), s[h]++;
for(int i = maxh; i >= 1; i--) s[i-1] += s[i];
以下完整解題程式碼:
#include<bits/stdc++.h>
using namespace std;
int const maxh = 2e5 + 10;
int n, k, s[maxh];
int main()
{
scanf("%d%d", &n, &k);
int h;
for(int i = 0; i < n; i++) scanf("%d", &h), s[h]++;
for(int i = maxh-1; i >= 1; i--) s[i-1] += s[i];
int sum = 0, ans = 0;
for(int i = maxh-1; i >= 1; i--) {
if(s[i] == n) {
if(sum) ans++;
break;
}
if(sum + s[i] > k) ans++, sum = 0;
sum += s[i];
}
printf("%d\n", ans);
return 0;
}
CODEFORCES 1140D Minimum Triangulation
ZEROJUDGE d652 貪婪之糊
ZEROJUDGE d133 00357 - Let Me Count The Ways
TIOJ 1861 蘿莉切割問題
TIOJ 1636 錢包的路
STEP5 0021 背包問題
這單字並沒有拼錯。 ↩︎