最优贸易

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

一次交易拆成买入价与卖出价两个独立的极值传播:正图从 1 做 min 松弛求路径最低买价,反图从 n 做 max 松弛求路径最高卖价,枚举分割点取差值最大。

OJ: roj

题目 ID: 3181

难度:提高+/省选-

标签:图论SPFA最短路模型

创建: 2026-10-01 23:44

更新: 2026-10-10 13:00

形式化题目

给定一张 nn 个点 mm 条边的混合图:每条边单向(z=1z=1)或双向(z=2z=2),双向边等价于两条相反的有向边。每个点 ii 有一个价格 pip_i。

从点 11 出发沿边移动,最终停在点 nn;点可以重复经过。移动途中至多完成一次交易:在经过的某个点 bb 买入,在之后经过的某个点 ss 卖出,获利 ps−pbp_s - p_b。求一次交易能获得的最大利润;不交易也是合法选择。

1⩽n⩽105,1⩽m⩽5×105,1⩽pi⩽1001 \leqslant n \leqslant 10^5,\quad 1 \leqslant m \leqslant 5 \times 10^5,\quad 1 \leqslant p_i \leqslant 100

::: tip 样例解释 价格依次为 p=[4,3,5,6,1]p = [4, 3, 5, 6, 1],边:1→21 \to 2、1→41 \to 4、2↔32 \leftrightarrow 3、3→53 \to 5、4↔54 \leftrightarrow 5。

答案 5 的走法:1→4→5→4→51 \to 4 \to 5 \to 4 \to 5。先走到 5 号,在 5 号以 1 元买入,沿双向边回到 4 号以 6 元卖出,再沿双向边回到 5 号结束旅行。关键在两点:双向边允许「回头」,点允许重复经过(所以 5→4→55 \to 4 \to 5 是合法走法);卖出点不要求第一次经过——走过 5 号不算数,回程再买再卖才是最优。 :::

正解

思路

朴素做法:枚举买入点 bb 和卖出点 ss,对每一对判断「1 能到 bb、bb 能到 ss、ss 能到 nn」,取 ps−pbp_s - p_b 的最大值。点对有 O(n2)O(n^2) 对,还得先花大代价算出每个点的可达集合,n=105n = 10^5 下完全不可行。瓶颈在于:一次交易本质上由「一段旅程」决定,而枚举点对把同一段旅程反复数了很多遍。

第一步优化:只枚举分割点。 把一次交易对应到旅程 1→⋯→b→⋯→s→⋯→n1 \to \cdots \to b \to \cdots \to s \to \cdots \to n 上的一个「分割点」vv,定义

mn[v]=min⁡走法 1→v(min⁡u∈走法pu),mx[v]=max⁡走法 v→n(max⁡u∈走法pu).\text{mn}[v] = \min_{\text{走法}\, 1 \to v} \big(\min_{u \in \text{走法}} p_u\big), \qquad \text{mx}[v] = \max_{\text{走法}\, v \to n} \big(\max_{u \in \text{走法}} p_u\big).

这里说「走法」而不是「简单路径」:允许重复经过点,样例里 v=5v=5 的最优卖出价正是靠 5→4→55 \to 4 \to 5 这个小环刷出来的。对每个 vv,在 vv 处以历史最低价 mn[v]\text{mn}[v] 买入、以未来最高价 mx[v]\text{mx}[v] 卖出,枚举取 mx[v]−mn[v]\text{mx}[v] - \text{mn}[v] 的最大值。

为什么两个量可以独立取最优:对任意真实旅程(在 bb 买、ss 卖),取 v=sv = s,则旅程前半段证明 mn[s]⩽pb\text{mn}[s] \leqslant p_b、后半段证明 mx[s]⩾ps\text{mx}[s] \geqslant p_s,枚举值 ⩾\geqslant 真实利润;反过来,mn[v]\text{mn}[v] 与 mx[v]\text{mx}[v] 各自对应一条真实存在的走法,两条走法在 vv 处拼起来仍是一条合法旅程(1 能到 vv、vv 能到 nn),所以每个枚举值都真能实现。两侧夹逼,答案精确。

