概率期望

概率

概率:随机事件发生的可能性,是一个 \(0\)\(1\) 之间的实数。

古典概型:\(P(A)= \frac{ A \mbox{ 发生的情况数 } }{\mbox{ 总情况数 }}\)

古典概型的特点:

  1. 有限性:所有可能出现的基本事件只有有限个
  2. 等可能性:每个基本事件出现的可能性相等

如投掷一个 \(6\) 面的质量分布均匀的骰子,数字 \(1 \sim 6\) 朝上的概率均为 \(1/6\),奇数朝上的概率为 \(1/2\),偶数朝上的概率为 \(1/2\)

计算概率:分类用加法原理,分步用乘法原理

概率加法:两个不会同时发生的事件 \(A,B\) 的并集发生的概率 \(P(A+B)=P(A)+P(B)\),如果 \(A\)\(B\) 可能同时发生,需要容斥,即再减去 \(AB\) 同时发生的概率 \(P(AB)\)

概率乘法:

  1. 独立事件:两个互相独立的事件 \(A,B\) 同时发生的概率 \(P(AB)=P(A) \cdot P(B)\)
  2. 条件概率:设 \(A\) 的发生概率为 \(P(A)\),在 \(A\) 发生的情况下 \(B\) 发生的概率为 \(P(B|A)\),则 \(A,B\) 都发生的概率 \(P(AB)=P(A) \cdot P(B|A)\)

\(n\) 个人排队抽 \(n\) 个签,每人抽一个,签里只有一个签是中奖的,无论站在哪里,抽中的概率都是 \(1/n\),可以用条件概率连乘验证。

例题:CF148D Bag of mice

状态:设 \(dp_{i,j}\) 表示当前袋中有 \(i\) 只白鼠 \(j\) 只黑鼠时,A 获胜的概率

起点:\(dp_{0,i}=0, dp_{i,0}=1\)

终点:\(dp_{w,b}\)

转移:

  1. 先手拿到白鼠:dp[i][j]+=i/(i+j)
  2. 先手黑鼠,后手白鼠:dp[i][j]+=0,这种情况不用处理
  3. 先手黑鼠,后手黑鼠,跑白鼠:dp[i][j]+=j/(i+j)*(j-1)/(i+j-1)*i/(i+j-2)*dp[i-1][j-2]
  4. 先手黑鼠,后手黑鼠,跑黑鼠:dp[i][j]+=j/(i+j)*(j-1)/(i+j-1)*(j-2)/(i+j-2)*dp[i][j-3]
参考代码
#include <cstdio>
const int N = 1005;
double dp[N][N];
int main()
{
    int w, b; scanf("%d%d", &w, &b);
    for (int i = 1; i <= w; i++) { // 白鼠
        dp[i][0] = 1;
        for (int j = 1; j <= b; j++) { // 黑鼠
            dp[i][j] += 1.0 * i / (i + j);
            double tmp = 1.0 * j / (i + j) * (j - 1) / (i + j - 1);
            if (j >= 2) dp[i][j] += tmp * i / (i + j - 2) * dp[i - 1][j - 2];
            if (j >= 3) dp[i][j] += tmp * (j - 2) / (i + j - 2) * dp[i][j - 3]; 
        }
    }
    printf("%.9f\n", dp[w][b]);
    return 0;
}

习题:CF768D Jon and Orbs

解题思路

使用动态规划来解决这个问题。设 \(dp_{i,j}\) 表示前 \(i\) 天取到 \(j\) 种物品的概率,考虑第 \(i\) 天的选择,如果第 \(i\) 天抽取到的是旧的种类的物品,这个概率是 \(\frac{j}{k}\),如果抽到的是新的种类的物品,这个概率是 \(\frac{k-j+1}{k}\),因为之前已经选了 \(i-1\) 种物品,第 \(i\) 天刚好选到剩下的 \(k-j+1\) 种物品中的一种。

状态转移方程为:\(dp_{i,j}=dp_{i-1,j-1} \times \frac{k-j+1}{k} + dp_{i-1,j} \times \frac{j}{k}\)

那么,怎么知道要算到多少天呢?理论上查询结果最大的情况应该是多少天 \(1000\) 种物品收集齐的概率能达到 \(\frac{1}{2}\)。这里可以通过打表的方式将天数不断增大,最终发现大约是 \(7500\) 天不到一点,则最后提交的代码中让 dp 天数枚举到约 \(7500\) 即可。

参考代码
#include <cstdio>
#include <cmath>
const int N = 1005;
const double EPS = 1e-7;
double dp[N * 10][N];
int main()
{
    int k, q;
    scanf("%d%d", &k, &q);
    dp[0][0] = 1.0;
    for (int i = 1; i <= 7500; i++) 
        for (int j = 1; j <= k; j++)
            dp[i][j] = dp[i - 1][j - 1] * (k - j + 1) / k + dp[i - 1][j] * j / k;
    while (q--) {
        int p;
        scanf("%d", &p);
        for (int i = k; i <= 7500; i++)
            if (dp[i][k] >= (p - EPS) / 2000) {
                printf("%d\n", i); break;
            }
    }
    return 0;
}

