Day 090-Q3 — Hirschberg's Algorithm(線形空間LCS復元)

2026-07-13 赤色 Master / Phase 8+ ★★★★★★★★★ 分割統治・O(N+M)メモリ

問題

英大文字からなる2つの文字列 $A$(長さ $N$)、$B$(長さ $M$)が与えられる。$A$ と $B$ の最長共通部分列(LCS)を1つ出力せよ(複数ある場合はどれでもよい)。ただし通常の $O(NM)$ サイズのDPテーブルは保持せず、$O(N+M)$ 程度のメモリで実際のLCS文字列を復元する分割統治アルゴリズム(Hirschberg's Algorithm)を実装すること。

制約

パラメータ範囲備考
$N, M$$1 \le N,M \le 5000$文字列長
文字種英大文字 A〜Z

入出力例

入力例1

ABCBDAB
BDCABA

出力例1

BDAB

入力例2

AGCAT
GAC

出力例2

GA

例1: LCS長は4。`BDAB`以外にも`BCBA`, `BCAB`等が正解として扱われる(特別ジャッジ)。

概念図: $A$ を中央分割し、$B$ 上の最適な分割点 $k$ を探す

$\max_j \; \mathrm{left}[j] + \mathrm{right}[M-j]$ を与える $j=k$ が分割点 A_L(前半) A_R(後半) A = A_L + A_R(中央で分割) B[:k] B[k:] B は分割点 k で B_L, B_R に分かれる LCS(A,B) = LCS(A_L,B[:k]) + LCS(A_R,B[k:])

ヒント

ヒント1(方向性)

LCSの長さだけならロールング配列で $O(\min(N,M))$ メモリで解けるが、実際の文字列を復元するには通常バックトラック用に $O(NM)$ のテーブルが必要に見える。この矛盾をどう解消するかが核心。

ヒント2(アプローチ)

$A$ を中央で $A_L, A_R$ に分割する。最適なLCSも $B$ のどこかの位置 $k$ で分割できるはず。$k$ は「$A_L$と$B$の各prefixとのLCS長」(前から)と「$A_R$と$B$の各suffixとのLCS長」(後ろから)をそれぞれ $O(M)$ メモリで求め、和が最大になる位置として特定できる。

ヒント3(ほぼ答え)
def hirschberg(a, b):
    if len(a) == 0: return ""
    if len(a) == 1: return a if a in b else ""
    mid = len(a) // 2
    left = lcs_len_row(a[:mid], b)
    right = lcs_len_row(a[mid:][::-1], b[::-1])
    total = [left[j] + right[len(b)-j] for j in range(len(b)+1)]
    k = max(range(len(b)+1), key=lambda j: total[j])
    return hirschberg(a[:mid], b[:k]) + hirschberg(a[mid:], b[k:])

模範解答

import sys

def lcs_len_row(a, b):
    m = len(b)
    prev = [0] * (m + 1)
    for i in range(1, len(a) + 1):
        cur = [0] * (m + 1)
        ai = a[i - 1]
        for j in range(1, m + 1):
            if ai == b[j - 1]:
                cur[j] = prev[j - 1] + 1
            else:
                pv, cv = prev[j], cur[j - 1]
                cur[j] = pv if pv > cv else cv
        prev = cur
    return prev

def hirschberg(a, b):
    if len(a) == 0:
        return ""
    if len(a) == 1:
        return a if a in b else ""
    if len(b) == 0:
        return ""
    mid = len(a) // 2
    left_row = lcs_len_row(a[:mid], b)
    right_row = lcs_len_row(a[mid:][::-1], b[::-1])
    m = len(b)
    best_j, best_val = 0, -1
    for j in range(m + 1):
        v = left_row[j] + right_row[m - j]
        if v > best_val:
            best_val = v
            best_j = j
    return hirschberg(a[:mid], b[:best_j]) + hirschberg(a[mid:], b[best_j:])

def main():
    data = sys.stdin.read().split()
    a, b = data[0], data[1]
    sys.setrecursionlimit(10000)
    print(hirschberg(a, b))

main()

計算量: 時間 $O(NM)$(標準DPと同じオーダー)。メモリは $O(N+M)$ に削減(再帰の深さ $O(\log N)$ × 各レベル $O(M)$ の一時配列)。

Step-by-Step 解説

Step 1: 長さだけならロールング配列で十分な理由

$dp[i][j]$ の計算には $i-1$ 行目しか使わないため、2行分の配列を使い回せば長さだけなら $O(\min(N,M))$ メモリで済む。

Step 2: 分割点を見つける仕組み

left_row[j] は $A_L$ と $B[:j]$ のLCS長、right_row[m-j] は $A_R$ と $B[j:]$ のLCS長。この和を最大化する $j$ が全体のLCS長と一致する(区分最適性)。

Step 3: 逆順DPで末尾からのLCS長を求める

$A_R$ と $B$ をそれぞれ反転させてから通常のLCS長DPを行うことで、「末尾から」のLCS長が得られる。

Step 4: 再帰と計算量の関係

レベル作業量
深さ $d$($2^d$個の呼び出し)各呼び出し $O(\frac{N}{2^d}\cdot M)$、合計 $O(NM)$
全レベル合計$O(NM)$(レベルごとに一定)

よくあるミス

ミス原因正しい書き方
right_rowのインデックス対応を間違える反転後の位置対応が直感的でないright_row[m - j]の対応を図で確認する
len(a)==1を特別扱いしない小さいケースで再帰が不要に深くなるベースケースを明示的に処理する
文字列連結を毎回+で行うPython文字列はimmutableでコピーが発生大規模時はリストに集めて最後にjoin

次のステップ

  • 発展問題: 編集距離の実際の操作列を同じ考え方で $O(N+M)$ メモリで復元する
  • 発展問題: 3つ以上の文字列の最長共通部分列をメモリ効率を意識して求める

自己評価

理解度: / /

自分の回答:

気づき・メモ: