DP專題-專項訓練:概率/期望 DP + 數位 DP
1. 前言
本文為 DP 演算法總結&專題訓練1,專門記載概率/期望 DP 和數位 DP 的經典練習題及其詳解。
沒有學過這兩種 DP?
傳送門:
接下來是題單。
2. 題單
概率/期望 DP:
數位 DP:
上面的題目是有一定難度,但是又經典的題目。如果想做更多題目,可以到 luogu 上面檢視,這份題單 非常好。
3. 概率/期望 DP
P1850 [NOIP2016 提高組] 換教室
這道題非常非常經典,可以說是概率/期望 DP 的一道好題目。
那麼為什麼我沒有拿這道題做入門題講解呢?主要是因為這道題的式子太長了,可能會嚇退初學者。
首先無論換不換教室,我們都需要求出一些教室之間的最短路徑,那麼考慮到 \(v \leq 300\)
那麼開始設計 DP 的狀態。
一個顯然的思路就是設 \(f_{i,j}\) 表示當前正在決策第 \(i\) 堂課是否申請換教室,包括第 \(i\) 堂課在內已經申請了 \(j\) 堂課換教室所需要的期望最短路徑。
於是這麼做下去,你就會發現一個問題:寫不出轉移方程。
為什麼?
因為我們在決策第 \(i\) 堂課的時候,不能只是考慮第 \(i\) 堂課換教室之後的位置,還需要考慮第 \(i-1\) 堂課有沒有換教室,因為這聯絡到最短路的計算。
但是我們不知道第 \(i-1\) 堂課有沒有換教室啊?於是我們需要新增一個狀態。
那麼設 \(f_{i,j,k}\) 表示當前正在決策第 \(i\) 堂課是否申請換教室,包括第 \(i\) 堂課在內已經申請了 \(j\) 堂課,且當前決定第 \(i\) 堂課申請換教室(\(k=1\))/不申請換教室(\(k=0\)) 的期望最短路徑。
注意:這裡只是說申請換教室,而不是換教室成功。
那麼接下來就可以設計狀態轉移方程了。
先看 \(f_{i,j,0}\)。
第一種情況:兩堂課都不申請,為 \(f_{i-1,j,0}+dis_{c_{i-1},c_i}\)
第二種情況:上一堂課申請,此時需要考慮申請成功的概率 \(k_{i-1}\),為 \(f_{i-1,j,1}+dis_{d_{i-1},c_i} \times k_{i-1}+dis_{c_{i-1},c_i} \times (1-k_{i-1})\)。
取最小值,那麼有:
\[f_{i,j,0}=\min{(f_{i-1,j,0}+dis_{c_{i-1},c_i},f_{i-1,j,1}+dis_{d_{i-1},c_i} \times k_{i-1}+dis_{c_{i-1},c_i} \times (1-k_{i-1}))} \]接下來考慮 \(k=1\) 的情況。還是兩種情況。
第一種情況,上一堂課不申請,那麼需要考慮這一堂課的概率 \(k_i\),為 \(f_{i-1,j-1,0}+dis_{c_{i-1},c_i} \times (1-k_i) + dis_{c_{i-1},d_i} \times k_i\)
第二種情況,兩堂課都要申請,此時分申請成功與失敗為四種情況,列表如下:
第 \(i\) 堂課 | 第 \(i-1\) 堂課 | 期望最短路徑 |
---|---|---|
成功 | 成功 | \(dis_{d_{i-1},d_i} \times k_i \times k_{i-1}\) |
成功 | 失敗 | \(dis_{c_{i-1},d_i} \times k_i \times (1-k_{i-1})\) |
失敗 | 成功 | \(dis_{d_{i-1},c_i} \times (1-k_i) \times k_{i-1}\) |
失敗 | 失敗 | \(dis_{c_{i-1},c_i} \times (1-k_i) \times (1-k_{i-1})\) |
求和,然後加上 \(f_{i-1,j-1,1}\),就可以得到這種情況的結果了。
那麼綜合一下,就是:
\[f_{i,j,1}=\min{(f_{i-1,j-1,0}+dis_{c_{i-1},c_i} \times (1-k_i) + dis_{c_{i-1},d_i} \times k_i,f_{i-1,j-1,1}+dis_{d_{i-1},d_i} \times k_i \times k_{i-1}+dis_{c_{i-1},d_i} \times k_i \times (1-k_{i-1})+dis_{d_{i-1},c_i} \times (1-k_i) \times k_{i-1}+dis_{c_{i-1},c_i} \times (1-k_i) \times (1-k_{i-1}))} \]這式子是真的長
最後答案是:
\[\min_{i=0}^{t}{(f_{n,i,0},f_{n,i,1})} \]初始狀態:\(f_{1,0,0}=f_{1,1,1}=0\)
於是就做完了。
幾個注意點:
- 注意邊界值的處理。
- 轉移方程不能
抄寫錯。 - 小心卡精度。
作者的程式碼被卡精度了,WA
了三個點,到目前為止沒有調出來,因此請謹慎學習作者的程式碼。
總結:這道題實乃期望 DP 好題目,考察了各位面對一道圖論上的序列性動態規劃問題的解決思路,像解題時的加一維狀態,列表求式子等等都是很常用的方法。
程式碼:
#include <bits/stdc++.h>
#define Min(a, b) ((a < b) ? a : b)
using namespace std;
typedef double db;
typedef long long LL;
const int MAXN = 2e3 + 10, MAXV = 3e2 + 10;
int n, m, v, e, c[MAXN], d[MAXN], dis[MAXV][MAXV];
db k[MAXN], f[MAXN][MAXN][2], ans = 1e17 + 5;
int read()
{
int sum = 0, fh = 1; char ch = getchar();
for (; ch < '0' || ch > '9'; ch = getchar()) fh -= (ch == '-') << 1;
for (; ch >= '0' && ch <= '9'; ch = getchar()) sum = (sum << 3) + (sum << 1) + (ch ^ 48);
return (fh == -1) ? -sum : sum;
}
int main()
{
n = read(), m = read(), v = read(), e = read();
for (int i = 1; i <= n; ++i) c[i] = read();
for (int i = 1; i <= n; ++i) d[i] = read();
for (int i = 1; i <= n; ++i) scanf("%lf", &k[i]);
memset(dis, 0x3f, sizeof(dis));
for (int i = 1; i <= n; ++i) dis[i][i] = 0;
for (int i = 1; i <= e; ++i)
{
int x = read(), y = read(), z = read();
dis[x][y] = Min(dis[x][y], z);
dis[y][x] = Min(dis[y][x], z);
}
for (int k = 1; k <= v; ++k)
for (int i = 1; i <= v; ++i)
for (int j = 1; j <= v; ++j)
dis[i][j] = Min(dis[i][j], dis[i][k] + dis[k][j]);
for (int i = 1; i <= n; ++i)
for (int j = 0; j <= m; ++j)
f[i][j][0] = f[i][j][1] = 1e17 + 5;
f[1][0][0] = f[1][1][1] = 0;
for (int i = 2; i <= n; ++i)
{
f[i][0][0] = f[i - 1][0][0] + dis[c[i - 1]][c[i]];
for (int j = 1; j <= min(i, m); ++j)
{
f[i][j][0] = Min(f[i - 1][j][0] + dis[c[i - 1]][c[i]], f[i - 1][j][1] + dis[d[i - 1]][c[i]] * k[i - 1] + dis[c[i - 1]][c[i]] * (1 - k[i - 1]));
if (j == 0) continue;
f[i][j][1] = Min(f[i - 1][j - 1][0] + dis[c[i - 1]][c[i]] * (1 - k[i]) + dis[c[i - 1]][d[i]] * k[i], f[i - 1][j - 1][1] + dis[d[i - 1]][d[i]] * k[i] * k[i - 1] + dis[c[i - 1]][d[i]] * k[i] * (1 - k[i - 1]) + dis[d[i - 1]][c[i]] * (1 - k[i]) * k[i - 1] + dis[c[i - 1]][c[i]] * (1 - k[i]) * (1 - k[i - 1]));
}
}
for (int i = 0; i <= m; ++i) ans = Min(ans, Min(f[n][i][0], f[n][i][1]));
printf ("%.2lf\n", ans);
return 0;
}
P1654 OSU!
方法一:
這道題的 DP 非常奇特,需要用到 差分 的思想。
我們先假設當前有連續 \(k\) 個 1,現在又添上一個 1,於是我們將貢獻做差:
\((k+1)^3-k^3=(k+1)^2(k+1)-k^3=(k^2+2k+1)(k+1)-k^3=k^3+3k^2+3k+1-k^3=3k^2+3k+1\)
於是我們需要維護 \(3k^2+3k+1\) 的結果。
那麼我們就需要維護 \(k^2\) 和 \(k\) 的結果。
接下來設 \(x1_i\) 表示處理到前 \(i\) 位 \(k\) 的期望,\(x2_i\) 表示處理到前 \(i\) 位 \(k^2\) 的期望。
那麼轉移的時候利用這兩個式子:
\(x-1+1=x,(x-1)^2+2(x-1)+1=(x-1+1)^2=x^2\)
我們可以推出這樣兩個式子:
\(x1_i=(x1_{i-1}+1) \times p_i,x2_i=(x2_{i-1}+2 \times x1_{i-1}+1) \times p_i\)
那麼由於答案的變化量為 \(3k^2+3k+1\),記 \(ans_i\) 為前 \(i\) 位的期望貢獻,有:
\(ans_i=ans_{i-1}+(3 \times x2_{i-1} + 3 \times x1_{i-1} + 1) \times p_i\)
於是就做完了。
總結:這道題是一道套路題,如果你不知道差分,那麼這道題是真的很難做。但是你知道差分,那麼這道題就會變得異常簡單——只要你往這方面想。
但是如果考場上真的不知道差分怎麼辦?
記得差分的本質是什麼嗎?相鄰兩項相減,也就是找出 \(f_{i+1}-f_i\) 的關係。
於是方法二:列期望方程!
列期望方程可以說是最常見的一種解題思路了,這個方法無往不利,是期望 DP 的通法。
考慮 \(f_{i+1}\) 和 \(f_i\) 的關係,設此時在第 \(i\) 個位置出現連續 \(l\) 個 1 的概率是 \(p_l\),第 \(i+1\) 位出現 1 的概率是 1,那麼此時對答案多出來的貢獻為 \(3l^2+3l+1\)。
考慮所有 \(l\) 的貢獻,那麼式子就長這樣:
\(f_{i+1}=f_i+p \times \sum{p_l(3l^2+3l+1)}\)
然後我們就會發現只需要維護 \(l^2\) 和 \(l\) 的期望即可。
仿照上面的方法,我們會發現最後 \(l^2\) 的計算只需要計算 \(l\) 的期望,而 \(l\) 的期望好算啊!成功為 \(i-1\) 的期望 + 1 乘 \(p\),失敗就是 0。
這樣,我們即使不知道差分,也可以愉快的通過此題了~
或許有的人會說了:差分這種套路比較難想啊,我怎麼知道什麼時候用差分呢?
還是使用期望方程來解釋。
首先,在期望 DP 中,期望方程是用來聯絡 \(f_{i+1}\) 和 \(f_i\) 的關係的,在演算法學習筆記裡面我詳細講過。
而差分,就是當兩者係數都是 1 而且移項之後兩者正好做差,因此可以認為差分是期望方程的一種特殊情況。
所以其實不知道差分沒有關係,照樣可以列期望方程求解,只不過知道差分,就可以省略列出期望方程這一步。
以上只是個人觀點,如有錯誤請讀者指出。
程式碼:
#include <bits/stdc++.h>
using namespace std;
typedef double db;
typedef long long LL;
const int MAXN = 1e5 + 10;
int n;
db p[MAXN], x1[MAXN], x2[MAXN], ans[MAXN];
int read()
{
int sum = 0, fh = 1; char ch = getchar();
for (; ch < '0' || ch > '9'; ch = getchar()) fh -= (ch == '-') << 1;
for (; ch >= '0' && ch <= '9'; ch = getchar()) sum = (sum << 3) + (sum << 1) + (ch ^ 48);
return (fh == -1) ? -sum : sum;
}
int main()
{
n = read();
for (int i = 1; i <= n; ++i) scanf("%lf", &p[i]);
for (int i = 1; i <= n; ++i)
{
x1[i] = (x1[i - 1] + 1) * p[i];
x2[i] = (x2[i - 1] + 2 * x1[i - 1] + 1) * p[i];
ans[i] = ans[i - 1] + (3 * x2[i - 1] + 3 * x1[i - 1] + 1) * p[i];
}
printf("%.1lf", ans[n]);
return 0;
}
CF518D Ilya and Escalator
這道題的 DP 還是比較簡單的。
設 \(f_{i,j}\) 表示第 \(i\) 個人在第 \(j\) 秒做出決策時的期望,那麼:
- 走上電梯,那麼為 \((f_{i-1,j-1}+1) \times p\)。
- 不走上電梯,那麼為 \(f_{i,j-1} \times (1-p)\)
綜上,我們有:
\[f_{i,j}=(f_{i-1,j-1}+1) \times p+f_{i,j-1} \times (1-p) \]然後遞推即可。
程式碼:
#include <bits/stdc++.h>
using namespace std;
typedef double db;
typedef long long LL;
const int MAXN = 2000 + 10;
int n, t;
double p, f[MAXN][MAXN];
int read()
{
int sum = 0, fh = 1; char ch = getchar();
for (; ch < '0' || ch > '9'; ch = getchar()) fh -= (ch == '-') << 1;
for (; ch >= '0' && ch <= '9'; ch = getchar()) sum = (sum << 3) + (sum << 1) + (ch ^ 48);
return (fh == -1) ? -sum : sum;
}
int main()
{
n = read(); scanf("%lf", &p); t = read();
for (int i = 1; i <= n; ++i)
for (int j = 1; j <= t; ++j)
f[i][j] = f[i][j - 1] * (1 - p) + (f[i - 1][j - 1] + 1) * p;
printf("%.10lf\n", f[n][t]);
return 0;
}
總結
概率/期望 DP 的關鍵是確定 DP 狀態,列舉各種可能性,推出各種可能性的狀態轉移方程,然後求和/取min/取max……,如果推不出 DP 方程可以列一下期望方程。而列期望方程這個方法非常好用,尤其是對付那些狀態一維的問題。
4. 數位 DP
P2602 [ZJOI2010]數字計數
事先吐槽一句:為什麼我陣列越界了返回我 WA
?查了好久的錯……
開始正題。
這道題要我們求每個數碼的出現次數,有兩種解法:
第一種:邊做邊存每個數碼出現的次數。
這個做法的原理還是比較好懂的,就是設狀態 \(f[pos][sum][zero][limit][d]\) 表示在第 \(pos\) 位上統計後 \(cnt-pos\) 位數碼 \(d\) 出現的次數,\(sum\) 表示前 \(pos\) 位數碼 \(d\) 出現的次數為 \(sum\),\(zero\) 和 \(limit\) 為前導零和最高位限制。
於是我們只需要設計這樣的函式:LL dfs(int pos, int zero, int limit, int d)
,數字位的統計使用一個全域性變數陣列(當然也可以採用引數為陣列的形式),然後記憶化一下即可。
但是我個人感覺比較難寫,於是第二種解法:暴力做 10 次,每一次確定一維數碼的答案。
這個做法看起來就清爽,雖然時間複雜度會多一個 10 的常數,但是跑的還是特別快的,我們可以省略狀態 \(d\),但是 dfs
函式中不能省略。
此時的函式就會變為:LL dfs(int pos, int sum, int zero, int limit, int d)
程式碼?套板子。
程式碼:
#include <bits/stdc++.h>
using namespace std;
typedef long long LL;
//const int MAXN = ;
LL l, r, f[15][15][2][2];
int cnt, a[15];
LL read()
{
LL sum = 0, fh = 1; char ch = getchar();
for (; ch < '0' || ch > '9'; ch = getchar()) fh -= (ch == '-') << 1;
for (; ch >= '0' && ch <= '9'; ch = getchar()) sum = (sum << 3) + (sum << 1) + (ch ^ 48);
return (fh == -1) ? -sum : sum;
}
LL dfs(int pos, int sum, int zero, int limit, int d)
{
if (pos == 0) return sum;
if (f[pos][sum][zero][limit] != -1) return f[pos][sum][zero][limit];
int t = limit ? a[pos] : 9; LL ans = 0;
for (int i = 0; i <= t; ++i)
{
if (i == 0 && zero) ans += dfs(pos - 1, sum, 1, i == a[pos] && limit, d);
else ans += dfs(pos - 1, sum + (i == d), 0, i == a[pos] && limit, d);
}
return f[pos][sum][zero][limit] = ans;
}
LL Get(LL k, int d)
{
memset(f, -1, sizeof(f)); cnt = 0;
for (; k; k /= 10) a[++cnt] = k % 10;
return dfs(cnt, 0, 1, 1, d);
}
int main()
{
l = read(), r = read();
for (int i = 0; i < 10; ++i)
printf("%lld ", Get(r, i) - Get(l - 1, i));
printf("\n"); return 0;
}
P4124 [CQOI2016]手機號碼
這道題也是一道比較簡單的數位 DP,雖然是紫題而且我調了 4 天
設計 dfs
函式:LL dfs(int pos, int pre1, int pre2, int tri, int num, int limit)
\(pos\) 是位置,\(pre1,pre2\) 表示前兩位數字,\(tri\) 標記是否出現過連續 3 個數字,\(num\) 為 4 和 8 的標記,\(num=1\) 表示出現過 4,\(num=2\) 表示出現過 8。\(limit\) 是最高位限制。
細心的讀者會發現了:為什麼沒有 \(zero\) 前導零標記呢?
這是因為,這道題題目上面明確規定必須是 11 位數,也就是 首位不能為 0。
然後就是套板子的事情。
當 \(i=pre1=pre2\) 時,\(tri=1\),而出現 \(i=4 \& num=2\) 或者是 \(i=8 \& num=1\) 時不合法。
看樣子很簡單呀,那麼為什麼我錯了呢?
理由很簡單,因為 可以完全不包含 4 和 8。
一開始我的終止條件是:
if (pos == 0) return tri && num;
然後就是 WA
,後來洛谷的 @keenlf 奆佬幫我指出了問題,在此表示感謝。
正確的寫法應該是:
if (pos == 0) return tri;
程式碼:
#include <bits/stdc++.h>
using namespace std;
typedef long long LL;
//const int MAXN = ;
int cnt, a[15];
LL l, r, f[15][15][15][2][3][2];
LL read()
{
LL sum = 0, fh = 1; char ch = getchar();
for (; ch < '0' || ch > '9'; ch = getchar()) fh -= (ch == '-') << 1;
for (; ch >= '0' && ch <= '9'; ch = getchar()) sum = (sum << 3) + (sum << 1) + (ch ^ 48);
return (fh == 1) ? sum : -sum;
}
LL dfs(int pos, int pre1, int pre2, int tri, int num, int limit)
{
if (pos == 0) return tri;
if (f[pos][pre1][pre2][tri][num][limit] != -1) return f[pos][pre1][pre2][tri][num][limit];
int t = limit ? a[pos] : 9; LL ans = 0;
for (int i = (pos == cnt) ? 1 : 0; i <= t; ++i)
{
if (i == 4 && num == 2) continue;
if (i == 8 && num == 1) continue;
if (pre1 == pre2 && pre2 == i) ans += dfs(pos - 1, pre2, i, 1, num ? num : ((i == 4) ? 1 : ((i == 8) ? 2 : 0)), i == a[pos] && limit);
else ans += dfs(pos - 1, pre2, i, tri, num ? num : ((i == 4) ? 1 : ((i == 8) ? 2 : 0)), i == a[pos] && limit);
}
return f[pos][pre1][pre2][tri][num][limit] = ans;
}
LL Get(LL k)
{
if (k < 1e10) return 0;
memset(f, -1, sizeof(f)); cnt = 0;
for (; k; k /= 10) a[++cnt] = k % 10;
return dfs(cnt, 0, 0, 0, 0, 1);
}
int main()
{
l = read(), r = read();
printf("%lld\n", Get(r) - Get(l - 1));
return 0;
}
P3286 [SCOI2014]方伯伯的商場之旅
這道題是道套路不太一般的數位 DP。
首先根據小學奧數知識我們可以得知:所有石子合併到最中間一定是最優的,然而這並沒有什麼用,也不知道什麼在中間。
那麼我們先思考一個問題:假設當前合併點為 \(tag\),當我們將合併點更新為 \(tag+1\) 時,記 \(tag+1\) 時的答案為 \(ans_{tag+1}\),第一位答案為 \(ans_1\),那麼:\(ans_{tag+1}-ans_1\) 是否具有單調性?
答案是:是。
為什麼?我們將合併點不斷右移的時候,顯然更多的點會到左邊,此時左邊的石頭會越來越多,導致每移動一格影響就會越來越大,因此有單調性。
那麼我們就有了一種思路:首先先計算出合併到 1 號點的答案,然後貪心右移,答案能變小就變小。
於是這道題就做完了
到目前為止還沒有做完,因為程式碼寫不出來。
這道題的特別之處在於我們要寫兩個 dfs
。
-
LL dfs1(int pos, int sum, int limit)
\(sum\) 表示已經算完位的貢獻。 -
LL dfs2(int pos, int sum, int tag, int limit)
\(tag\) 是新的合併點。
而在dfs2
中,我們需要計算的是新的左邊貢獻減去右邊貢獻的差值,相當於一種字首和的思想,如果算出來是正數,那麼更新答案。
到這裡就做完了。
程式碼注意:
- 隨時清空 \(f\) 陣列。
- 注意數位上界是 \(k-1\)!
程式碼:
#include <bits/stdc++.h>
#define Max(a, b) ((a > b) ? a : b)
using namespace std;
typedef long long LL;
const int MAXN = 1e5 + 10;
LL l, r, f[70][MAXN];
int k, cnt, a[70];
LL read()
{
LL sum = 0, fh = 1; char ch = getchar();
for (; ch < '0' || ch > '9'; ch = getchar()) fh -= (ch == '-') << 1;
for (; ch >= '0' && ch <= '9'; ch = getchar()) sum = (sum << 3) + (sum << 1) + (ch ^ 48);
return (fh == 1) ? sum : -sum;
}
LL dfs1(int pos, int sum, int limit)
{
if (pos == 0) return sum;
if (!limit && f[pos][sum] != -1) return f[pos][sum];
int t = limit ? a[pos] : k - 1; LL ans = 0;
for (int i = 0; i <= t; ++i) ans += dfs1(pos - 1, sum + i * (pos - 1), limit && i == a[pos]);
if (!limit) f[pos][sum] = ans;
return ans;
}
LL dfs2(int pos, int sum, int tag, int limit)
{
if (sum < 0) return 0;
if (pos == 0) return sum;
if (!limit && f[pos][sum] != -1) return f[pos][sum];
int t = limit ? a[pos] : k - 1; LL ans = 0;
for (int i = 0; i <= t; ++i) ans += dfs2(pos - 1, sum + ((pos < tag) ? -i : i), tag, limit && i == a[pos]);
if (!limit) f[pos][sum] = ans;
return ans;
}
LL Get(LL p)
{
memset(f, -1, sizeof(f)); cnt = 0;
for (; p; p /= k) a[++cnt] = p % k;
LL sum = dfs1(cnt, 0, 1);
for (int i = 2; i <= cnt; ++i)
{
memset(f, -1, sizeof(f));
sum -= dfs2(cnt, 0, i, 1);
}
return sum;
}
int main()
{
l = read(), r = read(), k = read();
printf ("%lld\n", Get(r) - Get(l - 1));
return 0;
}
總結
數位 DP 的板子好背,但是非常靈活,有的時候具有很大的思維難度。關鍵點就是能不能想到狀態,只要想到了狀態,打搜尋就輕而易舉。