Day 124-Q4 — Eppstein's K-Shortest Paths Algorithm(サイドトラック辺とpersistent leftist heapによるk最短ウォーク列挙)

2026-08-16 赤色 Master / Phase 8+ ★★★★★★★★★ Eppsteinのアルゴリズム(サイドトラック辺のpersistent leftist heapによる $O((N+M)\log N + K\log K)$ のk最短ウォーク列挙)

問題

$N$ 頂点 $M$ 辺の有向グラフ(辺重みは正の整数)と、始点 $s$・終点 $t$・整数 $K$ が与えられる。$s$ から $t$ へのウォーク(頂点や辺の重複を許す経路)のうち、コストが小さい方から $K$ 番目のコストを出力せよ。存在しない場合($t$ にそもそも到達できない、または到達可能なウォークの総数が $K$ 未満)は -1 を出力せよ。

入力形式

N M K
s t
u_1 v_1 w_1
...
u_M v_M w_M

制約

$1 \le N \le 10000$
$0 \le M \le 30000$
$1 \le K \le 100000$
$1 \le w_i \le 10^9$
$1 \le s, t, u_i, v_i \le N$

入出力例

入力例1

4 5 3
1 4
1 2 1
2 4 1
1 3 2
3 4 3
1 4 10

出力例1

10

$s$=1, $t$=4 への3本のウォークのコストは、1→2→4=2、1→3→4=5、1→4(直接辺)=10 の3通り。小さい順に並べて3番目は10

概念図

最短路木とサイドトラック辺(Eppsteinのアルゴリズム) tを根とした最短路木(実線)とサイドトラック辺(点線) t v u s サイドトラック辺 e (δ(e)>0) 別のサイドトラック辺 D(v) = merge(H_out(v), D(succ(v))) をpersistent leftist heapで構築し、コスト小さい順にK個生成

ヒント(段階的開示)

ヒント1(方向性)

$K$ 番目の最短路を求める古典的な方法(Yenのアルゴリズム)は「単純パス」を対象にしており、1回のK番目探索に $O(K \cdot N \cdot (N+M)\log N)$ 程度かかる。今回はウォーク(頂点の再訪を許す)でよいので、もっと高速な方法が使える。鍵になるのは「最短路木($t$ への最短距離)に対して、木に含まれない辺(サイドトラック辺)を何本使ったか」でウォークを分類する発想である。

ヒント2(アプローチ)

まず $t$ を根とした逆向きグラフ上でダイクストラを行い、各頂点 $v$ から $t$ への最短距離 $\mathrm{dist}[v]$ を求める。木に含まれない辺(サイドトラック辺)$e=(u,v,w)$ には「余分にかかるコスト」$\delta(e) = w + \mathrm{dist}[v] - \mathrm{dist}[u] \ge 0$ を割り当てる。$s$ から $t$ への任意のウォークは「最短路木をベースに、途中でいくつかのサイドトラック辺を使う」列として一意に表現でき、そのコストは $\mathrm{dist}[s] + \sum \delta(e_i)$ になる。つまり「サイドトラック辺の列を、コストの小さい順にK個列挙する」問題に帰着できる。

ヒント3(誘導)

各頂点 $v$ の「出ていくサイドトラック辺の中でコストが小さいものから並べたヒープ」$H_{\text{out}}(v)$ を作り、さらに「$v$ から $t$ へ向かう最短路上のどこでサイドトラックを使ってもよい」ことを表す結合ヒープ $D(v) = \text{merge}(H_{\text{out}}(v), D(\mathrm{succ}(v)))$ を、persistent(永続)なleftist heapで構築する($\mathrm{succ}(v)$ は $v$ から $t$ への最短路上の次の頂点)。あとはグローバルな優先度付きキューで $D(s)$ のルートから順に取り出し、各ノードを取り出すたびに「兄弟ノード(left/right child)」と「サイドトラック先での続き($D(\text{head}(e))$)」を新候補としてpushしていけば、K番目のコストまで生成できる。

