整理:概率 DP

关于概率DP的整理

1.概率DP是什么,用于解决什么问题?

所谓概率DP,是一类比较奇怪的DP,它不像其他DP,求解最值,方案或者方案的数量,而是求解某一事件的概率或者完成某一事件需要的代价的期望。

所以概率DP常用于解决概率问题和期望问题。

2.概率DP的基本形式是什么?

概率DP解决的问题分成两类,一类求解概率问题,另一类求解期望问题,这两类问题的基本思路差不多,但是形式上有所区别。

我们先来看第一类问题,使用概率DP求解某事件发生的概率。

一般来说,概率DP的状态设计与一般DP相反,而和记忆化搜索有些类似,也就是“逆推”的思路,定义 \(f_i\) 表示从 \(i\) 这个状态开始,到达终点状态的概率,然后从终点状态开始,向前转移,一直转移到初始状态,这样 \(f_1\) 就是最后的答案。

概率DP的转移方程有一些共通之处,可以写出一个一般式:\(f_i=\sum p_j\times f_j\),其中 \(j\) 代表从 \(i\) 这个状态一步可以到达的状态,\(p_j\) 代表从 \(i\) 这个状态一步走到 \(j\) 这个状态的概率,这部分内容的依据为条件概率和全概率公式。

条件概率指的是 \(A\) 事件在 \(B\) 事件发生的前提下发生的概率,记为 \(P(AB)\),在两个事件相互独立的情况下,计算公式为 \(P(AB)=P(A)P(B)\)

全概率公式为 \(P(A)=\sum P(B_i)P(AB_i)\),其中需要满足任意两个 \(B\) 事件之间相互不交,且所有 \(B\) 事件的并集恰为整个样本空间。

而上面的转移方程就是全概率公式的体现。

现在来看第二类问题,使用概率DP求解完成某一事件需要的代价期望。

在概率DP中提到的期望,一般指数学期望,一个随机变量 \(X\) 的期望记为 \(E(X)\),计算公式为 \(E(X)=\sum x_ip_i\),其中 \(x_i\) 为随机变量 \(X\) 的所有可能取值,\(p_i\) 表示变量 \(X\) 取值为 \(x_i\) 的概率。

类似求概率时的定义,我们定义 \(f_i\) 表示从 \(i\) 这个状态开始,到达终点状态需要的代价的期望,转移方程为 \(f_i=\sum p_j( f_j+w_j)\),其中 \(j\) 代表从 \(i\) 这个状态一步可以到达的状态,\(p_j\) 代表从 \(i\) 这个状态一步走到 \(j\) 这个状态的概率,\(w_j\) 表示从 \(i\) 这个状态一步走到 \(j\) 这个状态需要的代价,虽然在大部分的题目中 \(w_j\) 都是一个固定的数值。

这条转移方程的依据就是期望的计算公式。

3.例题与题解

例题1:ssworld VS DDD

我们按照概率DP的基本形式,设 \(f_{i,j}\) 表示 ssworld 有 \(i\) 点生命值和 DDD 有 \(j\) 点生命值,ssworld 的获胜概率有多大。

初始状态 \(f_{i,0}\) 均为 \(1\)\(f_{0,j}\) 均为 \(0\),转移方程为 \(f_{i,j}=fail\times f_{i,j-1}+win\times f_{i-1,j}+equal\times f_{i,j}\),其中 \(fail\) 表示 ssworld 在这轮掷骰子中输掉的概率,\(win\) 表示 ssworld 表示在这轮掷骰子中获胜的概率,\(equal\) 表示 ssworld 和 DDD 在这轮掷骰子中打成平局的概率,三个值满足 \(fail+win+equal=1\)

但是我们发现转移方程有自己转移自己的成分,所以考虑去掉自己转移自己的部分,由于这条式子实际上是一条递推式,所以我们可以将这条式子视为一条方程,而转移过程就是通过这条方程解除 \(f_{i,j}\),所以转移方程等价于 \((1-equal)f_{i,j}=fail\times f_{i,j-1}+win\times f_{i-1,j}\),所以 \(f_{i,j}=\frac{fail\times f_{i,j-1}+win\times f_{i-1,j}}{win+fail}\)

坑点:这道题两个人输入的 hp 是反的!先输入 \(hp_2\),再输入 \(hp_1\)

