Algorithm Search⌘K

Expression tree in Python

Tree whose internal nodes are operators and whose leaves are constants or symbols.

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

"""Expression trees over Fraction constants, symbols and the operators
+, -, * and ^ (a power with a constant nonnegative integer exponent).

Nodes are immutable, so a subtree may be shared by several parents; every
operation treats the structure as a tree all the same. All walks use an
explicit stack, so deep trees do not hit the recursion limit.

Cost model: eval, subs, diff, size, equal and to_string each visit every
node once, so they take O(n) node visits for a tree of n nodes; eval also
performs O(n) arithmetic operations on Fractions, plus O(log k) of them for
each power with exponent k.
"""

from fractions import Fraction
from typing import Dict, List, Optional, Tuple, Union

Number = Union[int, Fraction]
BINARY = ("+", "-", "*", "^")


class Expr:
    """One node: op is 'const', 'sym' or one of + - * ^."""

    __slots__ = ("op", "val", "kids")

    def __init__(self, op: str, val: object = None, kids: Tuple["Expr", ...] = ()) -> None:
        self.op = op
        self.val = val
        self.kids = kids

    def __repr__(self) -> str:
        return to_string(self)

    # One method per operation of the data structure; each delegates to the
    # module-level function of the same name.
    def eval(self, env: Dict[str, Number]) -> Fraction:
        return eval_expr(self, env)

    def subs(self, x: str, r: "Expr") -> "Expr":
        return subs(self, x, r)

    def diff(self, x: str) -> "Expr":
        return diff(self, x)

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

    def height(self) -> int:
        return height(self)

    def equal(self, other: "Expr") -> bool:
        return equal(self, other)

    def to_string(self) -> str:
        return to_string(self)

    def check_invariant(self) -> None:
        check_invariant(self)


def const(c: Number) -> Expr:
    return Expr("const", Fraction(c))


def sym(name: str) -> Expr:
    return Expr("sym", name)


def add(a: Expr, b: Expr) -> Expr:
    return Expr("+", None, (a, b))


def sub(a: Expr, b: Expr) -> Expr:
    return Expr("-", None, (a, b))


def mul(a: Expr, b: Expr) -> Expr:
    return Expr("*", None, (a, b))


def power(a: Expr, k: int) -> Expr:
    if k < 0:
        raise ValueError("exponent must be a nonnegative integer")
    return Expr("^", None, (a, const(k)))


def _apply(op: str, a: Fraction, b: Fraction) -> Fraction:
    if op == "+":
        return a + b
    if op == "-":
        return a - b
    if op == "*":
        return a * b
    return a ** int(b)


def eval_expr(e: Expr, env: Dict[str, Number]) -> Fraction:
    """Value of e when each symbol s takes the value env[s].

    Precondition: every symbol of e is a key of env. Cost: O(n) node visits.
    """
    stack: List[Tuple[Expr, bool]] = [(e, False)]  # @line L1
    vals: List[Fraction] = []
    while stack:  # @line L2
        u, done = stack.pop()  # @line L3
        if u.op == "const":  # @line L4
            vals.append(u.val)  # type: ignore[arg-type]
        elif u.op == "sym":  # @line L5
            vals.append(Fraction(env[u.val]))  # type: ignore[index]
        elif not done:  # @line L6
            stack.append((u, True))
            for k in reversed(u.kids):
                stack.append((k, False))
        else:  # @line L7
            b = vals.pop()
            a = vals.pop()
            vals.append(_apply(u.op, a, b))
    return vals[0]  # @line L8


def _postorder(e: Expr) -> List[Expr]:
    """Nodes of e in postorder (children before parents), iteratively."""
    out: List[Expr] = []
    stack: List[Tuple[Expr, bool]] = [(e, False)]
    while stack:
        u, done = stack.pop()
        if done or not u.kids:
            out.append(u)
        else:
            stack.append((u, True))
            for k in reversed(u.kids):
                stack.append((k, False))
    return out


