Optimal binary search tree dynamic programming in Python
O(n^3) interval dynamic program over key ranges that computes the minimum expected search cost tree.
- File
optimal_bst_dynamic_programming.py, 107 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/optimal-bst-dynamic-programming/. The authors are listed in prov/5HF5N0.jsonl.
"""Optimal binary search trees by dynamic programming (CLRS OPTIMAL-BST)."""
from typing import Any, Sequence
def optimal_bst(p: Sequence[Any], q: Sequence[Any]) -> tuple[list[list[Any]], list[list[int]]]:
"""Return the tables (e, root) of the optimal BST dynamic program.
p[1..n] are the key weights (p[0] is ignored) and q[0..n] the gap weights.
e[i][j] is the minimum expected cost (CLRS form, dummy leaves counted) of a
subtree on keys i..j, for 1 <= i <= n + 1 and i - 1 <= j <= n.
root[i][j] is a root of an optimal subtree for 1 <= i <= j <= n; ties go to
the smallest root. Weights may be int, Fraction or float. O(n^3) time.
"""
n = len(q) - 1
if len(p) != n + 1:
raise ValueError("p must have length len(q), with p[0] unused")
e: list[list[Any]] = [[0] * (n + 1) for _ in range(n + 2)]
w: list[list[Any]] = [[0] * (n + 1) for _ in range(n + 2)]
root = [[0] * (n + 1) for _ in range(n + 1)]
for i in range(1, n + 2): # @line L1
e[i][i - 1] = q[i - 1]
w[i][i - 1] = q[i - 1]
for length in range(1, n + 1): # @line L2
for i in range(1, n - length + 2): # @line L3
j = i + length - 1 # @line L4
w[i][j] = w[i][j - 1] + p[j] + q[j]
best = None
for r in range(i, j + 1): # @line L5
t = e[i][r - 1] + e[r + 1][j] + w[i][j] # @line L6
if best is None or t < best: # @line L7
best = t
root[i][j] = r
e[i][j] = best
return e, root # @line L8
def optimal_bst_knuth(p: Sequence[Any], q: Sequence[Any]) -> tuple[Any, list[list[int]]]:
"""Knuth's O(n^2) version: search r only in root[i][j-1] .. root[i+1][j].
Ties go to the smallest root, so the tables match optimal_bst.
"""
n = len(q) - 1
e: list[list[Any]] = [[0] * (n + 1) for _ in range(n + 2)]
w: list[list[Any]] = [[0] * (n + 1) for _ in range(n + 2)]
root = [[0] * (n + 1) for _ in range(n + 2)]
for i in range(1, n + 2):
e[i][i - 1] = q[i - 1]
w[i][i - 1] = q[i - 1]
for length in range(1, n + 1):
for i in range(1, n - length + 2):
j = i + length - 1
w[i][j] = w[i][j - 1] + p[j] + q[j]
lo = i if length == 1 else root[i][j - 1]
hi = j if length == 1 else root[i + 1][j]
best = None
for r in range(lo, hi + 1):
t = e[i][r - 1] + e[r + 1][j] + w[i][j]
if best is None or t < best:
best = t
root[i][j] = r
e[i][j] = best
return (e[1][n] if n >= 1 else q[0]), root
def build_tree(root: list[list[int]], n: int) -> dict[int, tuple[int, int]]:
"""Return {key: (left child, right child)} of the tree in the root table (0 = none)."""
children: dict[int, tuple[int, int]] = {}
if n == 0:
return children
stack = [(1, n)]
while stack:
i, j = stack.pop()
r = root[i][j]
left = root[i][r - 1] if i <= r - 1 else 0
right = root[r + 1][j] if r + 1 <= j else 0
children[r] = (left, right)
if i <= r - 1:
stack.append((i, r - 1))
if r + 1 <= j:
stack.append((r + 1, j))
return children
def tree_cost(children: dict[int, tuple[int, int]], top: int, p: Sequence[Any], q: Sequence[Any]) -> Any:
"""Expected cost (CLRS form) of a BST given as a child map with root top."""
n = len(q) - 1
if n == 0:
return q[0]
total: Any = 0
stack = [(top, 0)]
while stack:
k, d = stack.pop()
total += p[k] * (d + 1)
left, right = children[k]
if left:
stack.append((left, d + 1))
else:
total += q[k - 1] * (d + 2) # the gap d_{k-1} is a leaf below k
if right:
stack.append((right, d + 1))
else:
total += q[k] * (d + 2) # the gap d_k
return total
test_optimal_bst_dynamic_programming.py 73 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/optimal-bst-dynamic-programming/. The authors are listed in prov/5HF5N0.jsonl.
import random
from fractions import Fraction
from optimal_bst_dynamic_programming import build_tree, optimal_bst, optimal_bst_knuth, tree_cost
def all_trees(i, j):
"""Yield (root, children) for every BST shape on keys i..j (root 0 if empty)."""
if i > j:
yield 0, {}
return
for r in range(i, j + 1):
for lr, lc in all_trees(i, r - 1):
for rr, rc in all_trees(r + 1, j):
ch = dict(lc)
ch.update(rc)
ch[r] = (lr, rr)
yield r, ch
def brute(p, q):
n = len(q) - 1
if n == 0:
return q[0]
return min(tree_cost(ch, r, p, q) for r, ch in all_trees(1, n))
def test_clrs_example() -> None:
p = [0] + [Fraction(x, 100) for x in (15, 10, 5, 10, 20)]
q = [Fraction(x, 100) for x in (5, 10, 5, 5, 5, 10)]
e, root = optimal_bst(p, q)
assert e[1][5] == Fraction(11, 4)
assert root[1][5] == 2
ch = build_tree(root, 5)
assert ch == {2: (1, 5), 1: (0, 0), 5: (4, 0), 4: (3, 0), 3: (0, 0)}
assert tree_cost(ch, 2, p, q) == Fraction(11, 4)
assert optimal_bst_knuth(p, q)[0] == Fraction(11, 4)
def test_small() -> None:
assert optimal_bst([0], [7])[0][1][0] == 7
e, root = optimal_bst([0, 3], [1, 2])
assert e[1][1] == 3 * 1 + 1 * 2 + 2 * 2 and root[1][1] == 1
def test_random_against_brute_force() -> None:
rng = random.Random(5150)
for _ in range(600):
n = rng.randint(0, 6)
p = [0] + [rng.randint(0, 9) for _ in range(n)]
q = [rng.randint(0, 9) for _ in range(n + 1)]
e, root = optimal_bst(p, q)
best = brute(p, q)
cost = e[1][n] if n else e[1][0]
assert cost == best
if n:
ch = build_tree(root, n)
assert len(ch) == n
assert tree_cost(ch, root[1][n], p, q) == best
assert optimal_bst_knuth(p, q)[0] == best
def test_knuth_large_random() -> None:
rng = random.Random(77)
for _ in range(20):
n = rng.randint(10, 40)
p = [0] + [rng.randint(0, 100) for _ in range(n)]
q = [rng.randint(0, 100) for _ in range(n + 1)]
e, _ = optimal_bst(p, q)
assert optimal_bst_knuth(p, q)[0] == e[1][n]
What the next level needs
- the tests did not run in this build
Read the full page about Optimal binary search tree dynamic programming