第二步:两个极值各自是一个单源传播问题。 mn\text{mn} 在原图上从 1 出发松弛:沿边 (u,v)(u,v) 有

mn[v]←min⁡(mn[u], pv),mn[1]=p1;\text{mn}[v] \gets \min(\text{mn}[u],\ p_v), \qquad \text{mn}[1] = p_1;

mx\text{mx} 没有固定起点,把所有边反向、从 nn 出发做同样的传播,只是 min 换成 max、初值换成 pnp_n。两个松弛的形式完全一样,只是「初值 + 合并运算符 + 比较方向」不同,可以写成同一个函数跑两遍。

第三步:用什么算法做传播。 松弛式 best[v]=combine(best[u], pv)\text{best}[v] = \text{combine}(\text{best}[u],\ p_v) 与最短路的三角不等式更新同构(每条边沿松弛方向传播,直到没有更新为止),标准实现就是 SPFA:队列里放「值刚被更新、可能影响后继」的点,用 inq 数组防止重复入队。注意这里不能用堆优化的 Dijkstra 贪心——min/max 合并的松弛不满足 Dijkstra 所依赖的距离单调性质(比如 mx\text{mx} 的值会被环反复放大传播),队列松弛(Bellman-Ford 型)跑到不动点才是正确做法。

最后答案为

max⁡v: mn[v]≠+∞mx[v]≠−∞(mx[v]−mn[v]),\max_{\substack{v:\ \text{mn}[v] \neq +\infty \\ \text{mx}[v] \neq -\infty}} \big(\text{mx}[v] - \text{mn}[v]\big),

只统计「1 能到 vv 且 vv 能到 nn」的点(用 ±∞\pm\infty 哨兵排除单侧可达的点);若没有正利润,输出 00。

混合图的处理:双向边 z=2z=2 在正图里存两条有向弧;反图建图时直接把每条边以 (y,x,z)(y, x, z) 喂给同一个建图函数,双向边自然还是两条弧,单向边方向翻转。

下面这张图展示正反两个方向的传播(样例的前 4 个点):

graph LR
  A["1号 价格4<br/>mn=4"] -->|单向| B["2号 价格3<br/>mn=3"]
  B -->|双向| C["3号 价格5<br/>mn=3"]
  A -->|单向| D["4号 价格6<br/>mn=4"]

正图从 1 号沿实线箭头传播「到此为止的最低价」:mn[1]=4\text{mn}[1]=4,走到 2 号被低价刷新为 3,再传给 3 号仍是 3。反图(从 5 号出发)把所有箭头反过来后以同样方式传播「从这里到终点的最高价」,其中双向边 4↔54 \leftrightarrow 5 构成的小环让 mx[5]\text{mx}[5] 也能被 4 号的 6 元刷新——这正是必须用「松弛到不动点」而不是一次贪心的原因。

代码

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
 */

// 最优贸易:正图 SPFA 求最小买价,反图 SPFA 求最大卖价
#include <cstdio>
#include <vector>
using namespace std;

typedef long long ll;

const ll INF = 1LL << 60;

int n, m;
vector<int> xs, ys, zs; // 原始边表:zs = 1 单向,2 双向
vector<int> head, nxt, to;
vector<ll> price, best;
vector<char> inq;

// 链式前向星建图;z=2 的双向边在两个方向各挂一条
void build_graph() {
    int E = (int)xs.size();
    head.assign(n + 1, 0);
    nxt.assign(2 * E + 1, 0);
    to.assign(2 * E + 1, 0);
    int cnt = 1; // 边从 1 号开始编号,head 为 0 表示没有出边
    for (int i = 0; i < E; i++) {
        int u = xs[i], v = ys[i];
        to[cnt] = v; nxt[cnt] = head[u]; head[u] = cnt++;
        if (zs[i] == 2) {
            to[cnt] = u; nxt[cnt] = head[v]; head[v] = cnt++;
        }
    }
}