习题:P5492 [PKUWC2018] 随机算法

计算一个简单的贪心随机算法求最大独立集的正确率,算法流程是:随机一个全排列,按顺序检查每个点,若加入该点后仍为独立集则加入。求算法得到的独立集大小等于全图中最大独立集大小的概率。

解题思路

由于 \(n \le 20\),可以使用状压 DP 预处理出每个子集的最大独立集大小。设 \(f_S\) 表示点集为 \(S\) 时的最大独立集大小,则有转移方程 \(f_S = \max(f_{S \setminus \{u\}}, f_{S \setminus (\{u\} \cup N(u))} + 1)\),其中 \(u\)\(S\) 中的一个点,\(N(u)\)\(u\) 的邻居集合。最终全图的最大独立集大小为 \(f_{V}\)\(V\) 表示全图的点集。

贪心随机算法的核心逻辑是:在随机排列中,如果一个点 \(v\) 被加入独立集 \(S\),那么在排列中它必须比它所有的邻居都先出现。

定义状态 \(f_{S,i}\) 表示已经“处理完成”的点集为 \(S\)(即这些点要么已经进入了独立集,要么因为其邻居已在独立集中而被放弃),且当前独立集的大小为 \(i\) 的概率。

由于当前点集为 \(S\),那么剩余未处理的点集为 \(R = V \setminus S\)。在 \(R\) 中等概率随机选择一个点 \(v\),如果 \(v\)\(R\) 中第一个出现在排列中的点(概率为 \(1 / |R|\)),那么:

  1. \(v\) 一定会被加入独立集 \(S\),因为在 \(v\) 之前出现的点都在 \(S\) 中,而 \(v\) 的邻居如果在 \(S\) 中,说明 \(v\) 已经被处理过了(矛盾);如果 \(v\) 的邻居在 \(R\) 中,因为 \(v\) 是第一个出现的,所以此时 \(S\) 中还没有 \(v\) 的邻居。
  2. 选定 \(v\) 后,点 \(v\) 及其所有邻居 \(N(v)\) 都变成了“已处理”状态。

对于每个 \(v \in R\),有转移方程:\(f_{S \cup \{v\} \cup N(v), i+1 } \overset{+}{\longleftarrow} f_{S,i} \times \dfrac{1}{|R|}\)

参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
const int MOD = 998244353;
int a[20], f[1 << 20], inv[21], dp[1 << 20][20];
int main()
{
    int n, m; scanf("%d%d", &n, &m);
    // 初始化每个点及其邻居的掩码
    for (int i = 0; i < n; i++) a[i] = 1 << i;
    for (int i = 0; i < m; i++) {
        int u, v; scanf("%d%d", &u, &v);
        u--; v--;
        a[u] |= 1 << v;
        a[v] |= 1 << u;
    }
    // 预处理最大独立集大小 (MIS)
    f[0] = 0;
    int full = (1 << n) - 1;
    for (int i = 1; i <= full; i++) {
        int u = __builtin_ctz(i);
        f[i] = max(f[i ^ (1 << u)], f[i & (full ^ a[u])] + 1);
    }
    int mis = f[full];
    // 预处理 1..n 的模逆元
    inv[1] = 1;
    for (int i = 2; i <= n; i++) inv[i] = 1ll * (MOD - MOD / i) * inv[MOD % i] % MOD;
    // 概率 DP
    dp[0][0] = 1;
    for (int s = 0; s < full; s++) {
        int r = full ^ s;
        int p = inv[__builtin_popcount(r)];
        // 遍历当前已达到的独立集大小
        for (int i = 0; i <= f[s]; i++) {
            if (!dp[s][i]) continue;
            int val = 1ll * dp[s][i] * p % MOD;
            int tmp = r;
            while (tmp > 0) {
                int v = __builtin_ctz(tmp);
                tmp ^= 1 << v;
                int nxt = s | a[v];
                // 累加概率到下一个状态
                dp[nxt][i + 1] = (dp[nxt][i + 1] + val) % MOD;
            }
        }
    }
    // 输出最终正确率
    printf("%d\n", dp[full][mis]);
    return 0;
}

期望

期望用 \(E(x)\) 表示,定义为每种情况的结果乘这种情况发生的概率,再求和。

如果每种情况发生的概率相等,则也可以用结果之和除以情况数。如投掷一个 \(6\) 面的质量分布均匀的骰子,朝上的数的期望是:\(E(x)=1 \times \frac{1}{6} +2\times \frac{1}{6}+3 \times \frac{1}{6}+ 4 \times \frac{1}{6}+ 5 \times \frac{1}{6}+ 6 \times \frac{1}{6}=\frac{1+2+3+4+5+6}{6}=\frac{7}{2}\)

期望具有线性性:

  • 对同一问题重复 \(k\) 次,得到的结果的和的期望 \(E(kx)=kE(x)\)(可以理解为扔 \(k\) 次骰子)
  • 两个独立问题,得到的结果的和的期望 \(E(x+y)=E(x)+E(y)\)(可以理解为扔两个不同的骰子)
  • 结合起来有 \(E(ax+by)=aE(x)+bE(y)\)

注意期望只有线性性质,比如 \(E(x^2)=E^2(x)\) 是不成立的。

如果把发生事件看成 \(1\),不发生事件看成 \(0\),求出的期望就是该事件发生的概率。

首次期望:重复尝试同一件事,单次成功概率为 \(p\),收获第一次成功的期望次数是 \(1/p\)


选择题:一位玩家正在玩一个特殊的掷骰子的游戏,游戏要求连续掷两次骰子,收益规则如下:玩家第一次掷出 \(x\) 点,得到 \(2x\) 元;第二次掷出 \(y\) 点时,当 \(y=x\) 时玩家会失去之前得到的 \(2x\) 元而当 \(y \ne x\) 时玩家能保住第一次获得的 \(2x\) 元。上述 \(x, y \in 1,2,3,4,5,6\)。例如:玩家第一掷出 \(3\) 点得到 \(6\) 元后,但第二次再次掷出 \(3\) 点,会失去之前得到的 \(6\) 元,玩家最终收益为 \(0\) 元;如果玩家第一次掷出 \(3\) 点、第二次掷出 \(4\) 点,则最终收益是 \(6\) 元。假设骰子掷出任意一点的概率为 \(\dfrac{1}{6}\),玩家 连续掷两次骰子后,所有可能情形下收益的平均值为?

  • A. \(7\)
  • B. \(\dfrac{35}{6}\)
  • C. \(\dfrac{16}{3}\)
  • D. \(\dfrac{19}{3}\)
答案

这是一个典型的数学期望计算问题,可以分步计算所有可能情况下的收益及其概率,然后求加权平均。

掷两次骰子总共 36 种情况,其中有 6 次收益为 0,收益为 2/4/6/8/10/12 的各有 5 次,因此所有可能情形下收益的平均值为 \(5 \times (2+4+6+8+10+12) / 36 = 210 / 36 = 35 / 6\) 元。

答案为 B


选择题:在一条长度为 1 的线段上随机取两个点,则以这两个点为端点的线段的期望长度是?

  • A. \(\dfrac{1}{2}\)
  • B. \(\dfrac{1}{3}\)
  • C. \(\dfrac{2}{3}\)
  • D. \(\dfrac{3}{5}\)
答案

B

如果有一个点固定是两头中的其中一个,显然线段的期望长度是 \(\dfrac{1}{2}\)。而实际上两个点都是随机的,所以期望长度只会比 \(\dfrac{1}{2}\) 更小,选项中只有 B 更小。


选择题:假设一台抽奖机中有红、蓝两色的球,任意时刻按下抽奖按钮,都会等概率获得红球或蓝球之一。有足够多的人每人都用这台抽奖机抽奖,假如他们的策略均为:抽中蓝球则继续抽球,抽中红球则停止。最后每个人都把自己获得的所有球放到一个大箱子里,最终大箱子里的红球与蓝球的比例接近于?

  • A. 1:2
  • B. 2:1
  • C. 1:3
  • D. 1:1
答案

D

可以计算一个“足够大”的样本,例如 \(N\) 个人,看看他们最终贡献的红球和蓝球的期望数量。

根据策略,每一个人抽奖,都是以抽到一个红球而结束。因此,只要有 \(N\) 个人完成了抽奖,他们就会向箱子里贡献正好 \(N\) 个红球。

还需要计算平均每个人会抽到多少个蓝球,分析单次抽奖的各种可能性:

  • 第一次就抽到红球(R):获得 0 个蓝球,概率是 1/2
  • 第二次才抽到红球(BR):获得 1 个蓝球,概率是 1/4
  • 第三次才抽到红球(BBR):获得 2 个蓝球,概率是 1/8
  • 第 k+1 次才抽到红球:获得 k 个蓝球,概率是 (1/2)ᵏ⁺¹

一个人获得蓝球的期望值 E(蓝) = (1/2)×0 + (1/4)×1 + (1/8)×2 + (1/16)×3 + ...,这是一个无穷级数求和,可以算出其结果为 1。


例题:P4316 绿豆蛙的归宿

给定一个 \(n \ (1 \le n \le 10^5)\) 个点 \(m \ (1 \le m \le 2 \times n)\) 条边的有向无环图,从起点 1 出发走向终点 \(n\)。每到达一个顶点 \(u\),若该点有 \(k\) 条边出边,则等概率地选择其中一条边离开,求从起点到终点所经过路径总长度的期望值。

在概率与期望问题中,通常有两种定义方式:

  • 顺推:设 \(f_u\) 为从起点到 \(u\) 的概率或期望,这种方式往往需要同时维护概率和期望。
  • 逆推:设 \(f_u\) 为从节点 \(u\) 到终点 \(n\) 的期望路径长度,这种方式通常更简洁。

采用逆推思路,那么 \(f_1\) 即为答案,而一开始 \(f_n = 0\)(终点到终点的期望距离为 0)。

对于节点 \(u\),假设它有 \(k\) 条出边 \((u, v_1, w_1), (u, v_2, w_2), \dots, (u, v_k, w_k)\),每条边被选中的概率均为 \(\dfrac{1}{k}\)。如果选择了边 \((u,v_i)\),则期望长度为 \(w_i + f_{v_i}\)。根据期望的线性性质,转移方程为 \(f_u = \sum\limits_{i=1}^k \dfrac{1}{k} \times (f_{v_i} + w_i)\)

