每日算法 — 使用java实现期望DP:概率动态规划与状态转移方程构建

引言

期望DP(Expected Value Dynamic Programming)是处理随机过程与概率决策问题的利器。从掷骰子到随机游走,从抽卡游戏到马尔可夫决策,凡是涉及”在随机事件下平均需要多少步/多少代价”的问题,都可用期望DP建模求解。本文将用Java实现多个经典的期望DP问题,深入讲解状态设计、转移方程构建与线性方程组求解。

期望DP的核心思想

期望DP与常规DP的本质区别:转移不是确定性的,而是概率加权的。对于状态 $i$,设以概率 $p_k$ 转移到状态 $s_k$ 并产生代价 $c_k$,则期望代价满足:

$$E[i] = \sum_{k} p_k \cdot (E[s_k] + c_k)$$

若 $i$ 可能以概率 $p_{self}$ 保持自身(如需要重试),则需整理方程:

$$E[i] = p_{self} \cdot (E[i] + c_{self}) + \sum_{k \neq self} p_k \cdot (E[s_k] + c_k)$$

$$E[i] = \frac{\sum_{k \neq self} p_k \cdot (E[s_k] + c_k) + p_{self} \cdot c_{self}}{1 – p_{self}}$$

例题一:骰子期望投掷次数

问题:一个六面骰子,平均需要投掷多少次才能看到所有六个面至少各一次?

状态设计:$dp[i]$ 表示已经看到 $i$ 个不同面,还需的期望投掷次数。

转移方程:当已看到 $i$ 个面时,下一次投掷:
– 以概率 $\frac{i}{6}$ 看到已有面,状态不变
– 以概率 $\frac{6-i}{6}$ 看到新面,状态转移到 $i+1$

$$dp[i] = 1 + \frac{i}{6} \cdot dp[i] + \frac{6-i}{6} \cdot dp[i+1]$$

整理得:

$$dp[i] = \frac{6 + (6-i) \cdot dp[i+1]}{6-i}$$

import java.util.*;

/**
 * 期望DP核心算法实现
 * 涵盖:骰子问题、随机游走、迷宫逃生等经典模型
 */
public class ExpectedValueDP {

    /**
     * 例题1:骰子集齐问题
     * 六面骰子,求集齐所有面的期望投掷次数
     * @param faces 骰子面数
     * @return 期望投掷次数
     */
    public static double diceCollectorExpectation(int faces) {
        // dp[i] = 已收集i个面,还需的期望次数
        double[] dp = new double[faces + 1];
        dp[faces] = 0; // 全部集齐,还需0次

        // 倒推:从faces-1到0
        for (int i = faces - 1; i >= 0; i--) {
            // dp[i] = 1 + (i/faces)*dp[i] + ((faces-i)/faces)*dp[i+1]
            // 整理:dp[i] * (1 - i/faces) = 1 + (faces-i)/faces * dp[i+1]
            // dp[i] = (faces + (faces-i) * dp[i+1]) / (faces - i)
            dp[i] = (faces + (faces - i) * dp[i + 1]) / (faces - i);
        }

        return dp[0];
    }

例题二:一维随机游走

问题:数轴上位置0到n,从位置k出发,每次以等概率向左或向右移动一步。到达0或n时停止。求从k出发到达n的期望步数。

状态设计:$dp[i]$ 表示从位置 $i$ 出发到达 $n$ 的期望步数。边界条件 $dp[0]=0$(已到达0但目标为n,可视为失败或重新定义),$dp[n]=0$。

更经典的问题:求到达边界(0或n)的期望步数。设 $dp[i]$ 为从 $i$ 出发到达任一边界的期望步数:

$$dp[i] = 1 + 0.5 \cdot dp[i-1] + 0.5 \cdot dp[i+1]$$

边界:$dp[0] = 0$,$dp[n] = 0$

