Algorithm Search⌘K

Rope in Python

Balanced binary tree of string fragments supporting fast concatenation, split and insertion in long texts.

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

"""Rope: an immutable string kept as a height-balanced binary tree of short strings.

Leaves hold nonempty strings; an inner node holds its total length and its
height, and its text is the text of its left child followed by that of its
right child. Heights obey the AVL rule (children differ by at most 1), so the
depth is O(log n). Concatenation joins two trees down the spine of the taller
one; split, insert and delete are built from split and concatenation. All
operations return new ropes and share unchanged subtrees with the old ones.
Recursion is used only along root-to-leaf paths, whose length is O(log n).
"""

from __future__ import annotations


class _Leaf:
    __slots__ = ("s", "length", "height")

    def __init__(self, s: str) -> None:
        self.s = s
        self.length = len(s)
        self.height = 0


class _Node:
    __slots__ = ("left", "right", "length", "height")

    def __init__(self, left: _Tree, right: _Tree) -> None:
        self.left = left
        self.right = right
        self.length = left.length + right.length
        self.height = 1 + max(left.height, right.height)


_Tree = _Leaf | _Node


def _rotate_left(t: _Node) -> _Node:
    r = t.right
    assert isinstance(r, _Node)
    return _Node(_Node(t.left, r.left), r.right)


def _rotate_right(t: _Node) -> _Node:
    lft = t.left
    assert isinstance(lft, _Node)
    return _Node(lft.left, _Node(lft.right, t.right))


def _balance(t: _Node) -> _Node:
    """Fix a node whose children differ in height by at most 2."""
    lh, rh = t.left.height, t.right.height
    if lh > rh + 1:
        lft = t.left
        assert isinstance(lft, _Node)
        if lft.left.height < lft.right.height:
            t = _Node(_rotate_left(lft), t.right)
        return _rotate_right(t)
    if rh > lh + 1:
        r = t.right
        assert isinstance(r, _Node)
        if r.right.height < r.left.height:
            t = _Node(t.left, _rotate_right(r))
        return _rotate_left(t)
    return t


def _concat(a: _Tree | None, b: _Tree | None, leaf_max: int) -> _Tree | None:
    if a is None:  # @line L5
        return b
    if b is None:
        return a
    if isinstance(a, _Leaf) and isinstance(b, _Leaf) and a.length + b.length <= leaf_max:
        return _Leaf(a.s + b.s)
    if abs(a.height - b.height) <= 1:  # @line L6
        return _Node(a, b)
    if a.height > b.height:  # @line L7
        assert isinstance(a, _Node)
        c = _concat(a.right, b, leaf_max)
        assert c is not None
        return _balance(_Node(a.left, c))
    assert isinstance(b, _Node)  # @line L8
    c = _concat(a, b.left, leaf_max)
    assert c is not None
    return _balance(_Node(c, b.right))


def _split(t: _Tree | None, i: int, leaf_max: int) -> tuple[_Tree | None, _Tree | None]:
    """Split into the first i characters and the rest."""
    if t is None:
        return None, None
    if isinstance(t, _Leaf):  # @line L9
        x = _Leaf(t.s[:i]) if i > 0 else None
        y = _Leaf(t.s[i:]) if i < t.length else None
        return x, y
    if i <= t.left.length:  # @line L10
        x, y = _split(t.left, i, leaf_max)
        return x, _concat(y, t.right, leaf_max)
    x, y = _split(t.right, i - t.left.length, leaf_max)  # @line L11
    return _concat(t.left, x, leaf_max), y


def _build(s: str, leaf_max: int) -> _Tree | None:
    """Balanced tree over the chunks of s; halves differ by at most one chunk."""
    chunks = [_Leaf(s[k : k + leaf_max]) for k in range(0, len(s), leaf_max)]

    def make(lo: int, hi: int) -> _Tree:
        if hi - lo == 1:
            return chunks[lo]
        mid = (lo + hi + 1) // 2
        return _Node(make(lo, mid), make(mid, hi))

    return make(0, len(chunks)) if chunks else None