# leftist heap のマージ(persistent = 経路コピー)
def merge(a, b):
    if a is None: return b
    if b is None: return a
    if a.cost > b.cost: a, b = b, a
    node = LNode(a.edge, a.cost)
    node.l = a.l
    node.r = merge(a.r, b)
    if node.l is None or (node.r and node.l.npl < node.r.npl):
        node.l, node.r = node.r, node.l
    node.npl = (node.r.npl + 1) if node.r else 0
    return node

模範解答 (Python)

import sys, heapq

def solve():
    data = sys.stdin.read().split()
    idx = 0
    N = int(data[idx]); idx += 1
    M = int(data[idx]); idx += 1
    K = int(data[idx]); idx += 1
    s = int(data[idx]) - 1; idx += 1
    t = int(data[idx]) - 1; idx += 1
    edges = []
    rev = [[] for _ in range(N)]
    for _ in range(M):
        u = int(data[idx]) - 1; idx += 1
        v = int(data[idx]) - 1; idx += 1
        w = int(data[idx]); idx += 1
        edges.append((u, v, w))
        rev[v].append((u, w))

    INF = float('inf')
    dist = [INF] * N
    dist[t] = 0
    succ = [-1] * N
    pq = [(0, t)]
    while pq:
        d, u = heapq.heappop(pq)
        if d > dist[u]:
            continue
        for v, w in rev[u]:  # 元グラフの辺 v -> u
            nd = d + w
            if nd < dist[v]:
                dist[v] = nd
                succ[v] = u
                heapq.heappush(pq, (nd, v))

    if dist[s] == INF:
        print(-1)
        return

    class LNode:
        __slots__ = ['edge', 'cost', 'l', 'r', 'npl']
        def __init__(self, edge, cost):
            self.edge = edge
            self.cost = cost
            self.l = None
            self.r = None
            self.npl = 0

    def merge(a, b):
        if a is None:
            return b
        if b is None:
            return a
        if a.cost > b.cost:
            a, b = b, a
        node = LNode(a.edge, a.cost)
        node.l = a.l
        node.r = merge(a.r, b)
        if node.l is None or (node.r is not None and node.l.npl < node.r.npl):
            node.l, node.r = node.r, node.l
        node.npl = (node.r.npl + 1) if node.r is not None else 0
        return node

    # 各頂点の出辺をグループ化し、ツリー辺(1本だけ)を除いてサイドトラック辺集合を作る
    out_edges = [[] for _ in range(N)]
    for (u, v, w) in edges:
        if dist[u] == INF or dist[v] == INF:
            continue
        out_edges[u].append((u, v, w))

    H_out = [None] * N
    for v in range(N):
        skip_tree_edge = True  # ツリー辺を1本だけ除外する
        h = None
        for (u, vv, w) in out_edges[v]:
            if skip_tree_edge and vv == succ[v] and w == dist[v] - dist[vv]:
                skip_tree_edge = False
                continue
            delta = w + dist[vv] - dist[v]
            h = merge(h, LNode((u, vv, w), delta))
        H_out[v] = h

    order = sorted((v for v in range(N) if dist[v] < INF), key=lambda v: dist[v])
    D = [None] * N
    for v in order:
        if v == t:
            D[v] = H_out[v]
        else:
            D[v] = merge(H_out[v], D[succ[v]])

    # K番目のウォークコストを生成
    results = [dist[s]]
    counter = 0
    pq2 = []
    if D[s] is not None:
        heapq.heappush(pq2, (dist[s] + D[s].cost, counter, D[s]))
        counter += 1

    while pq2 and len(results) < K:
        cost, _, node = heapq.heappop(pq2)
        results.append(cost)
        if node.l is not None:
            heapq.heappush(pq2, (cost - node.cost + node.l.cost, counter, node.l))
            counter += 1
        if node.r is not None:
            heapq.heappush(pq2, (cost - node.cost + node.r.cost, counter, node.r))
            counter += 1
        v_land = node.edge[1]
        if D[v_land] is not None:
            heapq.heappush(pq2, (cost + D[v_land].cost, counter, D[v_land]))
            counter += 1

    if len(results) < K:
        print(-1)
    else:
        print(results[K - 1])

