\(AcWing\) \(361\) 观光奶牛
一、题目描述
背景
作为对奶牛们辛勤工作的回报,\(Farmer\) \(John\)决定带她们去附近的大城市玩一天。
旅行的前夜,奶牛们在兴奋地讨论如何最好地享受这难得的闲暇。
很幸运地,奶牛们找到了一张详细的城市地图,上面标注了城市中所有\(L(2⩽L⩽1000)\)座标志性建筑物(建筑物按\(1…L\)顺次编号),以及连接这些建筑物的\(P(2⩽P⩽5000)\)条道路。按照计划,那天早上\(Farmer\) \(John\)会开车将奶牛们送到某个她们指定的 建筑物 旁边,等奶牛们完成她们的整个旅行并回到出发点后,将她们接回农场。由于大城市中总是寸土寸金,所有的道路都很窄,政府不得不把它们都设定为通行方向固定的单行道。
尽管参观那些标志性建筑物的确很有意思,但如果你认为奶牛们同样享受穿行于大城市的车流中的话,你就大错特错了。与参观景点相反,奶牛们把走路定义为无趣且令她们厌烦的活动。对于编号为\(i\)的标志性建筑物,奶牛们清楚地知道参观它能给自己带来的乐趣值\(F_i\)(\(1⩽F_i⩽1000\))。相对于奶牛们在走路上花的时间,她们参观建筑物的耗时可以忽略不计。
奶牛们同样仔细地研究过城市中的道路。她们知道第\(i\)条道路两端的建筑物\(L1_i\)和\(L2_i\)(道路方向为\(L1_i \rightarrow L2_i\) ),以及她们从道路的一头走到另一头所需要的时间\(Ti(1⩽Ti⩽1000)\)。
为了最好地享受她们的休息日,奶牛们希望她们 在一整天中平均每单位时间内获得的乐趣值最大 。当然咯,奶牛们不会愿意把同一个建筑物参观两遍,也就是说,虽然她们可以两次经过同一个建筑物,但她们的乐趣值只会增加一次。顺便说一句,为了让奶牛们得到一些锻炼,\(Farmer\) \(John\)要求奶牛们参观至少\(2\)个建筑物。
请你写个程序,帮奶牛们计算一下她们能得到的最大平均乐趣值。
\(AcWing\) 抽象出的题意
给定一张 \(L\) 个点、\(P\) 条边的有向图,每个点都有一个权值 \(f[i]\),每条边都有一个权值 \(t[i]\)。
求图中的一个环,使 环上各点的权值之和 除以 环上各边的权值之和 最大
输出这个最大值。
注意:数据保证至少存在一个环
输入格式
第一行包含两个整数 \(L\) 和 \(P\)。
接下来 \(L\) 行每行一个整数,表示 \(f[i]\)。
再接下来 \(P\) 行,每行三个整数 \(a,b,t[i]\),表示点 \(a\) 和 \(b\) 之间存在一条边,边的权值为 \(t[i]\)。
输出格式
输出一个数表示结果,保留两位小数。
二、\(01\)分数规划
\(01\)分数规划 还是不太好理解的,有几个细节需要仔细考虑,单开一个专题文章讲解一下,点这里
三、本题思路
分析
题目要求$\displaystyle \frac{\sum {f_i}}{\sum{w_i}} $的最大值,这种问题称为 \(01\)分数规划,通俗点说,就是一堆的和除以一堆的和,要求比值最大。
对于本题
我们可以通过二分来做,二分啥呢?就是对于一个环,二分一个\(mid\)值,判断是否满足\(\displaystyle \frac{\sum{f_i}}{\sum{w_i}} \geq mid\),然后我们就可以来不断更改二分的区间,直到找到\(\displaystyle \frac{\sum{f_i}}{\sum{w_i}}\)的最大值。
思路确定了,那么具体如何实现呢?
\(\displaystyle \frac{\sum{f_i}}{\sum{w_i}} \geq mid\) 由于这里的边都是正权边,所以可以移项,变成
再变一下:
将求和符号提出,亦等价于:
如下建图:
- 题目明确告诉我们有环,让我们找出环,但没有明说是正环还是负环
- 求最长路,如果路径不断的增长,则说明存在正环
- 求最短路,如果路径不断的减小,则说明存在负环
上面我们得到了一个 柿子:
妥妥的边长不断累加增长,是正环!用最长路办法即可
当然,也可以变形一下 柿子
妥妥的边长不断累加减少,是负环!用最短路办法即可
\(Q\):有没有可能是 零环 呢?
\(A\):上面的分析过程是带等号的,所以会有零环,在真正的代码实现中,我们没有用等号,零环不会被检查。
总结一下\(01\)分数规划的套路
- 二分一个定值
- 整理表达式,重新定义边权
- 套用常规的图论算法(负环、最小生成树等等)
由于是求正环,与求负环类似,只不过最短路变成最长路。
而且,边权需要自定义,如上图,边权\(f[u] - mid * w[i]\),其中\(f[u]\)表示\(u\)点的点权,\(w[i]\)表示从\(u\)到\(j\)的边权,这样合起来作为该边的边权,相当于\(spfa\)求最长路的边权\(w[i]\).
四、正环解法
#include <bits/stdc++.h>
using namespace std;
const int N = 1010, M = 5010;
int n, m;
int f[N], cnt[N];
double dist[N];
bool st[N];
const double eps = 1e-4;
//邻接表
int idx, h[N], e[M], w[M], ne[M];
void add(int a, int b, int c) {
e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx++;
}
bool check(double mid) {
queue<int> q;
memset(cnt, 0, sizeof cnt);
memset(dist, 0x3f, sizeof dist);
memset(st, false, sizeof st);
for (int i = 1; i <= n; i++) {
q.push(i);
st[i] = true;
}
while (q.size()) {
int u = q.front();
q.pop();
st[u] = false;
for (int i = h[u]; ~i; i = ne[i]) {
int j = e[i];
//正环,最长路
if (dist[j] < dist[u] + f[u] - w[i] * mid) {
dist[j] = dist[u] + f[u] - w[i] * mid;
//判环
cnt[j] = cnt[u] + 1;
if (cnt[j] >= n) return true;
if (!st[j]) {
q.push(j);
st[j] = true;
}
}
}
}
return false;
}
int main() {
cin >> n >> m;
for (int i = 1; i <= n; i++) cin >> f[i]; //每个点都有一个权值f[i]
//初始化邻接表
memset(h, -1, sizeof h);
int a, b, c;
for (int i = 0; i < m; i++) {
cin >> a >> b >> c;
add(a, b, c);
}
//浮点数二分
double l = 0, r = 1000;
//左边界很好理解,因为最小是0;
//Σf[i]最大1000*n,Σt[i]最小是1*n,比值最大是1000
//当然,也可以无脑的设置r=INF,并不会浪费太多时间,logN的效率你懂的
//因为保留两位小数,所以这里精度设为1e-4
while (r - l > eps) {
double mid = (l + r) / 2;
if (check(mid))
l = mid;
else
r = mid;
}
printf("%.2lf\n", l);
return 0;
}
五、\(SPFA+dfs\)解法 【推荐】
个人理解,\(SPFA+dfs\)的方法才是判负环的正解,可以做到线性时间复杂度,原理见:这里
补充于 \(2022-11-11\)
#include <bits/stdc++.h>
using namespace std;
const int N = 1010, M = 5010;
int n, m;
int f[N], cnt[N];
double dist[N];
bool st[N];
const double eps = 1e-4;
//邻接表
int idx, h[N], e[M], w[M], ne[M];
void add(int a, int b, int c) {
e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx++;
}
// dfs 判环 Accepted 35 ms
bool dfs(int u, double mid) {
if (st[u]) return true; //如果又见u,说明有环
bool flag = false; //我的后代们是不是有环?
st[u] = true; // u我出现过一次了~
for (int i = h[u]; ~i; i = ne[i]) {
int j = e[i];
//更新最小值,判负环
if (dist[j] > dist[u] + w[i] * mid - f[u]) {
dist[j] = dist[u] + w[i] * mid - f[u];
//检查一下我的下一个节点j,它要是有负环检查到,我也汇报
flag = dfs(j, mid);
if (flag) break;
}
}
st[u] = false; //回溯
return flag;
}
bool check(double mid) {
memset(dist, 0, sizeof dist);
for (int i = 1; i <= n; i++)
if (dfs(i, mid)) return true;
return false;
}
int main() {
cin >> n >> m;
for (int i = 1; i <= n; i++) cin >> f[i]; //每个点都有一个权值f[i]
//初始化邻接表
memset(h, -1, sizeof h);
int a, b, c;
for (int i = 0; i < m; i++) {
cin >> a >> b >> c;
add(a, b, c);
}
//浮点数二分
double l = 0, r = 1000;
//左边界很好理解,因为最小是0;
//Σf[i]最大1000*n,Σt[i]最小是1*n,比值最大是1000
//当然,也可以无脑的设置r=INF,并不会浪费太多时间,logN的效率你懂的
//因为保留两位小数,所以这里精度设为1e-4
while (r - l > eps) {
double mid = (l + r) / 2;
if (check(mid))
l = mid;
else
r = mid;
}
printf("%.2lf\n", l);
return 0;
}