专栏文章
专栏文章
出行规划算法系列
1. 出行规划算法 #01:RAPTOR 2. 出行规划算法 #02:多标签 Pareto Dijkstra 3. 出行规划算法 #03:可拼票 DP 4. 出行规划算法 #04:Connection Scan Algorithm

出行规划算法 #02:多标签 Pareto Dijkstra

发布于 2026-06-08 09:04 👁 14 次阅读

多标签 Pareto Dijkstra 为每个节点维护一组「非支配标签」(Pareto 前沿),同时优化多个目标(如时间和费用),输出所有满足"没有其他方案在所有维度都更优"的解集,是携程可拼票、OTA 多目标排序的算法基础。

相关文章RAPTOR · 可拼票 DP · Connection Scan Algorithm

pareto labels

目录

章节 说明
为什么需要多目标优化 单目标无法满足用户真实需求
Pareto 支配关系 支配与非支配的定义
多标签 Dijkstra 算法 标签集维护,支配剪枝
Java 实现 双目标(时间+费用)完整代码
标签集大小分析 实践中 Pareto 前沿有多大
扩展到三个目标 换乘次数作为第三维
实际应用 携程、去哪儿、OTP
参考资料 论文与文档

为什么需要多目标优化

出行规划面临天然的多目标冲突:

目标 最优方案 与其他目标的冲突
最短时间 直飞 费用最贵
最低费用 多次中转 时间最长
最少换乘 直达(但可能绕路) 时间或费用次优

用户的真实需求是:"给我看所有合理方案,我自己权衡"——这正是 Pareto 前沿的价值。

单目标 Dijkstra 的局限:固定权重 w×time + (1-w)×cost 只能找到单条"最优"路径,无法输出完整的权衡空间。


Pareto 支配关系

定义

标签 A = (c₁, t₁) 支配 标签 B = (c₂, t₂),当且仅当:

c₁ ≤ c₂  且  t₁ ≤ t₂  且  (c₁ < c₂  或  t₁ < t₂)

即 A 在所有维度上不比 B 差,且至少一个维度严格更好。

Pareto 前沿(非支配解集)

方案          费用    时间    被支配?
直飞          ¥300    3.5h   ❌ 不被支配(时间最短)
高铁 G1       ¥450    2.0h   ❌ 不被支配(时间极短,费用居中)
高铁 G7       ¥600    1.5h   ❌ 不被支配(时间最短)
拼票方案      ¥180    6.0h   ❌ 不被支配(费用最低)
次优方案      ¥520    2.8h   ✅ 被 G1(¥450,2.0h) 支配 → 剪除

被支配的方案对任何理性用户都不会是最优选择,可安全从结果中剔除。


多标签 Dijkstra 算法

数据结构

每个节点 v 维护一个标签集 L[v],集合中的每个标签 (cost, time, path) 代表一条到 v 的非支配路径:

// 每个节点的标签集
Map<Integer, List<Label>> labels = new HashMap<>();

record Label(double cost, double time, int prevNode, Label prevLabel)
    implements Comparable<Label> {
    // 按 cost 主排序(优先队列用)
    public int compareTo(Label o) { return Double.compare(this.cost, o.cost); }
}

核心操作:插入新标签(支配检查)

/**
 * 尝试将 newLabel 加入节点 v 的标签集。
 * 若 newLabel 被现有标签支配 → 拒绝。
 * 否则 → 加入,并删除被 newLabel 支配的旧标签。
 * 返回是否真正插入(用于决定是否继续松弛)。
 */
boolean tryInsert(int v, Label newLabel) {
    List<Label> ls = labels.computeIfAbsent(v, k -> new ArrayList<>());

    // 检查是否被现有标签支配
    for (Label existing : ls) {
        if (dominates(existing, newLabel)) return false;
    }

    // 删除被 newLabel 支配的旧标签
    ls.removeIf(existing -> dominates(newLabel, existing));
    ls.add(newLabel);
    return true;
}

boolean dominates(Label a, Label b) {
    return a.cost <= b.cost && a.time <= b.time
        && (a.cost < b.cost || a.time < b.time);
}

主循环

PriorityQueue<Label> pq = new PriorityQueue<>();
pq.offer(new Label(0, 0, source, null));

