116 lines
3.2 KiB
Python
116 lines
3.2 KiB
Python
"""Longest common subsequence (LCS) utilities.
|
|
|
|
Provides:
|
|
- lcs_length(a, b): length of the LCS of two sequences.
|
|
- lcs(a, b): one LCS string/list (for hashable items).
|
|
- lcs_diff(a, b): minimal edit script as list of (op, item) where
|
|
op is 'keep' / 'delete' (in a only) / 'insert' (in b only).
|
|
|
|
All inputs are treated as sequences of hashable items. Time O(|a|*|b|),
|
|
space O(min(|a|, |b|)) for length and O(|a|*|b|) for reconstruction.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Sequence, TypeVar
|
|
|
|
T = TypeVar("T")
|
|
|
|
|
|
def _validate(seq, name):
|
|
try:
|
|
iter(seq)
|
|
except TypeError as e:
|
|
raise TypeError(f"{name} must be iterable") from e
|
|
# reject strings? No—strings are sequences of chars, allowed.
|
|
|
|
|
|
def lcs_length(a: Sequence[T], b: Sequence[T]) -> int:
|
|
"""Return length of longest common subsequence of a and b."""
|
|
_validate(a, "a")
|
|
_validate(b, "b")
|
|
a = list(a)
|
|
b = list(b)
|
|
# ensure b is the shorter axis for space
|
|
if len(a) < len(b):
|
|
a, b = b, a
|
|
# now a longer (or equal), b shorter
|
|
prev = [0] * (len(b) + 1)
|
|
cur = [0] * (len(b) + 1)
|
|
for x in a:
|
|
for j, y in enumerate(b, 1):
|
|
if x == y:
|
|
cur[j] = prev[j - 1] + 1
|
|
else:
|
|
cur[j] = prev[j] if prev[j] >= cur[j - 1] else cur[j - 1]
|
|
prev, cur = cur, [0] * (len(b) + 1)
|
|
return prev[len(b)]
|
|
|
|
|
|
def _lcs_table(a, b):
|
|
m, n = len(a), len(b)
|
|
# dp[i][j] = lcs length of a[:i], b[:j]
|
|
dp = [[0] * (n + 1) for _ in range(m + 1)]
|
|
for i in range(1, m + 1):
|
|
ai = a[i - 1]
|
|
row = dp[i]
|
|
prow = dp[i - 1]
|
|
for j in range(1, n + 1):
|
|
if ai == b[j - 1]:
|
|
row[j] = prow[j - 1] + 1
|
|
else:
|
|
# max(prow[j], row[j-1])
|
|
v = prow[j]
|
|
if row[j - 1] > v:
|
|
v = row[j - 1]
|
|
row[j] = v
|
|
return dp
|
|
|
|
|
|
def lcs(a: Sequence[T], b: Sequence[T]) -> list[T]:
|
|
"""Return one LCS of a and b as a list (preserving item type via list)."""
|
|
_validate(a, "a")
|
|
_validate(b, "b")
|
|
a = list(a)
|
|
b = list(b)
|
|
dp = _lcs_table(a, b)
|
|
# backtrack
|
|
i, j = len(a), len(b)
|
|
out = []
|
|
while i > 0 and j > 0:
|
|
if a[i - 1] == b[j - 1]:
|
|
out.append(a[i - 1])
|
|
i -= 1
|
|
j -= 1
|
|
elif dp[i - 1][j] >= dp[i][j - 1]:
|
|
i -= 1
|
|
else:
|
|
j -= 1
|
|
out.reverse()
|
|
return out
|
|
|
|
|
|
def lcs_diff(a: Sequence[T], b: Sequence[T]) -> list[tuple[str, T]]:
|
|
"""Minimal edit script turning a into b. Ops: keep/delete/insert."""
|
|
_validate(a, "a")
|
|
_validate(b, "b")
|
|
a = list(a)
|
|
b = list(b)
|
|
dp = _lcs_table(a, b)
|
|
# walk to produce ops
|
|
i, j = len(a), len(b)
|
|
ops_rev = []
|
|
while i > 0 or j > 0:
|
|
if i > 0 and j > 0 and a[i - 1] == b[j - 1] and dp[i][j] == dp[i - 1][j - 1] + 1:
|
|
ops_rev.append(("keep", a[i - 1]))
|
|
i -= 1
|
|
j -= 1
|
|
elif i > 0 and (j == 0 or dp[i - 1][j] >= dp[i][j - 1]):
|
|
ops_rev.append(("delete", a[i - 1]))
|
|
i -= 1
|
|
else:
|
|
ops_rev.append(("insert", b[j - 1]))
|
|
j -= 1
|
|
ops_rev.reverse()
|
|
return ops_rev
|