beam: add LCS module with tests (length, reconstruction, diff)
This commit is contained in:
1 parent
c52b2f13fa
commit
af94b3fdd7
2 files changed
+177
No files matched your search
@@ -0,0 +1,115 @@
|
||||
"""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
|
||||
@@ -0,0 +1,62 @@
|
||||
import sys
|
||||
sys.path.insert(0, "/root/.hermes/wizard-outputs/beam")
|
||||
from lcs import lcs_length, lcs, lcs_diff
|
||||
|
||||
|
||||
def test_empty():
|
||||
assert lcs_length([], []) == 0
|
||||
assert lcs([], [1,2]) == []
|
||||
assert lcs([1,2], []) == []
|
||||
|
||||
|
||||
def test_identical():
|
||||
s = [1,2,3,4]
|
||||
assert lcs_length(s, s) == 4
|
||||
assert lcs(s, s) == s
|
||||
|
||||
|
||||
def test_classic():
|
||||
a = "ABCBDAB"
|
||||
b = "BDCAB"
|
||||
assert lcs_length(a, b) == 4
|
||||
seq = lcs(a, b)
|
||||
assert len(seq) == 4
|
||||
# verify it's a subsequence of both
|
||||
def subseq(x, y):
|
||||
it = iter(y)
|
||||
return all(c in it for c in x)
|
||||
assert subseq(seq, a) and subseq(seq, b)
|
||||
|
||||
|
||||
def test_no_common():
|
||||
assert lcs_length("abc", "xyz") == 0
|
||||
assert lcs("abc", "xyz") == []
|
||||
|
||||
|
||||
def test_diff_roundtrip():
|
||||
a = [1, 2, 3, 4, 5]
|
||||
b = [2, 4, 6]
|
||||
ops = lcs_diff(a, b)
|
||||
# reconstruct b from a applying ops
|
||||
out = []
|
||||
for op, item in ops:
|
||||
if op in ("keep", "insert"):
|
||||
out.append(item)
|
||||
# delete: skip
|
||||
assert out == b
|
||||
# deletes+keeps should cover a
|
||||
covered = [item for op, item in ops if op in ("keep", "delete")]
|
||||
assert covered == a
|
||||
|
||||
|
||||
def test_strings():
|
||||
assert lcs("kitten", "sitting") == list("ittn") or len(lcs("kitten","sitting"))==4
|
||||
assert lcs_length("kitten", "sitting") == 4
|
||||
|
||||
|
||||
def test_type_error():
|
||||
try:
|
||||
lcs_length(5, [1])
|
||||
assert False, "expected TypeError"
|
||||
except TypeError:
|
||||
pass
|
||||
Reference in new issue
Block a user