/*by qwer6*/
/*略去缺省源和快读快写*/
int hp1,hp2;
double win,fail;
double dp[2005][2005],a[10],b[10];
signed main(){
	while(~scanf("%d%d\n",&hp2,&hp1)){
		win=fail=0;
		for(int i=1;i<=6;i++)scanf("%lf",&a[i]);
		for(int i=1;i<=6;i++)scanf("%lf",&b[i]);
		for(int i=1;i<=6;i++)
			for(int j=1;j<i;j++)
				win+=a[i]*b[j];
		for(int i=1;i<=6;i++)
			for(int j=6;j>i;j--)
				fail+=a[i]*b[j];
		for(int i=0;i<=hp1;i++)
			for(int j=0;j<=hp2;j++)dp[i][j]=0;
		for(int i=1;i<=hp1;i++)dp[i][0]=1;
		for(int i=1;i<=hp1;i++)
			for(int j=1;j<=hp2;j++)
				dp[i][j]=(fail*dp[i-1][j]+win*dp[i][j-1])/(win+fail); 
		printf("%.6lf\n",dp[hp1][hp2]);
	}
}

例题2:糖果大战

这道题类似上一题,但是有所不同,如果在一局中获胜的话,不仅对方会扣一点血,自己还会加一点血,所以我们考虑定义 \(f_{i}\) 表示 Speakless 手上有 \(i\) 颗糖果时的胜率。

转移方程好像十分简单 \(f_{i}=win\times f_{i+1}+fail\times f_{i-1}+equal\times f_{i}\),与上一题进行同样的操作 \(f_{i}=\frac{win\times f_{i+1}+fail\times f_{i-1}}{win+fail}\),但是我们发现这条方程并不好递推,因为 \(f_i\) 同时依赖于 \(f_{i+1}\)\(f_{i-1}\),我们无法确定枚举递推的顺序。

但是,我们同样利用上面的操作,将转移方程视为真正的方程,转移的过程就是求解,那么此时我们有 \(n+1\) 条方程(其中两条为初始值的定义,\(f_{n+m}=1,f_{0}=0\)),定义 \(p=\frac{win}{win+fail},q=\frac{fail}{win+fail}\)

\[\begin{cases} f_{n+m}=1\\ f_{n+m-1}=pf_{n+m}+qf_{n+m-2}\\ f_{n+m-2}=pf_{n+m-1}+qf_{n+m-3}\\ ......\\ f_{1}=pf_{2}+qf_{0}\\ f_{0}=0\\ \end{cases} \]

然后我们尝试求解这个方程组,显然当我们把 \(f_{n+m}=1\) 带入之后,\(f_i\) 就变成了只和 \(f_{i-1}\) 相关的量,所以设 \(f_{i}=a+bf_{i-1}\),然后带入 \(f_{i-1}=pf_i+qf_{i-2}\),可以得到:

\[f_{i-1}=p(a+bf_{i-1})+qf_{i-2}\\ f_{i-1}=ap+bpf_{i-1}+qf_{i-2}\\ (1-bp)f_{i-1}=ap+qf_{i-2}\\ f_{i-1}=\frac{ap}{1-bp}+\frac{q}{1-bp}f_{i-2} \]

所以我们可以先从后往前递推出 \(a\)\(b\) 两个常数,然后从前往后递推出 \(f_i\) 的值,就可以在 \(O(n+m)\) 的时间复杂度内完成求解。

/*by qwer6*/
/*略去缺省源和快读快写*/
const int N=105;
int n,m;
double p,q,win,fail,tmp;
double f[N],a[N],b[N];
signed main(){
	while(~scanf("%d%d%lf%lf",&n,&m,&p,&q)){
		if(n==0||q==1)printf("0.00\n");
		else if(m==0||p==1)printf("1.00\n");
		else{
			f[n+m]=1;
			f[0]=0;
			tmp=1-(p*q+(1-p)*(1-q));
			win=p*(1-q)/tmp,fail=q*(1-p)/tmp;
			a[n+m-1]=win,b[n+m-1]=fail;
			for(int i=n+m-2;i>=1;i--){
				double fm=(1-win*b[i+1]);
				a[i]=win*a[i+1]/fm,b[i]=fail/fm;
			}
			for(int i=1;i<=n;i++)
				f[i]=a[i]+b[i]*f[i-1];
			printf("%.2lf\n",f[n]);	
		}
	}
}

例题3:Black Jack

远离赌博,从我做起。

一道博弈论加概率DP的题目。

首先玩家可以选择加牌或者停止加牌轮到庄家,庄家可以选择加牌或者停止加牌结算。

