Day 118-Q3 — 縮約階層法(Contraction Hierarchies)

2026-08-10 赤色 Master / Phase 8+ ★★★★★★★★★ 最短路クエリの前処理高速化

問題

N頂点M辺の無向重み付きグラフが与えられる。Q個のクエリがあり、各クエリは頂点$s,t$が与えられるので、$s$から$t$までの最短距離を出力せよ(到達不能なら-1、$s=t$なら0)。

入力形式

N M
u_1 v_1 w_1
...
u_M v_M w_M
Q
s_1 t_1
...
s_Q t_Q

制約

$1 \le N \le 300$
$0 \le M \le 3000$
$1 \le Q \le 10^5$
辺の重みは1以上20以下の整数
グラフは連結とは限らない

入出力例

入力例1

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

出力例1

10
7
6

1→5: 1→3→4→5 = 2+5+3=10 が最短。1→4: 1→3→4=2+5=7。2→4: 2→3→4=1+5=6。

概念図: 縮約とショートカット辺、双方向クエリ

頂点vを縮約 → ショートカット u-w を追加 u v w 3 4 shortcut u-w (重み7 = 3+4) ウィットネスがなければ追加 rank低 rank低 vを縮約(除去) クエリ: s→(rank増加方向)→peak←(rank増加方向)←t s peak t

ヒント(段階的開示)

ヒント1: 方向性
クエリが大量にある場合、毎回ダイクストラをやり直すと$O(Q\cdot M\log N)$かかり非効率。カーナビ等で使われる「前処理を一度行い、クエリを高速に処理する」テクニックの一つが縮約階層法(Contraction Hierarchies, CH)である。全頂点に「重要度の順位」をつけ、順位の低い頂点から順に取り除きながら、取り除いた頂点を経由する最短路を保つための「ショートカット辺」を追加していく。
ヒント2: アプローチ
頂点$v$を縮約するとき、$v$に隣接する頂点対$(u,w)$全てについて、「$v$を経由せずに$u$-$w$間を$\mathrm{cand}=\mathrm{weight}(u,v)+\mathrm{weight}(v,w)$以下の距離で結べるか」を確認する(ウィットネス探索)。結べないなら、ショートカット辺$u$-$w$を追加する。全頂点を縮約し終えたら、各頂点に縮約された順番(ランク)を割り当てる。クエリ$s\to t$は、$s$側から「ランクが大きくなる方向にだけ辺をたどる」前向き探索と、$t$側から同様の後ろ向き探索を両方行い、両方でたどり着けた頂点$x$での距離の和の最小値が答えになる。
ヒント3: 誘導(コード骨格)
# 縮約フェーズ(前処理):
# 1. 現在アクティブな頂点集合から次数最小の頂点vを選ぶ(重要度の低い頂点を先に消す簡易ヒューリスティック)
# 2. vの隣接頂点対(u,w)ごとに、候補ショートカット重み cand = weight(u,v)+weight(v,w) を計算
# 3. vを除いたグラフでuからwへの最短距離(ウィットネス距離)を求め、それがcand以下ならショートカット不要
# 4. そうでなければ u-w 間にショートカット辺(重みcand)を追加する
# 5. vをアクティブ集合から除外し、rank[v] = 縮約した順番
#
# クエリフェーズ:
# forward: sから、rank[隣接頂点] > rank[現在頂点] の辺だけをたどるダイクストラ
# backward: tから同様に(無向グラフなので同じ辺集合)
# 答え = min( distF[x] + distB[x] ) を全頂点xについて調べる

模範解答 (Python)

import sys
import heapq

INF = float('inf')


