【演算法學習筆記】概率與期望DP

RioTian發表於2021-07-23

本文學習自 Sengxian 學長的部落格

之前也在CF上寫了一些概率DP的題並做過總結

建議閱讀完本文再去接著閱讀這篇文章:Here

前言

單純只用到概率的題並不是很多,從現有的 OI/ACM 比賽中來看,大多數題目需要概率與期望結合起來(期望就是用概率定義的),所以本文主要講述期望 DP。

期望 DP 有一些固定的方法,這裡分多種方法來講述。


講解

例一

#3036. 綠豆蛙的歸宿

題意:

給定一個起點為 \(1\),終點為 \(n\) 的有向無環圖。到達每一個頂點時,如果有 \(K\) 條離開該點的道路,可以選擇任意一條道路離開該點,並且走向每條路的概率為 \(\frac 1 K\)。問你從 \(1\) 出發走到 \(n\) 的路徑期望總長度是多少。

方法一:直接定義期望狀態

這道題的終點很明確,那就是走到 \(n\) 即停止。對於期望 DP,我們一般採用逆序的方式來定義狀態,即考慮從當前狀態到達終點的期望代價。因為在大多數情況下,終點不唯一,而起點是唯一的。
我們定義 \(dp(i)\)為從 \(i\) 出發走到終點 \(n\) 的路徑期望總長度,根據全期望公式,得到(設​ \(G_i\)為從 \(i\) 的邊的集合):

\[dp(i) = \sum\limits_{e\in G_i}\frac{dp(e_{to}) + e_{const}}{|G_i|} \]

因為這是一個有向無環圖,每個點需要其能到達的點的狀態,故我們採用拓撲序的逆序進行計算即可。

【AC Code】

const int N = 100000, M = 2 * N;
int n, m;
struct node { int v, w;};
vector<node>e[N];
int d[N];    // 出度
double f[N]; // dp

double dfs(int u) {
    if (f[u] >= 0)return f[u];
    f[u] = 0;
    for (auto [v, w] : e[u]) { // tuple 需開啟 C++17
        f[u] += (w + dfs(v)) / d[u];
    }
    return f[u];
}

int main() {
    cin.tie(nullptr)->sync_with_stdio(false);
    memset(f, -1, sizeof(f));
    cin >> n >> m;
    for (int i = 0; i < m; ++i) {
        int u, v, w;
        cin >> u >> v >> w;
        e[u].push_back(node{v, w});
        d[u]++;//出度++
    }
    cout << fixed << setprecision(2) << dfs(1);
}

方法二:利用期望的線性性質

根據期望的線性性質\(E[aX +bY]=aE[X] + bE[Y]\)。所以另外一種求期望的方式是分別求出每一種代價產生的期望貢獻,然後相加得到答案。在本題中,路徑期望總長度等於每條邊產生的期望貢獻之和。而每條邊產生又等於經過這條邊的期望次數乘這條邊的代價。所以,我們只需要算每條邊的期望經過次數即可。

\((u,v,w)\) 的期望經過次數是不好直接算的,但如果我們能算得點 \(u\) 的期望經過次數為 \(dp(u,v)\),那麼邊 \((u,v,w)\) 的期望經過次數是 \(dp(u)*\frac1{|G_u|}\) ,對答案的貢獻就是 \(w*dp(u)*\frac1{|G_u|}\)

如何計算點 \(u\) 的期望經過次數 \(dp(u)\)呢?我們依然考慮 DP 的方式,首先有 \(dp(u) = 1\),轉移採取刷表的方式:

\[dp(e_{to})\leftarrow dp(u)*\frac1{|G_u|},e\in G_u \]

在用邊 \(e\) 刷表的同時,邊 \(e\) 的貢獻就可以計算了,十分簡潔。因為這種方法計算答案十分的便捷,而且適用範圍廣,所以這種『利用期望的線性性質,單獨計算貢獻的方法』是我們計算期望首選的方法。

【AC Code】這裡貼 Sengxian 學長的程式碼

typedef long long ll;
inline int readInt() {
    static int n, ch;
    n = 0, ch = getchar();
    while (!isdigit(ch)) ch = getchar();
    while (isdigit(ch)) n = n * 10 + ch - '0', ch = getchar();
    return n;
}

const int MAX_N = 100000 + 3, MAX_M = MAX_N * 2;
struct edge {
    edge *next;
    int to, cost;
    edge(edge *next = NULL, int to = 0, int cost = 0): next(next), to(to), cost(cost) {}
} pool[MAX_M], *pit = pool, *first[MAX_N];
int n, m, deg[MAX_N], outDeg[MAX_N];
double f[MAX_N];

