「一本通 3.3 例 2」双调路径

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

将费用作为状态维度拆点,运用 Dijkstra 算法求出每种费用下的最小时间,再单调扫描提取 Pareto 最优前沿。

OJ: roj

题目 ID: 1505

难度:提高

标签:最短路Dijkstra动态规划

创建: 2026-09-30 15:05

更新: 2026-10-07 11:01

形式化题目

给定一个包含 nn 个节点、mm 条无向边的网络,起点为 ss,终点为 ee。每条边具有费用 cc 和时间 tt 两个非负属性。

对于从 ss 到 ee 的一条路径,其总费用 CC 为沿途各边费用之和,总时间 TT 为沿途各边时间之和。如果两条路径的 (C,T)(C, T) 相同,则视为同一种路径。

若一条路径 AA 满足 CA≤CBC_A \le C_B 且 TA≤TBT_A \le T_B(且两者不同时相等),则称路径 AA 支配路径 BB。不存在任何其他路径能支配的路径称为最小路径(即 Pareto 最优解)。

请求出从 ss 到 ee 互不相同的最小路径 (C,T)(C, T) 种数。

思路

这是一道典型的**双目标最短路(Bi-objective Shortest Path)**问题,要求统计 Pareto 前沿(非支配解集合)的大小。

1. 朴素方法与瓶颈

朴素思路是通过 DFS 搜索从起点 ss 到终点 ee 的所有简单路径,收集所有路径的 (C,T)(C, T) 点对,最后进行非支配排序与去重。 但在一张 n≤100,m≤300n \le 100, m \le 300 的无向图上,从 ss 到 ee 的简单路径数量在最坏情况下呈指数级爆炸,直接爆搜会严重超时。

2. 状态维度转化与上界估计

注意到两点关键性质:

  1. 边的权值非负:边费用 c∈[0,100]c \in [0, 100],边时间 t∈[0,100]t \in [0, 100]。如果一条路径包含环路,走环只会让费用或时间增加或不变,绝不会比不走环更优。因此,所有的最小路径都可以规约为不含环的简单路径。
  2. 费用有明确上界:简单路径包含的边数最多为 n−1≤99n - 1 \le 99 条。每条边费用最大为 100100,因此任何有效简单路径的总费用必不超过:
    Cmax⁡≤(n−1)×100≤99×100=9900≤10000C_{\max} \le (n - 1) \times 100 \le 99 \times 100 = 9900 \le 10000

既然费用总和的上界较小,我们可以将费用作为状态的一个维度,将原问题转化为:

对于每个可能的总费用 c∈[0,10000]c \in [0, 10000],求从起点 ss 到各节点 uu 的最小时间。

设 dist[u][c] 表示到达节点 uu 且累计花费恰好为 cc 时的最短时间。

3. 拆点图上的 Dijkstra

由于每条边的时间 t≥0t \ge 0,我们可以将每个原图节点 uu 拆成 (u,c)(u, c) 状态。 对于原图中的一条无向边 (u,v,ce,te)(u, v, c_e, t_e),状态转移为:

(u,c)→+te(v,c+ce)(u, c) \xrightarrow{+t_e} (v, c + c_e)

这等价于在一个有 O(N⋅Cmax⁡)O(N \cdot C_{\max}) 个状态的有向图上求单源最短路。我们使用优先队列(二叉堆)优化的 Dijkstra 算法进行状态扩展:

  • 堆中元素为三元组 (time, cost, u)。
  • 每次取出当前时间最小的状态进行松弛,更新 dist[v][c + edge_c] 并重新入堆。

4. 统计 Pareto 最优解