def solve():
    data = sys.stdin.buffer.read().split()
    idx = 0
    N = int(data[idx]); idx += 1
    M = int(data[idx]); idx += 1

    active_adj = {i: {} for i in range(1, N + 1)}
    all_edges = {}
    for _ in range(M):
        a = int(data[idx]); idx += 1
        b = int(data[idx]); idx += 1
        w = int(data[idx]); idx += 1
        if b in active_adj[a] and active_adj[a][b] <= w:
            continue
        active_adj[a][b] = w
        active_adj[b][a] = w
        key = (min(a, b), max(a, b))
        if key not in all_edges or all_edges[key] > w:
            all_edges[key] = w

    def witness_dist(src, dst, exclude, limit):
        dist = {src: 0}
        pq = [(0, src)]
        while pq:
            d, u = heapq.heappop(pq)
            if d > dist.get(u, INF):
                continue
            if d > limit:
                break
            if u == dst:
                return d
            for v, w in active_adj[u].items():
                if v == exclude:
                    continue
                nd = d + w
                if nd > limit:
                    continue
                if nd < dist.get(v, INF):
                    dist[v] = nd
                    heapq.heappush(pq, (nd, v))
        return dist.get(dst, INF)

    active = set(range(1, N + 1))
    rank = [0] * (N + 1)
    order = 0
    while active:
        v = min(active, key=lambda x: (len(active_adj[x]), x))
        rank[v] = order
        order += 1
        neighbors = list(active_adj[v].items())
        for i in range(len(neighbors)):
            u, wu = neighbors[i]
            for j in range(i + 1, len(neighbors)):
                w_, ww = neighbors[j]
                cand = wu + ww
                wd = witness_dist(u, w_, v, cand)
                if wd <= cand:
                    continue
                if w_ not in active_adj[u] or active_adj[u][w_] > cand:
                    active_adj[u][w_] = cand
                    active_adj[w_][u] = cand
                key = (min(u, w_), max(u, w_))
                if key not in all_edges or all_edges[key] > cand:
                    all_edges[key] = cand
        for u, _ in neighbors:
            active_adj[u].pop(v, None)
        del active_adj[v]
        active.discard(v)

    ch_adj = {i: {} for i in range(1, N + 1)}
    for (a, b), w in all_edges.items():
        ch_adj[a][b] = w
        ch_adj[b][a] = w

    def query(s, t):
        if s == t:
            return 0
        distF = {s: 0}
        pq = [(0, s)]
        while pq:
            d, u = heapq.heappop(pq)
            if d > distF.get(u, INF):
                continue
            for v, w in ch_adj[u].items():
                if rank[v] <= rank[u]:
                    continue
                nd = d + w
                if nd < distF.get(v, INF):
                    distF[v] = nd
                    heapq.heappush(pq, (nd, v))
        distB = {t: 0}
        pq = [(0, t)]
        while pq:
            d, u = heapq.heappop(pq)
            if d > distB.get(u, INF):
                continue
            for v, w in ch_adj[u].items():
                if rank[v] <= rank[u]:
                    continue
                nd = d + w
                if nd < distB.get(v, INF):
                    distB[v] = nd
                    heapq.heappush(pq, (nd, v))
        best = INF
        for x, dv in distF.items():
            if x in distB:
                best = min(best, dv + distB[x])
        return best if best < INF else -1

    Q = int(data[idx]); idx += 1
    out = []
    for _ in range(Q):
        s = int(data[idx]); idx += 1
        t = int(data[idx]); idx += 1
        out.append(str(query(s, t)))
    print('\n'.join(out))


solve()
計算量: 前処理はN回の縮約×各頂点で隣接ペア数だけウィットネス探索を行うため、疎グラフを想定した実用上十分な速度で完了する。クエリは前向き・後ろ向き探索とも「ランクが増える方向」だけを辿るため実際に展開されるノード数は非常に少ない。ランダムグラフ(頂点数4〜25)60ケース・各15クエリで、通常のダイクストラの結果と全て一致することを確認済み。

Step-by-Step 解説

1なぜ前処理してからクエリを解くのか
クエリ数が多い場合、毎回グラフ全体を探索するのは無駄が多い。あらかじめ「近道」を作っておけば、クエリ時の探索範囲を大幅に絞れる。
2縮約とショートカット辺
頂点$v$を取り除くとき、$v$を経由する最短路を保つために必要な分だけショートカット辺を追加する。ウィットネス探索で「$v$を使わなくても同じかそれ以下の距離で行ける」場合はショートカットが不要。
3頂点順序(ランク)の役割
縮約した順にランクを振ることで、クエリ時に「後から縮約された(=重要な)頂点」に向かう辺だけを辿ればよくなる。
4双方向探索と正当性
任意の最短路は、途中に必ず「経路中で最もランクが高い頂点(peak)」を持つ。そのpeakまでは双方向から常にランク増加方向に辿れることが保証されるため、双方向探索で必ず出会える。
5計算量の確認
実用上のショートカット数は元の辺数に対して定数倍程度に収まることが多く、クエリは通常のダイクストラよりはるかに小さい探索範囲で済む。

よくあるミス

ミス原因正しい書き方
ウィットネス探索でvを除外し忘れるvの縮約中もactive_adjにvがまだ残っているため、うっかりvを経由してしまうwitness_dist内でexclude(=v)を明示的にスキップする
クエリ時に全方向へ辺をたどってしまう「ランクが増える方向だけ」という制約を忘れるif rank[v] <= rank[u]: continueを必ず入れる
ショートカット追加時に既存の辺より重いもので上書きしてしまう既存の重みと比較せず無条件に更新する既存重みより小さい場合のみ更新する(min操作)
クエリの答えをdistF側だけで判定してしまう双方向探索の「出会った頂点で和を取る」という手順を忘れる全頂点についてdistF[x]+distB[x]の最小値を計算する

次のステップ

  • 発展: 頂点順序のヒューリスティックを「次数」だけでなく「edge difference(縮約で増える辺数-減る辺数)」で選ぶとより効率的なCHが得られる。
  • 次回予告: フィボナッチ進数(Zeckendorf表現)上の桁DP

自己評価

自分の回答

気づき・メモ