def subs(e: Expr, x: str, r: Expr) -> Expr:
    """e with every occurrence of the symbol x replaced by r. Cost O(n)."""
    res: Dict[int, Expr] = {}
    for u in _postorder(e):
        if u.op == "sym" and u.val == x:
            res[id(u)] = r
        elif not u.kids:
            res[id(u)] = u
        else:
            res[id(u)] = Expr(u.op, None, tuple(res[id(k)] for k in u.kids))
    return res[id(e)]


def _is_const(u: Expr, c: Optional[int] = None) -> bool:
    return u.op == "const" and (c is None or u.val == c)


def s_add(a: Expr, b: Expr) -> Expr:
    """Smart constructor: folds constants and drops a zero summand."""
    if _is_const(a) and _is_const(b):
        return const(a.val + b.val)  # type: ignore[operator]
    if _is_const(a, 0):
        return b
    if _is_const(b, 0):
        return a
    return add(a, b)


def s_sub(a: Expr, b: Expr) -> Expr:
    if _is_const(a) and _is_const(b):
        return const(a.val - b.val)  # type: ignore[operator]
    if _is_const(b, 0):
        return a
    return sub(a, b)


def s_mul(a: Expr, b: Expr) -> Expr:
    """Smart constructor: folds constants, 0 * u = 0 and 1 * u = u."""
    if _is_const(a) and _is_const(b):
        return const(a.val * b.val)  # type: ignore[operator]
    if _is_const(a, 0) or _is_const(b, 0):
        return const(0)
    if _is_const(a, 1):
        return b
    if _is_const(b, 1):
        return a
    return mul(a, b)


def diff(e: Expr, x: str) -> Expr:
    """Derivative of e with respect to x, by the sum, product and power rules.

    The result shares subtrees of e. Cost: O(n) node visits and O(n) new nodes.
    """
    d: Dict[int, Expr] = {}
    for u in _postorder(e):
        if u.op == "const":
            d[id(u)] = const(0)
        elif u.op == "sym":
            d[id(u)] = const(1 if u.val == x else 0)
        else:
            a, b = u.kids
            da, db = d[id(a)], d[id(b)]
            if u.op == "+":
                d[id(u)] = s_add(da, db)
            elif u.op == "-":
                d[id(u)] = s_sub(da, db)
            elif u.op == "*":
                d[id(u)] = s_add(s_mul(da, b), s_mul(a, db))
            else:
                k = int(b.val)  # type: ignore[arg-type]
                if k == 0:
                    d[id(u)] = const(0)
                else:
                    pk = const(1) if k == 1 else (a if k == 2 else power(a, k - 1))
                    d[id(u)] = s_mul(s_mul(const(k), pk), da)
    return d[id(e)]


def size(e: Expr) -> int:
    """Number of nodes, counting a shared subtree once per occurrence."""
    return len(_postorder(e))


def height(e: Expr) -> int:
    """Number of edges on a longest root-to-leaf path."""
    h: Dict[int, int] = {}
    for u in _postorder(e):
        h[id(u)] = 1 + max(h[id(k)] for k in u.kids) if u.kids else 0
    return h[id(e)]


def equal(a: Expr, b: Expr) -> bool:
    """Syntactic identity: same operators, constants and symbols in the same shape."""
    stack: List[Tuple[Expr, Expr]] = [(a, b)]
    while stack:
        u, v = stack.pop()
        if u is v:
            continue
        if u.op != v.op or u.val != v.val or len(u.kids) != len(v.kids):
            return False
        stack.extend(zip(u.kids, v.kids))
    return True


def to_string(e: Expr) -> str:
    """Fully parenthesized infix form; ^ is written **, so Python can parse it."""
    s: Dict[int, str] = {}
    for u in _postorder(e):
        if u.op == "const":
            c = u.val
            s[id(u)] = "Fraction(%d, %d)" % (c.numerator, c.denominator) if c.denominator != 1 else "(%d)" % c.numerator  # type: ignore[union-attr]
        elif u.op == "sym":
            s[id(u)] = str(u.val)
        else:
            a, b = u.kids
            o = "**" if u.op == "^" else u.op
            s[id(u)] = "(" + s[id(a)] + " " + o + " " + s[id(b)] + ")"
    return s[id(e)]