由于图是 DAG,可以按照逆向拓扑序进行计算。

时间复杂度为 \(O(n+m)\)

参考代码
#include <cstdio>
#include <vector>
#include <queue>
using namespace std;
const int N = 100005;
struct Edge {
    int to, w;
};
vector<Edge> g[N]; // 使用邻接表存储反图
int out[N]; // 原始出度,用于分母
int cnt[N]; // 动态出度,用于拓扑排序
double f[N]; // 期望值数组
int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 0; i < m; i++) {
        int u, v, w; scanf("%d%d%d", &u, &v, &w);
        g[v].push_back({u, w});
        out[u]++;
        cnt[u]++;
    }
    queue<int> q;
    for (int i = 1; i <= n; i++) {
        if (out[i] == 0) q.push(i);
    }
    while (!q.empty()) {
        int v = q.front();
        q.pop();
        for (Edge e : g[v]) {
            int u = e.to, w = e.w;
            // 根据期望公式转移
            f[u] += (f[v] + w) / out[u];
            // 拓扑排序维护
            cnt[u]--;
            if (cnt[u] == 0) q.push(u);
        }
    }
    printf("%.2f\n", f[1]); // 输出结果,保留两位小数
    return 0;
}

而如果采用顺推的思路,实现会稍复杂,需要同时维护概率和期望。

参考代码
#include <cstdio>
#include <vector>
#include <queue>
using namespace std;
const int N = 100005;
struct Edge {
    int to, w;
};
vector<Edge> g[N];
int in[N], out[N]; // 入度用于拓扑排序,出度用于计算概率
double p[N], e[N]; // p[u] 是到达 u 的概率,e[u] 是到达 u 的期望路径和
int main()
{
    int n, m;
    scanf("%d%d", &n, &m);
    for (int i = 0; i < m; i++) {
        int u, v, w;
        scanf("%d%d%d", &u, &v, &w);
        g[u].push_back({v, w});
        out[u]++; // 记录出度
        in[v]++; // 记录入度用于拓扑排序
    }
    queue<int> q;
    p[1] = 1; // 起点概率为 1
    e[1] = 0; // 起点期望长度为 0
    for (int i = 1; i <= n; i++) {
        if (in[i] == 0) q.push(i);
    }
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        if (u == n) continue; // 到达终点不再向后推
        for (Edge ed : g[u]) {
            int v = ed.to, w = ed.w;
            p[v] += p[u] / out[u]; // 顺推概率
            // 顺推期望值:(原期望 + 概率 * 当前边权) / 出度
            e[v] += (e[u] + p[u] * w) / out[u]; 
            if (--in[v] == 0) q.push(v);
        }
    }
    printf("%.2f\n", e[n]);
    return 0;
}

简单来说,逆推写法之所以不需要维护概率,是因为 \(f_u\) 的定义是“条件期望”,它已经假设了“已经站在了 \(u\) 点”这个前提。

顺推中定义的“从起点出发,到达 \(u\) 点时,已走过的路径长度的期望”是相对于全样本空间(即所有可能的路径)而言的。因为有些路径可能根本走不到 \(u\),所以必须知道“走到 \(u\) 的概率”是多少,才能正确计算权重的贡献。

而逆推中定义的“已知当前在 \(u\) 点,从 \(u\) 走到终点 \(n\) 所需经过的路径长度的期望”是一个条件期望,它不再关心“是怎么来到 \(u\) 的”或者“来到 \(u\) 的概率是多少”,它只关心既然已经在这儿了,接下来的路平均要走多长?

在 DAG 中,期望的计算满足马尔可夫性质:一旦到达了节点 \(u\),未来的期望路径长度只取决于 \(u\) 之后的图结构,而与通过哪条路径来到 \(u\) 无关。无论以 0.9 的概率来到 \(u\),还是以 0.0001 的概率来到 \(u\),只要已经站在了 \(u\) 点,从 \(u\)\(n\) 的期望距离 \(f_u\) 都是一模一样的。

想象在一个迷宫里,顺推像是在算所有可能进入迷宫的人,平均在 \(u\) 这个转角处一共贡献了多少里程(所以要看有多少人能走到这儿)。而逆推像是在 \(u\) 这个转角处立了一个告示牌,上面写着“从这儿出发,平均还需要走 500 米到出口”,这个告示牌上的“500 米”只取决于路怎么走,不取决于有多少人看过这个牌子。

例题:P10500 Rainbow 的信号

给定一个长度为 \(N\) 的自然数序列,随机等概率选取两个位置 \(l,r\)(若 \(l \gt r\) 则交换),求区间 \([l,r]\) 的 XOR 和、AND 和、OR 和的数学期望。
数据范围:\(N \le 10^5\),数值 \(\le 10^9\)

由于位运算在二进制的各个位上是独立的,可以分别计算每一位对最终期望的贡献,最后再求和。