    /**
     * 例题2:一维随机游走到达边界的期望步数
     * @param n 右边界
     * @param start 起始位置
     * @return 期望步数
     */
    public static double randomWalkBoundary(int n, int start) {
        // dp[i] = 从i出发到达0或n的期望步数
        // dp[i] = 1 + 0.5*dp[i-1] + 0.5*dp[i+1]
        // 整理:dp[i+1] - 2*dp[i] + dp[i-1] = -2
        // 这是二阶线性递推,通解为 dp[i] = -i^2 + A*i + B
        // 代入边界 dp[0]=0, dp[n]=0 得 dp[i] = i*(n-i)

        // 用DP数值验证通解
        double[] dp = new double[n + 1];
        dp[0] = 0;
        dp[n] = 0;

        // 高斯-赛德尔迭代求解线性方程组
        // 由于精确解是二次的,迭代收敛很快
        for (int iter = 0; iter < 10000; iter++) {
            double maxDiff = 0;
            for (int i = 1; i < n; i++) {
                double newVal = 1 + 0.5 * dp[i - 1] + 0.5 * dp[i + 1];
                maxDiff = Math.max(maxDiff, Math.abs(newVal - dp[i]));
                dp[i] = newVal;
            }
            if (maxDiff < 1e-12) break;
        }

        return dp[start];
    }

    /**
     * 随机游走精确解:dp[i] = i * (n - i)
     */
    public static double randomWalkExact(int n, int start) {
        return (double) start * (n - start);
    }

例题三:迷宫逃生期望时间

问题:给定一个包含通道和陷阱的迷宫,每个位置有若干出口,选择每个出口有一定概率。求从起点到达出口的期望时间。

    /**
     * 例题3:迷宫逃生期望时间
     * @param n 节点数(0为起点,n-1为出口)
     * @param edges 邻接表:edges[i] = List of {邻居, 通过时间}
     * @return 从起点到达出口的期望时间
     */
    public static double mazeEscapeExpectation(
            int n, 
            List<int[]>[] edges) {

        // dp[i] = 从节点i到达出口的期望时间
        double[] dp = new double[n];
        dp[n - 1] = 0; // 出口期望为0

        // 拓扑序处理:若图为DAG可直接递推
        // 一般图需要使用高斯消元或迭代法
        boolean[] visited = new boolean[n];
        Arrays.fill(visited, false);
        visited[n - 1] = true;

        // 迭代法(适用于无环或收敛的情况)
        for (int iter = 0; iter < 100000; iter++) {
            double maxDiff = 0;
            for (int i = n - 2; i >= 0; i--) {
                if (edges[i] == null || edges[i].isEmpty()) continue;

                double newVal = 0;
                double prob = 1.0 / edges[i].size();
                for (int[] e : edges[i]) {
                    int to = e[0];
                    int time = e[1];
                    // 若to已访问(即dp[to]已知),直接加
                    // 否则使用当前估计值
                    newVal += prob * (time + dp[to]);
                }

                maxDiff = Math.max(maxDiff, Math.abs(newVal - dp[i]));
                dp[i] = newVal;
            }
            if (maxDiff < 1e-12) break;
        }

        return dp[0];
    }

例题四:抽卡期望与有保底机制

问题:卡池中有n张不同的卡,每次抽卡等概率获得一张。求集齐所有卡的期望抽卡次数。进阶:加入保底机制——每k次必出未拥有的卡。

    /**
     * 例题4:抽卡集齐期望(优惠券收集问题)
     * @param n 卡牌总数
     * @return 集齐所有卡的期望抽卡次数
     */
    public static double cardCollectionExpectation(int n) {
        // dp[i] = 已收集i张,还需的期望次数
        // 每次抽到已有卡的概率 i/n,新卡概率 (n-i)/n
        // dp[i] = 1 + (i/n)*dp[i] + ((n-i)/n)*dp[i+1]
        // 整理得 dp[i] = (n + (n-i)*dp[i+1]) / (n-i)

        double[] dp = new double[n + 1];
        dp[n] = 0;

        for (int i = n - 1; i >= 0; i--) {
            dp[i] = (n + (n - i) * dp[i + 1]) / (n - i);
        }

        return dp[0];
    }