def check_invariant(e: Expr) -> None:
    """Every node has a valid operator and arity, and the structure is acyclic."""
    on_path: set = set()
    stack: List[Tuple[Expr, bool]] = [(e, False)]
    while stack:
        u, done = stack.pop()
        if done:
            on_path.discard(id(u))
            continue
        assert id(u) not in on_path, "cycle"
        if u.op == "const":
            assert isinstance(u.val, Fraction) and u.kids == ()
        elif u.op == "sym":
            assert isinstance(u.val, str) and u.kids == ()
        else:
            assert u.op in BINARY and len(u.kids) == 2
            if u.op == "^":
                k = u.kids[1]
                assert k.op == "const" and k.val.denominator == 1 and k.val >= 0  # type: ignore[union-attr]
            on_path.add(id(u))
            stack.append((u, True))
            for k in u.kids:
                stack.append((k, False))
test_expression_tree.py 167 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/expression-tree/. The authors are listed in prov/5TDXMG.jsonl.

import random
from fractions import Fraction
from typing import Dict, List, Tuple

import pytest

from expression_tree import (
    Expr,
    add,
    const,
    diff,
    equal,
    eval_expr,
    height,
    mul,
    power,
    size,
    sub,
    subs,
    sym,
    to_string,
)

Poly = Dict[Tuple[int, int], Fraction]  # exponents of (x, y) -> coefficient


def expand(e: Expr) -> Poly:
    """Independent oracle: the polynomial in Q[x, y] that e denotes."""
    out: Dict[int, Poly] = {}
    stack: List[Tuple[Expr, bool]] = [(e, False)]
    while stack:
        u, done = stack.pop()
        if not u.kids:
            if u.op == "const":
                out[id(u)] = {(0, 0): u.val} if u.val != 0 else {}
            else:
                out[id(u)] = {(1, 0) if u.val == "x" else (0, 1): Fraction(1)}
        elif not done:
            stack.append((u, True))
            stack.extend((k, False) for k in u.kids)
        else:
            a, b = (out[id(k)] for k in u.kids)
            if u.op in "+-":
                r = dict(a)
                for m, c in b.items():
                    r[m] = r.get(m, 0) + (c if u.op == "+" else -c)
            elif u.op == "*":
                r = pmul(a, b)
            else:
                r = {(0, 0): Fraction(1)}
                for _ in range(int(u.kids[1].val)):
                    r = pmul(r, a)
            out[id(u)] = {m: c for m, c in r.items() if c != 0}
    return out[id(e)]


def pmul(a: Poly, b: Poly) -> Poly:
    r: Poly = {}
    for (i, j), c in a.items():
        for (k, l), d in b.items():
            r[(i + k, j + l)] = r.get((i + k, j + l), 0) + c * d
    return {m: c for m, c in r.items() if c != 0}


def pdiff_x(a: Poly) -> Poly:
    return {(i - 1, j): c * i for (i, j), c in a.items() if i > 0}


def py_eval(e: Expr, env: Dict[str, Fraction]) -> Fraction:
    """Second oracle: Python's own parser and arithmetic on the printed form."""
    return Fraction(eval(to_string(e), {"Fraction": Fraction}, dict(env)))


def rand_tree(rng: random.Random, depth: int) -> Expr:
    if depth == 0 or rng.random() < 0.25:
        r = rng.random()
        if r < 0.4:
            return const(Fraction(rng.randint(-5, 5), rng.randint(1, 3)))
        return sym("x" if r < 0.75 else "y")
    op = rng.choice("+-*^" if depth > 1 else "+-*")
    if op == "^":
        return power(rand_tree(rng, depth - 2), rng.randint(0, 3))
    a, b = rand_tree(rng, depth - 1), rand_tree(rng, depth - 1)
    return {"+": add, "-": sub, "*": mul}[op](a, b)