对于每一位 \(j \in [0,29]\),设该位为 \(1\) 的区间个数为 \(\text{count}_j\),则该位对期望的贡献为 \(E_j = 2^j \times \dfrac{\text{count}_j}{N^2}\),最终答案为 \(\sum E_j\)

注意题目中 \(l,r\) 是随机选取的,总共有 \(N^2\) 种可能。如果 \(l=r\),区间长度为 \(1\);如果 \(l \ne r\)\((l,r)\)\((r,l)\) 都会指向同一个区间。

对于某一固定的位,序列变成了一个由 \(0\)\(1\) 组成的序列。

区间 AND 和为 \(1\),当且仅当区间内全部元素均为 \(1\)。找出序列中所有连续的 \(1\) 段,设某一段长度为 \(L\),则该段内任意选取 \(l,r\) 构成的区间 AND 和都为 \(1\),该段贡献的方案数为 \(L^2\)(包含 \(l=r\)\(l \ne r\) 的所有顺序),累加所有连续 \(1\) 段的 \(L^2\) 即为 \(\text{count}_{\text{and}}\)

区间 OR 和为 \(1\),当且仅当区间内至少存在一个 \(1\)。正向计数较难,采用反向计数,总方案数 \(N^2\) 减去区间内全为 \(0\) 的方案数。全为 \(0\) 的方案数统计方式同 AND,找出所有连续的 \(0\) 段,累加 \(L^2\)\(\text{count}_{\text{or}} = N^2 - \sum L_{0}^2\)

区间 XOR 和为 \(1\),当且仅当区间内有奇数个 \(1\)。利用前缀异或和,记 \(s_i\) 为前 \(i\) 个元素的异或和,其中 \(s_0 = 0\),那么区间 \([l,r]\) 的异或和为 \(1\) 当且仅当 \(s_r \oplus s_{l-1} = 1\)。统计 \(s_0 \dots s_n\)\(0\) 的个数 \(c_0\)\(1\) 的个数 \(c_1\),满足 \(s_r \oplus s_{l-1} = 1\)\(l-1 \lt r\) 的对数即为 \(c_0 \times c_1\)。由于题目中的选法包含 \((l,r)\)\((r,l)\),且当 \(l=r\) 且元素为 \(1\) 时也要计入。实现时可以先算 \(c_0 \times c_1 \times 2\),这涵盖了所有 \(l \ne r\) 的情况。由于 \(l=r\) 时只应算一次,需要减去重复算的那一次。

参考代码
#include <cstdio>
const int N = 100005;
using ll = long long;
int x[N], bit[N];
int main()
{
    int n; scanf("%d", &n);
    ll n2 = 1ll * n * n;
    for (int i = 1; i <= n; i++) scanf("%d", &x[i]);
    double a = 0, b = 0, c = 0;
    for (int j = 0; j < 30; j++) {
        for (int i = 1; i <= n; i++) bit[i] = (x[i] >> j) & 1;
        // XOR 统计
        int s = 0, c0 = 1, c1 = 0;
        for (int i = 1; i <= n; i++) {
            s ^= bit[i];
            if (s == 0) {
                c0++;
            } else {
                c1++;
            }
        }
        ll sum = 2ll * c0 * c1;
        for (int i = 1; i <= n; i++) 
            if (bit[i]) sum--;
        a += 1.0 * sum * (1 << j);

        // AND 统计
        int len = 0;
        ll cnt = 0;
        for (int i = 1; i <= n; i++) {
            if (bit[i]) {
                len++;
            } else {
                cnt += 1ll * len * len;
                len = 0;
            }
        }
        cnt += 1ll * len * len;
        b += 1.0 * cnt * (1 << j);

        // OR 统计
        cnt = len = 0;
        for (int i = 1; i <= n; i++) {
            if (bit[i]) {
                cnt += 1ll * len * len;
                len = 0;
            } else {
                len++;
            }
        }
        cnt += 1ll * len * len;
        c += 1.0 * (n2 - cnt) * (1 << j);
    }
    printf("%.3f %.3f %.3f\n", a / n2, b / n2, c / n2);
    return 0;
}

习题:P6154 游走

解题思路

本题选择所有路径的概率相等,而路径数和所有路径的总长都不难求,所以可以直接用所有路径的总长除以路径数。这类问题也可以看成是假期望题,真正的考察点是计数。

\(cnt_i\) 表示以 \(i\) 为终点的路径数,\(len_i\) 表示以 \(i\) 为终点的路径的总长。对于有向边 u->v,有状态转移:\(cnt_v = (\sum cnt_u) + 1\)(加 \(1\) 是指 \(v\) 自己原地的一条路径),\(len_v = \sum (len_u + cnt_u)\)(每条路径延伸过来时长度都加 \(1\),有 \(cnt_u\) 条路径延伸过来所以一共加 \(cnt_u\))。

最后答案就是 \(\frac{\sum len_i}{\sum cnt_i}\),取模就是把除法写成乘逆元。

