DP专题-专项训练:概率/期望 DP + 数位 DP

1. 前言

本文为 DP 算法总结&专题训练1,专门记载概率/期望 DP 和数位 DP 的经典练习题及其详解。

没有学过这两种 DP?

传送门:

  1. DP专题-学习笔记:概率/期望 DP
  2. DP专题-学习笔记:数位 DP

接下来是题单。

2. 题单

概率/期望 DP:

数位 DP:

上面的题目是有一定难度,但是又经典的题目。如果想做更多题目,可以到 luogu 上面查看,这份题单 非常好。

3. 概率/期望 DP

P1850 [NOIP2016 提高组] 换教室

这道题非常非常经典,可以说是概率/期望 DP 的一道好题目。

那么为什么我没有拿这道题做入门题讲解呢?主要是因为这道题的式子太长了,可能会吓退初学者。

首先无论换不换教室,我们都需要求出一些教室之间的最短路径,那么考虑到 v300,使用 Floyd 算法(中文名:弗洛伊德算法)求最短路径显然是最方便的。什么?你没有学过?左转百度。。。。。。

那么开始设计 DP 的状态。

一个显然的思路就是设 fi,j 表示当前正在决策第 i 堂课是否申请换教室,包括第 i 堂课在内已经申请了 j 堂课换教室所需要的期望最短路径。

于是这么做下去,你就会发现一个问题:写不出转移方程。

为什么?

因为我们在决策第 i 堂课的时候,不能只是考虑第 i 堂课换教室之后的位置,还需要考虑第 i1 堂课有没有换教室,因为这联系到最短路的计算。

但是我们不知道第 i1 堂课有没有换教室啊?于是我们需要添加一个状态。

那么设 fi,j,k 表示当前正在决策第 i 堂课是否申请换教室,包括第 i 堂课在内已经申请了 j 堂课,且当前决定第 i 堂课申请换教室(k=1)/不申请换教室(k=0) 的期望最短路径。

注意:这里只是说申请换教室,而不是换教室成功。

那么接下来就可以设计状态转移方程了。

先看 fi,j,0

第一种情况:两堂课都不申请,为 fi1,j,0+disci1,ci

第二种情况:上一堂课申请,此时需要考虑申请成功的概率 ki1,为 fi1,j,1+disdi1,ci×ki1+disci1,ci×(1ki1)

取最小值,那么有:

fi,j,0=min(fi1,j,0+disci1,ci,fi1,j,1+disdi1,ci×ki1+disci1,ci×(1ki1))

接下来考虑 k=1 的情况。还是两种情况。

第一种情况,上一堂课不申请,那么需要考虑这一堂课的概率 ki,为 fi1,j1,0+disci1,ci×(1ki)+disci1,di×ki

第二种情况,两堂课都要申请,此时分申请成功与失败为四种情况,列表如下:

i 堂课 i1 堂课 期望最短路径
成功 成功 disdi1,di×ki×ki1
成功 失败 disci1,di×ki×(1ki1)
失败 成功 disdi1,ci×(1ki)×ki1
失败 失败 disci1,ci×(1ki)×(1ki1)

求和,然后加上 fi1,j1,1,就可以得到这种情况的结果了。

那么综合一下,就是:

fi,j,1=min(fi1,j1,0+disci1,ci×(1ki)+disci1,di×ki,fi1,j1,1+disdi1,di×ki×ki1+disci1,di×ki×(1ki1)+disdi1,ci×(1ki)×ki1+disci1,ci×(1ki)×(1ki1))

这式子是真的长

最后答案是:

mini=0t(fn,i,0,fn,i,1)

初始状态:f1,0,0=f1,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)3k3=(k+1)2(k+1)k3=(k2+2k+1)(k+1)k3=k3+3k2+3k+1k3=3k2+3k+1

于是我们需要维护 3k2+3k+1 的结果。

那么我们就需要维护 k2k 的结果。

接下来设 x1i 表示处理到前 ik 的期望,x2i 表示处理到前 ik2 的期望。

那么转移的时候利用这两个式子:

x1+1=x,(x1)2+2(x1)+1=(x1+1)2=x2

我们可以推出这样两个式子:

x1i=(x1i1+1)×pi,x2i=(x2i1+2×x1i1+1)×pi

那么由于答案的变化量为 3k2+3k+1,记 ansi 为前 i 位的期望贡献,有:

ansi=ansi1+(3×x2i1+3×x1i1+1)×pi

于是就做完了。

总结:这道题是一道套路题,如果你不知道差分,那么这道题是真的很难做。但是你知道差分,那么这道题就会变得异常简单——只要你往这方面想。

但是如果考场上真的不知道差分怎么办?

记得差分的本质是什么吗?相邻两项相减,也就是找出 fi+1fi 的关系。

于是方法二:列期望方程!

列期望方程可以说是最常见的一种解题思路了,这个方法无往不利,是期望 DP 的通法。

考虑 fi+1fi 的关系,设此时在第 i 个位置出现连续 l 个 1 的概率是 pl,第 i+1 位出现 1 的概率是 1,那么此时对答案多出来的贡献为 3l2+3l+1

考虑所有 l 的贡献,那么式子就长这样:

fi+1=fi+p×pl(3l2+3l+1)

然后我们就会发现只需要维护 l2l 的期望即可。

仿照上面的方法,我们会发现最后 l2 的计算只需要计算 l 的期望,而 l 的期望好算啊!成功为 i1 的期望 + 1 乘 p,失败就是 0。

这样,我们即使不知道差分,也可以愉快的通过此题了~

或许有的人会说了:差分这种套路比较难想啊,我怎么知道什么时候用差分呢?

还是使用期望方程来解释。

首先,在期望 DP 中,期望方程是用来联系 fi+1fi 的关系的,在算法学习笔记里面我详细讲过。

而差分,就是当两者系数都是 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 还是比较简单的。

fi,j 表示第 i 个人在第 j 秒做出决策时的期望,那么:

  1. 走上电梯,那么为 (fi1,j1+1)×p
  2. 不走上电梯,那么为 fi,j1×(1p)

综上,我们有:

fi,j=(fi1,j1+1)×p+fi,j1×(1p)

然后递推即可。

代码:

#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 位上统计后 cntpos 位数码 d 出现的次数,sum 表示前 pos 位数码 d 出现的次数为 sumzerolimit 为前导零和最高位限制。

于是我们只需要设计这样的函数: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 时的答案为 anstag+1,第一位答案为 ans1,那么:anstag+1ans1 是否具有单调性?

答案是:是。

为什么?我们将合并点不断右移的时候,显然更多的点会到左边,此时左边的石头会越来越多,导致每移动一格影响就会越来越大,因此有单调性。

那么我们就有了一种思路:首先先计算出合并到 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. 注意数位上界是 k1

代码:

#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 的板子好背,但是非常灵活,有的时候具有很大的思维难度。关键点就是能不能想到状态,只要想到了状态,打搜索就轻而易举。

posted @   Plozia  阅读(240)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具
点击右上角即可分享
微信分享提示