def example() -> Expr:
    return mul(add(sym("x"), const(3)), sub(sym("x"), sym("y")))


def test_worked_example() -> None:
    e = example()
    e.check_invariant()
    assert size(e) == 7 and height(e) == 2
    assert eval_expr(e, {"x": 2, "y": 5}) == -15
    assert e.eval({"x": 2, "y": 5}) == -15
    d = diff(e, "x")
    # d/dx (x+3)(x-y) = (x - y) + (x + 3)
    assert to_string(d) == "((x - y) + (x + (3)))"
    assert eval_expr(d, {"x": 2, "y": 5}) == 2
    s = subs(e, "x", add(sym("y"), const(1)))
    assert to_string(s) == "(((y + (1)) + (3)) * ((y + (1)) - y))"
    assert eval_expr(s, {"y": 0}) == 4


def test_atoms_and_edge_cases() -> None:
    assert eval_expr(const(0), {}) == 0
    assert eval_expr(sym("x"), {"x": 7}) == 7
    assert size(const(4)) == 1 and height(sym("x")) == 0
    assert equal(diff(const(4), "x"), const(0))
    assert equal(diff(sym("y"), "x"), const(0))
    assert equal(diff(sym("x"), "x"), const(1))
    assert equal(diff(power(sym("x"), 0), "x"), const(0))
    assert eval_expr(power(sym("x"), 0), {"x": 0}) == 1
    assert subs(const(2), "x", sym("y")).val == 2
    with pytest.raises(ValueError):
        power(sym("x"), -1)
    with pytest.raises(KeyError):
        eval_expr(sym("z"), {"x": 1})


def test_deep_tree_no_recursion() -> None:
    e: Expr = sym("x")
    for i in range(50000):
        e = add(e, const(1)) if i % 2 == 0 else sub(e, const(Fraction(1, 2)))
    e.check_invariant()
    assert height(e) == 50000
    assert eval_expr(e, {"x": 0}) == Fraction(25000, 2)
    assert eval_expr(diff(e, "x"), {"x": 9}) == 1
    assert eval_expr(subs(e, "x", const(1)), {}) == 12501
    assert equal(e, e) and not equal(e, add(e, const(0)))


def test_equal_is_syntactic() -> None:
    a = add(sym("x"), sym("y"))
    b = add(sym("x"), sym("y"))
    c = add(sym("y"), sym("x"))
    assert equal(a, b) and not equal(a, c)
    assert eval_expr(a, {"x": 1, "y": 2}) == eval_expr(c, {"x": 1, "y": 2})


def test_random_against_oracles() -> None:
    rng = random.Random(20261107)
    for _ in range(600):
        e = rand_tree(rng, rng.randint(1, 6))
        e.check_invariant()
        p = expand(e)
        for _ in range(3):
            env = {"x": Fraction(rng.randint(-6, 6), rng.randint(1, 4)), "y": Fraction(rng.randint(-6, 6))}
            v = eval_expr(e, env)
            assert v == py_eval(e, env)
            assert v == sum(c * env["x"] ** i * env["y"] ** j for (i, j), c in p.items())
        # derivative: the expansion of diff(e) is d/dx of the expansion of e
        d = diff(e, "x")
        d.check_invariant()
        assert expand(d) == pdiff_x(p)
        # substitution law: eval(subs(e, x, r), env) = eval(e, env[x := eval(r, env)])
        r = rand_tree(rng, 3)
        env = {"x": Fraction(rng.randint(-4, 4)), "y": Fraction(rng.randint(-4, 4), 3)}
        se = subs(e, "x", r)
        se.check_invariant()
        assert eval_expr(se, env) == eval_expr(e, {"x": eval_expr(r, env), "y": env["y"]})
        assert equal(subs(e, "z", r), e)
        assert equal(e, rand_tree(random.Random(0), 0)) == (to_string(e) == to_string(rand_tree(random.Random(0), 0)))

What the next level needs

  • the tests did not run in this build

Read the full page about Expression tree