同时玩家要保证自己的胜率最大,庄家需要保证玩家的胜率最小,写两个概率 DP 即可解决。

定义 \(f_{1,i,j}\) 表示玩家回合,玩家手上总点数为 \(i\),庄家手上总点数为 \(j\) ,玩家的最大胜率,\(f_{0,i,j}\) 表示庄家回合,双方手上各有总点数为 \(i\)\(j\) ,玩家的最小胜率。

边界状态 \(f_{0,i,j}=1,(j>21),f_{1,i,j}=0,(i>21)\),转移方程 \(f_{0,i,j}=\begin{cases}0\ (i<j)\\ \frac{sum_{k=1}^9 f_{0,i,j+k}+4\times f_{0,i,j+10}}{13}\ (i\ge j)\end{cases},f_{1,i,j}=max(f_{0,i,j},\frac{\sum_{k=1}^9 f_{1,i+k,j}+4\times f_{1,i+10,j}}{13})\)

注意输入的 T 只代表 \(10\),不包括 J,Q,K,同时 A 代表 \(1\)

/*by qwer6*/
/*略去缺省源和快读快写*/
const double expr=1e-6;
int T;
double win;
int vis[2][25][25];
double dp[2][25][25];
char c[8]; 
double dfs1(int x,int y){
	if(y>21)return 1.0;
	if(vis[0][x][y]==T)return dp[0][x][y];
	vis[0][x][y]=T;
	double &res=dp[0][x][y];
	res=0;
	for(int i=1;i<=9;i++)res+=1.0*dfs1(x,y+i)/13;
	res+=4.0*dfs1(x,y+10)/13;
	res=min(res,x>y?1.0:0.0);
	return res;
}
double dfs2(int x,int y){
	if(x>21)return 0.0;
	if(vis[1][x][y]==T)return dp[1][x][y];
	vis[1][x][y]=T;
	double &res=dp[1][x][y];
	res=0;
	for(int i=1;i<=9;i++)res+=1.0*dfs2(x+i,y)/13;
	res+=4.0*dfs2(x+10,y)/13;
	res=max(res,dfs1(x,y));
	return res;
}
int cal(char c){
	if(c=='J'||c=='Q'||c=='K'||c=='T')return 10;
	if(c=='A')return 1;
	return c^48;
}
signed main(){
	int t;
	read(t);
	while(t--){
		cin>>c+1;
		T++;
		win=dfs2(cal(c[1])+cal(c[2]),cal(c[3])+cal(c[4]))+expr;
		if(win>0.5)puts("YES");
		else puts("NO");
	}
}

例题4:Hearthstone

炉石也有自己的 OTK

其实说这道题是概率DP并不准确,但是和概率计算有关,我们设可以获胜的卡组顺序有 \(x\) 种,一共有 \(y\) 种可能的卡组顺序,那么可能获胜的概率为 \(\frac{x}{y}\),所以现在的问题转变为求有多少种可以获胜的卡组顺序。

数据范围明示状压,设一共有 \(n\) 张牌,当前已经抽出的牌为 \(st\)\(atk_{st}\) 表示可以打出的伤害,\(A_{st}\) 表示抽出的 \(A\) 类型的牌数,\(cnt_{st}\) 表示有多少抽出了多少张牌。

这里使用记忆化搜索求解。

如果 \(atk_{st}\ge p\),那么剩下的 \(n-cnt_{st}\) 张牌可以随意排列,有 \((n-cnt_{st})!\) 种可以获胜的卡组顺序。

如果 \(cnt_{st}=2A_{st}+1\),那么不能继续摸牌,打出来的伤害不够,没有可以获胜的卡组顺序。

否则,可以继续摸牌,摸一张没有摸到的牌,继续搜索即可。