参考代码
#include <cstdio>
#include <vector>
#include <queue>
using std::vector;
using std::queue;
const int N = 1e5 + 5;
const int MOD = 998244353;
vector<int> graph[N];
int ind[N], len[N], cnt[N];
int quickpow(int x, int y) {
    int res = 1;
    while (y > 0) {
        if (y & 1) res = 1ll * res * x % MOD;
        x = 1ll * x * x % MOD;
        y >>= 1;
    }
    return res;
}
int main()
{
    int n, m; scanf("%d%d", &n, &m);
    while (m--) {
        int x, y; scanf("%d%d", &x, &y);
        graph[x].push_back(y); ind[y]++;
    }
    queue<int> q;
    for (int i = 1; i <= n; i++) {
        cnt[i] = 1;
        if (ind[i] == 0) q.push(i);
    }
        
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int v : graph[u]) {
            // u->v
            len[v] = (len[v] + (len[u] + cnt[u]) % MOD) % MOD;
            cnt[v] = (cnt[v] + cnt[u]) % MOD;
            if (--ind[v] == 0) q.push(v);
        }
    }
    int suml = 0, sumc = 0;
    for (int i = 1; i <= n; i++) {
        suml = (suml + len[i]) % MOD;
        sumc = (sumc + cnt[i]) % MOD;
    }
    printf("%d\n", 1ll * suml * quickpow(sumc, MOD - 2) % MOD);
    return 0;
}

习题:P1297 [国家集训队] 单选错位

解题思路

做对 \(i\) 题的情况数或概率很难直接算,但可以分析每道题是否做对,这个事情之间是互相独立的。

根据期望的线性性(多个独立问题的期望可加),可以去看每道题有多大概率做对,也就是有多大概率给答案所求的期望加 \(1\),有多大概率贡献 \(0\),再加起来就是所求的期望了。

\(i\) 道题做对的概率是 \(\frac{\min (a_{i-1},a_i)}{a_{i-1}a_i}\),因为第 \(i-1\) 题和第 \(i\) 题的答案总共有 \(a_{i-1}a_i\) 种可能,其中有 \(\min (a_{i-1},a_i)\) 种是两题答案相同的情况,当相邻两题答案一样时错位也能做对。

因此每一题得分的期望是 \(\frac{1}{\max (a_{i-1},a_i)}\),累加起来即可,时间复杂度为 \(O(n)\)

参考代码
#include <cstdio>
#include <algorithm>
using std::max;
const int N = 1e7 + 5;
int a[N];
int main()
{
    int n, A, B, C;
    scanf("%d%d%d%d%d", &n, &A, &B, &C, a + 1);
    for (int i = 2; i <= n; i++)
        a[i] = ((long long) a[i - 1] * A + B) % 100000001;
    for (int i = 1; i <= n; i++)
        a[i] = a[i] % C + 1;
    double ans = 0;
    a[n + 1] = a[1];
    for (int i = 2; i <= n + 1; i++) ans += 1.0 / max(a[i - 1], a[i]);
    printf("%.3f\n", ans);
    return 0;
}

习题:P1654 OSU!

解题思路

\(dp_i\) 表示前 \(i\) 个操作的期望得分,则有 \(dp_i = dp_{i-1} \cdot (1-p_i) + (dp_{i-1} + E(本次成功时增加的分数)) \cdot p_i = dp_{i-1} + E(本次增加的分数) \cdot p_i\)

设截止到 \(i-1\),最后一段有连续 \(X\)\(1\),因为有 \((X+1)^3=X^3+3X^2+3X+1\),所以 \(E(本次成功时增加的分数) = 3E(X^2) + 3E(X) + 1\)

\(E1_i\) 表示截止到 \(i\) 时,\(X\) 的期望,\(E2_i\) 表示截止到 \(i\) 时,\(X^2\) 的期望,则有 \(E1_i = (E1_{i-1} + 1) \cdot p_i + 0 \cdot (1-p_i) = (E1_{i-1}+1) \cdot p_i\)\(E2_i = (E2_{i-1} + 2 E1_{i-1} + 1) \cdot p_i + 0 \cdot (1-p_i) = (E2_{i-1}+2E1_{i-1}+1) \cdot p_i\)

最终 \(dp_i = dp_{i-1} + (3E2_{i-1}+3E1_{i-1}+1) \cdot p_i\)

参考代码
#include <cstdio>
int main()
{
    int n; scanf("%d", &n);
    double e1 = 0, e2 = 0, ans = 0;
    while (n--) {
        double p; scanf("%lf", &p);
        ans += p * (3 * e2 + 3 * e1 + 1);
        e2 = (e2 + 2 * e1 + 1) * p;
        e1 = (e1 + 1) * p;
    }
    printf("%.1f\n", ans);
    return 0;
}

习题:P1850 [NOIP2016 提高组] 换教室

解题思路

首先从一个教室去另一个教室一定会走最短路,所以可以先用 Floyd 算法求一下任意两点的最短路。

考虑 dp,设 \(dp_{i,j,0/1}\) 表示前 \(i\) 个时间段,提了 \(j\) 次申请,本次没提/提了申请的最优解。

枚举上个时间段提了还是没提,列出两个式子,把两个式子取 \(\min\),这个提不提是可以决策的,因此直接决策最优。

列式时如果上次或本次提了申请,算本次增加的距离的时候就要把成功和不成功两种情况的距离分别乘上其概率加起来。

image

