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
# 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