NeFut Logo NeFut
EN 管理员登录

深入理解期望动态规划:算法原理与实现

发布于:2026-05-27 09:17 最后更新:2026-06-06 13:04
#Dynamic Programming #DP #Expectation DP

核心逻辑与数学原理

期望动态规划(Expectation DP)是处理带有“决策路径随机性”或“后继状态不确定”问题的核心算法工具。它与常规动态规划的最大区别在于,状态转移不是确定性的选择(Max/Min),而是对所有可能后继状态代价的加权平均(求和归结)

1. 期望 DP 的转移机理

设当前处于状态 $u$,它有若干条分立的随机转移路径通往后继状态集合 $to(u)$。对于每一个后继状态 $v \in to(u)$,转移发生的概率为 $p(u \to v)$,转移本身付出的代价(如步数、花费、权值)为 $w(u \to v)$。

根据数学期望的定义以及全期望公式,从状态 $u$ 出发直到终点的期望总代价 $f[u]$ 满足以下代数方程:

$$f[u] = \sum_{v \in to(u)} p(u \to v) \cdot \Big( f[v] + w(u \to v) \Big)$$

利用概率归一化性质 $\sum p(u \to v) = 1$,该公式常被进一步化简为便于代码实现的经典形式:

$$f[u] = \left( \sum_{v \in to(u)} p(u \to v) \cdot f[v] \right) + \sum_{v \in to(u)} p(u \to v) \cdot w(u \to v)$$

2. 为什么期望 DP 大多采用“倒推(逆向移项)”?

在常规 DP 中,我们习惯于“正推”(从起点推向终点)。但在期望 DP 中,正推往往会导致逻辑崩溃


状态设计与算法推导

1. DAG 上的无环倒推

如果状态转移依赖关系构成一个有向无环图(DAG),我们可以直接利用逆向拓扑排序(或记忆化搜索),从终点 $T$ 开始倒推填表。

$$f[u] = \sum_{(u, v) \in E} p(u \to v) \cdot (f[v] + w(u \to v))$$

2. 包含“自环/环路”的期望 DP 与高斯消元

许多期望问题中存在“原地踏步”或“状态循环”的特征(例如:投掷骰子点数不对则原地不动,或者在图上随机游走)。此时状态转移方程会出现循环依赖,即 $f[u]$ 的代数式中包含 $f[u]$ 本身或其他互相包含的未知数。这导致无法直接顺序填表。

破局策略

  1. 单点自环化简:若仅存在当前状态向自身的转移(概率为 $p_{\text{self}}$),直接进行代数移项。

$$f[u] = p_{\text{self}} \cdot (f[u] + w_{\text{self}}) + \sum_{v \neq u} p(u \to v) \cdot (f[v] + w(u \to v))$$

把所有含 $f[u]$ 的项移到等左侧并提取系数 $(1 - p_{\text{self}})$,做除法即可消除自环阻碍。 2. 强连通块环路(全图游走):若状态间形成了复杂的网状环路,则放弃递推,直接将每个状态 $f[u]$ 视作一个未知数 $x_u$,根据转移拓扑关系列出 $N$ 个一元一次方程,组建出矩阵,直接使用高斯消元(Gaussian Elimination)在 $O(N^3)$ 复杂度内暴力求解。


C++ 标准源码

核心实现:记忆化搜索倒推期望 DP

针对典型具有分治结构的期望状态,使用记忆化搜索能够自动完成逆向拓扑序流转。

#include <iostream>
#include <vector>
#include <iomanip>

struct Edge {
    int to;
    double prob; // 转移概率
    double weight; // 转移代价
};

const int MAXN = 100000;
std::vector<Edge> graph[MAXN + 5];
double f[MAXN + 5];
bool visited[MAXN + 5];
int target_node;

// 记忆化搜索:求从 u 出发走到 target_node 的期望总代价
double get_expectation(int u) {
    if (u == target_node) return 0.0; // 终点边界:到自身的期望代价为 0
    if (visited[u]) return f[u];      // 拦截已计算状态

    visited[u] = true;
double total_exp = 0.0;

    for (const auto &edge : graph[u]) {
        int v = edge.to;
        // 核心倒推公式:p * (f[v] + weight)
        total_exp += edge.prob * (get_expectation(v) + edge.weight);
    }

    f[u] = total_exp;
    return f[u];
}

int main() {
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);

    int n, m, start_node;
    if (std::cin >> n >> m >> start_node >> target_node) {
        for (int i = 0; i < m; ++i) {
            int u, v;
            double p, w;
            std::cin >> u >> v >> p >> w;
            graph[u].push_back({v, p, w});
        }

        double ans = get_expectation(start_node);
        std::cout << std::fixed << std::setprecision(6) << ans << "\n";
    }
    return 0;
}

NOIP 实战避坑指南

  1. 错将终点状态初始化为非零值或正向赋初值 部分选手习惯了正向思维,在初始化时将起点 f[S] = 0,然后试图向后更新。这样写会导致在计算分支时,由于无法预知后继节点的全局收敛值,算出来的数字完全背离期望的定义。请务必牢记:期望 DP 的天然确定性边界在终点,f[T] = 0 是递推的始发引擎
  2. 高斯消元时未处理不可达状态导致的自由元崩溃 在面对需要高斯消元的图论随机游走题目时,图里可能存在一些由于单向联通导致永远不可能到达终点的死结节点,或者从起点根本走不到的孤立节点。这些节点对应的未知数方程可能会引发矩阵行列式为 $0$(出现多解或无解)。在送入高斯消元前,必须先用 BFS/DFS 跑一遍正反向连通性判定,将无效状态剔除出方程组。

经典 NOIP/洛谷 真题

1. 洛谷 P4550 收集邮票

$$f[i] = \frac{i}{n} f[i] + \frac{n-i}{n} f[i+1] + 1 \implies f[i] = f[i+1] + \frac{n}{n-i}$$

利用 $f[i]$ 作为代数系数再去驱动 $g[i]$ 的状态转移,从 $n$ 倒推到 $0$,$g[0]$ 即为最终解。

2. 洛谷 P1850 [NOIP2016 提高组] 换教室

原文链接: local://21.3

[h] 返回首页