像本题这样每一个事件发生的概率是独立的,就可以直接正推 DP。

时间复杂度为 \(O(v^3+nm)\)

参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 2005;
const int V = 305;
const int INF = 1e9;
int c[N], d[N], dist[V][V];
double s[N], f[N], dp[N][N][2];
int query(int i, int x, int y) { // query(i,0/1,0/1)
    int pre = (x == 0 ? c[i - 1] : d[i - 1]);
    int cur = (y == 0 ? c[i] : d[i]);
    return dist[pre][cur];
}
int main()
{
    int n, m, v, e;
    scanf("%d%d%d%d", &n, &m, &v, &e);
    if (m > n) m = n;
    for (int i = 1; i <= n; i++) scanf("%d", &c[i]);
    for (int i = 1; i <= n; i++) scanf("%d", &d[i]);
    for (int i = 1; i <= n; i++) {
        scanf("%lf", &s[i]); f[i] = 1 - s[i];
    }
    for (int i = 1; i <= v; i++) {
        for (int j = 1; j <= v; j++)
            dist[i][j] = INF;
        dist[i][i] = 0;
    }
    while (e--) {
        int a, b, w;
        scanf("%d%d%d", &a, &b, &w);
        dist[a][b] = dist[b][a] = min(dist[a][b], w);
    }
    for (int k = 1; k <= v; k++)
        for (int i = 1; i <= v; i++)
            for (int j = 1; j <= v; j++)
                dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]);
    for (int i = 0; i <= n; i++)
        for (int j = 0; j <= m; j++)
            dp[i][j][0] = dp[i][j][1] = INF;
    dp[1][0][0] = dp[1][1][1] = 0;
    for (int i = 2; i <= n; i++) {
        dp[i][0][0] = dp[i - 1][0][0] + query(i, 0, 0);
        int bound = min(m, i);
        for (int j = 1; j <= bound; j++) {
            // 上次不申请换,这次不申请换
            double tmp = dp[i - 1][j][0] + query(i, 0, 0);
            dp[i][j][0] = min(dp[i][j][0], tmp);
            // 上次申请换,这次不申请换
            tmp = dp[i - 1][j][1];
            tmp += s[i - 1] * query(i, 1, 0);
            tmp += f[i - 1] * query(i, 0, 0);
            dp[i][j][0] = min(dp[i][j][0], tmp);
            // 上次不申请换,这次申请换
            tmp = dp[i - 1][j - 1][0];
            tmp += s[i] * query(i, 0, 1);
            tmp += f[i] * query(i, 0, 0);
            dp[i][j][1] = min(dp[i][j][1], tmp);
            // 上次申请换,这次申请换
            tmp = dp[i - 1][j - 1][1];
            tmp += s[i - 1] * s[i] * query(i, 1, 1);
            tmp += s[i - 1] * f[i] * query(i, 1, 0);
            tmp += f[i - 1] * s[i] * query(i, 0, 1);
            tmp += f[i - 1] * f[i] * query(i, 0, 0);
            dp[i][j][1] = min(dp[i][j][1], tmp); 
        }
    }
    // double ans = 0;
    double ans = INF;
    for (int i = 0; i <= m; i++) {
        ans = min(ans, min(dp[n][i][0], dp[n][i][1]));
    }
    printf("%.2f\n", ans);
    return 0;
}

习题:P4550 收集邮票

解题思路

\(dp_i\) 表示如果初始有 \(i\) 种邮票,直到得到所有的邮票所花的钱数的期望值。

考虑从当前状态出发再抽一张邮票是哪种邮票(之前有的还是没有的),然后让后边所有的邮票化的钱加上这张邮票花钱的钱,而这张邮票花多少钱取决于已经买了多少张票。

所以再设 \(num_i\) 表示如果初始有 \(i\) 种邮票,直到得到所有邮票所买的票数的期望值。初始化 \(dp_n = 0, num_n = 0\)

可以列出状态转移方程:\(dp_i = \frac{i}{n} \times (dp_i + num_i + 1) + (1-\frac{i}{n}) \times (dp_{i+1}+num_{i+1}+1)\)

这个式子没法直接推,但这是个等式,可以移项,得 \(dp_i = \frac{i}{n-i} \times num_i + dp_{i+1} + num_{i+1} + \frac{n}{n-i}\),最终答案是 \(dp_0\)

对于 \(num\),则可以列出式子 \(num_i = \frac{i}{n} \times num_i + (1-\frac{i}{n}) \times num_{i+1}+1\),移项得 \(num_i = num_{i+1} + \frac{n}{n-i}\)

参考代码
#include <cstdio>
const int N = 10005;
double dp[N], num[N];
int main()
{
    int n; scanf("%d", &n);
    dp[n] = num[n] = 0;
    for (int i = n - 1; i >= 0; i--) {
        num[i] = num[i + 1] + 1.0 * n / (n - i);
        dp[i] = 1.0 * i / (n - i) * num[i] + dp[i + 1] + num[i + 1] + 1.0 * n / (n - i);
    }
    printf("%.2f\n", dp[0]);
    return 0;
}

例题:UVA12369 Cards