/*by qwer6*/
/*略去缺省源和快读快写*/
int p,n,m,tot,mx;
long long ans;
int a[25],atk[1<<20],lg[1<<20],cnt[1<<20],A[1<<20];
bool vis[1<<20];
long long dp[1<<20],f[25];
long long dfs(int st){
	if(atk[st]>=p)return f[tot-cnt[st]];
	if(vis[st])return dp[st];
	vis[st]=1;
	if(2*A[st]+1>cnt[st]){
		int C=mx^st;
		for(int i=lowbit(C),x;C;C-=i,i=lowbit(C)){
			x=lg[i]+1;
			dp[st]+=dfs(st+i);
		}
		return dp[st];
	}else return 0;
}
signed main(){
	lg[0]=-1;
	for(int i=1;i<(1<<20);i++){
		lg[i]=lg[i>>1]+1;
		cnt[i]=cnt[i>>1]+(i&1);
	}
	f[0]=1;
	for(int i=1;i<=20;i++)f[i]=f[i-1]*i;
	int t;
	read(t);
	while(t--){
		read(p),read(n),read(m);
		for(int i=1;i<=m;i++)read(a[i]);
		tot=n+m,mx=(1<<tot)-1;
		for(int st=0;st<=mx;st++){
			atk[st]=A[st]=dp[st]=0;
			vis[st]=0;
			for(int i=lowbit(st),tmp=st,x;tmp;tmp-=i,i=lowbit(tmp)){
				x=lg[i]+1;
				if(x<=n)A[st]++;
				else atk[st]+=a[x-n];
			}
		}
		if(atk[mx]<p){
			puts("0/1");
			continue;
		}
		ans=dfs(0);
		if(ans==f[tot])puts("1/1");
		else if(ans==0)puts("0/1");
		else{
			long long G=__gcd(ans,f[tot]);
			write(ans/G),putchar('/'),write(f[tot]/G),Nxt;
		}
	}
}

例题5:A Dangerous Maze

直接列出方程 \(ans=\frac{\sum_{i=1}^n|x_i|+ans[x_i<0]}{n}\),然后直接求解即可,解得 \(ans=\frac{\sum_{i=1}^n |x_i|}{n-cnt}\),其中 \(cnt\) 表示小于 \(0\)\(x_i\) 的数量,记得特判 \(cnt=n\) 的情况为 \(\inf\)

/*by qwer6*/
/*略去缺省源和快读快写*/
int n,cnt,sum,Case;
signed main(){
	int t;
	read(t);
	while(t--){
		read(n);
		cnt=0,sum=0;
		for(int i=1,x;i<=n;i++){
			read(x);
			if(x<0){
				cnt++;
				sum-=x;
			}else sum+=x;
		}
		if(n==cnt)printf("Case %d: inf\n",++Case);
		else{
			int fz=sum,fm=n-cnt;
			int G=__gcd(fz,fm);
			printf("Case %d: %d/%d\n",++Case,fz/G,fm/G);
		}
	}
}

例题6:Gambling Guide

列出 \(f_i\) 表示从 \(i\) 出发,到达 \(n\) 的期望值。

显然可以列出 \(f_{i}=\frac{\sum_{(i,v)\in E} min(f_i,f_v)}{d_i}+1\),简单变换有 \(f_i=\frac{cnt\times f_i+\sum_{(i,v)\in E}f_v[f_v<f_i]}{d_i}+1\),所以 \(f_i=\frac{\sum_{(i,v)\in E}f_v[f_v<f_i]+d_i}{d_i-cnt}\),设 \(cnt'=d_i-cnt\),那么有 \(f_i=\frac{\sum_{(i,v)\in E}f_v[f_v<f_i]}{cnt'}\)

而每次用一个点 \(u\) 更新另一个点 \(v\) 的值时,当且仅当 \(f_u<f_v\),这似乎和 Dijkstra 算法的松弛操作有异曲同工之妙,所以这里用 Dijskra 来优化概率 DP。

/*by qwer6*/
/*略去缺省源和快读快写*/
const int N=3e5+5;
int n,m;
int d[N],cnt[N];
double dis[N],sum[N],f[N];
bool vis[N];
vector<int>e[N];
void Dijkstra(){
	struct Node{
		double val;
		int u;
		bool operator >(const Node &a)const{
			return val>a.val;
		}
	};
	priority_queue<Node,vector<Node>,greater<Node>>q;
	q.push({0,n});
	while(!q.empty()){
		int u=q.top().u;
		double val=q.top().val;
		q.pop();
		if(vis[u])continue;
		if(u==1){
			printf("%.6lf",val);
			exit(0);
		}
		vis[u]=1;
		f[u]=val;
		for(int v:e[u]){
			if(vis[v])continue;
			sum[v]+=val;
			cnt[v]++;
			q.push({1.0*(sum[v]+d[v])/cnt[v],v});
		}
	}
}
signed main(){
	read(n),read(m);
	for(int i=1,u,v;i<=m;i++){
		read(u),read(v);
		e[u].push_back(v);
		e[v].push_back(u);
	}
	for(int i=1;i<=n;i++)d[i]=e[i].size();
	Dijkstra();	
}
posted @ 2025-04-06 18:46  陈牧九  阅读(166)  评论(0)    收藏  举报