Algorithm Search⌘K

AA tree in Python

A simpler red-black variant where only right-leaning red links are allowed, using a level field instead of colours.

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

from __future__ import annotations

from typing import Generic, Iterator, Protocol, TypeVar


class Ordered(Protocol):
    def __lt__(self, other: "Ordered", /) -> bool: ...


K = TypeVar("K", bound=Ordered)


class Node(Generic[K]):
    """A node with its level; nil has level 0."""

    __slots__ = ("key", "left", "right", "level")

    def __init__(self, key: K) -> None:
        self.key = key
        self.left: Node[K] | None = None
        self.right: Node[K] | None = None
        self.level = 1


def level(t: Node[K] | None) -> int:
    return 0 if t is None else t.level


def skew(t: Node[K] | None) -> Node[K] | None:
    """Remove a horizontal left link at t by a right rotation. O(1)."""
    if t is not None and t.left is not None and t.left.level == t.level:  # @line L1
        l = t.left  # @line L2
        t.left = l.right
        l.right = t
        return l
    return t


def split(t: Node[K] | None) -> Node[K] | None:
    """Remove two horizontal right links in a row at t by a left rotation
    that lifts the middle node one level. O(1)."""
    if (
        t is not None
        and t.right is not None
        and t.right.right is not None
        and t.right.right.level == t.level  # @line L3
    ):
        r = t.right  # @line L4
        t.right = r.left
        r.left = t
        r.level += 1  # @line L5
        return r
    return t


def insert(t: Node[K] | None, k: K) -> Node[K]:
    """Insert k below t (k must be absent) and return the new root. O(log n)."""
    if t is None:  # @line L6
        return Node(k)
    if k < t.key:  # @line L7
        t.left = insert(t.left, k)
    else:
        t.right = insert(t.right, k)
    t = skew(t)  # @line L8
    t = split(t)
    assert t is not None
    return t


def decrease_level(t: Node[K]) -> None:
    """Lower t (and a horizontal right child) to one above its lower child."""
    should = min(level(t.left), level(t.right)) + 1  # @line L16
    if should < t.level:  # @line L17
        t.level = should
        if t.right is not None and should < t.right.level:
            t.right.level = should


def delete(t: Node[K] | None, k: K) -> Node[K] | None:
    """Delete k from below t (k must be present) and return the new root.
    O(log n)."""
    assert t is not None
    if k < t.key:  # @line L9
        t.left = delete(t.left, k)
    elif t.key < k:
        t.right = delete(t.right, k)
    elif t.left is None and t.right is None:  # @line L10
        return None
    elif t.left is None:  # @line L11
        assert t.right is not None
        s = t.right
        while s.left is not None:
            s = s.left
        t.key = s.key
        t.right = delete(t.right, s.key)
    else:  # @line L12
        p = t.left
        while p.right is not None:
            p = p.right
        t.key = p.key
        t.left = delete(t.left, p.key)
    decrease_level(t)  # @line L13
    t = skew(t)  # @line L14
    assert t is not None
    t.right = skew(t.right)
    if t.right is not None:
        t.right.right = skew(t.right.right)
    t = split(t)  # @line L15
    assert t is not None
    t.right = split(t.right)
    return t


class AATree(Generic[K]):
    """An ordered set kept as an AA tree (Andersson 1993).

    Keys must be totally ordered. Search, insertion and deletion cost
    O(log n) time in the worst case; the height is at most 2 lg(n + 1).
    """

    def __init__(self) -> None:
        self.root: Node[K] | None = None
        self.size = 0

    def contains(self, k: K) -> bool:
        """Return whether k is in the set. O(log n)."""
        t = self.root
        while t is not None:
            if k < t.key:
                t = t.left
            elif t.key < k:
                t = t.right
            else:
                return True
        return False

    def insert(self, k: K) -> bool:
        """Add k; return False if it was already present. O(log n)."""
        if self.contains(k):
            return False
        self.root = insert(self.root, k)
        self.size += 1
        return True

    def delete(self, k: K) -> bool:
        """Remove k; return False if it was absent. O(log n)."""
        if not self.contains(k):
            return False
        self.root = delete(self.root, k)
        self.size -= 1
        return True

    def keys(self) -> Iterator[K]:
        """Yield the keys in increasing order. O(n)."""
        stack: list[Node[K]] = []
        t = self.root
        while stack or t is not None:
            while t is not None:
                stack.append(t)
                t = t.left
            t = stack.pop()
            yield t.key
            t = t.right

    def height(self) -> int:
        def ht(t: Node[K] | None) -> int:
            return 0 if t is None else 1 + max(ht(t.left), ht(t.right))

        return ht(self.root)

    def __len__(self) -> int:
        return self.size

    def check_invariant(self) -> None:
        """Raise AssertionError unless the tree is a valid AA tree."""
        count = 0

        def walk(t: Node[K] | None, lo: K | None, hi: K | None) -> None:
            nonlocal count
            if t is None:
                return
            count += 1
            assert lo is None or lo < t.key
            assert hi is None or t.key < hi
            if t.left is None and t.right is None:
                assert t.level == 1, "(A1) a leaf has level 1"
            assert level(t.left) == t.level - 1, "(A2) left child one level down"
            assert level(t.right) in (t.level, t.level - 1), "(A3) right child"
            if t.right is not None:
                assert level(t.right.right) < t.level, "(A4) no two horizontal links"
            if t.level > 1:
                assert t.left is not None and t.right is not None, "(A5)"
            walk(t.left, lo, t.key)
            walk(t.right, t.key, hi)

        walk(self.root, None, None)
        assert count == self.size
