diff --git a/README.md b/README.md index 508a9b7..ed7f501 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,57 @@ Even if the alignment of the two sequences is identical to `difflib`, sequences. The opcodes returned by this library represent individual character operations, and thus should never span two or more characters. +Custom costs +------------ +By default every edit costs one and a match costs nothing. Three optional cost +functions let you change that: + +```python +sm = edit_distance.SequenceMatcher( + a=ref, b=hyp, + substitution_cost=lambda x, y: 0 if x == y else 1, + insertion_cost=lambda y: 1, + deletion_cost=lambda x: 1, +) +``` + +They are also accepted by `edit_distance()` and `edit_distance_backpointer()`, +as keyword arguments. Costs may be fractional, in which case the distance is a +float. + +`substitution_cost` is consulted for *every* aligned pair, including pairs that +compare equal, so a custom function has to handle both cases -- returning a flat +penalty would charge for matches too: + +```python +# charges 0.5 even when x == y +substitution_cost=lambda x, y: 0.5 +``` + +Costs and equality are separate concerns. `test` decides whether two elements +are equal, which determines the opcode label and the match count; the cost +functions only decide the price. So a free substitution that `test` rejects is +still a `replace` and still isn't a match, and a substitution that costs +something but that `test` accepts is still an `equal` and still is. + +Two consequences worth knowing: + +- A substitution costing more than an insertion plus a deletion is never + chosen, because the search gets the same result more cheaply by deleting and + then inserting. Substitution costs are effectively capped at `ins + del`. +- `distance(a, a) == 0` only holds if a match costs nothing, and + `distance(a, b) == distance(b, a)` only holds if insertions and deletions + cost the same. Keeping the result a true metric is up to you. + +Setting the substitution cost to the gap-pair total maximizes the number of +matches rather than minimizing edits, which is what the removed +`highest_match_action` used to do: + +```python +sm = edit_distance.SequenceMatcher(a=ref, b=hyp, + substitution_cost=lambda x, y: 0 if x == y else 2) +``` + Notes ----- This doesn't implement the 'junk' matching features in difflib. diff --git a/edit_distance/edit_distance.py b/edit_distance/edit_distance.py index d7da460..0933f4d 100644 --- a/edit_distance/edit_distance.py +++ b/edit_distance/edit_distance.py @@ -20,55 +20,106 @@ import operator import sys -from collections.abc import Sequence +from collections.abc import Callable, Sequence +from typing import Any INSERT: str = "insert" DELETE: str = "delete" EQUAL: str = "equal" REPLACE: str = "replace" +TestFunction = Callable[[Any, Any], Any] +SubstitutionCostFunction = Callable[[Any, Any], float] +GapCostFunction = Callable[[Any], float] + -# Cost is basically: was there a match or not. -# The other numbers are cumulative costs and matches. -# # At each cell the search takes whichever of the three actions is cheapest, # breaking ties in favor of substitution, then insertion, then deletion. +# +# Two properties of custom costs are worth knowing: +# +# * A substitution costing more than an insertion plus a deletion is never +# chosen -- the search routes around it by deleting and then inserting -- so +# substitution costs are effectively capped at ``ins + del``. +# * ``distance(a, a) == 0`` only holds if a match costs nothing, and symmetry +# only holds if insertions and deletions cost the same. Keeping the result a +# true metric is the caller's responsibility. -def edit_distance(seq1: Sequence, seq2: Sequence, test=operator.eq): +def edit_distance( + seq1: Sequence, + seq2: Sequence, + *, + test: TestFunction = operator.eq, + substitution_cost: SubstitutionCostFunction | None = None, + insertion_cost: GapCostFunction | None = None, + deletion_cost: GapCostFunction | None = None, +) -> tuple[float, int]: """ Computes the edit distance between the two given sequences. This uses the relatively fast method that only constructs two columns of the 2d array for edits. This function actually uses four columns because we track the - number of matches too. + number of matches too. Returns a ``(distance, matches)`` tuple. + + ``test`` decides whether two elements are equal. It governs the opcode + label (``equal`` versus ``replace``) and the match count, and defaults to + :py:func:`operator.eq`. + + The three cost functions decide only what each operation *costs*: + + * ``substitution_cost(x, y)`` -- the cost of aligning ``x`` with ``y``. + This is consulted for **every** aligned pair, including pairs that + ``test`` considers equal, so a custom function must handle both cases; + the usual form is ``lambda x, y: 0 if x == y else penalty(x, y)``. + Defaults to ``0`` for a match and ``1`` otherwise. + * ``insertion_cost(y)`` -- the cost of inserting ``y``. Defaults to ``1``. + * ``deletion_cost(x)`` -- the cost of deleting ``x``. Defaults to ``1``. + + Costs and equality are independent: a zero-cost pair that ``test`` rejects + is still a ``replace`` and does not count as a match, and a nonzero-cost + pair that ``test`` accepts is still an ``equal`` and does. """ m = len(seq1) n = len(seq2) - # Special, easy cases: - if test is operator.eq and seq1 == seq2: + # Identical sequences are only free when a match costs nothing, which a + # custom substitution cost is under no obligation to honor. + if substitution_cost is None and test is operator.eq and seq1 == seq2: return 0, n - if m == 0: - return n, 0 - if n == 0: - return m, 0 - v0 = [0] * (n + 1) # The two 'error' columns - v1 = [0] * (n + 1) + + # Insertion costs are needed once per row, so pay for them only once. + ins_costs = [1] * n if insertion_cost is None else [insertion_cost(y) for y in seq2] + + v0: list[float] = [0] * (n + 1) # The two 'error' columns + v1: list[float] = [0] * (n + 1) m0 = [0] * (n + 1) # The two 'match' columns m1 = [0] * (n + 1) - for i in range(1, n + 1): - v0[i] = i + + # The first row: the running cost of inserting all of seq2 up to here. + for j in range(1, n + 1): + v0[j] = v0[j - 1] + ins_costs[j - 1] + if m == 0: + return v0[n], 0 + for i in range(1, m + 1): - v1[0] = i + a_elem = seq1[i - 1] + elem_del_cost = 1 if deletion_cost is None else deletion_cost(a_elem) + # The first column: the running cost of deleting all of seq1 up to here. + v1[0] = v0[0] + elem_del_cost for j in range(1, n + 1): - cost = 0 if test(seq1[i - 1], seq2[j - 1]) else 1 + b_elem = seq2[j - 1] + equal = test(a_elem, b_elem) + if substitution_cost is None: + cost: float = 0 if equal else 1 + else: + cost = substitution_cost(a_elem, b_elem) # The costs - ins_cost = v1[j - 1] + 1 - del_cost = v0[j] + 1 + ins_cost = v1[j - 1] + ins_costs[j - 1] + del_cost = v0[j] + elem_del_cost sub_cost = v0[j - 1] + cost # Match counts ins_match = m1[j - 1] del_match = m0[j] - sub_match = m0[j - 1] + int(not cost) + sub_match = m0[j - 1] + (1 if equal else 0) # Ties break in favor of substitution, then insertion, then deletion. if sub_cost <= ins_cost and sub_cost <= del_cost: @@ -87,51 +138,73 @@ def edit_distance(seq1: Sequence, seq2: Sequence, test=operator.eq): return v1[n], m1[n] -def edit_distance_backpointer(seq1, seq2, test=operator.eq): +def edit_distance_backpointer( + seq1: Sequence, + seq2: Sequence, + *, + test: TestFunction = operator.eq, + substitution_cost: SubstitutionCostFunction | None = None, + insertion_cost: GapCostFunction | None = None, + deletion_cost: GapCostFunction | None = None, +) -> tuple[float, int, list]: """ Similar to :py:func:`~edit_distance.edit_distance` except that this function keeps backpointers during the search. This allows us to return the opcodes (i.e. the specific edits that were used to change from one string to another). This function contructs the full 2d array for the - backpointers only. + backpointers only. Returns a ``(distance, matches, opcodes)`` tuple. + + ``test`` and the three cost functions mean the same thing they do for + :py:func:`~edit_distance.edit_distance`. """ m: int = len(seq1) n: int = len(seq2) # backpointer array: - bp = [[None for _ in range(n + 1)] for _ in range(m + 1)] + bp: list[list[str | None]] = [[None for _ in range(n + 1)] for _ in range(m + 1)] + + # Insertion costs are needed once per row, so pay for them only once. + ins_costs = [1] * n if insertion_cost is None else [insertion_cost(y) for y in seq2] # Two columns of the distance and match arrays - d0 = [0] * (n + 1) # The two 'distance' columns - d1 = [0] * (n + 1) + d0: list[float] = [0] * (n + 1) # The two 'distance' columns + d1: list[float] = [0] * (n + 1) m0 = [0] * (n + 1) # The two 'match' columns m1 = [0] * (n + 1) - # Fill in the first column - for i in range(1, n + 1): - d0[i] = i - bp[0][i] = INSERT + # Fill in the first row: the running cost of inserting all of seq2. + for j in range(1, n + 1): + d0[j] = d0[j - 1] + ins_costs[j - 1] + bp[0][j] = INSERT for i in range(1, m + 1): - d1[0] = i + a_elem = seq1[i - 1] + elem_del_cost = 1 if deletion_cost is None else deletion_cost(a_elem) + # The first column: the running cost of deleting all of seq1 up to here. + d1[0] = d0[0] + elem_del_cost bp[i][0] = DELETE for j in range(1, n + 1): - cost = 0 if test(seq1[i - 1], seq2[j - 1]) else 1 + b_elem = seq2[j - 1] + equal = test(a_elem, b_elem) + if substitution_cost is None: + cost: float = 0 if equal else 1 + else: + cost = substitution_cost(a_elem, b_elem) # The costs of each action... - ins_cost = d1[j - 1] + 1 # insertion - del_cost = d0[j] + 1 # deletion + ins_cost = d1[j - 1] + ins_costs[j - 1] # insertion + del_cost = d0[j] + elem_del_cost # deletion sub_cost = d0[j - 1] + cost # substitution/match # The match scores of each action ins_match = m1[j - 1] del_match = m0[j] - sub_match = m0[j - 1] + int(not cost) + sub_match = m0[j - 1] + (1 if equal else 0) # Ties break in favor of substitution, then insertion, then deletion. if sub_cost <= ins_cost and sub_cost <= del_cost: d1[j] = sub_cost m1[j] = sub_match - bp[i][j] = EQUAL if cost == 0 else REPLACE + bp[i][j] = EQUAL if equal else REPLACE elif ins_cost <= del_cost: d1[j] = ins_cost m1[j] = ins_match diff --git a/edit_distance/sequence_matcher.py b/edit_distance/sequence_matcher.py index ff71142..95e013f 100644 --- a/edit_distance/sequence_matcher.py +++ b/edit_distance/sequence_matcher.py @@ -21,9 +21,18 @@ import operator from collections.abc import Sequence -from edit_distance.edit_distance import EQUAL, edit_distance, edit_distance_backpointer - - +from edit_distance.edit_distance import ( + EQUAL, + GapCostFunction, + SubstitutionCostFunction, + TestFunction, + edit_distance, + edit_distance_backpointer, +) + + +# Two sequences, four comparison/cost hooks, and three cached results. +# pylint: disable-next=too-many-instance-attributes class SequenceMatcher: """ Similar to the :py:mod:`difflib` :py:class:`~difflib.SequenceMatcher`, but @@ -34,12 +43,20 @@ def __init__( self, a: Sequence | None = None, b: Sequence | None = None, - test=operator.eq, + test: TestFunction = operator.eq, + *, + substitution_cost: SubstitutionCostFunction | None = None, + insertion_cost: GapCostFunction | None = None, + deletion_cost: GapCostFunction | None = None, ): """ Initialize the object with sequences a and b. Optionally, one can specify a test function that is used to compare sequence elements. This defaults to the built in ``eq`` operator (i.e. :py:func:`operator.eq`). + + The three cost functions are passed straight through to + :py:func:`~edit_distance.edit_distance`; see that function for what + they mean and what they default to. """ if a is None: a = [] @@ -49,8 +66,11 @@ def __init__( self.seq2 = b self._reset_object() self.test = test - self.dist = None - self._matches = None + self.substitution_cost = substitution_cost + self.insertion_cost = insertion_cost + self.deletion_cost = deletion_cost + self.dist: float | None = None + self._matches: int | None = None self.opcodes = None def set_seqs(self, a: Sequence, b: Sequence) -> None: @@ -97,6 +117,9 @@ def get_opcodes(self): self.seq1, self.seq2, test=self.test, + substitution_cost=self.substitution_cost, + insertion_cost=self.insertion_cost, + deletion_cost=self.deletion_cost, ) if self.dist is not None: assert d == self.dist @@ -128,7 +151,14 @@ def real_quick_ratio(self) -> float: def _compute_distance_fast(self) -> None: """Calls edit_distance, and asserts that if we already have values for matches and distance, that they match.""" - d, m = edit_distance(self.seq1, self.seq2, test=self.test) + d, m = edit_distance( + self.seq1, + self.seq2, + test=self.test, + substitution_cost=self.substitution_cost, + insertion_cost=self.insertion_cost, + deletion_cost=self.deletion_cost, + ) if self.dist is not None: assert d == self.dist if self._matches is not None: diff --git a/test/test_edit_distance.py b/test/test_edit_distance.py index 764e83c..78ff9cd 100644 --- a/test/test_edit_distance.py +++ b/test/test_edit_distance.py @@ -72,6 +72,154 @@ def test_edit_distance2(self): ) self.assertEqual(edit_distance_backpointer(a, b), bp_expected_result) + def test_substitution_cost_reproduces_highest_match_alignment(self): + """Pricing a mismatched substitution at exactly insertion + deletion + recovers the alignment the removed highest_match_action produced: the + same opcodes and the same two matches. The distance is 6 rather than + that function's 4 because it is now measured in the new cost units -- + highest_match_action accumulated unit costs while choosing by match + count, which a cost function cannot do.""" + a = ["hi", "my", "name", "is", "andy"] + b = ["hi", "i'm", "my", "name's", "sandy"] + sub = lambda x, y: 0 if x == y else 2 # noqa: E731 + self.assertEqual(edit_distance(a, b, substitution_cost=sub), (6, 2)) + bp_expected_result = ( + 6, + 2, + [ + ["equal", 0, 1, 0, 1], + ["insert", 1, 1, 1, 2], + ["equal", 1, 2, 2, 3], + ["delete", 2, 3, 3, 3], + ["replace", 3, 4, 3, 4], + ["replace", 4, 5, 4, 5], + ], + ) + self.assertEqual( + edit_distance_backpointer(a, b, substitution_cost=sub), + bp_expected_result, + ) + + def test_substitution_costing_more_than_a_gap_pair_is_never_chosen(self): + """A substitution dearer than insertion + deletion is routed around, + so no replace survives even though the match count is unchanged.""" + a = ["hi", "my", "name", "is", "andy"] + b = ["hi", "i'm", "my", "name's", "sandy"] + sub = lambda x, y: 0 if x == y else 3 # noqa: E731 + dist, matches, opcodes = edit_distance_backpointer(a, b, substitution_cost=sub) + self.assertEqual((dist, matches), (6, 2)) + self.assertNotIn("replace", [op[0] for op in opcodes]) + + def test_match_can_cost_something(self): + """A custom substitution cost is consulted for equal pairs too, so + identical sequences need not be free. The pair is still an ``equal`` + and still counts as a match, since ``test`` decides that, not cost.""" + dist, matches, opcodes = edit_distance_backpointer( + ["a"], ["a"], substitution_cost=lambda x, y: 0.5 + ) + self.assertAlmostEqual(dist, 0.5) + self.assertEqual(matches, 1) + self.assertEqual(opcodes, [["equal", 0, 1, 0, 1]]) + + def test_zero_cost_substitution_is_not_a_match(self): + """The converse: a free substitution that ``test`` rejects is still a + replace and still does not count as a match.""" + never_eq = lambda x, y: False # noqa: E731 + dist, matches, opcodes = edit_distance_backpointer( + ["a", "b"], ["a", "b"], test=never_eq, substitution_cost=lambda x, y: 0 + ) + self.assertEqual((dist, matches), (0, 0)) + self.assertEqual(opcodes, [["replace", 0, 1, 0, 1], ["replace", 1, 2, 1, 2]]) + + def test_insertion_cost_accumulates_over_an_empty_seq1(self): + """With nothing to align against, the distance is the sum of the + per-element insertion costs rather than the number of insertions.""" + b = ["a", "bb", "ccc"] + self.assertEqual(edit_distance([], b, insertion_cost=len), (6, 0)) + self.assertEqual( + edit_distance_backpointer([], b, insertion_cost=len), + ( + 6, + 0, + [ + ["insert", 0, 0, 0, 1], + ["insert", 0, 0, 1, 2], + ["insert", 0, 0, 2, 3], + ], + ), + ) + + def test_deletion_cost_accumulates_over_an_empty_seq2(self): + """The same, for the deletion boundary.""" + a = ["a", "bb", "ccc"] + self.assertEqual(edit_distance(a, [], deletion_cost=len), (6, 0)) + self.assertEqual( + edit_distance_backpointer(a, [], deletion_cost=len), + ( + 6, + 0, + [ + ["delete", 0, 1, 0, 0], + ["delete", 1, 2, 0, 0], + ["delete", 2, 3, 0, 0], + ], + ), + ) + + def test_cheap_gaps_beat_a_substitution(self): + """Custom gap costs move the cap on substitution: when a deletion plus + an insertion is cheaper than replacing, the search prefers them.""" + cheap = lambda elem: 0.1 # noqa: E731 + dist, matches, opcodes = edit_distance_backpointer( + ["x"], ["y"], insertion_cost=cheap, deletion_cost=cheap + ) + self.assertAlmostEqual(dist, 0.2) + self.assertEqual(matches, 0) + self.assertEqual(opcodes, [["delete", 0, 1, 0, 0], ["insert", 1, 1, 0, 1]]) + + def test_per_element_insertion_cost(self): + """Insertion cost can depend on the element, so a filler word can be + cheaper to insert than a content word.""" + ref = ["i", "want", "coffee"] + hyp = ["i", "um", "want", "coffee"] + filler_is_cheap = lambda y: 0.1 if y == "um" else 1 # noqa: E731 + dist, matches = edit_distance(ref, hyp, insertion_cost=filler_is_cheap) + self.assertAlmostEqual(dist, 0.1) + self.assertEqual(matches, 3) + self.assertEqual(edit_distance(ref, hyp), (1, 3)) + + def test_sequence_matcher_uses_cost_functions(self): + """SequenceMatcher passes the cost functions to both the fast path and + the backpointer path, which must agree on distance and matches.""" + cheap = lambda elem: 0.1 # noqa: E731 + kwargs = {"insertion_cost": cheap, "deletion_cost": cheap} + expected_opcodes = [["delete", 0, 1, 0, 0], ["insert", 1, 1, 0, 1]] + + # Distance first, so the fast path populates the cache. + sm = SequenceMatcher(a=["x"], b=["y"], **kwargs) + self.assertAlmostEqual(sm.distance(), 0.2) + self.assertEqual(sm.get_opcodes(), expected_opcodes) + + # Opcodes first, so the backpointer path populates it instead. + sm = SequenceMatcher(a=["x"], b=["y"], **kwargs) + self.assertEqual(sm.get_opcodes(), expected_opcodes) + self.assertAlmostEqual(sm.distance(), 0.2) + self.assertEqual(sm.matches(), 0) + + # Matches first, which reaches the fast path through a third door. + sm = SequenceMatcher(a=["x"], b=["y"], **kwargs) + self.assertEqual(sm.matches(), 0) + self.assertAlmostEqual(sm.distance(), 0.2) + self.assertEqual(sm.get_opcodes(), expected_opcodes) + + def test_cost_functions_are_keyword_only(self): + """Everything after the two sequences is keyword-only, so a stray + positional argument fails loudly instead of being read as ``test``.""" + with self.assertRaises(TypeError): + edit_distance(["a"], ["b"], lambda x, y: True) + with self.assertRaises(TypeError): + edit_distance_backpointer(["a"], ["b"], lambda x, y: True) + def test_edit_distance3(self): """Test for 'are you at work now'.""" a = ["are", "you", "at", "work", "now"] diff --git a/test/test_properties.py b/test/test_properties.py index 79e48ba..8cc5776 100644 --- a/test/test_properties.py +++ b/test/test_properties.py @@ -26,15 +26,38 @@ strs = st.text("abc", max_size=10) seqs = ints | strs pairs = st.tuples(ints, ints) | st.tuples(strs, strs) +int_pairs = st.tuples(ints, ints) -@given(pairs) -def test_opcodes_contiguous_and_reconstruct(pair): - """Opcodes tile the alignment from (0, 0) to (len(a), len(b)) and - applying them to a yields b.""" - a, b = pair - _, _, opcodes = edit_distance_backpointer(a, b) +# Integer costs so the weighted assertions stay exact -- summing floats in a +# different order than the search did would make them flaky. Insertions and +# deletions are priced differently so each boundary is exercised on its own, +# and substitutions sometimes cost more than a gap pair and sometimes less. +def sub_c(x, y): + """Substitution cost: free for a match, otherwise 2 to 5.""" + return 0 if x == y else 2 + abs(x - y) + + +def ins_c(y): + """Insertion cost: 1 to 4.""" + return 1 + y + + +def del_c(x): + """Deletion cost: 2 to 5.""" + return 2 + x + +WEIGHTED = { + "substitution_cost": sub_c, + "insertion_cost": ins_c, + "deletion_cost": del_c, +} + + +def check_opcodes_tile(a, b, opcodes): + """Opcodes tile the alignment from (0, 0) to (len(a), len(b)) and applying + them to a yields b.""" pos = (0, 0) out = [] for tag, i1, i2, j1, j2 in opcodes: @@ -52,6 +75,24 @@ def test_opcodes_contiguous_and_reconstruct(pair): assert out == list(b) +@given(pairs) +def test_opcodes_contiguous_and_reconstruct(pair): + """Opcodes tile the alignment from (0, 0) to (len(a), len(b)) and + applying them to a yields b.""" + a, b = pair + _, _, opcodes = edit_distance_backpointer(a, b) + check_opcodes_tile(a, b, opcodes) + + +@given(int_pairs) +def test_weighted_opcodes_contiguous_and_reconstruct(pair): + """Custom costs change which alignment wins, not the structural + guarantees the opcodes have to satisfy.""" + a, b = pair + _, _, opcodes = edit_distance_backpointer(a, b, **WEIGHTED) + check_opcodes_tile(a, b, opcodes) + + @given(pairs) def test_distance_counts_non_equal_opcodes(pair): """The distance equals the number of non-equal opcodes and agrees with @@ -92,6 +133,34 @@ def test_agrees_with_wagner_fischer(pair): assert edit_distance_backpointer(a, b)[0] == expected +@given(int_pairs) +def test_weighted_agrees_with_wagner_fischer(pair): + """Both entry points agree with a reference weighted Wagner-Fischer + distance, whose boundary rows are cumulative sums of the gap costs.""" + a, b = pair + expected = weighted_wagner_fischer(a, b) + assert edit_distance(a, b, **WEIGHTED)[0] == expected + assert edit_distance_backpointer(a, b, **WEIGHTED)[0] == expected + + +@given(int_pairs) +def test_weighted_distance_equals_summed_opcode_costs(pair): + """The distance is exactly what the returned alignment costs. This is the + weighted generalization of counting non-equal opcodes.""" + a, b = pair + dist, matches, opcodes = edit_distance_backpointer(a, b, **WEIGHTED) + total = 0 + for tag, i1, _, j1, _ in opcodes: + if tag in ("equal", "replace"): + total += sub_c(a[i1], b[j1]) + elif tag == "insert": + total += ins_c(b[j1]) + else: + total += del_c(a[i1]) + assert total == dist + assert (dist, matches) == edit_distance(a, b, **WEIGHTED) + + def wagner_fischer(a, b): """Reference Levenshtein distance using the full DP table.""" m, n = len(a), len(b) @@ -105,3 +174,22 @@ def wagner_fischer(a, b): cost = 0 if a[i - 1] == b[j - 1] else 1 d[i][j] = min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost) return d[m][n] + + +def weighted_wagner_fischer(a, b): + """Reference weighted distance using the full DP table. Written + independently of the implementation, including the boundary rows.""" + m, n = len(a), len(b) + d = [[0] * (n + 1) for _ in range(m + 1)] + for i in range(1, m + 1): + d[i][0] = d[i - 1][0] + del_c(a[i - 1]) + for j in range(1, n + 1): + d[0][j] = d[0][j - 1] + ins_c(b[j - 1]) + for i in range(1, m + 1): + for j in range(1, n + 1): + d[i][j] = min( + d[i - 1][j] + del_c(a[i - 1]), + d[i][j - 1] + ins_c(b[j - 1]), + d[i - 1][j - 1] + sub_c(a[i - 1], b[j - 1]), + ) + return d[m][n]