void solve() {
    static int q[MAX_N];
    int l = 0, r = 0;
    q[r++] = 0;

    double ans = 0;
    f[0] = 1.0;
    while (r - l >= 1) {
        int u = q[l++];
        for (edge *e = first[u]; e; e = e->next) {
            f[e->to] += f[u] / outDeg[u];
            ans += f[u] * e->cost / outDeg[u];
            if (--deg[e->to] == 0) q[r++] = e->to;
        }
    }

    printf("%.2f\n", ans);
}

int main() {
    n = readInt(), m = readInt();
    for (int i = 0, u, v, w; i < m; ++i) {
        u = readInt() - 1, v = readInt() - 1, w = readInt();
        first[u] = new (pit++) edge(first[u], v, w);
        deg[v]++, outDeg[u]++;
    }
    solve();
}

例二

接著我們考慮 Codeforces 518D 這道題,以便體會方法二的好處。

題意:\(n\) 個人排成一列,每秒中隊伍最前面的人有 \(p\) 的概率走上電梯(一旦走上就不會下電梯),或者有 \(1-p\) 的概率不動。問你 \(T\) 秒過後,在電梯上的人的期望。

方法一

在本題這樣一個情況中,方法一是用不了的,因為我們的結束狀態不明確。

方法三:使用期望的定義計算

如果 \(X\) 是離散的隨機變數,輸出值為 \(x_1,x_2,...\),輸出值相應的概率為 \(p_1,p_2,...\),那麼期望值是一個無限數列的和(如果不收斂,那麼期望不存在):

\[E[x] =\sum\limits_ip_ix_i \]

在本題中,如果設 \(dp(i,j)\)\(i\) 秒過後,電梯上有 \(j\) 個人的概率,那麼答案是:

\[\sum\limits_{0\le k\le n}dp(T,K)*K \]

所以我們只需要求 \(dp(i, j)\) 就可以了,初始值 \(dp(0, 0) = 1\) 就可以了,仍然是採用刷表法:

\[dp(i + 1,j + 1) \leftarrow dp(i,j)*p\\ dp(i + 1,j)\leftarrow dp(i,j) * (1 - p) \]

【AC Code】

const int N = 2e3 + 10;
double p, dp[N][N];
int main() {
    cin.tie(nullptr)->sync_with_stdio(false);
    int n, t;
    cin >> n >> p >> t;
    dp[0][0] = 1;
    for (int i = 0; i < t; ++i) {
        dp[i + 1][n] += dp[i][n];
        for (int j = 0; j < n; ++j) if (dp[i][j] > 1e-10) {
                dp[i + 1][j + 1] += dp[i][j] * p;
                dp[i + 1][j] += dp[i][j] * (1 - p);
            }
    }
    double ans = 0;
    for (int i = 0; i <= n; ++i) ans += i * dp[t][i];
    cout << fixed << setprecision(6) << ans;
}

方法二

那麼之前提到的適用範圍廣的方法二,是否能在這裡用呢?答案是肯定的。

延續方法三的 DP,我們不妨將狀態之間的轉移抽象成邊,只不過只有 \(dp(i, j)\)\(dp(i + 1, j + 1)\) 的邊才有為 \(1\) 的邊權,其餘都為 \(0\)。因為這個 DP 涵蓋了所有可能出現的情況,所以我們仍然可以利用期望的線性性質,在刷表的過程中進行計算答案。

本題中,沒有直觀的邊的概念,但是我們可以將狀態之間的轉移抽象成邊,由於 \(dp(i, j)\)\(dp(i + 1, j + 1)\) 這一個轉移是對答案有 \(1\) 的貢獻的,所以我們將它們之間的邊權賦為 \(1\)

這一題將方法二抽象化了,實際上大多數題並非是直觀的,而是這種抽象的形式。

const int N = 2e3 + 10;
double p, dp[N][N];
int main() {
    cin.tie(nullptr)->sync_with_stdio(false);
    int n, t;
    cin >> n >> p >> t;
    dp[0][0] = 1;
    double ans = 0;
    for (int i = 0; i < t; ++i) {
        dp[i + 1][n] += dp[i][n];
        for (int j = 0; j < n; ++j) if (dp[i][j] > 1e-10) {
                dp[i + 1][j + 1] += dp[i][j] * p;
                dp[i + 1][j] += dp[i][j] * (1 - p);
                ans += dp[i][j] * p;
            }
    }
    cout << fixed << setprecision(6) << ans;
}

例三

BZOJ 3450

題意:給定一個序列,一些位置未確定(是 \(\texttt{o}\)\(\texttt{x}\) 的機率各佔 \(\%50%\))。對於一個 \(\texttt{ox}\) 序列,連續 \(x\) 長度的 \(\texttt{o}\) 會得到 \(x^2\) 的收益,請問最終得到的序列的期望收益是多少?