    /**
     * 带保底的抽卡:每k抽必出新卡
     * @param n 卡牌总数
     * @param k 保底间隔
     * @return 期望抽卡次数
     */
    public static double cardCollectionWithPity(int n, int k) {
        // 状态:dp[i][j] = 已收集i张,距离上次出新卡已抽了j次
        // 复杂度O(n*k),适合中小规模

        double[][] dp = new double[n + 1][k + 1];
        for (int i = 0; i <= n; i++) {
            Arrays.fill(dp[i], 0);
        }

        // 倒推
        for (int i = n - 1; i >= 0; i--) {
            for (int j = k - 1; j >= 0; j--) {
                double pOld = (double) i / n; // 抽到已有卡的概率
                double pNew = (double) (n - i) / n; // 抽到新卡的概率

                if (j + 1 == k) {
                    // 下次必出保底(新卡)
                    dp[i][j] = 1 + dp[i + 1][0];
                } else {
                    // 正常概率
                    dp[i][j] = 1 + pOld * dp[i][j + 1] + pNew * dp[i + 1][0];
                }
            }
        }

        return dp[0][0];
    }

例题五:赌徒破产问题

问题:赌徒初始有 $i$ 元,每局以概率 $p$ 赢1元,以概率 $q=1-p$ 输1元。达到 $n$ 元目标或破产(0元)时停止。求达到目标的概率和期望局数。

    /**
     * 例题5:赌徒破产问题
     * @param n 目标金额
     * @param start 初始金额
     * @param p 每局获胜概率
     * @return double[2]:{达到目标的概率, 期望局数}
     */
    public static double[] gamblerRuin(int n, int start, double p) {
        double q = 1 - p;

        // winProb[i] = 从i元达到n元的概率
        double[] winProb = new double[n + 1];
        winProb[0] = 0;
        winProb[n] = 1;

        if (Math.abs(p - q) < 1e-12) {
            // p = q = 0.5 的特殊情况
            for (int i = 1; i < n; i++) {
                winProb[i] = (double) i / n;
            }
        } else {
            // 通解:winProb[i] = (1 - (q/p)^i) / (1 - (q/p)^n)
            double ratio = q / p;
            double denom = 1 - Math.pow(ratio, n);
            for (int i = 1; i < n; i++) {
                winProb[i] = (1 - Math.pow(ratio, i)) / denom;
            }
        }

        // expected[i] = 从i元到达边界的期望局数
        double[] expected = new double[n + 1];
        expected[0] = 0;
        expected[n] = 0;

        // 迭代求解
        for (int iter = 0; iter < 100000; iter++) {
            double maxDiff = 0;
            for (int i = 1; i < n; i++) {
                double newVal = 1 + p * expected[i + 1] + q * expected[i - 1];
                maxDiff = Math.max(maxDiff, Math.abs(newVal - expected[i]));
                expected[i] = newVal;
            }
            if (maxDiff < 1e-12) break;
        }

        return new double[]{winProb[start], expected[start]};
    }

高斯消元求解一般期望DP

当状态转移形成环(非DAG)时,需要建立线性方程组并用高斯消元求解。

    /**
     * 使用高斯消元求解一般期望DP线性方程组
     * 方程形式:E[i] = sum(p[i][j] * (E[j] + cost[i][j]))
     * 整理为:E[i] - sum(p[i][j] * E[j]) = sum(p[i][j] * cost[i][j])
     * 
     * @param n 状态数
     * @param transitions 转移矩阵:transitions[i] = List of {to, prob, cost}
     * @param terminal 终止状态标记
     * @return 各状态的期望值
     */
    public static double[] solveByGaussianElimination(
            int n,
            List<double[]>[] transitions,
            boolean[] terminal) {

        // 构建增广矩阵 A * E = B
        double[][] A = new double[n][n];
        double[] B = new double[n];

        for (int i = 0; i < n; i++) {
            if (terminal[i]) {
                A[i][i] = 1;
                B[i] = 0;
                continue;
            }

            A[i][i] = 1;
            for (double[] trans : transitions[i]) {
                int to = (int) trans[0];
                double prob = trans[1];
                double cost = trans[2];

                A[i][to] -= prob;
                B[i] += prob * cost;
            }
        }

        // 高斯消元(部分主元)
        for (int col = 0; col < n; col++) {
            // 找主元
            int pivot = col;
            for (int row = col + 1; row < n; row++) {
                if (Math.abs(A[row][col]) > Math.abs(A[pivot][col])) {
                    pivot = row;
                }
            }

            // 交换行
            double[] tempA = A[col];
            A[col] = A[pivot];
            A[pivot] = tempA;
            double tempB = B[col];
            B[col] = B[pivot];
            B[pivot] = tempB;

            // 消元
            for (int row = col + 1; row < n; row++) {
                double factor = A[row][col] / A[col][col];
                for (int c = col; c < n; c++) {
                    A[row][c] -= factor * A[col][c];
                }
                B[row] -= factor * B[col];
            }
        }

        // 回代
        double[] E = new double[n];
        for (int i = n - 1; i >= 0; i--) {
            double sum = B[i];
            for (int j = i + 1; j < n; j++) {
                sum -= A[i][j] * E[j];
            }
            E[i] = sum / A[i][i];
        }

        return E;
    }

完整测试程序

