Files
troupe-standup/wizard-work/beam/lcs.py
T

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