class Rope:
    """An immutable string with O(log n) index, concatenation, split, insert and delete."""

    __slots__ = ("root", "leaf_max")

    def __init__(self, s: str = "", leaf_max: int = 64) -> None:
        if leaf_max < 1:
            raise ValueError("leaf_max must be positive")
        self.leaf_max = leaf_max
        self.root: _Tree | None = _build(s, leaf_max)

    @classmethod
    def _of(cls, root: _Tree | None, leaf_max: int) -> Rope:
        r = cls.__new__(cls)
        r.root, r.leaf_max = root, leaf_max
        return r

    def __len__(self) -> int:
        return 0 if self.root is None else self.root.length

    def char_at(self, i: int) -> str:
        """Return the character at position i."""
        if not 0 <= i < len(self):
            raise IndexError("position out of range")
        t = self.root
        while isinstance(t, _Node):  # @line L1
            if i < t.left.length:  # @line L2
                t = t.left
            else:  # @line L3
                i -= t.left.length
                t = t.right
        assert isinstance(t, _Leaf)
        return t.s[i]  # @line L4

    def concat(self, other: Rope) -> Rope:
        return Rope._of(_concat(self.root, other.root, self.leaf_max), self.leaf_max)

    def split(self, i: int) -> tuple[Rope, Rope]:
        """Return the ropes of the first i characters and of the rest."""
        if not 0 <= i <= len(self):
            raise IndexError("split position out of range")
        x, y = _split(self.root, i, self.leaf_max)
        return Rope._of(x, self.leaf_max), Rope._of(y, self.leaf_max)

    def insert(self, i: int, s: str) -> Rope:
        """Return the rope with s inserted so that it starts at position i."""
        x, y = self.split(i)  # @line L12
        return x.concat(Rope(s, self.leaf_max)).concat(y)  # @line L13

    def delete(self, i: int, j: int) -> Rope:
        """Return the rope without the characters at positions i .. j - 1."""
        if not 0 <= i <= j <= len(self):
            raise IndexError("delete range out of range")
        x, y = self.split(i)  # @line L14
        _, z = y.split(j - i)
        return x.concat(z)  # @line L15

    def substring(self, i: int, j: int) -> str:
        if not 0 <= i <= j <= len(self):
            raise IndexError("range out of range")
        return self.split(j)[0].split(i)[1].to_string()

    def to_string(self) -> str:
        out: list[str] = []
        stack = [self.root]
        while stack:
            t = stack.pop()
            if t is None:
                continue
            if isinstance(t, _Leaf):
                out.append(t.s)
            else:
                stack.append(t.right)
                stack.append(t.left)
        return "".join(out)

    def height(self) -> int:
        return -1 if self.root is None else self.root.height

    def leaf_count(self) -> int:
        count, stack = 0, [self.root]
        while stack:
            t = stack.pop()
            if isinstance(t, _Leaf):
                count += 1
            elif t is not None:
                stack.extend((t.left, t.right))
        return count

    def check_invariant(self) -> None:
        stack = [self.root]
        while stack:
            t = stack.pop()
            if t is None:
                continue
            if isinstance(t, _Leaf):
                assert 0 < t.length == len(t.s) <= self.leaf_max and t.height == 0, "bad leaf"
            else:
                assert t.length == t.left.length + t.right.length, "bad length"
                assert t.height == 1 + max(t.left.height, t.right.height), "bad height"
                assert abs(t.left.height - t.right.height) <= 1, "not balanced"
                stack.extend((t.left, t.right))
test_rope.py 75 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/rope/. The authors are listed in prov/K8GJQ9.jsonl.

import random

import pytest

from rope import Rope


def test_example() -> None:
    r = Rope("the_quick_fox_jumps", leaf_max=4)
    r.check_invariant()
    assert r.leaf_count() == 5 and r.height() == 3
    assert r.char_at(10) == "f"
    a, b = r.split(10)
    assert (a.to_string(), b.to_string()) == ("the_quick_", "fox_jumps")
    r2 = r.insert(10, "brown_")
    r2.check_invariant()
    assert r2.to_string() == "the_quick_brown_fox_jumps"
    assert r.to_string() == "the_quick_fox_jumps"  # the old version is unchanged
    r3 = r2.delete(4, 10)
    assert r3.to_string() == "the_brown_fox_jumps"
    r3.check_invariant()


def test_edges() -> None:
    e = Rope()
    assert len(e) == 0 and e.to_string() == "" and e.height() == -1
    assert e.concat(e).to_string() == ""
    assert e.insert(0, "abc").to_string() == "abc"
    r = Rope("abc", leaf_max=1)
    assert r.delete(0, 3).to_string() == ""
    x, y = r.split(0)
    assert x.to_string() == "" and y.to_string() == "abc"
    with pytest.raises(IndexError):
        r.char_at(3)
    with pytest.raises(IndexError):
        r.split(4)


def test_long_concat_chain_stays_balanced() -> None:
    r = Rope(leaf_max=2)
    for k in range(2000):
        r = r.concat(Rope("ab"[k % 2] * 3, leaf_max=2))
    r.check_invariant()
    assert len(r) == 6000 and r.height() <= 1.45 * 12 + 2


def test_random_against_string() -> None:
    rng = random.Random(9090)
    for _ in range(500):
        leaf = rng.randint(1, 5)
        model = "".join(rng.choice("abc") for _ in range(rng.randint(0, 20)))
        r = Rope(model, leaf_max=leaf)
        for _ in range(rng.randint(0, 25)):
            op = rng.random()
            if op < 0.4:
                i = rng.randint(0, len(model))
                s = "".join(rng.choice("xyz") for _ in range(rng.randint(0, 6)))
                r, model = r.insert(i, s), model[:i] + s + model[i:]
            elif op < 0.7:
                i = rng.randint(0, len(model))
                j = rng.randint(i, len(model))
                r, model = r.delete(i, j), model[:i] + model[j:]
            else:
                other = "".join(rng.choice("pq") for _ in range(rng.randint(0, 8)))
                r, model = r.concat(Rope(other, leaf_max=leaf)), model + other
            r.check_invariant()
        assert r.to_string() == model and len(r) == len(model)
        if model:
            i = rng.randrange(len(model))
            assert r.char_at(i) == model[i]
            j = rng.randint(i, len(model))
            assert r.substring(i, j) == model[i:j]

What the next level needs

  • the tests did not run in this build

Read the full page about Rope