    public static void main(String[] args) {
        System.out.println("=== 期望DP测试 ===\n");

        // 测试1:骰子集齐
        System.out.println("测试1:六面骰子集齐期望次数");
        double diceExp = diceCollectorExpectation(6);
        System.out.printf("  结果: %.6f%n", diceExp);
        System.out.printf("  理论值: %.6f (调和数 H6 * 6)%n%n", 6 * (1 + 0.5 + 1.0/3 + 0.25 + 0.2 + 1.0/6));

        // 测试2:随机游走
        System.out.println("测试2:一维随机游走 (n=10, start=5)");
        double walkExp = randomWalkBoundary(10, 5);
        double walkExact = randomWalkExact(10, 5);
        System.out.printf("  迭代解: %.6f%n", walkExp);
        System.out.printf("  精确解: %.6f%n%n", walkExact);

        // 测试3:抽卡集齐
        System.out.println("测试3:抽卡集齐期望 (n=5张卡)");
        double cardExp = cardCollectionExpectation(5);
        System.out.printf("  无保底期望: %.6f%n", cardExp);
        double cardPity = cardCollectionWithPity(5, 3);
        System.out.printf("  每3抽保底期望: %.6f%n%n", cardPity);

        // 测试4:赌徒破产
        System.out.println("测试4:赌徒破产 (n=10, start=5, p=0.5)");
        double[] gambler = gamblerRuin(10, 5, 0.5);
        System.out.printf("  达到目标概率: %.6f%n", gambler[0]);
        System.out.printf("  期望局数: %.6f%n%n", gambler[1]);

        // 测试5:高斯消元求解
        System.out.println("测试5:高斯消元验证赌徒破产");
        int gn = 11;
        @SuppressWarnings("unchecked")
        List<double[]>[] trans = new ArrayList[gn];
        for (int i = 0; i < gn; i++) {
            trans[i] = new ArrayList<>();
        }
        boolean[] term = new boolean[gn];
        term[0] = true;
        term[10] = true;
        for (int i = 1; i < 10; i++) {
            trans[i].add(new double[]{i - 1, 0.5, 1});
            trans[i].add(new double[]{i + 1, 0.5, 1});
        }
        double[] gaussResult = solveByGaussianElimination(gn, trans, term);
        System.out.printf("  从5元出发期望局数(高斯消元): %.6f%n", gaussResult[5]);
        System.out.printf("  从5元出发期望局数(迭代法):   %.6f%n%n", gambler[1]);

        System.out.println("所有测试完成!");
    }
}

期望DP的状态设计技巧

  1. 逆向思维:从终止状态倒推,终止状态的期望通常为0
  2. 加一法则:每次转移天然包含”执行本次操作”的1单位代价
  3. 自环处理:以概率 $p$ 保持当前状态时,需移项整理方程
  4. 方程组视角:复杂状态关系用线性方程组统一求解

复杂度分析

问题类型 状态数 时间复杂度 求解方法
DAG型期望DP $O(n)$ $O(n)$ 拓扑序递推
树型期望DP $O(n)$ $O(n)$ DFS后序遍历
一般图迭代 $O(n)$ $O(n \cdot iter)$ 高斯-赛德尔迭代
一般图精确 $O(n)$ $O(n^3)$ 高斯消元

总结

期望DP将概率论与动态规划巧妙结合,核心在于正确建立”期望 = 概率加权后继期望 + 当前代价”的转移方程。Java实现中需特别注意浮点精度问题——涉及金钱或精确比较时建议使用 double 并设置合理的容差。对于带环的复杂状态转移,高斯消元提供了通用的精确解法,而迭代法则在稀疏图上往往更高效。掌握期望DP后,从游戏策划中的抽卡概率设计到金融工程中的随机模型分析,都将有坚实的算法基础。