最优比率环是 01 分数规划:二分 mid,把每条边改成 mid·t−f_v(点权挂入边终点),存在比值更大的环等价于新图有负环,SPFA 用 cnt≥n 精确判负环即可。
OJ: roj
题目 ID: 3193
难度:提高+/省选-
标签:图论负环01分数规划二分python
创建: 2026-10-02 00:36
更新: 2026-10-10 13:00
形式化题目
给定
输出最大比值,保留两位小数。数据保证至少存在一个环。
下面用样例图展示环的结构:每个点标
graph LR
A(("1<br>f=30")) -->|t=3| B(("2<br>f=10"))
B -->|t=2| C(("3<br>f=10"))
C -->|t=5| D(("4<br>f=5"))
C -->|t=2| E(("5<br>f=10"))
D -->|t=5| E
E -->|t=3| A
E -->|t=2| B
style A fill:#ffe0b2
style B fill:#ffe0b2
style C fill:#ffe0b2
style E fill:#ffe0b2
样例答案来自粗线环
正解
思路
朴素做法及其瓶颈:把图中每个环都找出来,逐个算
第一步:01 分数规划(分式转线性)。 设当前猜测比值
右边就是「把每条边的权改成
这样环
「找环」变成了「判负环」,后者是经典可解问题。
第二步:SPFA 判负环。 图不一定强连通,等效做法是从一个虚拟超级源点向所有点连 dis 全 cnt[v] 记录点 dis 被成功松弛的次数:无负环时最短路至多经过 cnt[v] >= n,由抽屉原理必经过负环,立即返回有负环。
第三步:二分收敛。
| 判定(样例图) | 含义 | 二分动作 | |
|---|---|---|---|
| 有负环(最优环 |
存在比值 |
lo = mid |
|
| 有负环( |
存在比值 |
lo = mid |
|
| 无负环( |
所有环比值 |
hi = mid |
|
| 无负环( |
所有环比值 |
hi = mid |
即:有负环说明答案比 lo;无负环说明答案不超过 hi。比值上界是 hi 即答案。
代码
/**
* 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-10 13:00
* update_at: 2026-10-10 13:00
*/
// 观光旅游:01 分数规划 + SPFA 判负环
#include <cstdio>
#include <vector>
using namespace std;
typedef long long ll;
int L, P;
vector<ll> fun; // fun[i]:景点 i 的乐趣值
vector<int> eu, ev, et;
vector<vector<int> > out_edges; // out_edges[u]:u 的出边编号,按输入顺序
vector<double> dis;
vector<int> relax;
vector<char> inq;
// 图(边权为 mid*t - f)中是否存在负环
bool has_neg_cycle(double mid) {
// 超级源点连向所有点:dis 初始全 0,所有点一开始都在队列里
dis.assign(L + 1, 0.0);
relax.assign(L + 1, 0);
inq.assign(L + 1, 1);
vector<int> q;
for (int i = 1; i <= L; i++) {
q.push_back(i);
}
for (int qh = 0; qh < (int)q.size(); qh++) {
int u = q[qh];
inq[u] = 0;
for (int t = 0; t < (int)out_edges[u].size(); t++) {
int e = out_edges[u][t];
int v = ev[e];
double nd = dis[u] + (mid * et[e] - fun[v]); // 点权 f 记在边的终点上
if (nd < dis[v]) {
dis[v] = nd;
relax[v]++;
if (relax[v] >= L) { // 一个点被松弛 n 次 ⇒ 存在负环
return true;
}
if (!inq[v]) {
inq[v] = 1;
q.push_back(v);
}
}
}
}
return false;
}
int main() {
if (scanf("%d %d", &L, &P) != 2) {
return 0;
}
fun.assign(L + 1, 0);
for (int i = 1; i <= L; i++) {
scanf("%lld", &fun[i]);
}
eu.assign(P, 0); ev.assign(P, 0); et.assign(P, 0);
out_edges.assign(L + 1, vector<int>());
for (int i = 0; i < P; i++) {
scanf("%d %d %d", &eu[i], &ev[i], &et[i]);
out_edges[eu[i]].push_back(i);
}
// 二分比值 mid:有负环 ⇔ 存在环的 Σf/Σt > mid
double lo = 0.0, hi = 1000.0;
for (int it = 0; it < 30; it++) {
double mid = (lo + hi) / 2;
if (has_neg_cycle(mid)) {
lo = mid;
} else {
hi = mid;
}
}
printf("%.2f\n", hi);
return 0;
}#!/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-02 00:36
# update_at: 2026-10-02 00:36
import sys
from collections import deque
def has_neg_cycle(n: int, graph: list[list[tuple[int, int]]]) -> bool:
"""图(边权为 mid*t - f)中是否存在负环(SPFA 判负环)。"""
cnt = [0] * (n + 1) # cnt[v] = 点 v 被松弛的次数
in_queue = [False] * (n + 1)
dis = [0.0] * (n + 1) # 超级源点连向所有点,dis 初始全 0
queue = deque(range(1, n + 1)) # 超级源点入队等价于所有点初始就在队列里
in_queue[1:] = [True] * n
while queue:
u = queue.popleft()
in_queue[u] = False
for v, w in graph[u]:
nd = dis[u] + w
if nd < dis[v]:
dis[v] = nd
cnt[v] += 1
if cnt[v] >= n: # 一个点被松弛 n 次 ⇒ 经过它存在负环
return True
if not in_queue[v]:
in_queue[v] = True
queue.append(v)
return False
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
L = next(data) # 点数
P = next(data) # 边数
fun = [0] + [next(data) for _ in range(L)] # 点权:fun[i] = 景点的乐趣值
# 邻接表:u -> [(v, time), ...]
graph: list[list[tuple[int, int]]] = [[] for _ in range(L + 1)]
for _ in range(P):
a, b, t = next(data), next(data), next(data)
graph[a].append((b, t))
# 01分数规划:二分比值 mid,判是否存在环满足 Σ(mid*t - f) < 0,即环的 Σf/Σt > mid
lo, hi = 0.0, 1000.0 # f,t 均 <= 1000,比值不超过 1000
for _ in range(30): # 30 次二分后区间长 < 1e-6,足够保留两位小数
mid = (lo + hi) / 2
# 点权 f 记在边的终点上:环的边权和 = mid·Σt - Σf(环上点)
graph_adj = [[(v, mid * t - fun[v]) for v, t in adj] for u, adj in enumerate(graph)]
if has_neg_cycle(L, graph_adj): # 有负环 ⇒ 存在环的比值 > mid
lo = mid # 答案比 mid 大,抬高下界
else:
hi = mid # 所有环的比值 <= mid,压低上界
print(f'{hi:.2f}') # 二分收敛到最大比值
if __name__ == "__main__":
solve()复杂度
- 时间:每轮 SPFA 最坏
,二分 30 轮,总计 (上界;实际 SPFA 表现远好于最坏情况,全部测试数据在 Python 下本地最大耗时 0.2s)。 - 空间:邻接表与每轮重建的带权边表均为
。
总结
- 本题模型是最优比率环:分式目标
用 01 分数规划转成线性判定「新图是否有负环」,二分 + SPFA 解决。 - 三个实现关键点:
- 点权必须挂到边的终点(
),环上点与入边一一对应,环权和才恰好是 ; - 二分方向:有负环 ⇒ 答案
⇒ 抬 lo; - 判负环用精确准则
cnt[v] >= n,且所有点初始入队——图不强连通,「总松弛次数超过常数倍」这类启发式早停会在无负环的大图上误判。
- 点权必须挂到边的终点(
- 同一套套路适用于所有「最大化 分子量/分母量」的图上结构问题(最优比率生成树、最优比率路径等),差别只在分子分母挂在什么元素上、以及用什么算法判负权。
图示解析
01 分数规划的转化路线
从「枚举所有环」到「二分 + 判负环」的完整推导链:
graph TD
A["max 环的 Σf / Σt<br>枚举环:指数级,不可行"] -->|"移项:Σ(mid·t) − Σf < 0"| B["猜 mid<br>判定:存在比值 > mid 的环?"]
B -->|"点权挂入边终点<br>w(u→v) = mid·t − f_v"| C["新图有负环?<br>SPFA:cnt[v] ≥ n 判定"]
C -->|"有负环 ⇒ lo = mid<br>无负环 ⇒ hi = mid"| D["30 轮二分收敛<br>输出 hi = 最优比值"]
style A fill:#ffcdd2
style D fill:#c8e6c9
从上到下是本题的完整思路链:左列说明每一步消除了什么瓶颈——第一步把指数级的环枚举换成单个 main.py 里的哪一段:边权变换对应 graph_adj 推导式,负环判定对应 has_neg_cycle,二分方向对应 lo/hi 的更新。