Algorithm Search⌘K

PQ-tree in Python

A tree that represents all permutations satisfying a consecutive-ones constraint family.

File
pq_tree.py, 204 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
pq_tree.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/pq-tree/. The authors are listed in prov/HCBMN4.jsonl.
"""PQ-trees with a simple REDUCE.

A tree is made of nodes ("leaf", x), ("P", [children]) and ("Q", [children]).
A P-node allows any order of its children, a Q-node only the given order or
its reverse. The frontier of a tree is its leaves from left to right; the tree
represents every frontier obtained by these reorderings.

reduce(tree, s) returns a tree for the frontiers of tree in which the set s
is consecutive, or None if there are none. It applies the templates of Booth
and Lueker by one recursion over the pertinent part of the tree, in O(n) time
per call (the original bottom-up version runs in O(|s|) amortized time).
"""

from itertools import permutations
from typing import Iterable, Optional

EMPTY, FULL, PARTIAL = "empty", "full", "partial"


def leaf(x):
    return ("leaf", x)


def universal(items: Iterable) -> tuple:
    """The tree that allows every order of the items."""
    items = list(items)
    if len(items) == 1:
        return leaf(items[0])
    return ("P", [leaf(x) for x in items])


def leaves(t) -> list:
    if t[0] == "leaf":
        return [t[1]]
    out = []
    for c in t[1]:
        out.extend(leaves(c))
    return out


def _make(kind, kids):
    """Build a node, normalizing: one child -> that child, Q of two -> P."""
    if len(kids) == 1:
        return kids[0]
    if kind == "Q" and len(kids) == 2:
        kind = "P"
    return (kind, kids)


def _group(kids):
    """A P-node over kids (or the single kid), or None when kids is empty."""
    if not kids:
        return None
    return _make("P", kids)


def _count(t, s, memo):
    """Store (leaves of t in s, leaves of t) by id(t); return the pair."""
    if t[0] == "leaf":
        pair = (1 if t[1] in s else 0, 1)
    else:
        k = size = 0
        for c in t[1]:
            a, b = _count(c, s, memo)
            k, size = k + a, size + b
        pair = (k, size)
    memo[id(t)] = pair
    return pair


def _label(t, memo):
    k, size = memo[id(t)]
    if k == 0:
        return EMPTY
    return FULL if k == size else PARTIAL


class _Fail(Exception):
    pass


def _singly(t, memo):
    """Arrange a partial node as a sequence: empty part left, full part right."""
    kind, kids = t
    labels = [_label(c, memo) for c in kids]
    if kind == "P":  # templates P4 and P5 for a non-root node  # @line L10
        empties = [c for c, l in zip(kids, labels) if l == EMPTY]
        fulls = [c for c, l in zip(kids, labels) if l == FULL]
        partials = [c for c, l in zip(kids, labels) if l == PARTIAL]
        if len(partials) > 1:  # @line L10
            raise _Fail
        seq = []
        if empties:
            seq.append(_group(empties))  # @line L11
        if partials:
            seq.extend(_singly(partials[0], memo))
        if fulls:
            seq.append(_group(fulls))
        return seq
    # Q-node: the labels must read E* [partial] F*, in one of the two directions
    for order in (list(range(len(kids))), list(range(len(kids) - 1, -1, -1))):
        ls = [labels[i] for i in order]
        rank = {EMPTY: 0, PARTIAL: 1, FULL: 2}
        if all(rank[a] <= rank[b] for a, b in zip(ls, ls[1:])) and ls.count(PARTIAL) <= 1:  # @line L12
            seq = []
            for i in order:
                if labels[i] == PARTIAL:
                    seq.extend(_singly(kids[i], memo))  # @line L13
                else:
                    seq.append(kids[i])
            return seq
    raise _Fail  # @line L12


def _root(t, memo):
    """Rearrange the pertinent root so that its full leaves are consecutive."""
    kind, kids = t
    labels = [_label(c, memo) for c in kids]
    if kind == "P":  # templates P2, P4, P6 at the root  # @line L5
        empties = [c for c, l in zip(kids, labels) if l == EMPTY]
        fulls = [c for c, l in zip(kids, labels) if l == FULL]
        partials = [c for c, l in zip(kids, labels) if l == PARTIAL]
        if len(partials) > 2:  # @line L5
            raise _Fail
        if not partials:
            return _make("P", empties + [_group(fulls)])  # @line L7
        seq = _singly(partials[0], memo)  # @line L6
        if fulls:
            seq.append(_group(fulls))
        if len(partials) == 2:
            seq.extend(reversed(_singly(partials[1], memo)))
        return _make("P", empties + [_make("Q", seq)])  # @line L7
    # Q-node: the non-empty children form a block, inner ones full
    nonempty = [i for i, l in enumerate(labels) if l != EMPTY]
    i, j = nonempty[0], nonempty[-1]
    if any(labels[x] == EMPTY for x in range(i, j + 1)):  # @line L8
        raise _Fail
    if any(labels[x] == PARTIAL for x in range(i + 1, j)):
        raise _Fail
    seq = []
    for x, c in enumerate(kids):
        if labels[x] == PARTIAL and x == i:
            seq.extend(_singly(c, memo))  # full part toward the block
        elif labels[x] == PARTIAL and x == j:
            seq.extend(reversed(_singly(c, memo)))
        else:
            seq.append(c)
    return _make("Q", seq)  # @line L9


def _reduce(t, s, memo):
    if t[0] == "leaf" or _label(t, memo) != PARTIAL:
        return t
    kids = t[1]
    touched = [x for x, c in enumerate(kids) if _label(c, memo) != EMPTY]
    if len(touched) == 1 and _label(kids[touched[0]], memo) == PARTIAL:  # @line L3
        # all of s lies below one child: the pertinent root is lower
        x = touched[0]
        new = list(kids)
        new[x] = _reduce(kids[x], s, memo)
        return (t[0], new)
    return _root(t, memo)  # @line L4


def reduce(tree, s) -> Optional[tuple]:
    """Restrict tree to the frontiers in which the set s is consecutive."""
    s = set(s)
    memo: dict = {}
    _count(tree, s, memo)  # @line L2
    if len(s) <= 1:  # @line L1
        return tree
    try:
        return _reduce(tree, s, memo)
    except _Fail:
        return None


def frontiers(t) -> set:
    """All frontiers that t represents (exponential; for testing)."""
    if t[0] == "leaf":
        return {(t[1],)}
    kind, kids = t
    subs = [frontiers(c) for c in kids]
    orders = permutations(range(len(kids))) if kind == "P" else [
        tuple(range(len(kids))), tuple(range(len(kids) - 1, -1, -1))]
    out = set()
    for order in orders:
        partial = {()}
        for i in order:
            partial = {p + f for p in partial for f in subs[i]}
        out |= partial
    return out


def consecutive_ones_order(universe, sets) -> Optional[list]:
    """An order of universe in which every set is consecutive, or None."""
    tree = universal(universe)
    for s in sets:
        tree = reduce(tree, s)
        if tree is None:
            return None
    return leaves(tree)
test_pq_tree.py 97 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/pq-tree/. The authors are listed in prov/HCBMN4.jsonl.
import random
from itertools import permutations

from pq_tree import consecutive_ones_order, frontiers, leaves, reduce, universal


def consecutive(order, s):
    pos = [i for i, x in enumerate(order) if x in s]
    return not pos or pos[-1] - pos[0] == len(pos) - 1


def brute(universe, sets):
    return {p for p in permutations(universe) if all(consecutive(p, s) for s in sets)}


def build(universe, sets):
    t = universal(universe)
    for s in sets:
        t = reduce(t, s)
        if t is None:
            return None
    return t


def check(universe, sets):
    t = build(universe, sets)
    want = brute(universe, sets)
    if t is None:
        assert want == set()
    else:
        assert sorted(leaves(t)) == sorted(universe)
        assert frontiers(t) == want
    return t


def test_example():
    u = [1, 2, 3, 4, 5]
    sets = [{1, 3}, {2, 3, 4}, {4, 5}, {2, 4}]
    t = check(u, sets)
    assert frontiers(t) == {(1, 3, 2, 4, 5), (5, 4, 2, 3, 1)}
    assert consecutive_ones_order(u, sets) in ([1, 3, 2, 4, 5], [5, 4, 2, 3, 1])


def test_example_steps():
    u = [1, 2, 3, 4, 5]
    t = reduce(universal(u), {2, 3, 4})
    assert len(frontiers(t)) == 3 * 2 * 6  # {2,3,4} as one block of a P-node
    t = reduce(t, {4, 5})
    # P(1, Q(P(2, 3), 4, 5)): 2 orders of the root, 2 of the Q-node, 2 of P(2, 3)
    assert t == ("P", [("leaf", 1), ("Q", [("P", [("leaf", 2), ("leaf", 3)]), ("leaf", 4), ("leaf", 5)])])
    assert len(frontiers(t)) == 8
    check(u, [{2, 3, 4}, {4, 5}])


def test_non_examples():
    assert check(list("abc"), [set("ab"), set("bc"), set("ac")]) is None
    assert check(list("abcd"), [set("ab"), set("ac"), set("ad")]) is None
    # clique matrix of a 4-cycle, transposed: each vertex in two consecutive edges
    assert check(["e1", "e2", "e3", "e4"], [{"e1", "e2"}, {"e2", "e3"}, {"e3", "e4"}, {"e4", "e1"}]) is None


def test_edge_cases():
    assert frontiers(universal([7])) == {(7,)}
    check([1, 2, 3], [])
    check([1, 2, 3], [set(), {2}, {1, 2, 3}])
    check([1, 2], [{1, 2}, {1}])
    # the same set twice, and nested sets
    check([1, 2, 3, 4], [{1, 2}, {1, 2}, {1, 2, 3}, {2}])


def test_random_against_brute_force():
    rng = random.Random(4)
    for _ in range(700):
        n = rng.randint(1, 6)
        u = list(range(n))
        sets = []
        for _ in range(rng.randint(0, 5)):
            k = rng.randint(0, n)
            sets.append(set(rng.sample(u, k)))
        check(u, sets)


def test_random_interval_families_always_succeed():
    rng = random.Random(9)
    for _ in range(200):
        n = rng.randint(1, 6)
        hidden = list(range(n))
        rng.shuffle(hidden)
        sets = []
        for _ in range(rng.randint(1, 6)):
            i = rng.randint(0, n - 1)
            j = rng.randint(i, n - 1)
            sets.append(set(hidden[i:j + 1]))
        t = check(list(range(n)), sets)
        assert t is not None and tuple(hidden) in frontiers(t)

What the next level needs

  • the tests did not run in this build

Read the full page about PQ-tree