// 从 start 出发沿边传播路径上价格的极值;mode = 0 取 min(最小买价),1 取 max(最大卖价)
void spfa(int start, ll init, int mode) {
    best.assign(n + 1, init);
    inq.assign(n + 1, 0);
    best[start] = price[start];
    vector<int> q;
    q.push_back(start);
    inq[start] = 1;
    for (int h = 0; h < (int)q.size(); h++) {
        int u = q[h];
        inq[u] = 0;
        for (int e = head[u]; e; e = nxt[e]) {
            int v = to[e];
            ll nd = mode ? (best[u] > price[v] ? best[u] : price[v])
                         : (best[u] < price[v] ? best[u] : price[v]);
            bool better = mode ? (nd > best[v]) : (nd < best[v]);
            if (better) {
                best[v] = nd;
                if (!inq[v]) {
                    inq[v] = 1;
                    q.push_back(v);
                }
            }
        }
    }
}

int main() {
    if (scanf("%d %d", &n, &m) != 2) {
        return 0;
    }
    price.assign(n + 1, 0);
    for (int i = 1; i <= n; i++) {
        scanf("%lld", &price[i]);
    }
    xs.assign(m, 0); ys.assign(m, 0); zs.assign(m, 0);
    for (int i = 0; i < m; i++) {
        scanf("%d %d %d", &xs[i], &ys[i], &zs[i]);
    }

    // 正图上从 1 做 min:mn[v] = 从 1 走到 v 的路上能买到的最低价
    build_graph();
    spfa(1, INF, 0);
    vector<ll> mn = best;

    // 反图上从 n 做 max:mx[v] = 从 v 走到 n 的路上能卖出的最高价
    for (int i = 0; i < m; i++) {
        int t = xs[i];
        xs[i] = ys[i];
        ys[i] = t;
    }
    build_graph();
    spfa(n, -INF, 1);
    vector<ll> mx = best;

    ll ans = 0; // 买卖都发生在 v:两头都可达才有定义
    bool found = false;
    for (int v = 1; v <= n; v++) {
        if (mn[v] != INF && mx[v] != -INF) {
            ll cand = mx[v] - mn[v];
            if (!found || cand > ans) {
                ans = cand;
                found = true;
            }
        }
    }
    if (!found) {
        ans = 0;
    }
    printf("%lld\n", ans);
    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-01 23:44
# update_at: 2026-10-01 23:44

import sys
from collections import deque
from operator import gt, lt

INF = 1 << 60


def build_csr(n: int, edges: list[tuple[int, int, int]]) -> tuple[list[int], list[int], list[int]]:
    """把边表建成链式前向星;z=2 的双向边在两个方向各挂一条。返回 (head, nxt, to)。"""
    head = [0] * (n + 1)
    nxt = [0] * (2 * len(edges) + 1)
    to = [0] * (2 * len(edges) + 1)
    cnt = 1  # 边从 1 号开始编号,head 为 0 表示没有出边
    for x, y, z in edges:
        pairs = [(x, y), (y, x)] if z == 2 else [(x, y)]  # 双向边存两条
        for u, v in pairs:
            to[cnt] = v
            nxt[cnt] = head[u]
            head[u] = cnt
            cnt += 1
    return head, nxt, to


def spfa(n: int, head: list[int], nxt: list[int], to: list[int], price: list[int],
         start: int, init: int, combine, is_better) -> list[int]:
    """从 start 出发,沿边传播“路径上价格的极值”:combine=min 得最小买价,=max 得最大卖价。

    best[v] = 起点到 v 的所有路径上,combine(路径上各点价格) 的最优值;
    每次松弛取 combine(best[u], price[v]),队列 + inq 就是标准 SPFA。
    不可达点保持初值 init:min 版为 INF,max 版为 -INF。
    """
    best = [init] * (n + 1)
    best[start] = price[start]
    inq = bytearray(n + 1)
    inq[start] = 1
    dq = deque([start])
    while dq:
        u = dq.popleft()
        inq[u] = 0
        e = head[u]
        while e:
            v = to[e]
            nd = combine(best[u], price[v])
            if is_better(nd, best[v]):
                best[v] = nd
                if not inq[v]:
                    inq[v] = 1
                    dq.append(v)
            e = nxt[e]
    return best


def solve() -> None:
    data = iter(map(int, sys.stdin.buffer.read().split()))
    n, m = next(data), next(data)
    price = [0] + [next(data) for _ in range(n)]

    edges = [(next(data), next(data), next(data)) for _ in range(m)]

    # 正图上从 1 做 min:mn[v] = 从 1 走到 v 的路上能买到的最低价
    head, nxt, to = build_csr(n, edges)
    mn = spfa(n, head, nxt, to, price, 1, INF, min, lt)

    # 反图上从 n 做 max:mx[v] = 从 v 走到 n 的路上能卖出的最高价
    rhead, rnxt, rto = build_csr(n, [(y, x, z) for x, y, z in edges])
    mx = spfa(n, rhead, rnxt, rto, price, n, -INF, max, gt)

    # 买卖都发生在 v:两头都可达才有定义,等初值说明该端不可达
    print(max((s - b for b, s in zip(mn, mx)
               if b != INF and s != -INF), default=0))


if __name__ == "__main__":
    solve()

复杂度

  • 时间:两次 SPFA 型松弛,均摊 O(km)O(km)(kk 为小常数;与 SPFA 求最短路的最坏情形一样,理论上限 O(nm)O(nm),实践中远小于此),外加枚举分割点 O(n)O(n)。对 n⩽105n \leqslant 10^5、m⩽5×105m \leqslant 5 \times 10^5 的加强数据,Python 实测最慢约 0.15s。
  • 空间:链式前向星存至多 2m2m 条弧 × 正反两份,O(n+m)O(n + m);两轮松弛各一个值数组,O(n)O(n)。

样例走法

把样例的 5 个城市列成表,标出每个点经过两次传播后的 mn\text{mn} / mx\text{mx}:

城市 vv 价格 pvp_v mn[v]\text{mn}[v](1 到 vv 最低买价) mx[v]\text{mx}[v](vv 到 5 最高卖价) mx−mn\text{mx}-\text{mn}
1 4 4 6 2
2 3 3 6 3
3 5 3 6 3
4 6 4 6 2
5 1 1 6 5

观察这张表:

  • mn\text{mn} 沿「1→2→31 \to 2 \to 3」被 2 号的低价(3 元)刷新后一路下传;到 5 号时被 p5=1p_5 = 1 刷到 1。
  • mx\text{mx} 被价格最高的 4 号(6 元)主导:双向边 4↔54 \leftrightarrow 5 让 mx[4]\text{mx}[4]、mx[5]\text{mx}[5] 互相刷新都变成 6——mx[5]=6\text{mx}[5] = 6 的含义是「从 5 号先走到 4 号、再回 5 号」这条走法上最高能以 6 元卖出。
  • 最大差值出现在 v=5v = 5:在 5 号以 1 元买入、去 4 号以 6 元卖出,利润 5,与样例输出一致。
  • 注意 mx−mn\text{mx}-\text{mn} 在 v=4v=4 上只有 2:mx[4]−mn[4]=6−4\text{mx}[4]-\text{mn}[4] = 6-4 看似不错,但分割点模型允许每个 vv 独立取两侧最优,枚举完所有点自然选中 v=5v=5,无需比较不同「方案」。

总结

  • 一次交易 = 走法上的一个分割点;枚举分割点把 O(n2)O(n^2) 的点对枚举降为 O(n)O(n),并顺带正确处理「重复经过点」的环。
  • 「走法上价格的 min/max」是与最短路同构的单源传播问题:正图从 1 做 min、反图从 nn 做 max,同一个 SPFA 型松弛函数参数化后跑两遍;不能用 Dijkstra 贪心。
  • 答案 max⁡v(mx[v]−mn[v])\max_v (\text{mx}[v] - \text{mn}[v]),只统计两侧都可达的点,无正利润输出 0。
  • 该模型(正反两遍带约束极值 + 枚举分割点)是「路径上先买后卖」一类问题的通用套路,可直接迁移到「两次旅行拼接」「路径最长边最小化」等变体。