test_aa_tree.py 107 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/aa-tree/. The authors are listed in prov/TPBFGF.jsonl.

import math
import random

from aa_tree import AATree, Node, skew, split


def shape(t: "Node[int] | None") -> object:
    if t is None:
        return None
    if t.left is None and t.right is None:
        return f"{t.key}:{t.level}"
    return (shape(t.left), f"{t.key}:{t.level}", shape(t.right))


def test_page_example() -> None:
    t: AATree[int] = AATree()
    steps = {
        2: (None, "1:1", "2:1"),
        3: ("1:1", "2:2", "3:1"),
        5: ("1:1", "2:2", ("3:1", "4:2", "5:1")),
        7: (("1:1", "2:2", "3:1"), "4:3", ("5:1", "6:2", "7:1")),
    }
    for k in range(1, 8):
        assert t.insert(k)
        t.check_invariant()
        if k in steps:
            assert shape(t.root) == steps[k]
    assert t.delete(1)
    t.check_invariant()
    assert shape(t.root) == ((None, "2:1", "3:1"), "4:2", ("5:1", "6:2", "7:1"))
    assert t.delete(4)
    t.check_invariant()
    assert shape(t.root) == ("2:1", "3:2", ("5:1", "6:2", "7:1"))
    assert list(t.keys()) == [2, 3, 5, 6, 7]


def test_skew_and_split() -> None:
    a, b = Node(1), Node(2)
    b.left = a  # horizontal left link: 1 and 2 both at level 1
    r = skew(b)
    assert r is a and a.right is b and b.left is None
    c = Node(3)
    b.right = c  # now 1 -> 2 -> 3 all at level 1
    r2 = split(a)
    assert r2 is b and b.level == 2 and b.left is a and b.right is c


def test_edge_cases() -> None:
    t: AATree[int] = AATree()
    assert not t.contains(1)
    assert not t.delete(1)
    t.check_invariant()
    assert t.insert(1)
    assert not t.insert(1)
    assert len(t) == 1
    assert t.delete(1)
    assert t.root is None
    t.check_invariant()


def test_delete_needs_all_three_skews() -> None:
    # Pitfall: the deletion fix-up must skew t, t.right and t.right.right.
    # Deleting 7 lowers the root and its right child 18 to level 1; then
    # 13 and 15 are horizontal left links at two places on the right spine.
    t: AATree[int] = AATree()
    for k in [18, 15, 19, 7, 3, 13]:
        t.insert(k)
    assert shape(t.root) == ("3:1", "7:2", ((None, "13:1", "15:1"), "18:2", "19:1"))
    assert t.delete(7)
    t.check_invariant()
    assert shape(t.root) == ("3:1", "13:2", ("15:1", "18:2", "19:1"))


def test_sorted_input_height() -> None:
    t: AATree[int] = AATree()
    n = 2000
    for k in range(n):
        t.insert(k)
    t.check_invariant()
    assert t.height() <= 2 * math.log2(n + 1)
    for k in range(n - 1, -1, -2):
        assert t.delete(k)
    t.check_invariant()
    assert list(t.keys()) == list(range(0, n, 2))


def test_random_against_set() -> None:
    rng = random.Random(1993)
    for _ in range(600):
        t: AATree[int] = AATree()
        s: set[int] = set()
        for _ in range(rng.randrange(1, 80)):
            k = rng.randrange(60)
            if rng.random() < 0.55:
                assert t.insert(k) == (k not in s)
                s.add(k)
            else:
                assert t.delete(k) == (k in s)
                s.discard(k)
            t.check_invariant()
            assert t.contains(k) == (k in s)
        assert list(t.keys()) == sorted(s)
        if s:
            assert t.height() <= 2 * math.log2(len(s) + 1)

What the next level needs

  • the tests did not run in this build

Read the full page about AA tree