分析

這個題如果一段一段的處理,實際上並不是很好做。我們觀察到 \((x + 1) ^ 2 - x ^ 2 = 2x + 1\),那麼根據期望的線性性質,我們可以單獨算每一個字元的貢獻。我們設 \(dp_i\) 為考慮前 ii 個字元的期望得分,\(l_i\) 為以 \(i\) 為結尾的 comb 的期望長度,\(Comb_i\) 為第 \(i\)個字元,那麼有 3 種情況:

  1. \(s_i = o\) ,則 \(dp_i = dp_{i - 1} + l_{i - 1} * 2 + 1,l_i = l_{i - 1} + 1\)
  2. \(s_i = x\) ,則 \(dp_i = dp_{i - 1}\)
  3. \(s_i =\ ?\), 則 \(dP_i = dp_{i - 1} + \frac{l_i*2 + 1}{2},l_i = \frac{l_{i - 1} + 1}{2}\)

對於前兩種情況,其實是非常直觀的,對於第三種情況,實際上是求了一個平均長度。例如 ?oo,兩種情況的長度 \(l_i\) 分別為 \([0,1,2]\)\([1,2,3]\) ,但是求了平均之後,長度 \(l_i\) 變成了 \([0.5,1.5,2.5]\) ,這樣由於我們的貢獻是一個關於長度的一次多項式 \((2x + 1)\) ,所以長度平均之後,貢獻也相當於求了一個平均,自然能夠求得正確的得分期望。

【AC Code】

const int N = 3e5 + 10;
double dp[N], Comb[N];
int main() {
    cin.tie(nullptr)->sync_with_stdio(false);
    int n; string s;
    cin >> n >> s;
    for (int i = 0; i < n; ++i) {
        if (s[i] == 'o') {
            dp[i] = dp[i - 1] + Comb[i - 1] * 2 + 1;
            Comb[i] = Comb[i - 1] + 1;
        } else if (s[i] == 'x') {
            dp[i] = dp[i - 1];
            Comb[i] = 0;
        } else {
            dp[i] = dp[i - 1] + (Comb[i - 1] * 2 + 1) / 2;
            Comb[i] = (Comb[i - 1] + 1) / 2;
        }
    }
    cout << setprecision(4) << fixed << dp[n - 1];
}

思考:如果長度為 \(a\) 的 comb 的貢獻為 \(a^3\) 時該如何解決?

Tips:由於 \((a + 1)^3 - a^3 = 3a^3 + 3a + 1\) ,所以我們要維護 \(a^2\)\(a\) 的期望,注意 \(E_{a^2} \not= E^2_a\),所以維護 \(a^2\) 的期望是必要的。


例四

BZOJ 4318

題意:給定一個序列,每個位置 \(o\) 的機率為 \(p_i\) ,為 \(x\) 的機率為 \(1-p_i\) 。對於一個 \(\texttt{ox}\) 序列,連續 \(x\) 長度的 \(\texttt{o}\) 會得到 \(x^3\) 的收益,請問最終得到的 \(ox\) 序列的期望收益是多少?

分析

延續例三的思路,我們還是分別求每一個位置的貢獻。根據 \((a + 1)^3 - a^3 = 3a^3 + 3a + 1\),我們只需要維護 \(l(i)\)為以 \(i\) 為結尾的 comb 的期望長度,\(l_2(i)\)為以 \(i\) 為結尾的 comb 的期望長度的平方。注意 \(E[a^2] \not =E^2[a]\),所以維護 \(a^2\) 的期望是必要的。

int main() {
    cin.tie(nullptr)->sync_with_stdio(false);
    int n;
    double p, l1 = 0, l2 = 0, ans = 0;
    cin >> n;
    for (int i = 0; i < n; ++i) {
        cin >> p;
        ans += (3 * l2 + 3 * l1 + 1) * p;
        l2 = (l2 + 2 * l1 + 1) * p;
        l1 = (l1 + 1) * p;
    }
    cout << fixed << setprecision(1) << ans;
}

總結

期望 DP 一般來說有它固定的模式,一種模式是直接 DP,定義狀態為到終點期望,採用逆序計算得到答案。一種模式是利用期望的線性性質,對貢獻分別計算,這種模式一般要求我們求出每種代價的期望使用次數,而每種代價往往體現在 DP 的轉移之中。最後的兩個例題是典型的分離變數,用期望的線性性質計算答案的例子,如果狀態過於巨大,那麼就得考慮分離隨機變數了。

本總結只是解釋了概率與期望 DP 的冰山一角,它可以變化多端,但那些實際上並不只屬於概率與期望 DP,真正核心的內容,還是逃不出我們幾種方法。

想要深入瞭解一些概率的DP的請閱讀這篇文章:Here

相關文章