Landau-Vishkin algorithm in Python
Computes furthest reaching diagonal points with longest-common-extension queries in O(kn) time.
- File
landau_vishkin_algorithm.py, 119 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/landau-vishkin-algorithm/. The authors are listed in prov/2PT2TT.jsonl.
"""The Landau-Vishkin diagonal algorithm for approximate string matching.
Edit model: unit-cost insertion, deletion and substitution (Levenshtein),
no transpositions. Indices are 0-based. The result is the sorted list of end
positions j in 0..len(text) such that some substring text[h:j] has edit
distance at most k to the pattern.
Longest common extensions come from a suffix array of pattern + sep + text +
sep, its LCP array (Kasai) and a sparse table for range minima. All loops are
iterative.
"""
NONE = -(10**18) # stands for "no cell of the diagonal has a value <= e"
class LCE:
"""Longest common extension of a suffix of a and a suffix of b."""
def __init__(self, a: str, b: str) -> None:
syms = sorted(set(a) | set(b))
code = {c: r + 2 for r, c in enumerate(syms)}
s = [code[c] for c in a] + [1] + [code[c] for c in b] + [0]
self.off = len(a) + 1
n = len(s)
rank = s[:]
sa = list(range(n))
h = 1
while True: # prefix doubling: sort by (rank[i], rank[i + h])
key = [rank[i] * (n + 2) + (rank[i + h] + 1 if i + h < n else 0) for i in range(n)]
sa.sort(key=key.__getitem__)
new = [0] * n
for t in range(1, n):
new[sa[t]] = new[sa[t - 1]] + (key[sa[t]] != key[sa[t - 1]])
rank = new
if rank[sa[-1]] == n - 1:
break
h *= 2
self.rank = rank
lcp = [0] * n # lcp[t] = lcp of suffixes sa[t - 1] and sa[t]
q = 0
for i in range(n):
if rank[i] == 0:
q = 0
continue
j = sa[rank[i] - 1]
while i + q < n and j + q < n and s[i + q] == s[j + q]:
q += 1
lcp[rank[i]] = q
if q:
q -= 1
self.table = [lcp]
w = 1
while 2 * w <= n:
prev = self.table[-1]
self.table.append([min(prev[t], prev[t + w]) for t in range(n - 2 * w + 1)])
w *= 2
def query(self, i: int, j: int) -> int:
"""Length of the longest common prefix of a[i:] and b[j:]."""
x, y = self.rank[i], self.rank[self.off + j]
if x > y:
x, y = y, x
x += 1 # minimum of lcp[x..y]
lev = (y - x + 1).bit_length() - 1
row = self.table[lev]
return min(row[x], row[y - (1 << lev) + 1])
def landau_vishkin(pattern: str, text: str, k: int) -> list[int]:
"""Return the end positions of the approximate matches of pattern in text.
Precondition: k >= 0. Cost: O((k + 1)(n + k)) constant-time extensions
after O((n + m) log^2 (n + m)) preprocessing, O(n + m) space for the rows.
"""
if k < 0:
raise ValueError("k must be at least 0")
m, n = len(pattern), len(text)
lce = LCE(pattern, text) # @line L1
lo = -min(k, m)
prev: list[int] = [] # prev[d - lo] = L[d][e - 1]
for e in range(k + 1): # @line L2
cur = [NONE] * (n - lo + 1)
for d in range(max(-e, -m), n + 1): # @line L3
x = d - lo
r = max(0, -d) # @line L4
if e > 0: # @line L5
if prev[x] != NONE:
r = max(r, prev[x] + 1)
if x > 0 and prev[x - 1] != NONE:
r = max(r, prev[x - 1])
if x + 1 < len(prev) and prev[x + 1] != NONE:
r = max(r, prev[x + 1] + 1)
r = min(r, m, n - d) # @line L6
if r < m and r + d < n: # @line L7
r += lce.query(r, r + d)
cur[x] = r
prev = cur
return [m + d for d in range(max(lo, -m), n - m + 1) if prev[d - lo] == m] # @line L8
def diagonal_table(pattern: str, text: str, k: int) -> dict[tuple[int, int], int]:
"""Return L[d][e] for e = 0..k and all diagonals d with a cell of value <= e."""
m, n = len(pattern), len(text)
lce = LCE(pattern, text)
table: dict[tuple[int, int], int] = {}
for e in range(k + 1):
for d in range(max(-e, -m), n + 1):
r = max(0, -d)
for cand in (table.get((d, e - 1), NONE) + 1, table.get((d - 1, e - 1), NONE),
table.get((d + 1, e - 1), NONE) + 1):
r = max(r, cand)
r = min(r, m, n - d)
if r < m and r + d < n:
r += lce.query(r, r + d)
table[(d, e)] = r
return table
test_landau_vishkin_algorithm.py 85 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/landau-vishkin-algorithm/. The authors are listed in prov/2PT2TT.jsonl.
import random
import pytest
from landau_vishkin_algorithm import LCE, diagonal_table, landau_vishkin
def edit_distance(a: str, b: str) -> int:
d = [[i + j if i * j == 0 else 0 for j in range(len(b) + 1)] for i in range(len(a) + 1)]
for i in range(1, len(a) + 1):
for j in range(1, len(b) + 1):
d[i][j] = min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] != b[j - 1]))
return d[len(a)][len(b)]
def brute(pattern: str, text: str, k: int) -> list[int]:
return [
j for j in range(len(text) + 1)
if min(edit_distance(pattern, text[h:j]) for h in range(j + 1)) <= k
]
def sellers_table(p: str, t: str) -> list[list[int]]:
c = [[i if j == 0 else 0 for j in range(len(t) + 1)] for i in range(len(p) + 1)]
for i in range(1, len(p) + 1):
for j in range(1, len(t) + 1):
c[i][j] = min(c[i - 1][j] + 1, c[i][j - 1] + 1, c[i - 1][j - 1] + (p[i - 1] != t[j - 1]))
return c
def test_example() -> None:
assert landau_vishkin("GATAA", "CAGATAAGAGAA", 1) == [6, 7, 8, 12]
assert landau_vishkin("GATAA", "CAGATAAGAGAA", 0) == [7]
assert landau_vishkin("survey", "surgery", 2) == [5, 6, 7]
def test_edges() -> None:
assert landau_vishkin("", "", 0) == [0]
assert landau_vishkin("", "ab", 0) == [0, 1, 2]
assert landau_vishkin("ab", "", 1) == []
assert landau_vishkin("ab", "", 2) == [0]
assert landau_vishkin("ab", "xyz", 5) == [0, 1, 2, 3]
with pytest.raises(ValueError):
landau_vishkin("a", "a", -1)
def test_lce() -> None:
rng = random.Random(7)
for _ in range(200):
a = "".join(rng.choice("ab") for _ in range(rng.randint(1, 10)))
b = "".join(rng.choice("ab") for _ in range(rng.randint(1, 10)))
s = LCE(a, b)
for i in range(len(a)):
for j in range(len(b)):
q = 0
while i + q < len(a) and j + q < len(b) and a[i + q] == b[j + q]:
q += 1
assert s.query(i, j) == q
def test_table_is_furthest_row() -> None:
rng = random.Random(1989)
for alphabet in ("ab", "acgt"):
for _ in range(150):
p = "".join(rng.choice(alphabet) for _ in range(rng.randint(0, 6)))
t = "".join(rng.choice(alphabet) for _ in range(rng.randint(0, 10)))
k = rng.randint(0, 4)
c = sellers_table(p, t)
table = diagonal_table(p, t, k)
for e in range(k + 1):
for d in range(-len(p), len(t) + 1):
rows = [i for i in range(len(p) + 1) if 0 <= i + d <= len(t) and c[i][i + d] <= e]
assert table.get((d, e)) == (max(rows) if rows else None)
def test_random_against_brute_force() -> None:
rng = random.Random(1986)
for alphabet in ("ab", "acgt"):
for _ in range(400):
p = "".join(rng.choice(alphabet) for _ in range(rng.randint(0, 6)))
t = "".join(rng.choice(alphabet) for _ in range(rng.randint(0, 12)))
k = rng.randint(0, 4)
assert landau_vishkin(p, t, k) == brute(p, t, k)
What the next level needs
- the tests did not run in this build