题解 luogu P1850 【换教室】
题解 luogu P1850 【换教室】
时间:2019.8.6 一晚上(约 3.5h 写完)
题目描述
对于刚上大学的牛牛来说,他面临的第一个问题是如何根据实际情况申请合适的课程。
在可以选择的课程中,有 \(2n\) 节课程安排在 \(n\) 个时间段上。在第 \(i\)(\(1 \leq i \leq n\))个时间段上,两节内容相同的课程同时在不同的地点进行,其中,牛牛预先被安排在教室 \(c_i\) 上课,而另一节课程在教室 \(d_i\) 进行。
在不提交任何申请的情况下,学生们需要按时间段的顺序依次完成所有的 \(n\) 节安排好的课程。如果学生想更换第 \(i\) 节课程的教室,则需要提出申请。若申请通过,学生就可以在第 \(i\) 个时间段去教室 \(d_i\) 上课,否则仍然在教室 \(c_i\) 上课。
由于更换教室的需求太多,申请不一定能获得通过。通过计算,牛牛发现申请更换第 \(i\) 节课程的教室时,申请被通过的概率是一个已知的实数 \(k_i\),并且对于不同课程的申请,被通过的概率是互相独立的。
学校规定,所有的申请只能在学期开始前一次性提交,并且每个人只能选择至多 \(m\) 节课程进行申请。这意味着牛牛必须一次性决定是否申请更换每节课的教室,而不能根据某些课程的申请结果来决定其他课程是否申请;牛牛可以申请自己最希望更换教室的 \(m\) 门课程,也可以不用完这 \(m\) 个申请的机会,甚至可以一门课程都不申请。
因为不同的课程可能会被安排在不同的教室进行,所以牛牛需要利用课间时间从一间教室赶到另一间教室。
牛牛所在的大学有 \(v\) 个教室,有 \(e\) 条道路。每条道路连接两间教室,并且是可以双向通行的。由于道路的长度和拥堵程度不同,通过不同的道路耗费的体力可能会有所不同。 当第 \(i\)(\(1 \leq i \leq n\))节课结束后,牛牛就会从这节课的教室出发,选择一条耗费体力最少的路径前往下一节课的教室。
现在牛牛想知道,申请哪几门课程可以使他因在教室间移动耗费的体力值的总和的期望值最小,请你帮他求出这个最小值。
题意分析
有 \(n\) 个时间段,每个时间段要么去课室 \(c _ i\) 上课,要么去课室 \(d _ i\) 上课。牛牛一开始默认在 \(c _ i\) 上课,若申请换课室则有 \(k _ i\) 的概率成功,且不论是否成功,最多都只能进行 \(m\) 次申请。所有的课室组成了一个 \(v\) 个点 \(e\) 条边的无向图 \(G\)。在课室之间移动的代价为两点之间的最短路。求最优策略下的期望代价。
数据范围:\(n, m \le 2000, v \le 300, e \le v ^ 2\),即有可能组成完全图。
可能有自环,可能有重边 邻接矩阵建图被坑了 QAQ...
算法:DP
观察数据范围,发现 \(nm (2000 \times 2000)\) 的空间复杂度是可以接受的。考虑设计 DP 方程为 \(f[i][j]\) 表示对于前 \(i\) 个时间段,已经申请了 \(j\) 次换课室时的最优期望代价。
想想如何转移。对于 \(f[i][j]\),既要考虑这个时间段在哪里上课,又要考虑上一个时间段在哪里上课,从而将两个时间段间课室移动的代价累加到答案里。我们不知道上一个时间段有没有申请换教室,因此我们应当将“是否换教室”也纳入状态中。
重新设计 DP 方程为:设 \(f[i][j][0 / 1]\) 表示对于前 \(i\) 个时间段,已经申请了 \(j\) 次换教室,其中第 \(i\) 个时间段 有 (1) / 没有 (0) 申请换教室,此时最优方案下期望代价。
转移方程
按照 DP 方程的定义列出转移方程。需要进行一些分类讨论。
期望的可加性
你也许知道,若随机变量 \(X\) 的期望值是 \(E(X)\),那么有 \(E(X + Y) = E(X) + E(Y)\)。前提是 \(X\) 和 \(Y\) 互斥(即互不干扰)。
看不懂?哈哈,也许你需要补习一下概率知识啦。这个性质是这题 DP 方程正确的前提。
这是为什么呢?举个例子,如果从起点到物理 1 室需要走 100m 远,到物理 2 室需要走 50m 远。申请换教室成功而到两个课室的概率分别是 \(x _ 1, x _ 2\)。
然而,下一节课从上一个课室走到化学 1、2 室对应距离如下:
| | 化学 1 室 | 化学 2 室 |
| 物理 1 室 | 30 | 20 |
| 物理 2 室 | 60 | 40 |
申请换教室成功而到两个化学室的概率分别是 \(y _ 1, y _ 2\)。
那么总共会有 4 条可走路径。分别是从物理 1 / 2 室走到化学 1 / 2 室。
- 起点 -> 物理 1 室 -> 化学 1 室:路程 $ = 100m + 30m = 130m$,概率 $ = x _ 1 \times y _ 1$。
- 起点 -> 物理 1 室 -> 化学 2 室:路程 $ = 100m + 20m = 120m$,概率 $ = x _ 1 \times y _ 2$。
- 起点 -> 物理 2 室 -> 化学 1 室:路程 $ = 50m + 60m = 110m$,概率 $ = x _ 2 \times y _ 1$。
- 起点 -> 物理 2 室 -> 化学 2 室:路程 $ = 50m + 40m = 90m$,概率 $ = x _ 2 \times y _ 2$。
如果我们要计算两次移动的总路程期望,我们可能得将 4 种情况都枚举出来,然后分别将路程与概率的乘积累加到答案里。
如果课室数量很多(甚至有几个起点),还有没有什么更简便的方法呢?将起点 -> 物理室的期望路程求出来,然后加上物理室 -> 化学室的期望路程可以吗?乍一看,好像会将物理、化学室之间路径的起点和终点打乱,既然前面的期望已经打成一团了,后面又怎么将它们分开呢?难道可以直接将两团期望不加连接地加在一起吗?
答案是可以。试想:4 种可走路径中,设某一条路径前一半(起点 -> 物理室)的路径为 \(X\),后一半(物理室 -> 化学室)的路径为 \(Y\),那么这条路径的期望贡献为 \(E(X + Y)\)。
运用期望的可加性质,由于对于一个确定的点来说,后一半路径选哪条与前一半无关,我们发现 \(E(X + Y)\) 就等于 \(E(X) + E(Y)\),而且 4 条路径的期望贡献总和中,将每一条路径的贡献如此拆开并交换相加顺序,就会得到,刚刚好地,得到 起点 -> 物理室的期望路程 与 物理室 -> 化学室的期望路程 之和。
讲了这么多,对我们的 DP 有什么帮助吗?我们发现,如果从一个状态 \(S\) 转移到状态 \(S'\) 的过程中产生的代价的期望贡献 \(x\) 可以计算得出,那么可以不加连接的直接将 \(S\) 的 DP 值与 \(x\) 累加得到新状态的 DP 值。
(感觉讲了好多废话啊o(*////▽////*)q(逃
代价计算
当然,进行 DP 之前,先要用 \(\mathrm {Floyd}\) 算法预处理出任意两点之间的最短路,以方便计算两个教室之间移动的代价。
令两点 \(u, v\) 之间的最短路为
为了方便,令
表示第 \(i - 1\) 个时间段选择的状态为 \(x\)(有 / 没有 申请换课室),第 \(i\) 个时间段选择的状态为 \(y\),此时在两个时间段内移动的代价。即,若在第 \(i\) 个时间段申请换课室 成功 / 不成功 分别要在课室 \(room[i][1], room[i][0]\) 上课,那么
这样就避免了下文推导方程的麻烦(也可以让代码更好写!)
概率表示
另外,为了方便,令 \(p[i]\) 表示第 \(i\) 个时间段申请成功的概率,且令 \(q[i]\) 表示不成功的概率,即 \(q[i] = 1 - p[i]\)。
初始值
对于任意的 \(i, j\),一开始 DP 的初始值都是
这是因为我们要计算代价的最小值。设成 \(-1\) 再进行特判?太麻烦了,而且不方便剔除不合法的情况。
边界
首先是边界。
在第一个时间段内,不管有没有申请换教室,代价都是 0。
接下来,要按照 第 \(i\) 个时间段是否申请 / 第 \(i - 1\) 个时间段是否申请 进行分类讨论。
第 \(i\) 个时间段不申请
对应的 DP 状态为 \(f[i][j][0]\)。
若第 \(i -1\) 个时间段没有申请,那么两个时间段之间的移动代价就是完全确定的。
有:
若第 \(i - 1\) 个时间段申请了换教室,那么就有两种情况:
- 第 \(i - 1\) 个时间段申请失败,代价贡献为 \(dis(i, 0, 0)\),概率为 \(q[i - 1]\)。
- 第 \(i - 1\) 个时间段申请成功,代价贡献为 \(dis(i, 1, 0)\),概率为 \(p[i - 1]\)。
有:
两者取 \(\min\) 即可。
第 \(i\) 个时间段申请
对应的 DP 状态为 \(f[i][j][1]\)。
若第 \(i - 1\) 个时间段没有申请,那么就有两种情况:
- 第 \(i\) 个时间段申请失败,代价贡献为 \(dis(i, 0, 0)\),概率为 \(q[i]\)。
- 第 \(i\) 个时间段申请成功,代价贡献为 \(dis(i, 0, 0)\),概率为 \(p[i]\)。
有:
若第 \(i - 1\) 个时间段申请了换教室,那么情况就有点复杂了。总共有四种情况:
-
第 \(i - 1\) 个时间段申请失败,第 \(i\) 个时间段申请失败,
代价贡献为 \(dis(i, 0, 0)\),概率为 \(q[i - 1] \times q[i]\)。
-
第 \(i - 1\) 个时间段申请失败,第 \(i\) 个时间段申请成功,
代价贡献为 \(dis(i, 0, 1)\),概率为 \(q[i - 1] \times p[i]\)。
-
第 \(i - 1\) 个时间段申请成功,第 \(i\) 个时间段申请失败,
代价贡献为 \(dis(i, 1, 0)\),概率为 \(p[i - 1] \times q[i]\)。
-
第 \(i - 1\) 个时间段申请成功,第 \(i\) 个时间段申请成功,
代价贡献为 \(dis(i, 1, 1)\),概率为 \(p[i - 1] \times p[i]\)。
有:
两者同样取 \(\min\) 即可。
代码
由于使用了 \(dis(i, x, y)\) 的简写方式,代码写起来很轻松。
注意代码中 DP 数组的命名使用了 dp
而不是 \(f\)。
注意一个小坑点:因为可能有重边,因此邻接矩阵存图时应该判断一下新边是否更优(我不告诉你我为这个调了一年)。终归还是经验太少呢。
#include <bits/stdc++.h>
using namespace std;
#define DePrint(A...) { fprintf(stderr, A); }
typedef long long LL;
const int kMaxN = 2000 + 10;
const int kMaxV = 300 + 10;
const LL kInf = 100000000;
LL floyd[kMaxV][kMaxV];
int n, m, v_cnt, e_cnt;
int room[kMaxN][2];
double p[kMaxN], q[kMaxN], dp[kMaxN][kMaxN][2];
inline LL Dis(int i, int a, int b) {
return floyd[room[i - 1][a]][room[i][b]];
}
int main() {
scanf("%d %d %d %d", &n, &m, &v_cnt, &e_cnt);
for (int i = 1; i <= n; i++)
scanf("%d", &room[i][0]);
for (int i = 1; i <= n; i++)
scanf("%d", &room[i][1]);
for (int i = 1; i <= n; i++)
scanf("%lf", &p[i]);
for (int i = 1; i <= n; i++)
q[i] = 1 - p[i];
memset(floyd, 0x3F, sizeof(floyd));
for (int i = 1; i <= e_cnt; i++) {
int u, v, w;
scanf("%d %d %d", &u, &v, &w);
floyd[u][v] = floyd[v][u] = min(floyd[u][v], (LL) w);
}
for (int i = 1; i <= v_cnt; i++)
floyd[i][i] = 0;
for (int k = 1; k <= v_cnt; k++)
for (int i = 1; i <= v_cnt; i++)
for (int j = 1; j <= v_cnt; j++)
floyd[i][j] = min(floyd[i][j], floyd[i][k] + floyd[k][j]);
for (int i = 1; i <= n; i++)
for (int j = 0; j <= m; j++)
dp[i][j][0] = dp[i][j][1] = kInf;
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] + Dis(i, 0, 0);
for (int j = 1; j <= min(i, m); j++) {
dp[i][j][0] = min(dp[i - 1][j][0] + Dis(i, 0, 0),
dp[i - 1][j][1] + Dis(i, 0, 0) * q[i - 1]
+ Dis(i, 1, 0) * p[i - 1]);
dp[i][j][1] = min(dp[i - 1][j - 1][0] + Dis(i, 0, 0) * q[i]
+ Dis(i, 0, 1) * p[i],
dp[i - 1][j - 1][1] + Dis(i, 0, 0) * q[i - 1] * q[i]
+ Dis(i, 0, 1) * q[i - 1] * p[i]
+ Dis(i, 1, 0) * p[i - 1] * q[i]
+ Dis(i, 1, 1) * p[i - 1] * p[i]);
}
}
double ans = kInf;
for (int i = 0; i <= m; i++) {
ans = min(ans, dp[n][i][0]);
ans = min(ans, dp[n][i][1]);
}
printf("%.2lf\n", ans);
return 0;
}
感觉跟 lgj 学了这么久 DP 写起代码来也越来越顺溜了。lgj 赛高!