Usage: #include "bst.h"
The library provides binary search trees, sorted dictionaries and sorted sets, in two
layers. A binary search tree is the tree from lecture: you take it apart
at its root using expose and build new trees using join_mid. A dictionary
or set is built on a tree, but you use it through operations on keys and values, such as
find, insert and set_union.
Each layer is specified by concepts, the C++ way of specifying an abstract data
type, as the sequence concept is for sequences.
Code written against a concept, rather than a particular type, is generic: it works
with any type that satisfies the concept. The library's types are treaps, and the
treap page describes each of their operations with its cost. This
page describes the concepts and how to write generic code with them.
The library defines five concepts:
par::bst, a binary search tree;par::aug_bst, an augmented binary search tree;par::sorted_set, a sorted set.par::sorted_dict, a sorted dictionary;par::aug_sorted_dict, an augmented sorted dictionary;
A tree holds key-value pairs. Its keys are distinct and increase from left to right, in the
order given by <. A type Tree satisfies the
par::bst concept if it names its key and value types as
Tree::key_type and Tree::value_type, its keys are totally ordered,
comparable with ==, !=, <, <=, > and >=, and it has the four operations from lecture. With K and
V for the key and value types:
auto size() const -> std::size_t; // the number of elements
auto expose(const Tree& t) -> std::optional<par::node_view<Tree>>; // view a tree's root
static auto empty() -> Tree; // the empty tree
auto join_mid(Tree L, const K& k, const V& v, Tree R) -> Tree; // create a new tree
par::key_t<Tree> and par::value_t<Tree> also name the
key and value types. The treap page describes
empty,
size,
expose and
join_mid in detail.
expose is the primary mechanism for writing generic code over BSTs. It returns
a view of the root element: an empty optional for the empty tree,
and otherwise a node_view of the root's key, value and subtrees:
template<typename Tree>
struct node_view {
const Tree& left;
const par::key_t<Tree>& key;
const par::value_t<Tree>& value;
const Tree& right;
};
The view refers into the tree instead of copying it, so exposing a tree costs nothing, but the view is only valid while the tree is.
join_mid(L, k, v, R) works the other way, building one tree from a left
subtree, a key-value pair and a right subtree. It requires every key of L to be
less than k, and k to be less than every key of R.
This is where a balanced tree does its balancing, so the result holds exactly those pairs
but need not have k at its root.
Every other tree algorithm, from searching to splitting to taking a union, can be written
with these four operations. The library provides two of them generically for every tree, as free
functions: split and
join.
template<par::bst Tree>
auto join(const Tree& L, const Tree& R) -> Tree
template<par::bst Tree>
auto split(const Tree& t, const par::key_t<Tree>& k) -> std::tuple<Tree, std::optional<par::value_t<Tree>>, Tree>
We will typically write generic tree code as a function or class template whose type parameter must satisfy
par::bst:
template<par::bst Tree>
auto find(const Tree& t, const par::key_t<Tree>& k) -> std::optional<par::value_t<Tree>>;
A constrained template is very similar to constrained auto
that we used frequently when writing sequence algorithms (recall f(const sequence auto& S) as a
function parameter). So, the above code is (almost) equivalent to writing
auto find(const par::bst auto& t, const auto& k);
except that we give an explicit name, Tree, to the type parameter. This is useful when writing
generic tree code so that we can write Tree::empty(), par::key_t<Tree>,
and par::value_t<Tree> which require us to have a name to refer to.
Technically, if you really prefer constrained auto, you could also write
auto find(const par::bst auto& t, const par::key_t<decltype(t)>& k) -> std::optional<par::value_t<decltype(t)>>;
making use of the decltype keyword, which gives the type of its argument, but this is far less nice to write, hence
why we prefer using an explicit constrained template.
A function or class template written against par::bst should use only
the four operations promised by the concept and the generic split and
join, so that it works with any tree implementation.
The usual shape is a recursion that exposes the tree, handles the empty and non-empty cases,
and then recurs on the left or right subtree, or both.
// The value at k in t, or nothing if k is not in t
template<par::bst Tree>
auto find(const Tree& t, const par::key_t<Tree>& k) -> std::optional<par::value_t<Tree>> {
if (auto e = expose(t)) {
auto [left, key, value, right] = e.value();
if (k < key) return find(left, k);
if (key < k) return find(right, k);
return value;
}
return std::nullopt;
}
// t with k mapped to v, replacing any value k already had
template<par::bst Tree>
auto insert(const Tree& t, const par::key_t<Tree>& k, const par::value_t<Tree>& v) -> Tree {
if (auto e = expose(t)) {
auto [left, key, value, right] = e.value();
if (k < key) return join_mid(insert(left, k, v), key, value, right);
if (key < k) return join_mid(left, key, value, insert(right, k, v));
return join_mid(left, k, v, right);
}
return join_mid(Tree::empty(), k, v, Tree::empty());
}
find only reads the tree, following one path down from the root.
insert follows the same path, then rebuilds it with join_mid on the
way back up. The new key always goes into the subtree where it belongs, so every
join_mid gets the ordering it requires, and each one gives the tree a chance to
rebalance. Both work on the library's tree, par::treap:
using tree = par::treap<int, std::string>;
auto leaf = [](int k, std::string v) { return join_mid(tree::empty(), k, v, tree::empty()); };
tree t = join_mid(leaf(1, "a"), 2, "b", leaf(3, "c"));
tree t2 = insert(t, 4, "d");
auto four = find(t2, 4); // an optional holding "d"
A dictionary maps keys to values, and a set is a collection of keys. Their keys are sorted by
<, which gives meaning to operations such as first,
next, split and get_range. Like everything in the
library, dictionaries and sets are persistent: an operation such as insert
returns a new dictionary and leaves its argument unchanged.
A type Dict satisfies the par::sorted_dict concept if it names its
key and value types as Dict::key_type and Dict::value_type, its keys are
totally ordered as a tree's are, and it has these operations. An operation on one
dictionary is a member function, one on two dictionaries is a free function, and the
operations that make a dictionary from scratch are static:
// Making a dictionary
static auto empty() -> Dict;
static auto singleton(K k, V v) -> Dict;
static auto from_sequence(const sequence auto& pairs) -> Dict;
auto size() const -> std::size_t;
// Converting into a sequence
auto to_sequence() const -> par::array_sequence<std::pair<K, V>>;
// Searching
auto find(const K& k) const -> std::optional<V>;
auto first() const -> std::optional<std::pair<const K&, const V&>>;
auto last() const -> std::optional<std::pair<const K&, const V&>>;
auto prev(const K& k) const -> std::optional<std::pair<const K&, const V&>>;
auto next(const K& k) const -> std::optional<std::pair<const K&, const V&>>;
// Persistent updates
auto insert(K k, V v) const -> Dict;
auto remove(const K& k) const -> Dict;
auto filter(std::predicate<K, V> auto&& p) const -> Dict;
// Order statistics operations
auto rank(const K& k) const -> std::size_t;
auto select(std::size_t i) const -> std::optional<std::pair<const K&, const V&>>;
// Splitting and joining
auto split(const K& k) const -> std::tuple<Dict, std::optional<V>, Dict>;
auto get_range(const K& k1, const K& k2) const -> Dict;
auto join(const Dict& a, const Dict& b) -> Dict;
// Set-theoretic operations
auto set_union(const Dict& a, const Dict& b) -> Dict;
auto set_intersection(const Dict& a, const Dict& b) -> Dict;
auto set_difference(const Dict& a, const Dict& b) -> Dict;
The treap page describes each operation, from
making a dictionary to
combining two. A few rules hold for every
dictionary: in set_union and set_intersection, a key in both keeps
the left argument's value; from_sequence keeps the first value of a repeated
key; get_range(k1, k2) includes k1 but not k2; and
ranks start at zero.
A set, which satisfies par::sorted_set, has the same operations without values.
It has contains in place of find, its keys come back as copies
rather than references, and its split reports whether the key was present. With
Set for the set's type:
// Making a set
static auto empty() -> Set;
static auto singleton(K k) -> Set;
static auto from_sequence(const sequence<K> auto& keys) -> Set;
auto size() const -> std::size_t;
// Converting into a sequence
auto to_sequence() const -> par::array_sequence<K>;
// Searching
auto contains(const K& k) const -> bool;
auto first() const -> std::optional<K>;
auto last() const -> std::optional<K>;
auto prev(const K& k) const -> std::optional<K>;
auto next(const K& k) const -> std::optional<K>;
// Persistent updates
auto insert(K k) const -> Set;
auto remove(const K& k) const -> Set;
auto filter(std::predicate<K> auto&& p) const -> Set;
// Order statistics operations
auto rank(const K& k) const -> std::size_t;
auto select(std::size_t i) const -> std::optional<K>;
// Splitting and joining
auto split(const K& k) const -> std::tuple<Set, bool, Set>;
auto get_range(const K& k1, const K& k2) const -> Set;
auto join(const Set& a, const Set& b) -> Set;
// Set-theoretic operations
auto set_union(const Set& a, const Set& b) -> Set;
auto set_intersection(const Set& a, const Set& b) -> Set;
auto set_difference(const Set& a, const Set& b) -> Set;
Generic dictionary code is written the same way, as a template whose type parameter must
satisfy par::sorted_dict. It can use the concept's operations and nothing else,
so it works with whichever dictionary it is given.
For example, a histogram counts how many times each key has occurred. It can be
represented as a dictionary that maps each key to its count. The class template below takes
the dictionary type as its parameter, as an SML functor takes a structure, and its static
members are the histogram's three operations: empty makes a histogram in which
nothing has occurred, add records one more occurrence of a key, and
count returns how many times a key has occurred, which is 0 if it never has:
// A persistent histogram type
// - add returns a new histogram with the updated count
template<par::sorted_dict Dict>
class histogram {
Dict hist{};
histogram(Dict h) : hist(std::move(h)) { }
public:
histogram() = default;
// add one more occurrence of k
auto add(const par::key_t<Dict>& k) const -> histogram {
if (auto freq = hist.find(k)) return {hist.insert(k, freq.value() + 1)};
return {hist.insert(k, 1)};
}
// Return the number of times k has occurred
auto count(const par::key_t<Dict>& k) const -> int {
if (auto freq = hist.find(k)) return freq.value();
return 0;
}
};
Since insert replaces the value of a key that is already present,
add looks up the old count first and then inserts one more. Given a dictionary
from strings to counts, the histogram counts words:
using word_histogram = histogram<par::treap_dict<std::string, int>>;
word_histogram empty{};
auto h1 = empty.add("pear");
auto h2 = h1.add("apple");
auto h3 = h2.add("pear");
int pears = h3.count("pear"); // 2
int earlier = h1.count("pear"); // 1, since the type is persistent
Generic set code takes a type that satisfies par::sorted_set instead. These two
tests, whether one set is a subset of another and whether two sets have no keys in common,
work with any set:
// Whether every key of a is also in b
template<par::sorted_set Set>
auto is_subset(const Set& a, const Set& b) -> bool {
return set_difference(a, b).size() == 0;
}
// Whether a and b have no keys in common
template<par::sorted_set Set>
auto is_disjoint(const Set& a, const Set& b) -> bool {
return set_intersection(a, b).size() == 0;
}
An augmented tree or dictionary also keeps all of its values combined into one. It satisfies
par::aug_bst or par::aug_sorted_dict, which require everything
par::bst or par::sorted_dict does, plus one additional function:
// Returns the reduction of every value in the structure with respect to
// the associative binary function Combine with the given identity value
auto reduced_val() const -> augmented_value_type;
Given a par::aug_bst<K,V,Combine,Identity> or par::aug_sorted_dict<K,V,Combine,Identity>,
the reduced_val function returns the values combined
with Combine in increasing order of their keys, or Identity if there
are none. Its result has type augmented_value_type, which is usually the value
type itself. It takes constant time, so together with get_range it gives the
combined value of any range of keys in logarithmic time. The treap page's augmentation section shows
how to make augmented trees and dictionaries, with examples of three different ways to
combine values.
template<typename T, typename K = void, typename V = void>
concept bst =
requires { typename T::key_type; typename T::value_type; } &&
std::totally_ordered<key_t<T>> &&
(std::same_as<K, void> || std::same_as<key_t<T>, K>) &&
(std::same_as<V, void> || std::same_as<value_t<T>, V>) &&
requires(const T& t, const key_t<T>& k, const value_t<T>& v) {
{ T::empty() } -> std::same_as<T>;
{ t.size() } -> std::convertible_to<std::size_t>;
{ expose(t) } -> std::same_as<std::optional<node_view<T>>>;
{ join_mid(t, k, v, t) } -> std::same_as<T>; // a balanced tree of L, (k, v), R; keys of L < k < keys of R
};
template<typename D, typename K = void, typename V = void>
concept sorted_dict =
requires { typename D::key_type; typename D::value_type; } &&
std::totally_ordered<key_t<D>> &&
(std::same_as<K, void> || std::same_as<key_t<D>, K>) &&
(std::same_as<V, void> || std::same_as<value_t<D>, V>) &&
// construction and bulk conversion
requires(const D& d, const key_t<D>& k, value_t<D> v, const array_sequence<std::pair<key_t<D>, value_t<D>>>& pairs) {
{ D::empty() } -> std::same_as<D>;
{ D::singleton(k, v) } -> std::same_as<D>;
{ D::from_sequence(pairs) } -> std::same_as<D>;
{ d.to_sequence() } -> std::same_as<array_sequence<std::pair<key_t<D>, value_t<D>>>>;
} &&
// one key
requires(const D& d, const key_t<D>& k, value_t<D> v) {
{ d.size() } -> std::convertible_to<std::size_t>;
{ d.find(k) } -> std::same_as<std::optional<value_t<D>>>;
{ d.insert(k, v) } -> std::same_as<D>;
{ d.remove(k) } -> std::same_as<D>;
} &&
// two dictionaries
requires(const D& a, const D& b) {
{ set_union(a, b) } -> std::same_as<D>;
{ set_intersection(a, b) } -> std::same_as<D>;
{ set_difference(a, b) } -> std::same_as<D>;
{ join(a, b) } -> std::same_as<D>;
} &&
// filtering
requires(const D& d, bool (&p)(const key_t<D>&, const value_t<D>&)) {
{ d.filter(p) } -> std::same_as<D>;
} &&
// order
requires(const D& d, const key_t<D>& k) {
{ d.first() } -> std::same_as<std::optional<std::pair<const key_t<D>&, const value_t<D>&>>>;
{ d.last() } -> std::same_as<std::optional<std::pair<const key_t<D>&, const value_t<D>&>>>;
{ d.prev(k) } -> std::same_as<std::optional<std::pair<const key_t<D>&, const value_t<D>&>>>;
{ d.next(k) } -> std::same_as<std::optional<std::pair<const key_t<D>&, const value_t<D>&>>>;
{ d.split(k) } -> std::same_as<std::tuple<D, std::optional<value_t<D>>, D>>;
{ d.get_range(k, k) } -> std::same_as<D>;
} &&
// ranks
requires(const D& d, const key_t<D>& k, std::size_t i) {
{ d.rank(k) } -> std::same_as<std::size_t>;
{ d.select(i) } -> std::same_as<std::optional<std::pair<const key_t<D>&, const value_t<D>&>>>;
};
template<typename S, typename K = void>
concept sorted_set =
requires { typename S::key_type; } &&
std::totally_ordered<key_t<S>> &&
(std::same_as<K, void> || std::same_as<key_t<S>, K>) &&
// construction and bulk conversion
requires(const S& s, const key_t<S>& k, const array_sequence<key_t<S>>& keys) {
{ S::empty() } -> std::same_as<S>;
{ S::singleton(k) } -> std::same_as<S>;
{ S::from_sequence(keys) } -> std::same_as<S>;
{ s.to_sequence() } -> std::same_as<array_sequence<key_t<S>>>;
} &&
// one key
requires(const S& s, const key_t<S>& k) {
{ s.size() } -> std::convertible_to<std::size_t>;
{ s.contains(k) } -> std::same_as<bool>;
{ s.insert(k) } -> std::same_as<S>;
{ s.remove(k) } -> std::same_as<S>;
} &&
// two sets
requires(const S& a, const S& b) {
{ set_union(a, b) } -> std::same_as<S>;
{ set_intersection(a, b) } -> std::same_as<S>;
{ set_difference(a, b) } -> std::same_as<S>;
{ join(a, b) } -> std::same_as<S>;
} &&
// filtering
requires(const S& s, bool (&p)(const key_t<S>&)) {
{ s.filter(p) } -> std::same_as<S>;
} &&
// order
requires(const S& s, const key_t<S>& k) {
{ s.first() } -> std::same_as<std::optional<key_t<S>>>;
{ s.last() } -> std::same_as<std::optional<key_t<S>>>;
{ s.prev(k) } -> std::same_as<std::optional<key_t<S>>>;
{ s.next(k) } -> std::same_as<std::optional<key_t<S>>>;
{ s.split(k) } -> std::same_as<std::tuple<S, bool, S>>;
{ s.get_range(k, k) } -> std::same_as<S>;
} &&
// ranks
requires(const S& s, const key_t<S>& k, std::size_t i) {
{ s.rank(k) } -> std::same_as<std::size_t>;
{ s.select(i) } -> std::same_as<std::optional<key_t<S>>>;
};
They refine par::bst and par::sorted_dict with the requirements in
detail::augmented.
namespace detail {
struct unspecified {};
// An augmentation, identified by its three policies.
template<auto Combine, auto Identity, auto Lift> struct augmentation {};
// T's values form a monoid under combine and identity, mapped into augmented_value_type by lift,
// and reduced_val is the reduction of the whole of T. Given policies pin the augmentation.
template<typename T, auto Combine, auto Identity, auto Lift>
concept augmented =
requires { typename T::augmented_value_type; } &&
requires(const T& t, const typename T::augmented_value_type& a, const value_t<T>& v) {
{ t.reduced_val() } -> std::same_as<typename T::augmented_value_type>;
{ T::combine(a, a) } -> std::convertible_to<typename T::augmented_value_type>;
{ T::identity } -> std::convertible_to<typename T::augmented_value_type>;
{ T::lift(v) } -> std::convertible_to<typename T::augmented_value_type>;
} &&
(std::same_as<decltype(Combine), unspecified> ||
std::same_as<augmentation<T::combine, T::identity, T::lift>, augmentation<Combine, Identity, Lift>>);
} // namespace detail
template<typename T, typename K = void, typename V = void,
auto Combine = detail::unspecified{}, auto Identity = detail::unspecified{}, auto Lift = std::identity{}>
concept aug_bst = bst<T, K, V> && detail::augmented<T, Combine, Identity, Lift>;
template<typename D, typename K = void, typename V = void,
auto Combine = detail::unspecified{}, auto Identity = detail::unspecified{}, auto Lift = std::identity{}>
concept aug_sorted_dict = sorted_dict<D, K, V> && detail::augmented<D, Combine, Identity, Lift>;