while (!pq.isEmpty()) {
    Label cur = pq.poll();
    int u = cur.prevNode;  // 当前节点

    // 如果 cur 已被支配,跳过(lazy deletion)
    if (isDominated(u, cur)) continue;

    if (u == target) continue;  // 可继续找更多 Pareto 解,不立即终止

    for (Edge e : graph.get(u)) {
        Label next = new Label(
            cur.cost + e.cost,
            cur.time + e.time,
            e.to, cur
        );
        if (tryInsert(e.to, next)) {
            pq.offer(next);
        }
    }
}

// 目标节点的标签集即为 Pareto 前沿
return labels.get(target);

Java 实现

import java.util.*;

public class ParetoRouter {

    record Edge(int to, double cost, double time) {}
    record Label(double cost, double time, int node, Label parent)
        implements Comparable<Label> {
        public int compareTo(Label o) { return Double.compare(cost, o.cost); }
    }

    private final List<List<Edge>> graph;

    public ParetoRouter(int n) {
        graph = new ArrayList<>();
        for (int i = 0; i < n; i++) graph.add(new ArrayList<>());
    }

    public void addEdge(int u, int v, double cost, double time) {
        graph.get(u).add(new Edge(v, cost, time));
    }

    public List<Label> query(int src, int dst) {
        int n = graph.size();
        List<List<Label>> labels = new ArrayList<>();
        for (int i = 0; i < n; i++) labels.add(new ArrayList<>());

        PriorityQueue<Label> pq = new PriorityQueue<>();
        Label start = new Label(0, 0, src, null);
        labels.get(src).add(start);
        pq.offer(start);

        while (!pq.isEmpty()) {
            Label cur = pq.poll();
            // lazy deletion:若已被支配则跳过
            if (isDominated(labels.get(cur.node), cur)) continue;

            for (Edge e : graph.get(cur.node)) {
                Label next = new Label(cur.cost + e.cost, cur.time + e.time, e.to, cur);
                if (tryInsert(labels.get(e.to), next)) {
                    pq.offer(next);
                }
            }
        }
        return labels.get(dst);
    }

    private boolean tryInsert(List<Label> ls, Label newL) {
        for (Label l : ls) if (dominates(l, newL)) return false;
        ls.removeIf(l -> dominates(newL, l));
        ls.add(newL);
        return true;
    }

    private boolean isDominated(List<Label> ls, Label l) {
        return ls.stream().anyMatch(x -> dominates(x, l) && x != l);
    }

    private boolean dominates(Label a, Label b) {
        return a.cost <= b.cost && a.time <= b.time
            && (a.cost < b.cost || a.time < b.time);
    }
}

标签集大小分析

场景 平均 Pareto 解数 说明
城市内(地铁/公交) 2~5 路网稠密,选择少
国内出行(机票+高铁) 5~20 中转城市多样
国际出行 10~50 航司组合爆炸
理论上界 O(E^(k-1)) k=目标数,实践远低于此

实践中通常给标签集设上限(如 50),超过时按支配性剪枝,保留最有价值的解。


扩展到三个目标

加入「换乘次数」作为第三维:

record Label(double cost, double time, int transfers, int node, Label parent) {}

boolean dominates(Label a, Label b) {
    return a.cost <= b.cost && a.time <= b.time && a.transfers <= b.transfers
        && (a.cost < b.cost || a.time < b.time || a.transfers < b.transfers);
}

三维 Pareto 前沿更大,但剪枝效果依然显著——大多数用户不会接受换乘 4 次的方案,可预先过滤。


实际应用

携程可拼票

携程的「拼票」功能(A→C + C→B 两张单独机票)本质上是在以价格和时间为两个目标的图上运行 Pareto Dijkstra:

去哪儿 / 飞猪

OTA 的"智能排序"并非单纯最低价,而是展示 Pareto 前沿上的几个代表性方案(极速到达、最低价格、综合推荐),由用户在 UI 上拖动滑块权衡。

OpenTripPlanner

OTP 的多模式规划(公交+自行车+步行)使用三维 Pareto 优化(到达时间、步行距离、换乘次数)。


参考资料

参考资料

  • Hansen, Bicriterion Path Problems (1980) — 多标签 Dijkstra 奠基论文
  • RAPTOR(RAPTOR 的多目标扩展 McRAPTOR 也使用 Pareto 标签集)
  • 可拼票 DP(携程具体应用:带约束的 Pareto 最短路)
← 返回列表

评论 (0)

暂无评论,来留下第一条吧。
登录注册 后才能发表评论