1. 程式人生 > 其它 >DP專題-專項訓練:概率/期望 DP + 數位 DP

DP專題-專項訓練:概率/期望 DP + 數位 DP

目錄

1. 前言

本文為 DP 演算法總結&專題訓練1,專門記載概率/期望 DP 和數位 DP 的經典練習題及其詳解。

沒有學過這兩種 DP?

傳送門:

  1. 演算法學習筆記:概率/期望 DP
  2. 演算法學習筆記:數位 DP

接下來是題單。

2. 題單

概率/期望 DP:

數位 DP:

上面的題目是有一定難度,但是又經典的題目。如果想做更多題目,可以到 luogu 上面檢視,這份題單 非常好。

3. 概率/期望 DP

P1850 [NOIP2016 提高組] 換教室

這道題非常非常經典,可以說是概率/期望 DP 的一道好題目。

那麼為什麼我沒有拿這道題做入門題講解呢?主要是因為這道題的式子太長了,可能會嚇退初學者。

首先無論換不換教室,我們都需要求出一些教室之間的最短路徑,那麼考慮到 \(v \leq 300\)

,使用 \(\operatorname{Floyd}\) 演算法(中文名:弗洛伊德演算法)求最短路徑顯然是最方便的。什麼?你沒有學過?左轉百度。。。。。。

那麼開始設計 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\)

於是就做完了。

幾個注意點:

  1. 注意邊界值的處理。
  2. 轉移方程不能寫錯。
  3. 小心卡精度。
    作者的程式碼被卡精度了,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\) 秒做出決策時的期望,那麼:

  1. 走上電梯,那麼為 \((f_{i-1,j-1}+1) \times p\)
  2. 不走上電梯,那麼為 \(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

  1. LL dfs1(int pos, int sum, int limit)
    \(sum\) 表示已經算完位的貢獻。
  2. LL dfs2(int pos, int sum, int tag, int limit)
    \(tag\) 是新的合併點。
    而在 dfs2 中,我們需要計算的是新的左邊貢獻減去右邊貢獻的差值,相當於一種字首和的思想,如果算出來是正數,那麼更新答案。

到這裡就做完了。

程式碼注意:

  1. 隨時清空 \(f\) 陣列。
  2. 注意數位上界是 \(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 的板子好背,但是非常靈活,有的時候具有很大的思維難度。關鍵點就是能不能想到狀態,只要想到了狀態,打搜尋就輕而易舉。