一副标准的 54 张扑克牌(52 张常规牌 + 2 张王牌),常规牌分为四种花色(梅花、方块、红心、黑桃),每种 13 张,目标是桌面上至少出现 \(C\) 张梅花、\(D\) 张方块、\(H\) 张红心和 \(S\) 张黑桃。洗牌后一张张翻开,如果翻到王牌,必须立即将其指定为四种花色中的一种,使得达成目标的期望抽卡次数最小,求该最小期望值。

需要记录当前已翻开的各类牌的数量,定义状态 \(f(c, d, h, s, j_1, j_2)\) 表示:

  • \(c,d,h,s\):已翻开的四种花色的常规牌数量(\(0 \le c,d,h,s \le 13\))。
  • \(j_1, j_2\):两张王牌的状态,\(0 \sim 3\) 代表已指定给某种花色,\(4\) 代表尚未翻开。

设当前剩余牌数为 \(R = 54 - (c + d + h + s + [j_1 \lt 4] + [j_2 \lt 4])\)

若当前各花色总数(常规牌 + 王牌指定)已满足 \(C,D,H,S\),则 \(f=0\)

对于一般情况,\(f = 1 + \text{后续步数的期望}\),具体转移要分类讨论:

  • 抽到常规花色 \(i\):概率 \(P_i = (13 - \text{已抽的该花色数量}) / R\),转移至 \(f(\text{新状态})\)
  • 抽到王牌:若王牌尚未翻完,抽到下一张王牌的概率为 \(\text{剩余王牌数} / R\)。翻开王牌后,需要选择一个花色 \(i \in \{0,1,2,3\}\),使得 \(f(\dots, \text{指定花色为 } i, \dots)\) 最小。

使用六维数组存储计算结果,由于两张王牌是等价的,可以规定 \(j_1 \le j_2\) 来减少重复状态。

参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
double f[14][14][14][14][5][5]; // 记忆化搜索表
int C, D, H, S;
// 检查目标是否可能达成
bool check(int c, int d, int h, int s) {
    if (c + d + h + s > 54) return false; // 总数不能超过 54 张
    int extra = 0;
    if (c > 13) extra += c - 13;
    if (d > 13) extra += d - 13;
    if (h > 13) extra += h - 13;
    if (s > 13) extra += s - 13;
    return extra <= 2; // 必须要用的王牌数不能超过 2 张
}
double solve(int c, int d, int h, int s, int j1, int j2) {
    // 计算当前各花色的总数(常规 + 已分配王牌)
    int cc = c + (j1 == 0) + (j2 == 0);
    int cd = d + (j1 == 1) + (j2 == 1);
    int ch = h + (j1 == 2) + (j2 == 2);
    int cs = s + (j1 == 3) + (j2 == 3);
    // 如果已满足目标,返回 0
    if (cc >= C && cd >= D && ch >= H && cs >= S) return 0;
    // 王牌分配顺序不影响期望,确保 j1 <= j2 以减少重复状态
    if (j1 > j2) swap(j1, j2);
    if (f[c][d][h][s][j1][j2] >= 0) return f[c][d][h][s][j1][j2];
    int sum = c + d + h + s + (j1 < 4) + (j2 < 4);
    int rem = 54 - sum;
    double res = 1;
    // 抽到常规花色
    if (c < 13) res += (13.0 - c) / rem * solve(c + 1, d, h, s, j1, j2);
    if (d < 13) res += (13.0 - d) / rem * solve(c, d + 1, h, s, j1, j2);
    if (h < 13) res += (13.0 - h) / rem * solve(c, d, h + 1, s, j1, j2);
    if (s < 13) res += (13.0 - s) / rem * solve(c, d, h, s + 1, j1, j2);
    // 抽到王牌
    if (j1 == 4) {
        // 还有两张王牌没抽到
        double v = 1e18;
        for (int i = 0; i < 4; i++) {
            v = min(v, solve(c, d, h, s, i, 4));
        }
        res += 2.0 / rem * v;
    } else if (j2 == 4) {
        // 还有一张王牌没抽到
        double v = 1e18;
        for (int i = 0; i < 4; i++) {
            v = min(v, solve(c, d, h, s, j1, i));
        }
        res += 1.0 / rem * v;
    }
    return f[c][d][h][s][j1][j2] = res;
}
int main()
{
    int t; 
    scanf("%d", &t);
    for (int id = 1; id <= t; id++) {
        scanf("%d%d%d%d", &C, &D, &H, &S);
        printf("Case %d: ", id);
        if (!check(C, D, H, S)) {
            printf("-1.000\n");
            continue;
        }
        // 重置记忆化搜索表
        for (int i = 0; i <= 13; i++)
            for (int j = 0; j <= 13; j++)
                for (int k = 0; k <= 13; k++)
                    for (int l = 0; l <= 13; l++)
                        for (int m = 0; m < 5; m++)
                            for (int n = 0; n < 5; n++)
                                f[i][j][k][l][m][n] = -1;
        printf("%.3f\n", solve(0, 0, 0, 0, 4, 4));
    }
    return 0;
}
posted @ 2024-07-25 09:33  RonChen  阅读(242)  评论(0)    收藏  举报