sys.setrecursionlimit(50000)
solve()

Step-by-Step 解説

1最短路木とサイドトラック辺
まず $t$ を根とした逆グラフでダイクストラを行い、全頂点から $t$ への最短距離 $\mathrm{dist}[v]$ を求める。各頂点 $v$ は「最短路木」上でただ1つの次の頂点 $\mathrm{succ}(v)$ を持つ。木に含まれない辺(サイドトラック辺)$e=(u,v,w)$ を使うと、最短経路より $\delta(e) = w + \mathrm{dist}[v] - \mathrm{dist}[u]$ だけ余分にコストがかかる。
2ウォークとサイドトラック列の対応
$s$ から $t$ への任意のウォークは「基本は最短路木に沿って進み、時々サイドトラック辺で寄り道する」という列として一意に表現できる。総コストは $\mathrm{dist}[s]$ にすべてのサイドトラックの $\delta$ を足したものになる。つまり、コストが小さいウォークから順に列挙する問題は、「サイドトラック列の $\delta$ の和が小さい順に列挙する」問題に完全に帰着する。
3Persistent Leftist Heapによる効率化
各頂点 $v$ について「$v$ を通過した後に選べるサイドトラック辺すべて」を表すヒープ $D(v)$ が必要になるが、これは $v$ 自身のサイドトラック辺のヒープ $H_{\text{out}}(v)$ と、次の頂点のヒープ $D(\mathrm{succ}(v))$ をマージしたものである。多くの頂点が同じ $D(\mathrm{succ}(v))$ を共有するため、persistent(経路コピーで共有部分を破壊しない)leftist heapを使うことで、全体の構築を $O(M \log N)$ に抑えられる。
4グローバル優先度付きキューでのK個生成
$D(s)$ のルートをキューに入れ、pop するたびに①leftist heap内の兄弟ノード(left/right)②サイドトラック先の頂点でのさらなる寄り道($D(\text{head}(e))$)の2種類の新候補をpushする。これにより、コストの小さい順にサイドトラック列を $O(\log K)$ ずつのコストで生成でき、全体で $O(K \log K)$ で $K$ 番目まで到達できる。
5なぜウォーク(パスでなく)なのか
このアルゴリズムは頂点の再訪を許す「ウォーク」に対しては非常に高速だが、単純パス(頂点重複なし)に制限すると、サイドトラックの組み合わせが単純性を壊す場合の除外処理が必要になり、一般には多項式時間で解けない(NP困難になりうる)ことが知られている。ウォークで良いという問題設定だからこそこの高速アルゴリズムが使える。

よくあるミス

ミス原因正しい書き方
ダイクストラの向きを間違え、$s$ からの最短距離を使ってしまうサイドトラックコストは「$t$ までの残り距離」基準で定義される逆グラフで $t$ を始点にダイクストラを行い、各頂点から $t$ への距離を求める
ツリー辺を除外せずサイドトラック扱いする$\delta=0$ のツリー辺がサイドトラック扱いされると同じウォークが重複カウントされる各頂点で最短路木に使われている辺を1本だけ除外する
leftist heapのマージで非破壊性(persistent性)を保っていない通常の破壊的マージだと共有部分木が壊れて他の頂点のヒープに影響するマージのたびに新しいノードを作る経路コピー(path copying)を徹底する
ヒープのタイブレークを怠り TypeError になる同一コストのノード同士を比較しようとして例外が出るheapq に積むタプルに一意なカウンタを含めて比較を回避する

次のステップ

  • 発展: 単純パスに制限したK最短路が必要な場合はYenのアルゴリズム($O(K \cdot N \cdot (\text{Dijkstra}))$)を使う。負辺がある場合はBellman-Ford系の前処理でポテンシャルを作ってから同様の手法を適用できる。
  • 次回予告: Berlekamp's Q-Matrix Algorithm(有限体上の多項式因数分解)

自己評価

自分の回答

気づき・メモ