期望dp
期望的基本性质
首先,随机变量分为离散型随机变量和连续型随机变量两种,值域大小有限(取值个数有限)或为可列无穷大的随机变量称为离散型随机变量。
离散型随机变量和连续型随机变量
离散型随机变量的期望等于它的每一个取值乘该取值对应的概率的总和,而连续型随机变量需要以积分形式表示为: ∫ − ∞ + ∞ x p ( x ) d x \int_{-\infty}^{+\infty}xp(x)dx ∫−∞+∞xp(x)dx ,其中 p ( x ) p(x) p(x) 指的是 x x x 取值的密度分布函数(通俗的来说大概就是 x x x 这个值出现的概率)。
例题1:红包发红包
(洛谷P5104)
这道题的题意非常简单明了,问题就在于这个
[
0
,
w
]
[0,w]
[0,w] 内随机取值是一个无穷大的值域,所以这题所问的是一个连续型随机变量的期望。
既然如此,考虑一下它的分布函数是什么。其实也很简单,即
p
(
x
)
=
1
w
(
0
≤
x
≤
w
)
p(x)=\frac{1}{w}\,\,(0\leq x \leq w)
p(x)=w1(0≤x≤w) ,代入公式可得期望为
∫
0
w
x
d
x
w
\int_{0}^{w}\frac{xdx}{w}
∫0wwxdx 。这个积分形式很简单,介于积分可以看做是求导的一个逆运算,所以可以直接得到答案是
w
2
\frac{w}{2}
2w 。
现在求的这个是第一个人得到的钱的期望,第二个人得到的期望实质上就是把原来的
w
w
w 换成
(
w
−
w
2
)
(w-\frac{w}{2})
(w−2w) ,以此类推,第
k
k
k 个人抢到的钱的期望就是
w
2
k
\frac{w}{2^k}
2kw ,写个快速幂这题就解决了。代码略。
OI中一般不常考连续型随机变量的期望问题,所以这里也只是简单举一个例子尝尝鲜,真正关键的是离散型随机变量的期望问题(为便于表述,以下的“离散型随机变量的期望”简称为“期望”,“离散型随机变量”简称为“随机变量”,“xxx的期望”也可能称为“期望xxx”)。
期望的基本运算性质
一般以 。设期望的主要性质有四:
(
c
c
c 为一个常数,
x
x
x 和
y
y
y 是两个随机变量,
E
(
x
)
E(x)
E(x) 表示
x
x
x 的期望)
E
(
c
)
=
c
E(c) = c
E(c)=c
E
(
c
x
)
=
c
E
(
x
)
E(cx)=cE(x)
E(cx)=cE(x)
x
x
x 和
y
y
y 相互独立时,
E
(
x
y
)
=
E
(
x
)
E
(
y
)
E(xy)=E(x)E(y)
E(xy)=E(x)E(y)
E
(
x
+
y
)
=
E
(
x
)
+
E
(
y
)
E(x+y)=E(x)+E(y)
E(x+y)=E(x)+E(y)
最后一条被称为“期望的线性性”,是化一个看似不可解的期望问题为若干相对简单的子任务的工具,因此也是期望问题中最常见的转化问题求解的技巧。
除此之外,还有一条概率前缀和的性质:
P
(
x
=
n
)
=
P
(
x
≤
n
)
+
P
(
x
≤
n
−
1
)
P(x=n)=P(x\leq n)+P(x\leq n-1)
P(x=n)=P(x≤n)+P(x≤n−1) ,本质上是一个补集思想,这可以在一些求特定值例如最值的期望问题当中用到。
例题2:Little Pony and Expected Maximum
(CF453A)
尝尝鲜。
考虑枚举每一个最大值,计算它出现的概率。但是发现这个概率直接算不太好计算,因为不好排除扔出别的更大点数的情况,这就可以套用前面的思维,扔出的最大值为
k
k
k 的概率,就是扔出的点数不超过
k
k
k 去掉扔出的不超过
(
k
−
1
)
(k-1)
(k−1) 的概率,这两个就可以直接看作在对应的点数当中枚举了。答案就是
∑
i
=
1
m
(
i
n
−
(
i
−
1
)
n
)
×
i
m
n
=
∑
i
=
1
m
(
(
i
m
)
n
−
(
i
−
1
m
)
n
)
×
i
.
\frac{\sum\limits_{i=1}^m(i^n-(i-1)^n)\times i}{m^n}=\sum\limits_{i=1}^m((\frac{i}{m})^n-(\frac{i-1}{m})^n)\times i.
mni=1∑m(in−(i−1)n)×i=i=1∑m((mi)n−(mi−1)n)×i.
代码略。
期望的一些经典题型
期望的题经典的问题并不多,整体上还是千变万化,还是像所有的dp一样没什么好套路,凭思维见招拆招。不过一些很经典的问题还是可以作为参考。
图上期望问题
要理解期望,我觉得还是得从图这种最直观的形式开始。经典分裂图论和树论了属于是
这里所要讨论的图分为两种,一种是DAG,一种是有环图(无自环)。这两者的区别就在于,前者一定是单向更新的,而后者不一定,所以可能涉及到用高斯消元解方程组(事实上大部分时候到底是状态设计有问题还是这题确实就是得高斯消元靠着看数据范围就能看出来)。
例题3:绿豆蛙的归宿
(洛谷P4316)
一道不能更经典的图上期望问题。
这道题首先保证了是一个连通DAG,好评。
记
d
e
g
x
deg_x
degx 表示点
x
x
x 的出度数。
首先可以想到,假设存在
u
−
v
u-v
u−v ,那么绿豆蛙从
u
u
u 点走到
v
v
v 点的概率就是
1
d
e
g
u
\frac{1}{deg_u}
degu1 ,
f
v
=
∑
(
f
u
+
l
e
n
u
,
v
)
×
1
d
e
g
u
f_v=\sum \,(f_u+len_{u,v})\times \frac{1}{deg_u}
fv=∑(fu+lenu,v)×degu1 ,完事了!
然而并不如此。那么问题出在哪儿呢?
这里的关键在于,确实考虑了从
u
u
u 点走到
v
v
v 点的概率,也确实是在用
u
u
u 的期望在计算贡献,但是
u
−
v
u-v
u−v 的边长并不是期望意义下的长度,它应该乘以到达
u
u
u 这个点的概率才是有期望意义的。记
p
x
p_x
px 表示到
x
x
x 点的概率,有
p
v
=
∑
p
u
d
e
g
u
p_v=\sum \frac{p_u}{deg_u}
pv=∑degupu ,那么正确的式子就应该是
f
v
=
∑
(
f
u
+
l
e
n
u
,
v
×
p
u
)
×
1
d
e
g
u
.
f_v=\sum \,(f_u+len_{u,v}\times p_u)\times \frac{1}{deg_u}.
fv=∑(fu+lenu,v×pu)×degu1.
现在换个方向思考,假设反着做这道题,记
f
x
f_x
fx 表示从
x
x
x 点到终点还需要走的期望步数,转移方程会如何变化?
还是套用前面的状态,设
p
x
p_x
px 为从
x
x
x 点走到终点的概率,由于此题的约束,会发现
p
x
=
1
p_x=1
px=1 ,于是就不用考虑它了,可得
f
u
=
∑
(
f
v
+
l
e
n
u
,
v
)
×
1
d
e
g
u
.
f_u=\sum \,(f_v+len_{u,v})\times \frac{1}{deg_u}.
fu=∑(fv+lenu,v)×degu1.
尽管此题有若干有利的约束,但是通过上面的讨论,我们还是能学到两点:一是期望的转移本质上是事件的期望乘以转移发生的概率贡献到目标事件的期望 ,简单来说,期望贡献的一切参量归根结底都应该是在期望意义下的;二是一些期望问题倒推可能更加简单。前者看似是一句废话,但是确实容易被忽略,应该格外重视。
以上两种都是topo+递推的做法,不妨再来考虑一下用线性性怎么解决这题。答案就等于经过每一条边的期望次数(注意并不是概率)乘以边的长度之和。经过每一条边的期望次数就是经过它的起点的期望次数除以出度数,而经过一个点的期望次数又等于经过它的入度的期望次数之和。
形式化地,设
g
x
,
y
g_{x,y}
gx,y 表示经过边
x
−
y
x-y
x−y 的期望次数,
h
x
h_x
hx 表示经过点
x
x
x 的期望次数,那么就有
g
u
,
v
=
∑
h
u
d
e
g
u
,
h
v
=
∑
g
u
,
v
,
a
n
s
=
∑
g
u
,
v
×
l
e
n
u
,
v
.
g_{u,v}=\sum \frac{h_u}{deg_u},\,h_v=\sum g_{u,v},\,ans=\sum g_u,v\times len_{u,v}.
gu,v=∑deguhu,hv=∑gu,v,ans=∑gu,v×lenu,v. 不难发现,其实这样分析这道题更为清晰,也不那么太玄学,充分说明了线性性的意义。
代码很简单,略过。注意反图只是用来约束更新顺序的,所以建反图的时候不要把统计出度给反过来。
例题4:游走
(洛谷P3232)
无重边无自环,没有别的限制了。
这题要求设计一个边长的排列,使得经过的期望总边长最小。这算是一个小插曲,显然是期望经过次数越多的边长应该越小,于是问题就是求每一条边的期望经过次数。
延续上面的方法,由于这回是无向边,起点有两个,所以
g
u
,
v
=
h
u
d
e
g
u
+
h
v
d
e
g
v
,
h
v
=
∑
h
u
d
e
g
u
.
g_{u,v}=\frac{h_u}{deg_u}+\frac{h_v}{deg_v},\,h_v=\sum \frac{h_u}{deg_u}.
gu,v=deguhu+degvhv,hv=∑deguhu. 由于转移关系成环,所以需要上高斯消元求解。注意一是不要把无意义的
h
n
h_n
hn 放进去消元,二是不要忘了初始状态下
h
1
=
1.
h_1=1.
h1=1.
代码如下:
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<cmath>
using namespace std;
const int N = 125001;
struct yjx{
int nxt,to;
}e[N << 1];
int ecnt = -1,head[505],deg[505],u[N],v[N];
double a[505][505],f[N];
void save(int x,int y){
e[++ecnt].nxt = head[x];
e[ecnt].to = y;
head[x] = ecnt;
++deg[y];
}
void Gauss(int n){
int i,j,k,temp;
for(i = 1;i <= n;i++){
temp = i;
for(j = i + 1;j <= n;j++){
if(fabs(a[j][i]) > fabs(a[temp][i])) temp = j;
}
if(temp != i){
for(j = 1;j <= n + 1;j++) swap(a[i][j],a[temp][j]);
}
if(fabs(a[i][i]) < 1e-8) continue;
for(j = 1;j <= n;j++){
if(j != i){
for(k = i + 1;k <= n + 1;k++){
a[j][k] -= a[i][k] * a[j][i] / a[i][i];
}
}
}
}
for(i = 1;i <= n;i++) a[i][n + 1] /= a[i][i];
}
int main(){
int i,j,n,m,temp;
double res = 0;
scanf("%d %d",&n,&m);
memset(head,-1,sizeof(head));
for(i = 1;i <= m;i++){
scanf("%d %d",&u[i],&v[i]);
save(u[i],v[i]),save(v[i],u[i]);
}
a[1][n] = -1.0;
for(i = 1;i < n;i++){
a[i][i] = -1.0;
for(j = head[i];~j;j = e[j].nxt){
temp = e[j].to;
if(temp == n) continue;
a[i][temp] = 1.0 / deg[temp];
}
}
Gauss(n - 1);
for(i = 1;i <= m;i++){
f[i] = a[u[i]][n] / deg[u[i]] + a[v[i]][n] / deg[v[i]];
}
sort(f + 1,f + m + 1);
for(i = 1;i <= m;i++) res += (m - i + 1) * f[i];
printf("%.3lf\n",res);
return 0;
}
高斯消元即使在dp当中也是一种效率不高的算法,属于一种没法转移的无奈之举。因此即便转移是有环的,根据题目的许多性质,也不一定就必须要用高斯消元。
例题5:线形生物
(洛谷P6835)
这道题如果还上高斯消元就不太合适了 ,因为数据范围
仍然是套线性性,答案等于经过链上每一条边的期望次数之和,那么
g
u
,
u
+
1
=
1
d
e
g
u
+
1
d
e
g
u
∑
(
g
v
,
u
+
1
+
1
)
g_{u,u+1}=\frac{1}{deg_u}+\frac{1}{deg_u}\sum\,(g_{v,u+1}+1)
gu,u+1=degu1+degu1∑(gv,u+1+1) ,考虑到只有链和返祖边,因此
v
−
(
u
+
1
)
v-(u+1)
v−(u+1) 也是若干链上的边组成的,所以这里就可以记一个前缀和
s
u
m
sum
sum ,然后化简一波:
g
u
,
u
+
1
=
1
d
e
g
u
+
1
d
e
g
u
∑
(
g
v
,
u
+
1
+
1
)
=
1
d
e
g
u
+
1
d
e
g
u
∑
(
s
u
m
u
−
s
u
m
v
−
1
+
1
)
=
1
d
e
g
u
+
1
d
e
g
u
∑
(
g
u
,
u
+
1
+
s
u
m
u
−
1
−
s
u
m
v
−
1
+
1
)
=
1
d
e
g
u
+
d
e
g
u
−
1
d
e
g
u
(
g
u
,
u
+
1
+
1
)
+
1
d
e
g
u
∑
(
s
u
m
u
−
1
−
s
u
m
v
−
1
+
1
)
∴
g
u
,
u
+
1
=
d
e
g
u
+
1
d
e
g
u
∑
(
s
u
m
u
−
1
−
s
u
m
v
−
1
+
1
)
.
\begin{aligned} g_{u,u+1}&=\frac{1}{deg_u}+\frac{1}{deg_u}\sum\,(g_{v,u+1}+1)\\ &=\frac{1}{deg_u}+\frac{1}{deg_u}\sum\,(sum_{u}-sum_{v-1}+1)\\ &=\frac{1}{deg_u}+\frac{1}{deg_u}\sum\,(g_{u,u+1}+sum_{u-1}-sum_{v-1}+1)\\ &=\frac{1}{deg_u}+\frac{deg_{u}-1}{deg_u}(g_{u,u+1}+1)+\frac{1}{deg_u}\sum\,(sum_{u-1}-sum_{v-1}+1)\\ \\ \therefore g_{u,u+1}&=deg_u+\frac{1}{deg_u}\sum\,(sum_{u-1}-sum_{v-1}+1). \end{aligned}
gu,u+1∴gu,u+1=degu1+degu1∑(gv,u+1+1)=degu1+degu1∑(sumu−sumv−1+1)=degu1+degu1∑(gu,u+1+sumu−1−sumv−1+1)=degu1+degudegu−1(gu,u+1+1)+degu1∑(sumu−1−sumv−1+1)=degu+degu1∑(sumu−1−sumv−1+1).
最终答案即为 s u m n sum_n sumn ,代码略。
例题6:Intergalaxy Trips
(CF605E)
这题就复杂一些了。
先来考虑这个“最优策略”。很显然,我一定会在连通的点当中选到终点期望天数最小的点去走,因此需要倒推。
有了这个思路,设
f
x
f_x
fx 表示从
x
x
x 走到
n
n
n 的期望天数,对于一条边
u
−
v
u-v
u−v ,它会被选中当且仅当
v
v
v 与
u
u
u 连通,而到
n
n
n 的期望天数比
v
v
v 小的点都不与
u
u
u 连通,即:
f
u
=
∑
v
=
1
n
f
v
×
p
u
,
v
×
∏
w
f
w
<
f
v
(
1
−
p
u
,
w
)
+
1
f_u=\sum\limits_{v=1}^{n} f_v\times p_{u,v}\times\prod\limits_{w}^{f_w<f_v}(1-p_{u,w})+1
fu=v=1∑nfv×pu,v×w∏fw<fv(1−pu,w)+1
然而这个转移式是有瑕疵的,因为光考虑了怎么去转移,而没有考虑转移发生的概率(也就是前面说的缺少一个期望/概率意义) 。所以还应该给
f
v
f_v
fv 乘以一个期望天数,即
1
1
−
∏
x
=
1
n
(
1
−
p
v
,
x
)
.
\frac{1}{1-\prod\limits_{x=1}^{n}(1-p_{v,x})}.
1−x=1∏n(1−pv,x)1.
转移的时候使用类似于Dijkstra的方法就可以了,每次找最小未更新的来更新。
代码如下:
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<cmath>
using namespace std;
const int N = 1001;
int n;
double e[N][N],f[N],g[N],mx;
bool vis[N];
int main(){
int i,j;
scanf("%d",&n);
for(i = 1;i <= n;i++){
for(j = 1;j <= n;j++){
scanf("%lf",&e[i][j]);
e[i][j] *= 0.01;
}
}
if(n == 1){
puts("0.0000000000");
return 0;
}
f[n] = 0;
vis[n] = 1;
for(i = 1;i < n;i++){
f[i] = 1;
g[i] = 1.0 - e[i][n];
}//预处理
for(i = 1;i <= n;i++){
mx = 1e18;
int pos = 0;
for(j = 1;j <= n;j++){
if(!vis[j] && f[j] / (1.0 - g[j]) < mx){
mx = f[j] / (1.0 - g[j]),pos = j;
}
}//找当前未更新的期望天数最小的点
vis[pos] = 1;
if(pos == 1){
printf("%.10lf\n",f[1] / (1.0 - g[1]));
return 0;
}
for(j = 1;j <= n;j++){
f[j] += f[pos] / (1.0 - g[pos]) * e[j][pos] * g[j],g[j] *= (1.0 - e[j][pos]);
}//把这个点的价值和约束一次性都刷完
}
return 0;
}
总结一下,通过图上期望问题,直观地了解了期望是如何计算的,不仅在实践当中验证了线性性的强大,而且也尝试了通过倒推来避开一些繁复的概率计算。期望神秘的面纱也算是开始被揭开了 (自行脑补语气) 。
dp状态设计
经过图上期望,很容易感觉到,期望的线性性是解决相当大部分期望问题的关键一步。更绝对地,解决大部分期望问题的步骤大致可以抽象如下:分段,设计转移方程—套线性性—根据转移方程求所需的参量设计新转移方程—套线性性—…—得到解。对于所有dp,怎么设计转移方程永远是最关键的一环。而通过一些比较奇妙的或是有代表性的题目,可以尽量多地总结出一些经验。
例题7:收集邮票
(洛谷P4550)
一道也是经典的不能更经典的题。
设
f
x
f_x
fx 表示已经取了
x
x
x 张邮票,要取完
n
n
n 种邮票还需要的期望花费(如果没有前面的部分,这一状态设计看起来会多少有点不自然)。假设再取一次,那么情况显然分两种,一种是取到了新的,一种是没取到新的。注意到取这一次的花费和一共取了多少次有关,所以再设
g
x
g_x
gx 表示已经取了
x
x
x 张邮票,要取完
n
n
n 种邮票还需要的期望次数。分析它的转移方程的方法同理,更简单,就是
g
i
=
i
n
×
(
g
i
+
1
)
+
n
−
i
n
×
(
g
i
+
1
+
1
)
g_i=\frac{i}{n}\times (g_i+1)+\frac{n-i}{n}\times (g_{i+1}+1)
gi=ni×(gi+1)+nn−i×(gi+1+1) ,那么就有
f
i
=
i
n
×
(
g
i
+
1
+
f
i
)
+
n
−
i
n
×
(
g
i
+
1
+
1
+
f
i
+
1
)
.
f_i=\frac{i}{n}\times (g_i+1+f_i)+\frac{n-i}{n}\times (g_{i+1}+1+f_{i+1}).
fi=ni×(gi+1+fi)+nn−i×(gi+1+1+fi+1). 化简一下就可以了。
和上面的绿豆蛙的问题类似,如果采取正推,不能像这样来写转移方程。不妨把这种转移关系看成这样一张图:
相比于前面的绿豆蛙的那一题,其实这里正推在数字上更为直观。想象一下,如果形式一致,那么大概形如
g
i
=
i
−
1
n
×
(
g
i
+
1
)
+
n
−
i
+
1
n
×
(
g
i
−
1
+
1
)
g_i=\frac{i-1}{n}\times (g_i+1)+\frac{n-i+1}{n}\times (g_{i-1}+1)
gi=ni−1×(gi+1)+nn−i+1×(gi−1+1) ,但是仔细观察就发现这样转移显然是没道理的,从
(
i
−
1
)
(i-1)
(i−1) 张抽到自己并不能更新
f
i
f_i
fi ,所以这个转移方程也是没意义的,必须算出抽到新的一张邮票的期望次数才能更新。
那么怎么计算呢?不同于之前,自环的存在导致这是一个无限的和,枚举买的次数,即
∑
k
k
×
(
i
−
1
n
)
k
−
1
×
n
−
i
+
1
n
\sum\limits_k k\times(\frac{i-1}{n})^{k-1}\times\frac{n-i+1}{n}
k∑k×(ni−1)k−1×nn−i+1 ,经过一系列化简(这个属于数列知识,不展开解释了)得到
n
n
−
i
+
1
\frac{n}{n-i+1}
n−i+1n ,于是有
g
i
=
g
i
−
1
+
n
n
−
i
+
1
g_i=g_{i-1}+\frac{n}{n-i+1}
gi=gi−1+n−i+1n ,
f
i
f_i
fi 同理。
上面的这一系列推导的结果十分有特点:期望等于概率的倒数。更加严谨的定义是:一件事发生的期望次数等于它发生的概率的倒数。 这个结论是来自于上面的推导,所以这个结论成立,一是算的必须是期望次数,二是可以等概率发生无限次。
代码略。
例题8:Game Relics
(CF1267G)
接着上一题的思路,现在已经有 i i i 个圣物的情况下抽到一个新的圣物的期望花费是 ( n n − i + 1 ) × x 2 (\frac{n}{n-i}+1)\times\frac{x}{2} (n−in+1)×2x ,买到一个新的圣物的期望花费是 1 n − i × ∑ j c j \frac{1}{n-i} \times\sum\limits_jc_j n−i1×j∑cj ,这里如何决策与购买次数和总花费都有关系,所以设 f i , j f_{i,j} fi,j 表示购买了 i i i 个圣物花费为 j j j 的概率。这个概率不好转移,而方案数用背包就算出来了,所以算个方案数最后除以 C n i C_n^i Cni 就可以了。这个时候再用线性性,期望最小花费是各个状态下期望最小花费的总和,此题就做出来了。
代码如下:
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<cmath>
using namespace std;
const int N = 101;
const int M = 1e4 + 1;
int n,m,c[N];
double res,w,fac[N],f[N][M];
#define C(n,m) (fac[n] / fac[m] / fac[(n) - (m)])
int main(){
int i,j,k;
scanf("%d %lf",&n,&w);
for(i = 1;i <= n;i++){
scanf("%d",&c[i]);
m += c[i];
}
fac[0] = 1;
for(i = 1;i <= n;i++) fac[i] = 1.0 * i * fac[i - 1];
f[0][0] = 1.0;
for(i = 1;i <= n;i++){
for(j = n;j >= 1;j--){
for(k = m;k >= c[i];k--){
f[j][k] += f[j - 1][k - c[i]];
}
}
}
for(i = 0;i < n;i++){
for(j = 0;j <= m;j++){
if(!f[i][j]) continue;//减少常数
res += f[i][j] / (1.0 * C(n,i)) * min(1.0 * (m - j) / (1.0 * (n - i)),(1.0 * n / (1.0 * (n - i)) + 1) * w / 2);
}
}
printf("%.10lf\n",res);
return 0;
}
例题9:分手是祝愿
(洛谷P3750)
这道题就比较玄了。
首先,如果存在一种方案能够把当前局面变成全灭,那么这个操作的步骤是可以随便换的(开关灯本质上是异或运算,而异或运算具有交换律),所以一个开关只有操作或不操作两种可能。不难想到一种暴力的方法:从大到小扫一遍,有亮的就关掉。
这种方法是最优的吗?
显然,每一种操作方案只能解决唯一的一种局面。那么是否存在多种操作方案对应同一种局面呢?不存在,因为操作方案和局面都是
2
n
2^n
2n 种,假设存在的话,就会出现某种局面无解,而这明显不可能。因此我们上面提到的那种暴力做法就是最优的。
按这种方法模拟一遍,算出需要关多少个开关。根据线性性,全灭的期望操作次数等于每一步的期望操作次数。这道题特殊的地方在于,关错了会导致需要关的开关变多,所以就不能直接设距离全灭还有多少步。不妨设
f
i
f_i
fi 表示从
i
i
i 个需要关的开关当中减少一个的期望操作次数,那么转移方程就是
f
i
=
i
n
+
n
−
i
n
×
(
f
i
+
1
+
f
i
+
1
)
f_i=\frac{i}{n}+\frac{n-i}{n}\times(f_{i+1}+f_i+1)
fi=ni+nn−i×(fi+1+fi+1) ,注意别忘了关错了的情况下这一次开关本身。化简上式即可递推,最终答案就是
∑
i
=
k
+
1
t
o
t
f
i
.
\sum\limits_{i=k+1}^{tot}f_i.
i=k+1∑totfi.
代码如下:
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
const int mod = 1e5 + 3;
const int N = 1e5 + 1;
int n,k,st[N],cnt;
long long f[N];
long long ksm(long long a,int b){
long long ret = 1;
while(b){
if(b & 1) ret = ret * a % mod;
a = a * a % mod;
b >>= 1;
}
return ret;
}
int main(){
int i,j;
long long res = 0;
scanf("%d %d",&n,&k);
for(i = 1;i <= n;i++) scanf("%d",&st[i]);
for(i = n;i >= 1;i--){
if(st[i]){
++cnt;
st[i] ^= 1;
for(j = 1;j * j <= i;j++){
if(i % j == 0){
st[j] ^= 1;
if(j * j != i) st[i / j] ^= 1;
}
}
}
}
if(cnt <= k) res = cnt;
else{
res = k;
f[n] = 1;
for(i = n - 1;i >= 1;i--){
f[i] = ((n - i) * f[i + 1] % mod + n) % mod * ksm(i,mod - 2) % mod;
}
for(i = cnt;i > k;i--){
res = (res + f[i]) % mod;
}
}
for(i = 1;i <= n;i++){
res = res * i % mod;
}
printf("%lld\n",res);
return 0;
}
例题10:亚瑟王
(洛谷P3239)
根据线性性,应该计算出每一张牌打出的概率,而这种概率是和轮数有关的,具体的计算方法也和前面的购买邮票有些相似。设
f
i
,
j
f_{i,j}
fi,j 表示前
i
i
i 张牌打了
j
j
j 张的概率,由于每一张牌打出都要消耗掉一轮,所以讨论第
i
i
i 张是否被打出,可得
f
i
,
j
=
f
i
−
1
,
j
×
(
1
−
p
i
)
r
−
j
+
f
i
−
1
,
j
−
1
×
(
1
−
(
1
−
p
i
)
r
−
j
+
1
)
f_{i,j}=f_{i-1,j}\times(1-p_i)^{r-j}+f_{i-1,j-1}\times(1-(1-p_i)^{r-j+1})
fi,j=fi−1,j×(1−pi)r−j+fi−1,j−1×(1−(1−pi)r−j+1) ,要注意由于
j
j
j 的不同导致指数的不同。
有了这个就好办了。设
g
i
g_i
gi 表示第
i
i
i 张牌被打出的概率,首先
g
1
=
(
1
−
(
1
−
p
1
)
r
)
g_1=(1-(1-p_1)^r)
g1=(1−(1−p1)r) ;对于剩下的,由于
f
f
f 计算的是考虑结束的状态,所以应该从
f
i
−
1
,
j
f_{i-1,j}
fi−1,j 的状态推出
g
i
=
∑
j
=
0
r
f
i
−
1
,
j
×
(
1
−
(
1
−
p
i
)
r
−
j
)
.
g_i=\sum\limits_{j=0}^{r} f_{i-1,j}\times(1-(1-p_i)^{r-j}).
gi=j=0∑rfi−1,j×(1−(1−pi)r−j). 大功告成。
总结一下,这道题当中的线性性主要体现在第一步上,而后续的计算每一个部分仍然是要从前面的状态上推导而来的,不能完全把问题彻底割裂开来。这也算是组合数学+期望dp的一个简单版本。
代码略。
总结一下,上面的5道题都在讨论如何设计dp状态的问题。例题7算是一个开端,把图上问题当中讨论正推逆推的方法推广到了线性递推问题上,而例题8,9从两个方面介绍了怎么在这个基础问题上再应用线性性解题的方法,以及在动态规划当中仍然去考虑“局部最优”的情况。例题10引入了一点排列组合问题,扩展了线性性应用后一步当中的转移复杂度。经过这些问题,不仅是把期望问题抽象化了,也强化了应用线性性的灵活度。
树上期望问题
由于树的结构比一般图更特殊,树的性质也更多,所以期望dp发挥的空间也大了,自然问题的难度也高了很多。
例题11:仓鼠找sugar II
(洛谷P3412)
一道我所认为的树上期望经典入门题。
应用线性性,考虑如何计算一条边被走过的期望步数。根据前面游走的经验,起点不同被走过的期望次数也是不同的,由于树的结构是可以单向化的,不妨设
f
x
f_x
fx 为经过
x
→
f
a
(
x
)
x\rightarrow fa(x)
x→fa(x) 的期望步数,
g
x
g_x
gx 为经过
f
a
(
x
)
→
x
fa(x)\rightarrow x
fa(x)→x 的期望步数。这里又要借鉴前面例题9的经验,计算
f
x
f_x
fx 时,起点是
x
x
x ,那么情况有二:一是直接走到
f
a
(
x
)
fa(x)
fa(x) ,二是走到子节点,回来,走到
f
a
(
x
)
.
fa(x).
fa(x). 因此转移方程是:
f
u
=
1
d
e
g
u
+
1
d
e
g
u
×
∑
v
∈
s
o
n
(
u
)
(
f
v
+
f
u
+
1
)
=
d
e
g
u
+
∑
v
∈
s
o
n
(
u
)
f
v
f_u=\frac{1}{deg_u}+\frac{1}{deg_u}\times\sum\limits_{v\in son(u)}(f_v+f_u+1)=deg_u+\sum\limits_{v\in son(u)}f_v
fu=degu1+degu1×v∈son(u)∑(fv+fu+1)=degu+v∈son(u)∑fv
化简方法可以直接参考前面的例题5,不再赘述。
现在用类似的方法考虑
g
x
g_x
gx ,情况有三:直接走到
x
x
x ;走到
f
a
(
f
a
(
x
)
)
fa(fa(x))
fa(fa(x)) 然后回头;走到
f
a
(
x
)
fa(x)
fa(x) 的其他子节点然后回来。那么转移方程如下:
g
u
=
1
d
e
g
f
a
u
+
1
d
e
g
f
a
u
×
(
g
f
a
u
+
g
u
+
1
)
+
1
d
e
g
f
a
u
×
∑
v
∈
s
o
n
(
f
a
(
u
)
)
,
v
≠
u
(
f
v
+
g
u
+
1
)
g_u=\frac{1}{deg_{fa_u}}+\frac{1}{deg_{fa_u}}\times(g_{fa_u}+g_u+1)+\frac{1}{deg_{fa_u}}\times\sum\limits_{v\in son(fa(u)),v\neq u} (f_v+g_u+1)
gu=degfau1+degfau1×(gfau+gu+1)+degfau1×v∈son(fa(u)),v=u∑(fv+gu+1)
这个就复杂一些了,具体写一下它的化简方法:(以下推导过程中
f
a
(
u
)
fa(u)
fa(u) 简记为
f
a
fa
fa)
g
u
=
1
d
e
g
f
a
+
1
d
e
g
f
a
×
(
g
f
a
+
g
u
+
1
)
+
1
d
e
g
f
a
×
∑
v
∈
s
o
n
(
f
a
)
,
v
≠
u
(
f
v
+
g
u
+
1
)
=
1
+
d
e
g
f
a
−
1
d
e
g
f
a
×
g
u
+
1
d
e
g
f
a
×
g
f
a
+
1
d
e
g
f
a
∑
v
∈
s
o
n
(
f
a
)
f
v
−
f
u
=
1
+
d
e
g
f
a
−
1
d
e
g
f
a
×
g
u
+
1
d
e
g
f
a
×
g
f
a
+
1
d
e
g
f
a
(
f
f
a
−
d
e
g
f
a
−
f
u
)
=
d
e
g
f
a
−
1
d
e
g
f
a
×
g
u
+
1
d
e
g
f
a
×
g
f
a
+
1
d
e
g
f
a
(
f
f
a
−
f
u
)
∴
g
u
=
g
f
a
+
f
f
a
−
f
u
.
\begin{aligned} g_u&=\frac{1}{deg_{fa}}+\frac{1}{deg_{fa}}\times(g_{fa}+g_u+1)+\frac{1}{deg_{fa}}\times\sum\limits_{v\in son(fa),v\neq u} (f_v+g_u+1)\\ &=1+\frac{deg_{fa}-1}{deg_{fa}}\times g_u+\frac{1}{deg_{fa}}\times g_{fa}+\frac{1}{deg_{fa}}\sum\limits_{v\in son(fa)}f_v-f_u\\ &=1+\frac{deg_{fa}-1}{deg_{fa}}\times g_u+\frac{1}{deg_{fa}}\times g_{fa}+\frac{1}{deg_{fa}}(f_{fa}-deg_{fa}-f_u)\\ &=\frac{deg_{fa}-1}{deg_{fa}}\times g_u+\frac{1}{deg_{fa}}\times g_{fa}+\frac{1}{deg_{fa}}(f_{fa}-f_u)\\ \therefore g_u&=g_{fa}+f_{fa}-f_u. \end{aligned}
gu∴gu=degfa1+degfa1×(gfa+gu+1)+degfa1×v∈son(fa),v=u∑(fv+gu+1)=1+degfadegfa−1×gu+degfa1×gfa+degfa1v∈son(fa)∑fv−fu=1+degfadegfa−1×gu+degfa1×gfa+degfa1(ffa−degfa−fu)=degfadegfa−1×gu+degfa1×gfa+degfa1(ffa−fu)=gfa+ffa−fu.
算完这两个函数之后
x
−
f
a
(
x
)
x-fa(x)
x−fa(x) 的贡献就等于这条边两侧点对数乘以
(
f
x
+
g
x
)
(f_x+g_x)
(fx+gx) ,总和除以
n
2
n^2
n2 就是答案了。
核心代码如下:
void dfs1(int now,int fa){
int i,temp;
f[now] = d[now];
siz[now] = 1;
for(i = head[now];~i;i = e[i].nxt){
temp = e[i].to;
if(temp == fa) continue;
dfs1(temp,now);
f[now] = (f[now] + f[temp]) % mod;
siz[now] += siz[temp];
}
}
void dfs2(int now,int fa){
int i,temp;
if(now ^ 1) g[now] = (g[fa] + f[fa] - f[now] + mod) % mod;
res = (res + siz[now] * (n - siz[now]) % mod * (f[now] + g[now]) % mod) % mod;
for(i = head[now];~i;i = e[i].nxt){
temp = e[i].to;
if(temp == fa) continue;
dfs2(temp,now);
}
}
这里需要注意的是,尽管根节点没有父亲,仍然要如常计算它的 f f f 函数值用于转移。
例题12:概率充电器
(洛谷P4284)
由于每一个元件通电的贡献都是1,所以这题实际上就是求每一个元件通电的概率的和。
元件通电有三种方式:自身通电,被子节点通电,被父节点通电。
设最终元件
i
i
i 被通电的概率是
f
i
.
f_i.
fi. 考虑把前两种放一起计算,自身通不了电就让子节点给自己通电,初始化
f
u
=
p
u
f_u=p_u
fu=pu ,从子节点转移
f
u
=
∑
v
∈
s
o
n
(
u
)
(
1
−
f
u
)
×
f
v
×
e
u
,
v
.
f_u=\sum\limits_{v\in son(u)}(1-f_u)\times f_v\times e_{u,v}.
fu=v∈son(u)∑(1−fu)×fv×eu,v.
然后是被父节点通电的情况,模仿上面的正向再扫一遍就行了。
但这样实际是错的,因为可能出现一个点给父节点通电之后自己又被父节点给通电了的情况,这时候的概率是重复的,所以要求被父节点通电时,这个父节点通电的概率必须是没有算被该子节点时的概率。
具体来说,对于
u
u
u 的一个子节点
v
v
v ,不妨假设它是最后一个更新
u
u
u 的,有
f
u
=
f
u
′
+
(
1
−
f
u
′
)
×
f
v
×
e
u
,
v
f_u=f'_u+(1-f'_u)\times f_v\times e_{u,v}
fu=fu′+(1−fu′)×fv×eu,v ,此时的
f
u
′
f'_u
fu′ 就是排除了
v
v
v 的贡献时
u
u
u 通电的概率,用它更新
f
v
f_v
fv ,可得
f
v
=
f
v
+
(
1
−
f
v
)
×
f
u
′
×
e
u
,
v
f_v=f_v+(1-f_v)\times f'_u\times e_{u,v}
fv=fv+(1−fv)×fu′×eu,v ,而
f
u
′
f'_u
fu′ 只需要移项就可以表示了。
核心代码如下:
void dfs1(int now,int fa){
int i,temp;
for(i = head[now];~i;i = e[i].nxt){
temp = e[i].to;
if(temp == fa) continue;
dfs1(temp,now);
f[now] += (1 - f[now]) * f[temp] * e[i].c;
}
}
void dfs2(int now,int fa){
int i,temp;
for(i = head[now];~i;i = e[i].nxt){
temp = e[i].to;
if(temp == fa) continue;
if(fabs(1.0 - f[temp] * e[i].c) > eps){
double w = (f[now] - f[temp] * e[i].c) / (1.0 - f[temp] * e[i].c);
f[temp] += (1 - f[temp]) * w * e[i].c;
}
dfs2(temp,now);
}
}
例题13:Shrinking Tree
一个看着非常离谱的题。
为了能够求解,最终想要剩下哪一个点就是哪一个点作为根。
一种合并的方案可以看作是一种边的排列,只需要除以
(
n
−
1
)
!
(n-1)!
(n−1)! 就能得到想要的情况出现的概率。现在考虑怎么从这个排列入手计算。对于一个点,要想保留它的编号,需要讨论它的子树内边排列的顺序。具体来说,设
f
u
,
i
f_{u,i}
fu,i 表示对于
u
u
u 子树内的这些边,后
i
i
i 条合并时需要确定编号的总概率,对于每一个子节点
v
v
v ,再单独设一个意义相同的数组
g
i
g_i
gi 用来帮助计算这个子树内产生的贡献。
现在对于一对父子
u
−
v
u-v
u−v ,对于一个特定的
i
i
i ,再枚举这条边在这个排列里的倒数位置
j
j
j 。如果
j
≤
i
j\leq i
j≤i ,说明在这条边被收起之前编号已经确定,合并的时候必须要选
u
u
u ;否则可以随便选(这也可以是看作什么时候根节点被合并到当前的节点)。因此有如下的形式:
for(k = head[now];~k;k = e[k].nxt){
temp = e[k].to;
if(temp == fa) continue;
dfs(temp,now);
memset(g,0,sizeof(g));
for(i = 0;i <= siz[temp];i++){
for(j = 1;j <= siz[temp];j++){
if(j <= i) g[i] += f[temp][j - 1] / 2;
else g[i] += f[temp][i];
}
}
//...
}
现在考虑怎么用
g
g
g 进行更新。枚举之前的子树里选择了
i
i
i 条边的编号,在当前子树里面选择了
j
j
j 条边的编号,更新时需要保持各自内部顺序不变的同时,随机合并的和被选定的仍然是相互分开的,于是有
f
u
,
i
+
j
=
f
u
,
i
+
j
+
f
u
,
i
×
C
i
+
j
i
×
C
s
i
z
u
−
i
+
s
i
z
v
−
j
s
i
z
u
−
i
f_{u,i+j}=f_{u,i+j}+f_{u,i}\times C_{i+j}^i\times C_{siz_u-i+siz_v-j}^{siz_u-i}
fu,i+j=fu,i+j+fu,i×Ci+ji×Csizu−i+sizv−jsizu−i (
s
i
z
u
siz_u
sizu 指的是当前的子树大小而不是总的子树大小),为了防止重复需要再开一个数组维护转移。
这一部分核心代码如下:
memset(h,0,sizeof(h));
for(i = siz[now] - 1;i >= 0;i--){
for(j = siz[temp];j >= 0;j--){
h[i + j] += f[now][i] * g[j] * C[i + j][i] * C[siz[now] - i - 1 + siz[temp] - j][siz[now] - i - 1];
//这里之所以-1是因为初始化siz[now]=1
}
}
siz[now] += siz[temp];
for(i = 0;i < siz[now];i++) f[now][i] = h[i];
总结一下,例题11和12表现了树上期望dp的一个难点(或者说是树形dp自身的一种特性),即从两个方向更新,前者侧重于如何完成计算,后者侧重于如何避免算重。例题13则说明,如果问题很复杂,要完成定向再去进行对应的计算,而且如果一个子树内的情况很复杂,可能需要先把当前节点放进子节点操作之后再合并(换句话说,就是把子节点和父节点合并、子节点之间的合并分成两个独立的步骤)。
期望dp+优化
这部分的难点其实就跟期望dp本身没有很大的关系了,算是收集补完。
例题14:Inversions After Shuffle
(CF749E)
显然,由于给定的是一个排列,所以每一对数不是逆序对就是正序对。
关于修改区间求逆序对的问题早已不陌生了。这题所谓的随机重排可以直接看作是对于每一对被选中的数,都有一半的概率被交换先后顺序,有一半的概率不被交换先后顺序,也就是说,每一对被选中的正序对可以产生
1
2
\frac{1}{2}
21 的贡献,逆序对可以产生
−
1
2
-\frac{1}{2}
−21 的贡献,由于区间修改只影响完全在区间内的逆序对个数,问题就变成了求这对数被选中的概率。一共有
n
(
n
+
1
)
2
\frac{n(n+1)}{2}
2n(n+1) 个可选区间(千万不要组合数学上头) ,选中点对
(
i
,
j
)
(i,j)
(i,j) 就相当于从
[
j
,
n
]
[j,n]
[j,n] 当中选右端点,从
[
1
,
i
]
[1,i]
[1,i] 当中选左端点。于是最终的期望就是
i
(
n
−
j
+
1
)
n
(
n
+
1
)
\frac{i(n-j+1)}{n(n+1)}
n(n+1)i(n−j+1) ,只需要加上正负就行了。套个树状数组即可通过。
代码如下:
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<cmath>
using namespace std;
const int N = 1e5 + 1;
double res,sum;
int n,a[N];
struct yjx{
double tre[N];
#define lowbit(x) (x & (-x))
void modify(int x,double c){
for(;x <= n;x += lowbit(x)){
tre[x] += c;
}
}
double query(int x){
double ret = 0;
for(;x;x -= lowbit(x)){
ret += tre[x];
}
return ret;
}
}bit1,bit2;
int main(){
int i;
scanf("%d",&n);
for(i = 1;i <= n;i++){
scanf("%d",&a[i]);
sum += bit1.query(n) - bit1.query(a[i]);
res -= (n - i + 1) * bit2.query(a[i]);
res += (n - i + 1) * (bit2.query(n) - bit2.query(a[i]));
bit1.modify(a[i],1.0);
bit2.modify(a[i],1.0 * i);
}
res /= (1.0 * n * (n + 1));
printf("%.11lf\n",sum - res);
return 0;
}
例题15:小 Y 和恐怖的奴隶主
(洛谷P4007)
由于随从血量不超过3,所以可以直接设
f
i
,
a
,
b
,
c
f_{i,a,b,c}
fi,a,b,c 表示攻击了
i
i
i 次后,剩下
a
/
b
/
c
a/b/c
a/b/c 个有1/2/3点血量的随从的概率。 这个转移方式比较简单。
现在考虑怎么算期望伤害,根据线性性,总的期望伤害就是每一个
f
i
,
a
,
b
,
c
f_{i,a,b,c}
fi,a,b,c (单次攻击)乘以对应的概率
1
a
+
b
+
c
+
1
\frac{1}{a+b+c+1}
a+b+c+11 ,考虑到这个数据范围,加上此题的系数与函数值无关,所以可以用矩阵加速。在
m
=
3
m=3
m=3 的情况下,可能的状态一共只有166种(加上一个状态计算boss的血量),所以复杂度是
O
(
16
6
3
l
o
g
n
)
O(166^3logn)
O(1663logn) ,又由于多组测试数据,可以直接像快速幂一样预处理出所有
2
i
2^i
2i 的情况,时间复杂度变为
O
(
16
6
3
l
o
g
n
+
T
16
6
2
l
o
g
n
)
.
O(166^3logn+T166^2logn).
O(1663logn+T1662logn).
核心代码如下:
signed main(){
for(i = 0;i <= p;i++){
if(m > 1) u = p - i;
else u = 0;
for(j = 0;j <= u;j++){
if(m > 2) v = p - i - j;
else v = 0;
for(k = 0;k <= v;k++){
id[i][j][k] = ++cnt;
}
}
}
++cnt;
A[0].mat[cnt][cnt] = 1;
A[0].l = A[0].r = cnt;
for(i = 0;i <= p;i++){
if(m > 1) u = p - i;
else u = 0;
for(j = 0;j <= u;j++){
if(m > 2) v = p - i - j;
else v = 0;
for(k = 0;k <= v;k++){
int x = id[i][j][k],op = (i + j + k < p);
long long inv = Inv[i + j + k + 1];
A[0].mat[x][id[i - 1][j][k]] = 1ll * i * inv % mod;
if(m == 2){
if(j) A[0].mat[x][id[i + 1][j - 1 + op][k]] = 1ll * j * inv % mod;
}
else if(m == 3){
if(j) A[0].mat[x][id[i + 1][j - 1][k + op]] = 1ll * j * inv % mod;
if(k) A[0].mat[x][id[i][j + 1][k - 1 + op]] = 1ll * k * inv % mod;
}
A[0].mat[x][x] = A[0].mat[x][cnt] = inv;
}
}
}
mi[0] = 1;
for(i = 1;i <= 60;i++){
A[i] = A[i - 1] * A[i - 1];
mi[i] = mi[i - 1] * 2;
}
while(tt--){
scanf("%lld",&n);
for(i = 1;i <= cnt;i++) res[i] = 0;
res[id[m == 1][m == 2][m == 3]] = 1;
for(i = 0;i <= 60;i++){
if(n & mi[i]) calc(i);
}
printf("%lld\n",res[cnt]);
}
return 0;
}