A simple binary search tree
In this section, we’re going to be implementing and verifying a Binary Search Tree (BST).
In the study of data structures, there are many known ways to balance a binary search tree. To keep things simple, we won’t be implementing any of them—instead, we’ll be implementing a straightforward, unbalanced binary search tree. Improving the design to be more efficient will be left as an exercise.
Furthermore, our first draft of an implementation is going to map keys
of the fixed orderable type, u64
, to values of type V
. In a later chapter,
we’ll change the keys to also be generic, thus mapping K
to V
for arbitrary types
K
and V
.
The implementation
The structs
We’ll start by defining the tree shape itself, which contains one (key, value) pair at every node. We make no distinction between “leaf nodes” and “interior nodes”. Rather, every node has an optional left child and an optional right child. Furthermore, the tree might be entirely empty, in which case there is no root.
struct Node<V> {
key: u64,
value: V,
left: Option<Box<Node<V>>>,
right: Option<Box<Node<V>>>,
}
pub struct TreeMap<V> {
root: Option<Box<Node<V>>>,
}
Note that only TreeMap
is marked pub
. Its field, root
, as well as the Node
type
as a whole, are implementation details, and thus are private to the module.
The abstract view
When creating a new data structure, there are usually two important first steps:
- Establish an interpretation of the data structure as some abstract datatype that will be used to write specifications.
- Establish the well-formedness invariants of the data structure.
We’ll do the first one first (in part because it will actually help with the second one).
In this case, we want to interpret the data structure as a
Map<u64, V>
.
We can define such a function recursively.
impl<V> Node<V> {
spec fn optional_as_map(node_opt: Option<Box<Node<V>>>) -> Map<u64, V>
decreases node_opt,
{
match node_opt {
None => Map::empty(),
Some(node) => node.as_map(),
}
}
spec fn as_map(self) -> Map<u64, V>
decreases self,
{
Node::<V>::optional_as_map(self.left)
.union_prefer_right(Node::<V>::optional_as_map(self.right))
.insert(self.key, self.value)
}
}
impl<V> TreeMap<V> {
pub closed spec fn as_map(self) -> Map<u64, V> {
Node::<V>::optional_as_map(self.root)
}
}
Again note that only TreeMap::as_map
is marked pub
, and furthermore, that it’s marked
closed
. The definition of as_map
is, again, an implementation detail.
It is customary to also implement the
View
trait
as a convenience. This lets clients refer to the map implementation using the @
notation,
e.g., tree_map@
as a shorthand for tree_map.view()
.
We’ll be writing our specifications in terms of tree_map.view()
.
impl<V> View for TreeMap<V> {
type V = Map<u64, V>;
open spec fn view(&self) -> Map<u64, V> {
self.as_map()
}
}
Establishing well-formedness
Next we establish well-formedness. This amounts to upholding the BST ordering property,
namely, that for every node N, the nodes in N’s left subtree have keys less than
N, while the nodes in N’s right subtree have keys greater than N.
Again, this can be defined by a recursive spec
function.
impl<V> Node<V> {
spec fn well_formed(self) -> bool
decreases self
{
&&& (forall |elem| Node::<V>::optional_as_map(self.left).dom().contains(elem) ==> elem < self.key)
&&& (forall |elem| Node::<V>::optional_as_map(self.right).dom().contains(elem) ==> elem > self.key)
&&& (match self.left {
Some(left_node) => left_node.well_formed(),
None => true,
})
&&& (match self.right {
Some(right_node) => right_node.well_formed(),
None => true,
})
}
}
impl<V> TreeMap<V> {
pub closed spec fn well_formed(self) -> bool {
match self.root {
Some(node) => node.well_formed(),
None => true, // empty tree always well-formed
}
}
}
Implementing a constructor: TreeMap::new()
Defining a constructor is simple; we create an empty tree with no root. The specification indicates that the returned object must represent the empty map.
impl<V> TreeMap<V> {
pub fn new() -> (tree_map: Self)
ensures
tree_map.well_formed(),
tree_map@ == Map::<u64, V>::empty()
{
TreeMap::<V> { root: None }
}
}
Recall that tree_map@
is equivalent to tree_map.as_map()
.
An inspection of the definition of tree_map.as_map()
and Node::optional_as_map()
should
make it apparent this will be the empty map when root
is None
.
Observe again that this specification does not refer to the tree internals at all, only that it is well-formed and that its abstract view is the empty map.
Implementing the insert
operation
We can also implement insert
using a recursive traversal. We search for the given node,
using the well-formedness conditions to prove that we’re doing the right thing.
During this traversal, we’ll either find a node with the right key, in which case we update
the value
, or we’ll reach a leaf without ever finding the desired node, in which case we
create a new node.
(Aside: One slight snag has to do with a limitation of Verus’s handing of mutable references.
Specifically, Verus doesn’t yet support an easy way to get a
&mut T
out of a &mut Option<T>
. To get around this, we use std::mem::swap
to get ownership of the node.)
impl<V> Node<V> {
fn insert_into_optional(node: &mut Option<Box<Node<V>>>, key: u64, value: V)
requires
old(node).is_some() ==> old(node).unwrap().well_formed(),
ensures
node.is_some() ==> node.unwrap().well_formed(),
Node::<V>::optional_as_map(*node) =~= Node::<V>::optional_as_map(*old(node)).insert(key, value)
{
if node.is_none() {
*node = Some(Box::new(Node::<V> {
key: key,
value: value,
left: None,
right: None,
}));
} else {
let mut tmp = None;
std::mem::swap(&mut tmp, node);
let mut boxed_node = tmp.unwrap();
(&mut *boxed_node).insert(key, value);
*node = Some(boxed_node);
}
}
fn insert(&mut self, key: u64, value: V)
requires
old(self).well_formed(),
ensures
self.well_formed(),
self.as_map() =~= old(self).as_map().insert(key, value),
{
if key == self.key {
self.value = value;
assert(!Node::<V>::optional_as_map(self.left).dom().contains(key));
assert(!Node::<V>::optional_as_map(self.right).dom().contains(key));
} else if key < self.key {
Self::insert_into_optional(&mut self.left, key, value);
assert(!Node::<V>::optional_as_map(self.right).dom().contains(key));
} else {
Self::insert_into_optional(&mut self.right, key, value);
assert(!Node::<V>::optional_as_map(self.left).dom().contains(key));
}
}
}
impl<V> TreeMap<V> {
pub fn insert(&mut self, key: u64, value: V)
requires
old(self).well_formed()
ensures
self.well_formed(),
self@ == old(self)@.insert(key, value)
{
Node::<V>::insert_into_optional(&mut self.root, key, value);
}
}
Observe that the specification of TreeMap::insert
is given in terms of
Map::insert
.
Implementing the delete
operation
Implementing delete
is a little harder, because if we need to remove an interior node,
we might have to reshape the tree a bit. However, since we aren’t trying to follow
any particular balancing strategy, it’s still not that bad:
impl<V> Node<V> {
fn delete_from_optional(node: &mut Option<Box<Node<V>>>, key: u64)
requires
old(node).is_some() ==> old(node).unwrap().well_formed(),
ensures
node.is_some() ==> node.unwrap().well_formed(),
Node::<V>::optional_as_map(*node) =~= Node::<V>::optional_as_map(*old(node)).remove(key)
{
if node.is_some() {
let mut tmp = None;
std::mem::swap(&mut tmp, node);
let mut boxed_node = tmp.unwrap();
if key == boxed_node.key {
assert(!Node::<V>::optional_as_map(boxed_node.left).dom().contains(key));
assert(!Node::<V>::optional_as_map(boxed_node.right).dom().contains(key));
if boxed_node.left.is_none() {
*node = boxed_node.right;
} else {
if boxed_node.right.is_none() {
*node = boxed_node.left;
} else {
let (popped_key, popped_value) = Node::<V>::delete_rightmost(&mut boxed_node.left);
boxed_node.key = popped_key;
boxed_node.value = popped_value;
*node = Some(boxed_node);
}
}
} else if key < boxed_node.key {
assert(!Node::<V>::optional_as_map(boxed_node.right).dom().contains(key));
Node::<V>::delete_from_optional(&mut boxed_node.left, key);
*node = Some(boxed_node);
} else {
assert(!Node::<V>::optional_as_map(boxed_node.left).dom().contains(key));
Node::<V>::delete_from_optional(&mut boxed_node.right, key);
*node = Some(boxed_node);
}
}
}
fn delete_rightmost(node: &mut Option<Box<Node<V>>>) -> (popped: (u64, V))
requires
old(node).is_some(),
old(node).unwrap().well_formed(),
ensures
node.is_some() ==> node.unwrap().well_formed(),
Node::<V>::optional_as_map(*node) =~= Node::<V>::optional_as_map(*old(node)).remove(popped.0),
Node::<V>::optional_as_map(*old(node)).dom().contains(popped.0),
Node::<V>::optional_as_map(*old(node))[popped.0] == popped.1,
forall |elem| Node::<V>::optional_as_map(*old(node)).dom().contains(elem) ==> popped.0 >= elem,
{
let mut tmp = None;
std::mem::swap(&mut tmp, node);
let mut boxed_node = tmp.unwrap();
if boxed_node.right.is_none() {
*node = boxed_node.left;
assert(Node::<V>::optional_as_map(boxed_node.right) =~= Map::empty());
assert(!Node::<V>::optional_as_map(boxed_node.left).dom().contains(boxed_node.key));
return (boxed_node.key, boxed_node.value);
} else {
let (popped_key, popped_value) = Node::<V>::delete_rightmost(&mut boxed_node.right);
assert(!Node::<V>::optional_as_map(boxed_node.left).dom().contains(popped_key));
*node = Some(boxed_node);
return (popped_key, popped_value);
}
}
}
impl<V> TreeMap<V> {
pub fn delete(&mut self, key: u64)
requires old(self).well_formed()
ensures
self.well_formed(),
self@ == old(self)@.remove(key)
{
Node::<V>::delete_from_optional(&mut self.root, key);
}
}
Observe that the specification of TreeMap::delete
is given in terms of
Map::remove
.
Implementing the get
operation
Finally, we implement and verify TreeMap::get
.
This function looks up a key and returns an Option<&V>
(None
if the key isn’t in the
TreeMap
).
impl<V> Node<V> {
fn get_from_optional(node: &Option<Box<Node<V>>>, key: u64) -> Option<&V>
requires node.is_some() ==> node.unwrap().well_formed(),
returns (match node {
Some(node) => (if node.as_map().dom().contains(key) { Some(&node.as_map()[key]) } else { None }),
None => None,
}),
{
match node {
None => None,
Some(node) => {
node.get(key)
}
}
}
fn get(&self, key: u64) -> Option<&V>
requires self.well_formed(),
returns (if self.as_map().dom().contains(key) { Some(&self.as_map()[key]) } else { None })
{
if key == self.key {
Some(&self.value)
} else if key < self.key {
proof { assert(!Node::<V>::optional_as_map(self.right).dom().contains(key)); }
Self::get_from_optional(&self.left, key)
} else {
proof { assert(!Node::<V>::optional_as_map(self.left).dom().contains(key)); }
Self::get_from_optional(&self.right, key)
}
}
}
impl<V> TreeMap<V> {
pub fn get(&self, key: u64) -> Option<&V>
requires self.well_formed(),
returns (if self@.dom().contains(key) { Some(&self@[key]) } else { None })
{
Node::<V>::get_from_optional(&self.root, key)
}
}
Using the TreeMap
as a client
A short client program illustrates how we can reason about the TreeMap
as if it were
a Map
.
fn test() {
let mut tree_map = TreeMap::<bool>::new();
tree_map.insert(17, false);
tree_map.insert(18, false);
tree_map.insert(17, true);
assert(tree_map@ == map![17u64 => true, 18u64 => false]);
tree_map.delete(17);
assert(tree_map@ == map![18u64 => false]);
let elem17 = tree_map.get(17);
let elem18 = tree_map.get(18);
assert(elem17.is_none());
assert(elem18 == Some(&false));
}
Full source
The full source for this example can be found here.