在 Dijkstra 结束后,我们获得了所有到达终点 ee 的状态 dist[e][c](即费用为 cc 时的最小时间)。 如何筛选出真正的非支配解? 我们将费用 cc 从 00 到 1000010000 升序枚举:

  • 随着费用 cc 逐渐增大,其对应的时间必须严格小于之前出现过的所有时间最小值(即 dist[e][c]<min⁡0≤k<cdist[e][k]dist[e][c] < \min_{0 \le k < c} dist[e][k])。
  • 否则,若 dist[e][c]≥min⁡0≤k<cdist[e][k]dist[e][c] \ge \min_{0 \le k < c} dist[e][k],则说明之前已经存在一个费用更低(≤c\le c)且时间更优或相等(≤dist[e][c]\le dist[e][c])的方案,当前状态被它支配,必须舍弃。

因此,只需维护一个变量 min_time 记录历史最小时间。每当遇到更小的时间,就找到了一组新的 Pareto 最优解,计数器加 1,并更新 min_time。

图示过程

以样例为例:44 个节点,55 条边,从 11 到 44。

graph LR
    1((1)) -- c=2, t=1 --- 2((2))
    1((1)) -- c=1, t=4 --- 3((3))
    2((2)) -- c=1, t=2 --- 3((3))
    2((2)) -- c=2, t=4 --- 4((4))
    3((3)) -- c=3, t=1 --- 4((4))

所有简单路径产生的 (C,T)(C, T) 组合如下:

路径 总费用 CC 总时间 TT 是否为最小路径 原因说明
1→2→41 \to 2 \to 4 44 55 是 费用为 4 时时间最短
1→3→41 \to 3 \to 4 44 55 是 与上一条费用和时间相同,算作同一种
1→2→3→41 \to 2 \to 3 \to 4 66 44 是 费用虽高,但时间更快(4<54 < 5),互不支配
1→3→2→41 \to 3 \to 2 \to 4 44 1010 否 被 (4,5)(4, 5) 支配(费用相同但时间更长)

按费用 cc 升序扫描:

  1. c=4c = 4 时,时间为 55,5<∞5 < \infty,计入答案,当前 min_time = 5;
  2. c=6c = 6 时,时间为 44,4<54 < 5,计入答案,当前 min_time = 4;
  3. 最终不同种类的最小路径数为 22。

代码

Python 版:

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-07-05 21:47
# update_at: 2026-07-05 21:47

import heapq
import sys

INF = 10**9
MAX_COST = 10000


def solve() -> None:
    data = sys.stdin.read().split()
    if not data:
        return
    it = iter(map(int, data))
    n, m, s, e = next(it), next(it), next(it), next(it)

    adj: list[list[tuple[int, int, int]]] = [[] for _ in range(n + 1)]
    for _ in range(m):
        u, v, c, t = next(it), next(it), next(it), next(it)
        adj[u].append((v, c, t))
        adj[v].append((u, c, t))

    # dist[u][cost]: 到达节点 u 恰好花费 cost 时的最小时间
    dist = [[INF] * (MAX_COST + 1) for _ in range(n + 1)]
    dist[s][0] = 0
    pq: list[tuple[int, int, int]] = [(0, 0, s)]  # (time, cost, u)

    while pq:
        t, c, u = heapq.heappop(pq)
        if t > dist[u][c]:
            continue
        for v, edge_c, edge_t in adj[u]:
            nc, nt = c + edge_c, t + edge_t
            if nc <= MAX_COST and nt < dist[v][nc]:
                dist[v][nc] = nt
                heapq.heappush(pq, (nt, nc, v))

    # 统计帕累托最优解:按 cost 从小到大扫描,只有 time 严格小于当前历史最小 time 才是非支配解
    min_time = INF
    ans = 0
    for c in range(MAX_COST + 1):
        t = dist[e][c]
        if t < min_time:
            min_time = t
            ans += 1

    print(ans)


if __name__ == "__main__":
    solve()

C++ 版(同一算法):

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-05 05:18
 * update_at: 2026-10-05 05:18
 */
#include <iostream>
#include <queue>
using namespace std;

const int MAXN = 105;
const int MAXM = 305;
const int MAX_COST = 10000; // 简单路径边数不超过 n-1,总费用最多 99*100,取 10000 作上界
const int INF = 1000000000;

typedef long long ll;

