tags |
---|
成大高階競技程式設計 2020, ys |
第三週教材中分治法,提到了每個子問題都需互相獨立,就是每分割都將產生全新的問題 而本次介紹的動態規劃則適合在大部分的問題互相重疊
回憶教材中的那段話: "從邊界遞推地紀錄所有問題的解,且一個項用到前一項的最佳結果,就是動態規劃的精神。"
這裡給出正式的定義: 問題若能用 Dynamic Programming (以下簡稱 DP) 求解,其該問題含有以下三個性質:
- 最優子結構 問題的最優解,是由子問題們的最優解推得。其子問題也具有同樣的特性
- 重複子問題 有很多子問題可歸為同樣的問題
- 無後效性 確定的子問題解,並不會受到其他決策影響(還在整個問題的求解過程中)
==DP 不滿足第一週教材中提到的演算法三步驟,它僅是個方法==
考慮一個問題
:::info
要買價值
:::info
若現在硬幣額面只有 3 種分別為
設
- 選
$11$ 元則硬幣數為$f(4) + 1$ - 選
$5$ 元則硬幣數為$f(10) + 1$ - 選
$1$ 元則硬幣數為$f(14) + 1$
所以從三種方案中挑最小值即可,則將問題以
也就是說,求解
以及邊界:
$0$ 元只需$0$ 個硬幣
若求出了
若求
在繼續往下讀前,先複習第五週教材中關於搜尋的術語
有非常多實用的演算法是使用 DP 方法所設計的, 比起學習這些演算法的實作,更重要的是要學習如何應用 DP 去設計出演算法 同時在介紹經典問題的解法中,會順便引入一些基本的實作技巧。
:::info
數列由
求數列第
直接根據問題規則,用遞迴寫:
int solve(int n) {
if (n == 0 || n == 1) return n; // 邊界
return solve(n-1) + solve(n-2);
}
第
而實際上此演算法將同樣的子問題計算過兩次以上
例如在 solve(3)
兩次:
digraph {
nodesep=0.4; //was 0.8
ranksep=0.5;
{ node[style="invis", label=""]; x_5; }
{ node[style="invis", label="", width=.1]; x_4; x_3; lx_3}
{ node[style="filled", fillcolor="#FFA500"]; 3; }
{ node[label="3", style="filled", fillcolor="#FFA500"]; l_3; }
{ node[label="2"]; l_2; }
{ node[label="2"]; r_2; }
{ node[label="2"]; ll_2; }
{ node[label="1"]; r_1; }
{ node[label="1"]; l_1; }
{ rank=same; 4; 3; x_5 }
{ rank=same; l_3; l_2; x_4 }
{ rank=same; r_2; r_1; x_3 }
{ rank=same; ll_2; l_1; lx_3 }
5 -> 4
5 -> 3
4 -> l_3
4 -> l_2
3 -> r_2
3 -> r_1
l_3 -> ll_2
l_3 -> l_1
{
edge[style="invis"];
5 -> x_5
4 -> x_5 -> 3
4 -> x_4
l_3 -> x_4 -> l_2
3 -> x_3
r_2 -> x_3 -> r_1
l_3 -> lx_3
ll_2 -> lx_3 -> l_1
}
}
記憶化 (memoization1)
也就是說,只要在每次解出子問題後將結果記錄,下一次遇到重複的子問題就無需計算
複雜度改進為
由原問題開始遞歸逐步計算子問題的解
int solve(int n) {
if (n == 0 || n == 1) return n;
if (fib[n]) return fib[n];
return fib[n] = solve(n-1) + solve(n-2); // memoization
}
- 較好實作
- 不會計算太多子問題
- 較不需著重於求解順序
- 呼叫函式的成本頗高
由邊界(最小子問題)逐步計算到原問題的解
fib[0] = 0;
fib[1] = 1;
for (int i = 2; i <= n; i++) fib[i] = fib[i-1] + fib[i-2];
- 容易設計適當的迭代順序以優化空間/時間
- 較容易掌握複雜度
- 可能會多計算無用的子問題
CODEFORCES 327A Flipping Game CODEFORCES 1033C Permutation Game
:::info
給
觀察到某數累加至
這給了動機去設計狀態
因為
$(N-a_i)$ 只要加上$a_i$ 就會變為$N$
最開始所有問題待求解,預設為
因為方法數是從
$0$ 開始累加來的
且已知邊界為
和為
$0$ 的方法數,直接就是$1$
memset(dp, 0, sizeof dp);
dp[0] = 1;
求
因為
$\forall a_i > 0$ 則$x-a_i < x$
for (int x = 1; x <= N; x++)
for (int i = 1; i <= m; i++)
if (x-a[i] >= 0) dp[x] += dp[x-a[i]];
由已知的狀態解,去更新還未得解的狀態解
for (int x = 0; x < N; x++)
for (int i = 1; i <= m; i++)
dp[x+a[i]] += dp[x];
同樣計算狀態從小到大,那麼當迭代至 dp[x]
時,已經被較小的的狀態更新完了
NCKU OJ 8 彩彩劈瓦 * CODEFORCES 1133E K Balanced Teams
:::info
從第
回顧教材的上樓梯問題
dp[0] = dp[1] = 1;
for(int i = 2; i <= n; i++) dp[i] = dp[i-1] + dp[i-2];
造成答案變動的決策有每次要走
由於走一次會從當前
$j$ 不能超過$k$ 次,也沒辦法用$i$ 次以上走到第$i$ 階樓梯
邊界為
$dp(0, 0) = 1$ 表示站在地板不需要走就是一種方法
最終答案為
dp[0][0] = dp[1][1] = dp[2][1] = 1;
for(int i = 2; i <= N; i++)
for(int j = 2; j <= min(k, i); j++)
dp[i][j] = dp[i-1][j-1] + dp[i-2][j-1];
int sum = 0;
for(int j = 0; j <= k; j++) sum += dp[N][j];
在求解過程中,變數可能會溢位
:::info
給定圖
直接定義狀態
for(auto [b, w]: E[a]) // E[a] 紀錄 a 的鄰點 b 以及邊權重 w
delta[b] = min(delta[b], delta[a] + w);
這就是種 pushing 轉移
反過來說,對於
這就是種 pulling 轉移
如果圖出現環怎辦? 例如:
digraph {
s -> b
a -> s
a -> c -> b
rankdir="LR";
{ rank="same" b->a }
}
發生這個原因就是因為環,所以需設法使圖為無環 (DAG) 再多加一個狀態或是改變狀態求解的順序,可藉此改變依賴關係的路徑
設狀態
這樣轉移的依賴關係就不會出現環了。
int dfs(int k, int v) {
if(delta[k][v] != INF) return delta[k][v];
if(k <= 0) return INF; // 無解
for(auto [u, w]: E[v]) // E[v] 紀錄 v 的入點 u 以及邊權重 w
delta[k][v] = min(delta[k][v], dfs(k-1, u) + w);
return delta[k][v];
}
用
INF
表示無解或尚未得解
計算
memset(delta, 0x3f, sizeof delta); // INF := 0x3f3f3f3f, 表示尚未得解
for(int k = 0; k < V.size(); k++) delta[k][s] = 0; // 邊界
for(int v: V) dfs(V.size()-1, v);
V.size()-1
由於對於任意點,$s$ 最多只需透過
Kickstart 2018 Round B No Nine
:::info
給固定體積的背包,以及各種體積
經典的背包問題有以下幾類:
- 無限背包問題: 對每種物品,其個數是無限多個
- 01 背包問題:
對每種物品,其個數是
$1$ 個 - 多重背包問題: 對每種物品,其個數是有限多個
狀態
- 包含第
$i$ 個物品:$S(i-1, n - V_i) + P_i$ - 不包含第
$i$ 個物品:$S(i-1, n)$
也就是狀態轉移方程:
$$ S(i, n) = \max(S(i-1, n - V_i) + P_i, S(i-1, n))$$
而當背包體積小於
假設固定體積
for (int n = 0; n <= N; n++) S[0][n] = 0; // 還未考慮任何物品時
for (int i = 1; i < C; i++)
for (int n = 0; n <= N; 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 = N; n >= V[i]; n--)
S[n] = max(S[n], S[n-V[i]] + P[i]);
這裡有個重點:
for (int n = N; n >= V[i]; n--)
此順序必須為從大到小,
舉個反例,若當
這個優化叫做滾動陣列
:::info
辛普森先生吃 Krusty 漢堡需要
給定
要求輸出辛普森先生在時限內可以吃進最多的漢堡數,若可以喝啤酒,則需要再輸出浪費多少時間。 :::
:::spoiler
因為每種漢堡可以吃無限多個,但 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
首先設計出此問題的狀態定義:
最好盡可能簡單直覺
假設背包體積為
對於解
$S(n)$ ,每次討論$i$ 物品時,子問題都需先得到最優解 也就是$S(n-V_i)$ 要先得到最優解,才能求解$S(n)$
假設有
memset(S, 0, sizeof S); // 還未挑任何物品的初始化
for (int i = 1; i <= C; i++)
for (int n = V[i]; n <= N; n++)
S[n] = max(S[n], S[n-V[i]] + P[i]);
與 01 背包的空間優化版本相反,這裡
理解這段程式碼得先看懂 01 背包的空間優化
:::spoiler
與 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 Common Subsequence 中譯為 最長公共子序列
:::info
在兩個序列
例如:
與 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 的一種特例
回憶第三週教材的那段話: "每次做一個在當下看起來最佳的決策,進而漸漸求出全局最佳解"
一旦證明問題能被貪心法解決,通常貪心法是問題的最佳策略,因為放棄搜索所有解答空間,效率會提高不少。
通常觀察出一個問題是否有最優子結構時 就可假設看看:下某個決策能漸漸朝全局最佳解的方向走 接著證明每次做這個決策一定不會更差
證明(或直覺?)很重要,有些看似美好的假設,不見得能求得問題的解
範例 TIOJ 1072 A.誰先晚餐:
:::spoiler
直覺是先讓吃最慢的人先上菜
比較看看,若
- 先讓
$a$ 上,總時間:$C_a + \max(E_a, C_b + E_b) = C_a + C_b + E_b$ - 先讓
$b$ 上,總時間:$C_b + \max(E_b, C_a + E_a)$ - 不管
$a, b$ 誰先上,其他人用餐時間都不會受到影響 (自行證明吧)
將總時間的
#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;
}
:::
:::spoiler
模擬切格子的步驟就行了
從最高的地方 (maxh
) 一路一層層往最低處 (1
) 看
for(int i = maxh; i >= 1; i--) {
:
.
}
對於每一層,收集該層的格子們 sum
中
sum += s;
但是當該層的格子加進 sum
中會超過
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 背包問題
<style> #len{ width: 100%; height: 100%; background-color: #FFAAAA; color: #D91C21; } #lcs{ width: 100%; height: 100%; background-color: #C7B6EC; color: #655091; } table.part tbody tr td { text-align: center; line-height: 36.5px; padding: 0 0 0 0; } </style>Footnotes
-
這單字並沒有拼錯。 ↩