Algorithm Search⌘K

Zhang-Shasha algorithm in Python

Compute tree edit distance in polynomial time via keyroot-based dynamic programming.

File
zhang_shasha_algorithm.py, 100 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
zhang_shasha_algorithm.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/zhang-shasha-algorithm/. The authors are listed in prov/Y3PPPP.jsonl.

from typing import Callable, Hashable, Optional


def _postorder(children: list[list[int]], root: int) -> tuple[list[int], list[int]]:
    """Return (order, lml): the nodes in postorder, and for position k
    (1-based) the 1-based position of the leftmost leaf of that node.
    Uses an explicit stack, so deep trees are safe."""
    order: list[int] = []
    pos: dict[int, int] = {}
    lml: list[int] = [0]
    stack = [(root, 0)]
    while stack:
        v, k = stack.pop()
        if k < len(children[v]):
            stack.append((v, k + 1))
            stack.append((children[v][k], 0))
            continue
        order.append(v)
        pos[v] = len(order)
        if children[v]:
            lml.append(lml[pos[children[v][0]]])
        else:
            lml.append(pos[v])
    return order, lml


def _keyroots(lml: list[int]) -> list[int]:
    """The keyroots in increasing order: for each leftmost leaf, the
    highest postorder position that has it."""
    best: dict[int, int] = {}
    for i in range(1, len(lml)):
        best[lml[i]] = i
    return sorted(best.values())


def tree_edit_distance(
    children1: list[list[int]],
    labels1: list[Hashable],
    children2: list[list[int]],
    labels2: list[Hashable],
    delete: Optional[Callable[[Hashable], float]] = None,
    insert: Optional[Callable[[Hashable], float]] = None,
    rename: Optional[Callable[[Hashable, Hashable], float]] = None,
    root1: int = 0,
    root2: int = 0,
) -> float:
    """Tree edit distance of two ordered, labelled, rooted trees (Zhang-Shasha).

    Tree t has the nodes 0 .. n_t - 1 with n_t >= 1; children_t[v] lists the
    children of v from left to right, and labels_t[v] is its label. The
    trees are not weighted. By default every deletion and insertion costs 1,
    and a rename costs 0 for equal labels and 1 otherwise.
    Time O(n1 n2 min(depth1, leaves1) min(depth2, leaves2)), space O(n1 n2).
    """
    if delete is None:
        delete = lambda a: 1  # noqa: E731
    if insert is None:
        insert = lambda b: 1  # noqa: E731
    if rename is None:
        rename = lambda a, b: 0 if a == b else 1  # noqa: E731
    o1, l1 = _postorder(children1, root1)  # @line L1
    o2, l2 = _postorder(children2, root2)
    n1, n2 = len(o1), len(o2)
    dl = [0] + [delete(labels1[v]) for v in o1]
    ins = [0] + [insert(labels2[w]) for w in o2]
    lab1 = [None] + [labels1[v] for v in o1]
    lab2 = [None] + [labels2[w] for w in o2]
    k1 = _keyroots(l1)  # @line L2
    k2 = _keyroots(l2)
    td = [[0.0] * (n2 + 1) for _ in range(n1 + 1)]
    fd = [[0.0] * (n2 + 1) for _ in range(n1 + 1)]
    for i in k1:  # @line L3
        li = l1[i]
        for j in k2:  # @line L4
            lj = l2[j]
            fd[li - 1][lj - 1] = 0  # @line L5
            for i1 in range(li, i + 1):  # @line L6
                fd[i1][lj - 1] = fd[i1 - 1][lj - 1] + dl[i1]
            row = fd[li - 1]
            for j1 in range(lj, j + 1):  # @line L7
                row[j1] = row[j1 - 1] + ins[j1]
            for i1 in range(li, i + 1):  # @line L8
                cur, prev = fd[i1], fd[i1 - 1]
                d_i1 = dl[i1]
                tree_i = l1[i1] == li
                for j1 in range(lj, j + 1):  # @line L9
                    a = prev[j1] + d_i1
                    b = cur[j1 - 1] + ins[j1]
                    if tree_i and l2[j1] == lj:  # @line L10
                        c = prev[j1 - 1] + rename(lab1[i1], lab2[j1])
                        best = min(a, b, c)  # @line L11
                        cur[j1] = best
                        td[i1][j1] = best  # @line L12
                    else:
                        c = fd[l1[i1] - 1][l2[j1] - 1] + td[i1][j1]
                        cur[j1] = min(a, b, c)  # @line L13
    return td[n1][n2]  # @line L14