ll n, m, s, e;

// 链式前向星存无向图,每条无向边拆成两条有向边
int head[MAXN];
int nxt[2 * MAXM];
int to[2 * MAXM];
int edge_cost[2 * MAXM]; // 边的费用
int edge_time[2 * MAXM]; // 边的时间
int edge_cnt;

// dist[u][c]:到达节点 u 且累计费用恰好为 c 时的最小时间
int dist[MAXN][MAX_COST + 1];

struct State {
    int t; // 已累计的时间
    int c; // 已累计的费用
    int u; // 当前节点
    bool operator<(const State &other) const {
        return t > other.t; // 时间小的优先,配合 priority_queue 实现小根堆
    }
};

// 加入一条 u -> v 的有向边,携带费用 c 和时间 t。
void add_edge(int u, int v, int c, int t) {
    edge_cnt++;
    to[edge_cnt] = v;
    edge_cost[edge_cnt] = c;
    edge_time[edge_cnt] = t;
    nxt[edge_cnt] = head[u];
    head[u] = edge_cnt;
}

// 以费用为状态维度拆点,在 (节点, 费用) 状态图上跑 Dijkstra。
void dijkstra() {
    for (int u = 1; u <= n; u++) {
        for (int c = 0; c <= MAX_COST; c++) {
            dist[u][c] = INF;
        }
    }
    dist[s][0] = 0;

    priority_queue<State> pq;
    State start;
    start.t = 0;
    start.c = 0;
    start.u = s;
    pq.push(start);

    while (!pq.empty()) {
        State cur = pq.top();
        pq.pop();
        if (cur.t > dist[cur.u][cur.c]) {
            continue; // 过期状态,跳过
        }
        for (int i = head[cur.u]; i != 0; i = nxt[i]) {
            int v = to[i];
            int nc = cur.c + edge_cost[i];
            int nt = cur.t + edge_time[i];
            if (nc > MAX_COST) {
                continue;
            }
            if (nt < dist[v][nc]) {
                dist[v][nc] = nt;
                State nxt_state;
                nxt_state.t = nt;
                nxt_state.c = nc;
                nxt_state.u = v;
                pq.push(nxt_state);
            }
        }
    }
}

// 按费用升序扫描终点状态,只有时间严格小于历史最小值时才是 Pareto 最优解。
int count_pareto() {
    int min_time = INF;
    int ans = 0;
    for (int c = 0; c <= MAX_COST; c++) {
        int t = dist[e][c];
        if (t < min_time) {
            min_time = t;
            ans++;
        }
    }
    return ans;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> m >> s >> e;
    for (ll i = 0; i < m; i++) {
        int p, r, c, t; // 端点编号不超过 100,费用与时间不超过 100,用 int 即可
        cin >> p >> r >> c >> t;
        add_edge(p, r, c, t);
        add_edge(r, p, c, t);
    }

    dijkstra();
    cout << count_pareto() << "\n";

    return 0;
}

复杂度分析

  • 时间复杂度:状态总数为 V′=N×Cmax⁡≤100×10000=106V' = N \times C_{\max} \le 100 \times 10000 = 10^6。边数 E′=M×Cmax⁡≤300×10000=3×106E' = M \times C_{\max} \le 300 \times 10000 = 3 \times 10^6。使用二叉堆优化 Dijkstra 算法的时间复杂度为 O(E′log⁡V′)O(E' \log V')。实际可达状态远少于理论上限,在 Python 下运行效率高。
  • 空间复杂度:存储距离数组 dist 的空间为 O(N×Cmax⁡)O(N \times C_{\max}),约需几兆字节内存,远低于评测系统 128 MB 的限制。

总结

面对双目标权值优化问题,当其中一个维度的取值范围较小(或受简单路径长度限制)时,将该维度化作状态维度进行拆点,并用 Dijkstra 算法求解另一个维度的最优值,是一种非常高效的标准解法。最后通过一次单调扫描即可去重并滤出所有 Pareto 最优解。