「Sightseeing Cows」 观光奶牛

GitHub跳转原题关系图返回列表

最优比率环是 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

形式化题目

给定 LL 个点、PP 条边的有向图。点 vv 有点权 fvf_v(乐趣值),边 u→vu \to v 有边权 t(u,v)t(u,v)(耗时)。找一个环 CC(点与边交替、首尾相接,至少含一条边),最大化

∑v∈Cfv∑e∈Cte \frac{\sum_{v \in C} f_v}{\sum_{e \in C} t_e}

输出最大比值,保留两位小数。数据保证至少存在一个环。

下面用样例图展示环的结构:每个点标 ff 值,每条边标 tt 值,加粗路线 1→2→3→5→11 \to 2 \to 3 \to 5 \to 1 就是最优环。

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

样例答案来自粗线环 1→2→3→5→11 \to 2 \to 3 \to 5 \to 1:分子 f1+f2+f3+f5=30+10+10+10=60f_1+f_2+f_3+f_5 = 30+10+10+10 = 60,分母 3+2+2+3=103+2+2+3 = 10,比值 60/10=6.0060/10 = 6.00。观察这张图可以发现一个关键结构:环上每个点恰好对应一条入边(点 vv 的入边就是环上指向它的那条边),点与边一一交替——这正是下一步把点权"挂"到边上的伏笔。

正解

思路

朴素做法及其瓶颈:把图中每个环都找出来,逐个算 ∑f/∑t\sum f / \sum t 取最大。有向图简单环的数量是指数级的(L⩽1000L \leqslant 1000),根本枚举不完。瓶颈在于「环」没法线性枚举,必须把「找一个环」换成「回答一个能高效判定的问题」。

第一步:01 分数规划(分式转线性)。 设当前猜测比值 midmid,问:存在环 CC 使 F(C)/T(C)>midF(C)/T(C) > mid 吗?其中 F(C)F(C) 是环上点权和、T(C)T(C) 是环上边权和。移项:

F(C)−mid⋅T(C)>0  ⟺  mid⋅T(C)−F(C)<0 F(C) - mid \cdot T(C) > 0 \iff mid \cdot T(C) - F(C) < 0

右边就是「把每条边的权改成 mid⋅temid \cdot t_e、再把环上点权全部减掉后,环权和为负」。利用上面观察到的「环上点与入边一一对应」,把 fvf_v 挂到 vv 的入边上——给每条边 u→vu \to v 定新权:

w(u→v)=mid⋅t(u,v)−fv w(u \to v) = mid \cdot t(u,v) - f_v

这样环 CC 的边权和恰好等于 mid⋅T(C)−F(C)mid \cdot T(C) - F(C),于是

存在比值>mid 的环  ⟺  新图存在负环 \text{存在比值} > mid \text{ 的环} \iff \text{新图存在负环}

「找环」变成了「判负环」,后者是经典可解问题。

第二步:SPFA 判负环。 图不一定强连通,等效做法是从一个虚拟超级源点向所有点连 00 边跑最短路——实现上就是 dis 全 00、所有点初始入队。用 cnt[v] 记录点 vv 的 dis 被成功松弛的次数:无负环时最短路至多经过 n−1n-1 条边,每点至多被松弛 n−1n-1 次;一旦 cnt[v] >= n,由抽屉原理必经过负环,立即返回有负环。

第三步:二分收敛。 midmid 越大,每条边权 mid⋅te−fvmid \cdot t_e - f_v 越大,负环从「有」单调变「无」,所以二分方向明确:

midmid 取值 判定(样例图) 含义 二分动作
5.05.0 有负环(最优环 10⋅5−60=−10<010 \cdot 5 - 60 = -10 < 0) 存在比值 >5>5 的环 lo = mid
5.95.9 有负环(10⋅5.9−60=−1<010 \cdot 5.9 - 60 = -1 < 0) 存在比值 >5.9>5.9 的环 lo = mid
6.06.0 无负环(10⋅6−60=010 \cdot 6 - 60 = 0) 所有环比值 ⩽6\leqslant 6 hi = mid
7.07.0 无负环(10⋅7−60=10>010 \cdot 7 - 60 = 10 > 0) 所有环比值 ⩽7\leqslant 7 hi = mid

即:有负环说明答案比 midmid 大,抬 lo;无负环说明答案不超过 midmid,压 hi。比值上界是 1000/1=10001000/1 = 1000(分子每点至多 10001000、分母每边至少 11),下界 00。30 轮二分后区间长 1000/230<10−61000/2^{30} < 10^{-6},两位小数输出绰绰有余。最优比值由某个真实环取到,收敛后输出 hi 即答案。

代码

cpp
/**
 * 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;
}
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-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 最坏 O(L⋅P)O(L \cdot P),二分 30 轮,总计 O(30⋅L⋅P)≈1.5×108O(30 \cdot L \cdot P) \approx 1.5 \times 10^8(上界;实际 SPFA 表现远好于最坏情况,全部测试数据在 Python 下本地最大耗时 0.2s)。
  • 空间:邻接表与每轮重建的带权边表均为 O(L+P)O(L + P)。

总结

  • 本题模型是最优比率环:分式目标 max⁡F(C)/T(C)\max F(C)/T(C) 用 01 分数规划转成线性判定「新图是否有负环」,二分 + SPFA 解决。
  • 三个实现关键点:
    1. 点权必须挂到边的终点(w=mid⋅t−fvw = mid \cdot t - f_v),环上点与入边一一对应,环权和才恰好是 mid⋅T−Fmid \cdot T - F;
    2. 二分方向:有负环 ⇒ 答案 >mid> mid ⇒ 抬 lo;
    3. 判负环用精确准则 cnt[v] >= n,且所有点初始入队——图不强连通,「总松弛次数超过常数倍 nn」这类启发式早停会在无负环的大图上误判。
  • 同一套套路适用于所有「最大化 分子量/分母量」的图上结构问题(最优比率生成树、最优比率路径等),差别只在分子分母挂在什么元素上、以及用什么算法判负权。

图示解析

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

从上到下是本题的完整思路链:左列说明每一步消除了什么瓶颈——第一步把指数级的环枚举换成单个 midmid 的判定问题,第二步把「存在比值更大的环」换成图论里成熟的负环判定,第三步利用单调性把判定问题还原成数值答案。读者对照代码时,可以逐层确认 Mermaid 图中每个箭头对应 main.py 里的哪一段:边权变换对应 graph_adj 推导式,负环判定对应 has_neg_cycle,二分方向对应 lo/hi 的更新。