test_zhang_shasha_algorithm.py 112 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/zhang-shasha-algorithm/. The authors are listed in prov/Y3PPPP.jsonl.

import random
import sys
from functools import lru_cache

sys.dont_write_bytecode = True

from zhang_shasha_algorithm import tree_edit_distance  # noqa: E402


def nested(children, labels, v=0):
    return (labels[v], tuple(nested(children, labels, c) for c in children[v]))


def size(forest):
    return sum(1 + size(t[1]) for t in forest)


@lru_cache(maxsize=None)
def forest_dist(f, g, w=1):
    """Forest edit distance by the rightmost-root recursion, with deletion
    and insertion cost w and rename cost 0 or 1."""
    if not f:
        return w * size(g)
    if not g:
        return w * size(f)
    (a, fa), (b, gb) = f[-1], g[-1]
    return min(
        forest_dist(f[:-1] + fa, g, w) + w,
        forest_dist(f, g[:-1] + gb, w) + w,
        forest_dist(f[:-1], g[:-1], w) + forest_dist(fa, gb, w) + (a != b),
    )


def oracle(c1, l1, c2, l2, w=1):
    return forest_dist((nested(c1, l1),), (nested(c2, l2),), w)


def random_tree(n, rng, alphabet):
    parent = [-1] + [rng.randrange(i) for i in range(1, n)]
    children = [[] for _ in range(n)]
    for v in range(1, n):
        children[parent[v]].append(v)
    for ch in children:
        rng.shuffle(ch)
    return children, [rng.choice(alphabet) for _ in range(n)]


# T1 = f(d(a, c(b)), e) and T2 = f(c(d(a, b)), e), nodes numbered in preorder
C1 = [[1, 5], [2, 3], [], [4], [], []]
L1 = ["f", "d", "a", "c", "b", "e"]
C2 = [[1, 5], [2], [3, 4], [], [], []]
L2 = ["f", "c", "d", "a", "b", "e"]


def test_example():
    assert tree_edit_distance(C1, L1, C2, L2) == 2
    assert tree_edit_distance(C2, L2, C1, L1) == 2
    assert tree_edit_distance(C1, L1, C1, L1) == 0


def test_weighted_costs():
    d = tree_edit_distance(C1, L1, C2, L2, delete=lambda a: 3, insert=lambda b: 3,
                           rename=lambda a, b: 0 if a == b else 1)
    assert d == oracle(C1, L1, C2, L2, 3)


def test_single_vertex():
    assert tree_edit_distance([[]], ["a"], [[]], ["a"]) == 0
    assert tree_edit_distance([[]], ["a"], [[]], ["b"]) == 1


def test_star_and_path():
    n = 6
    star = [list(range(1, n))] + [[] for _ in range(n - 1)]
    path = [[i + 1] for i in range(n - 1)] + [[]]
    lab = ["x"] * n
    assert tree_edit_distance(star, lab, path, lab) == oracle(star, lab, path, lab)
    # a star minus k leaves
    assert tree_edit_distance(star, lab, [list(range(1, 3))] + [[], []], ["x"] * 3) == 3


def test_long_path():
    n = 100_000
    path = [[i + 1] for i in range(n - 1)] + [[]]
    lab = ["a"] * n
    assert tree_edit_distance(path, lab, [[]], ["a"]) == n - 1
    assert tree_edit_distance([[]], ["b"], path, lab) == n


def test_random_against_oracle():
    rng = random.Random(20240613)
    for _ in range(600):
        n1, n2 = rng.randint(1, 7), rng.randint(1, 7)
        c1, l1 = random_tree(n1, rng, "ab")
        c2, l2 = random_tree(n2, rng, "abc")
        w = rng.choice([1, 1, 2])
        d = tree_edit_distance(c1, l1, c2, l2, delete=lambda a: w, insert=lambda b: w)
        assert d == oracle(c1, l1, c2, l2, w)
        assert d == tree_edit_distance(c2, l2, c1, l1, delete=lambda a: w, insert=lambda b: w)


if __name__ == "__main__":
    test_example()
    test_weighted_costs()
    test_single_vertex()
    test_star_and_path()
    test_long_path()
    test_random_against_oracle()
    print("ok")

What the next level needs

  • the tests did not run in this build

Read the full page about Zhang-Shasha algorithm