期望线性性把总花费拆成相邻两门课的段费,DP 状态只需记住“上一门是否申请”与已用申请数,配合 Floyd 全源最短路 O(v^3+nm)。
OJ: roj
题目 ID: 3103
难度:提高
标签:动态规划数学期望最短路Floydpython
创建: 2026-10-01 17:16
更新: 2026-10-07 13:50
形式化题目
给定
给定
的精确到小数点后两位的值。样例:2.80。
正解
思路
1. 朴素做法与瓶颈
最直接的写法是枚举申请集合:枚举所有
瓶颈不在常数,而在把"选集合"当成无结构的子集选择。题目里藏着一个很强的结构:
总花费是
2. 第一阶段:一次 Floyd 求出所有教室对的距离
DP 的转移系数完全由最短路决定,而且每段都要问 4 个教室对,
| 做法 | 复杂度 | 量级 |
|---|---|---|
| 每段跑一次 Dijkstra | ||
| 跑一次 Floyd–Warshall |
所以先用 Floyd 把
3. 期望的线性性 ⇒ 逐段相加
因为期望可以逐项拆开,
这是整道题的枢纽:不需要枚举"哪些申请通过"这
4. 第 段的期望只依赖两个决策位
把"申请第
上一位申请结果与这一位申请结果共有四种组合,期望段费是
把样例的数字代进去(
| 段 | |||||||
|---|---|---|---|---|---|---|---|
| 第 1 段 上: 下: |
4 | 0 | 0 | 4 | |||
| 第 2 段 上: 下: |
4 | 0 | 0 | 4 |
读法是:
5. 状态里必须记住"上一位是否申请"
由于
含义直白:本轮不申请,从前一态的两种
样例的滚动过程(- 表示不可达):
| 阶段 | dp0[0] |
dp0[1] |
dp0[2] |
dp1[0] |
dp1[1] |
dp1[2] |
|---|---|---|---|---|---|---|
| 初始(第 1 门) | 0 | - | - | - | 0 | - |
| 合并第 1 段后 | 4.00 | 0.80 | - | - | 3.20 | 1.28 |
| 合并第 2 段后 | 8.00 | 4.80 | 4.48 | - | 6.00 | 2.80 |
以第二行为例:dp1[2]=1.28 表示"第 2 门申请、用掉 2 次申请(即第 1、2 门都申请),
第 1 段的期望段费最小值是 1.28",来源是 dp0[2]=4.48 是
6. 输出:按"分"做四舍五入,不要直接用 f"{x:.2f}"
题面要求"四舍五入精确到小数点后恰好 2 位",且输出必须和标准输出完全一样。
直接写 f"{ans:.2f}" 会踩两个坑(都已实测):
format用银行家舍入(round-half-even)。当真值恰好等于"半分"时它向偶数靠:f"{0.125:.2f}"得到0.12(是二进制精确值,所以这个坑真的会触发), 而题面要的"四舍五入"是 0.13。- 十进制三位小数在 float64 里往往不精确。
的实际存储值略小于 , 1.005 * 100 == 100.49999999999999,于是int(... + 0.5) == 100,输出1.00; 同理f"{4.545:.2f}"得到4.54而不是4.55。这与第 1 条是两回事, 但在边界上会给出同样的错误方向。
正确做法是先转成整数"分"再格式化,并且加一个极小的
cents = int(ans * 100 + 0.5 + 1e-9) # +0.5 实现 half-up,+1e-9 兜住第 2 条的表示误差
print(f"{cents / 100:.2f}")代码
Python 版:
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-01 17:20
# update_at: 2026-10-01 17:37
import sys
from collections.abc import Iterable
INF = float("inf") # 期望 DP 里"用这么多次申请走不到"的哨兵
BIG = 10**9 # 最短路里"没有路"的哨兵:远大于任意教室对的最远距离(< 300*100)
def all_pairs(dist: list[list[int]], v: int, edges: Iterable[tuple[int, int, int]]) -> None:
"""原地 Floyd–Warshall:跑完后 dist[i][j] 是教室 i 到 j 的最少体力。"""
for i in range(v):
dist[i][i] = 0 # 教室自己到自己,同时也覆盖掉输入里的自环
for a, b, w in edges:
if w < dist[a][b]: # 重边只留最短的那条
dist[a][b] = dist[b][a] = w
for k in range(v):
dk = dist[k]
for di in dist:
dik = di[k]
if dik < BIG: # 经 k 不可达就整行跳过,省掉一次全行扫描
for j in range(v):
alt = dik + dk[j]
if alt < di[j]:
di[j] = alt
def expected_cost(dist: list[list[int]], c: list[int], d: list[int],
k: list[float], m: int) -> float:
"""期望 DP:返回"移动体力总和"的期望最小值。
只有第 t+1 门课的教室确定之后,第 t 段的移动距离才确定,所以"第 t 门是否申请"
必须留在状态里。dp0[j] / dp1[j] 表示用了 j 次申请、且第 t 门不申请 / 申请时,
前 t-1 段移动体力期望和的最小值;每轮把第 t 段的期望并进来。
"""
dp0 = [0.0] + [INF] * m # 第 1 门不申请:用掉 0 次申请
dp1 = [INF] * (m + 1) # 第 1 门申请:恰好用掉 1 次申请
if m:
dp1[1] = 0.0
for t in range(len(c) - 1):
cc = dist[c[t]][c[t + 1]] # 上、下两门都不申请
cd = dist[c[t]][d[t + 1]] # 只有第 t+1 门申请且通过
dc = dist[d[t]][c[t + 1]] # 只有第 t 门申请且通过
dd = dist[d[t]][d[t + 1]] # 两门都申请且都通过
win = k[t + 1] # 第 t+1 门申请通过的概率
lose = 1 - k[t] # 第 t 门申请被拒的概率
# 四种"上/下是否申请"的组合下,这一段的期望距离:
cost01 = (1 - win) * cc + win * cd # 上不申请、下申请
cost10 = lose * cc + (1 - lose) * dc # 上申请、下不申请
cost11 = lose * cost01 + (1 - lose) * ((1 - win) * dc + win * dd)
head0, head1 = dp0[:m], dp1[:m] # 本轮申请数要 +1,旧数组只用到前 m 位
dp0 = [min(a + cc, b + cost10) for a, b in zip(dp0, dp1)]
dp1 = [INF] + [min(a + cost01, b + cost11) for a, b in zip(head0, head1)]
return min(min(dp0), min(dp1))
def solve() -> None:
data = iter(sys.stdin.buffer.read().split())
n, m, v, e = int(next(data)), int(next(data)), int(next(data)), int(next(data))
m = min(m, n) # 申请超过 n 次也用不完
c = [int(next(data)) - 1 for _ in range(n)]
d = [int(next(data)) - 1 for _ in range(n)]
k = [float(next(data)) for _ in range(n)]
edge_tokens = [int(next(data)) for _ in range(3 * e)] # 边按 a, b, w 依次平铺
edges = [(edge_tokens[i] - 1, edge_tokens[i + 1] - 1, edge_tokens[i + 2])
for i in range(0, 3 * e, 3)]
dist = [[BIG] * v for _ in range(v)]
all_pairs(dist, v, edges)
# 精确到分。直接写 f"{ans:.2f}" 会踩两个坑:Python 默认银行家舍入(.005 向偶数靠),
# 以及 1.005*100 = 100.49999999999999 这类表示误差;先转整数分四舍五入,+1e-9 兜住后者。
cents = int(expected_cost(dist, c, d, k, m) * 100 + 0.5 + 1e-9)
print(f"{cents / 100:.2f}")
if __name__ == "__main__":
solve()C++ 版(同一算法):
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-06 18:58
* update_at: 2026-10-06 18:58
*/
#include <cstdio>
#include <algorithm>
using namespace std;
typedef long long ll;
const int MAXN = 2005; // 课程数
const int MAXV = 305; // 教室数
const double INF = 1e18; // DP 不可达哨兵
const int INF_DIST = 1000000000; // 最短路里“没有路”的哨兵
int n, m, v, e;
int c[MAXN], d[MAXN]; // 每门课的两个候选教室
double k[MAXN]; // 每门课申请通过的概率
int dist[MAXV][MAXV]; // 任意两教室间的最少体力
double dp0[MAXN], dp1[MAXN]; // dp0[j] / dp1[j]:到当前课、用 j 次申请、当前课不申请/申请时的最小期望
double tmp0[MAXN], tmp1[MAXN];
int main() {
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", &k[i]);
// 初始化最短路
for (int i = 1; i <= v; i++) {
for (int j = 1; j <= v; j++) {
dist[i][j] = (i == j) ? 0 : INF_DIST;
}
}
for (int i = 1; i <= e; i++) {
int a, b, w;
scanf("%d %d %d", &a, &b, &w);
if (w < dist[a][b]) {
dist[a][b] = dist[b][a] = w;
}
}
// Floyd–Warshall 求全源最短路
for (int kk = 1; kk <= v; kk++) {
for (int i = 1; i <= v; i++) {
if (dist[i][kk] >= INF_DIST) continue;
for (int j = 1; j <= v; j++) {
int alt = dist[i][kk] + dist[kk][j];
if (alt < dist[i][j]) dist[i][j] = alt;
}
}
}
// 初始化第 1 门课的状态
for (int j = 0; j <= m; j++) {
dp0[j] = INF;
dp1[j] = INF;
}
dp0[0] = 0.0; // 第 1 门不申请,用 0 次
if (m >= 1) dp1[1] = 0.0; // 第 1 门申请,用 1 次
// 从第 2 门课开始,逐段合并 n-1 段
for (int i = 2; i <= n; i++) {
int cc = dist[c[i - 1]][c[i]]; // 上、下都不申请
int cd = dist[c[i - 1]][d[i]]; // 下申请通过
int dc = dist[d[i - 1]][c[i]]; // 上申请通过
int dd = dist[d[i - 1]][d[i]]; // 两门都申请通过
double win = k[i]; // 第 i 门申请通过的概率
double lose = 1 - k[i - 1]; // 第 i-1 门申请被拒的概率
double cost01 = (1 - win) * cc + win * cd; // 第 i-1 门不申请,第 i 门申请
double cost10 = lose * cc + (1 - lose) * dc; // 第 i-1 门申请,第 i 门不申请
double cost11 = lose * cost01 + (1 - lose) * ((1 - win) * dc + win * dd); // 都申请
// 当前课 i 不申请:从 dp0[j] / dp1[j] 同位转移
for (int j = 0; j <= m; j++) {
tmp0[j] = min(dp0[j] + cc, dp1[j] + cost10);
}
// 当前课 i 申请:申请数 +1,从 dp0[j-1] / dp1[j-1] 转移
tmp1[0] = INF;
for (int j = 1; j <= m; j++) {
tmp1[j] = min(dp0[j - 1] + cost01, dp1[j - 1] + cost11);
}
for (int j = 0; j <= m; j++) {
dp0[j] = tmp0[j];
dp1[j] = tmp1[j];
}
}
double ans = INF;
for (int j = 0; j <= m; j++) {
ans = min(ans, dp0[j]);
ans = min(ans, dp1[j]);
}
// 四舍五入到两位小数,先转整数分避免银行家舍入与浮点噪声
int cents = (int)(ans * 100.0 + 0.5 + 1e-9);
printf("%.2f\n", cents / 100.0);
return 0;
}复杂度
- 时间复杂度:
。Floyd 是 次比较, DP 是 次转移,与标准 C++ 正解同阶。 - 空间复杂度:
,主要是 的最短路表和两个长度 的 DP 数组。
实测(Apple Silicon / CPython 3.14,最坏一组 for 而不是 di[:] = map(min, di, ...):
后者虽然把循环压到 C 层,却要为每次迭代创建 map 对象与绑定方法对象,实测反而慢一倍
(整题 1.18 s,Floyd 内核 0.83 s)。
总结
- 见到"期望 + 选一个子集"先想期望线性性:总期望拆成每段期望之和,段间就不需要相关性信息了。
- 拆完段之后看每段的依赖范围。本题第
段只依赖 ,这正是线性 DP 的信号。 - 状态里一定要带回"上一位的决策":
依赖 与 两者, 只记计数 不够。这与很多区间 DP 要记住"端点状态"是同一类理由。 - 四种组合不要手写 16 项概率乘积,按"上一位拒/过"二分并复用
, 既短又不容易漏项。 是确定性计数(申请了就消耗一次机会,与运气无关),所以不必对 取期望; 又因为 ,答案要在所有 上取 min。 - 输出小数别直接信
f"{x:.2f}":先乘 100 加 0.5 取整再除,才能既避开银行家舍入又吸收浮点噪声。
图示解析
这张图串起本题从输入到答案的完整主线:
输入:n 门课(每门两个候选教室)+ v 个教室的道路图
|- 阶段一:v <= 300,且 DP 每段要查 4 个教室对 -> 先跑一次 Floyd
| `- dist[u][w] = 任意两教室的最少体力(O(1) 查表)
`- 阶段二:期望线性性 -> 总期望 = n-1 段期望之和
`- 第 t 段只依赖 b_t, b_{t+1}(b = 是否提交申请)
|- 四组合段费 cost01 / cost10 / cost11(由 k 与 cc/cd/dc/dd 加权)
`- 滚动 DP:dp0[j] / dp1[j] = 上一位不申请 / 申请
|- 转移要读 j-1 位 -> dp1 左边补 INF 实现错位
|- 逐轮合并 n-1 段,允许 j <= m(不必用满)
`- 答案 = min over j of min(dp0[j], dp1[j])
`- half-up 到整数分 -> 输出 "x.xx"读图时抓两个分叉的因果:第一个分叉说明"为什么必须把最短路一次性预处理完"
(
再用一张状态依赖图说明
flowchart LR
subgraph old["上一轮(已合并第 t-1 段)"]
A0["dp0[j]<br/>第 t 门不申请"]
A1["dp1[j]<br/>第 t 门申请"]
end
subgraph new["本轮(合并第 t 段后)"]
B0["dp0'[j]<br/>第 t+1 门不申请"]
B1["dp1'[j+1]<br/>第 t+1 门申请"]
end
A0 -->|"+ cost00,申请数不变"| B0
A1 -->|"+ cost10,申请数不变"| B0
A0 -->|"+ cost01,申请数 +1"| B1
A1 -->|"+ cost11,申请数 +1"| B1
图中左右两列分别是相邻两轮的 DP 数组。dp1 = [INF] + [...] 与
head0, head1 = dp0[:m], dp1[:m] 的来源:
[INF] + 实现目标下标整体右移一位,而源数组只用到前