15.2. Binary Search Trees¶
A binary tree T is a binary search tree if, for each node n
with sub-trees left and right,
The value in
nis greater than the values in every node inleft.The value in
nis less than the values in every node inright.Both
leftandrightare binary search trees.
This page uses unique keys, like std::set. If a value equivalent to an
existing value is inserted, the insertion has no effect. Two values are
equivalent when neither compares less than the other.
The comparisons are assumed to define a strict weak ordering,
as they do for the keys in an ordered standard-library container. A production
tree would usually make that ordering an explicit comparator type.
These assertions define the binary search tree property.
Is this a BST?
Yes.
Each node is greater than all of its left descendants, and is less than all of its right descendants. Equivalent values are not inserted.
15.2.1. The Binary Search Tree ADT¶
Structurally, a BST contains pointers to its left and right children. As discussed in Recursion, a binary tree can be implemented simply as a recursive data structure. A binary search tree can also be implemented recursively.
It is a bit simpler to define the tree nodes as a separate type. Whether you design this class as a completely independent class, like this one, or implement it as a nested (inner) class, is largely a matter of choice.
Since a tree_node is a data structure that can exist independently
of a tree that enforces the binary search tree property,
it makes sense in this case to define it as a completely separate
struct with no invariants.
The tree_node encapsulates the general characteristics
common to all binary trees:
A variable to store the node value
Links to the left and right child nodes, which might themselves be sub-trees.
BST node
The node stores a value and owns its children. A null unique_ptr
means that the corresponding child is absent. The node itself does not
enforce the binary search tree property; the tree operations do that.
#include <memory>
template<class T>
struct tree_node {
T value;
std::unique_ptr<tree_node> left;
std::unique_ptr<tree_node> right;
explicit tree_node(const T& value) : value{value} {}
};
An in-order traversal function allows us to print values in ascending order for a binary search tree.
#include <iostream>
template<class T>
void print_in_order(const tree_node<T>* node) {
if (node == nullptr) {
return;
}
print_in_order(node->left.get());
std::cout << node->value << ' ';
print_in_order(node->right.get());
}
Run It
1#include <memory>
2
3template<class T>
4struct tree_node {
5 T value;
6 std::unique_ptr<tree_node> left;
7 std::unique_ptr<tree_node> right;
8
9 explicit tree_node(const T& value) : value{value} {}
10};
11
12#include <iostream>
13
14template<class T>
15void print_in_order(const tree_node<T>* node) {
16 if (node == nullptr) {
17 return;
18 }
19 print_in_order(node->left.get());
20 std::cout << node->value << ' ';
21 print_in_order(node->right.get());
22}
23
24int main() {
25 auto root = std::make_unique<tree_node<int>>(4);
26 root->left = std::make_unique<tree_node<int>>(2);
27 root->right = std::make_unique<tree_node<int>>(6);
28 root->left->left = std::make_unique<tree_node<int>>(1);
29 root->left->right = std::make_unique<tree_node<int>>(3);
30 root->right->left = std::make_unique<tree_node<int>>(5);
31 root->right->right = std::make_unique<tree_node<int>>(7);
32
33 print_in_order(root.get());
34 std::cout << '\n';
35}
In other words, a tree_node is a general purpose
binary tree data structure and has no knowledge of
any binary search tree properties or behavior.
Much like our earlier tree objects, all of the functions used to manipulate
a tree_node will be free functions.
To avoid collision with other similarly named functions,
all the functions will be defined in the mesa::tree namespace.
The binary search tree is built up from individual tree_node objects.
An owning bstree should store its root in a std::unique_ptr so that
destroying the root recursively destroys its children. This makes ownership
explicit and prevents the leaks that are easy to introduce with raw owning
pointers.
The default copy operations of a class containing std::unique_ptr are
deleted. A class with std::set-like copyable behavior would need to write
a deep-copy operation; that ownership detail is separate from the search-tree
algorithms. The complete examples below use move-only ownership and return
non-owning node pointers as temporary position handles. The next page replaces
those handles with proper tree iterators.
Our primary focus for the rest of this section is on the functions that define the key operations associated with a BST:
contains and find
insert and erase
15.2.2. Searching binary trees¶
Efficient search of a binary tree uses the same algorithm
you would use when playing the 'number guessing' game.
If asked to guess a random number between 1 and 100 in the
fewest possible tries, with a hint higher or lower
after each attempt, few people would start at 1,
then guess 2, 3, and so on until they guessed correctly.
Most people would start with 50 and continue to split the
remaining unknown partition in half until they found the correct number.
The strategy most people apply to this problem intuitively is known as the binary search algorithm. This algorithm is easily applied to binary search trees.
The running time of a search is \(O(h)\), where h is the height of
the tree. A balanced BST has height \(O(\log N)\), while a tree built from
already sorted input can have height \(O(N)\) -- in other words, a list.
contains
We always search a binary search tree by comparing the value we're searching for to the 'current' node value. If the target value is smaller, then we search the left subtree. If the target value is larger, then we search the right subtree.
If it is neither of these things, then we found the value.
1#include <iostream>
2
3template<class T>
4bool contains(const tree_node<T>* node, const T& query_value) {
5 if (node == nullptr) {
6 return false;
7 }
8 if (query_value < node->value) {
9 return contains(node->left.get(), query_value);
10 }
11 if (node->value < query_value) {
12 return contains(node->right.get(), query_value);
13 }
14 return true;
15}
16
17int main() {
18 auto root = std::make_unique<tree_node<int>>(4);
19 root->left = std::make_unique<tree_node<int>>(2);
20 root->right = std::make_unique<tree_node<int>>(6);
21 root->left->left = std::make_unique<tree_node<int>>(1);
22 root->left->right = std::make_unique<tree_node<int>>(3);
23
24 std::cout << std::boolalpha
25 << contains(root.get(), 3) << ' '
26 << contains(root.get(), 5) << '\n';
27}
15.2.3. Inserting into binary trees¶
Inserting into a binary tree means adding a new node in the tree such that the binary search tree property remains intact.
Insertion also takes \(O(h)\) time, where h is the tree height. A
balanced BST therefore supports insertion in \(O(\log N)\) time, while an
unbalanced tree can require \(O(N)\) time.
insert
The insert process begins with a search for a place to insert a new value. But how do we find the place at which to insert that new node? Ask "where would we go if we were searching for this data in the tree?" This process is identical to the search used for the contains function.
The standard std::set::insert contract returns a pair containing
a position and a Boolean. The Boolean is true only when insertion took
place; inserting an equivalent value has no effect. This example uses
a non-owning node pointer as the position until the next page introduces
a real tree iterator.
template<class T>
std::pair<tree_node<T>*, bool>
insert(std::unique_ptr<tree_node<T>>& node, const T& value) {
if (node == nullptr) {
node = std::make_unique<tree_node<T>>(value);
return {node.get(), true};
}
if (value < node->value) {
return insert(node->left, value);
}
if (node->value < value) {
return insert(node->right, value);
}
return {node.get(), false};
}
There are a few important things to notice about this function.
The insert function receives the current owning pointer by reference. It can replace that pointer when traversal reaches an empty child link. A new node is inserted at that null link, below a leaf or in place of an empty child of an internal node.
When the value is equivalent to an existing value, the function returns
the existing node and false.
It does not overwrite the value, which is an implementation choice.
Another container implementation might have chosen to overwrite, but in
this case, we are matching the standard library behavior.
Run It
1template<class T>
2std::pair<tree_node<T>*, bool>
3insert(std::unique_ptr<tree_node<T>>& node, const T& value) {
4 if (node == nullptr) {
5 node = std::make_unique<tree_node<T>>(value);
6 return {node.get(), true};
7 }
8 if (value < node->value) {
9 return insert(node->left, value);
10 }
11 if (node->value < value) {
12 return insert(node->right, value);
13 }
14 return {node.get(), false};
15}
16
17int main() {
18 std::unique_ptr<tree_node<int>> root;
19 insert(root, 4);
20 insert(root, 2);
21 insert(root, 6);
22 auto result = insert(root, 4);
23
24 std::cout << std::boolalpha
25 << "inserted duplicate: " << result.second << '\n';
26 print_in_order(root.get());
27 std::cout << '\n';
28}
Try This!
Walk through this algorithm yourself with different sets of values.
Experiment with inserting nodes into binary search trees. Take particular note of what happens if you insert data in ascending or descending order, as opposed to inserting unordered data.
15.2.4. Erasing binary tree nodes¶
The erase process also begins with a search for the place to erase. This process is identical to the search used for contains and insert.
The tricky part of removing a value from a binary search tree is what to do when we actually find the value we want to delete. We can't just delete the tree node. Consider the following tree.
If we remove values 10, 40, or 60
by simply deleting the tree node, that might work.
However, deleting any other node would break the links between tree nodes.
We have three cases to consider:
Removing a leaf
Removing a node that has only one child
only a left child
only a right child
Removing a node that has two children
15.2.4.1. Removing a leaf node¶
It's easy to see that we can always remove any leaf in a binary search tree without affecting anything else. That is, if we remove any leaf from a binary search tree, we still have a valid binary search tree. There is nothing else to do.
When node points to a leaf that contains the data we want to remove
we replace the owning pointer in node with an empty pointer.
With std::unique_ptr, assigning nullptr automatically destroys the
removed node.
In other words, leaf nodes are replaced with the null pointer.
15.2.4.2. Removing a non-leaf node with a null child¶
Removing nodes from the interior of the tree is a bit more work as we need to maintain links between nodes.
Given the same tree we have been working with so far:
Question
Suppose we wanted to remove the 20 or the 70 from this tree.
What would we have to do so that the
remaining nodes would still be a valid BST?
Show
There is one pointer to the node being deleted, and one pointer from that node to its only child.
So this is actually a bit like deleting a node from the middle of a linked list.
All we need to do is to update the pointer from the
parent 30 node.
That pointer should point to the child of the node we are going to remove.
Verify that if we remove either 20 or 70,
the resulting tree is still a valid binary search tree.
The code we used to remove a leaf also works when there is only one child.
If we reach this code, we know there is at most one non-null child. In the previous case of a leaf node, both children are null, but the same ownership replacement works for one child also.
If the left child is not null, then reassign the left child to the current node, otherwise assign the right child.
15.2.4.3. Removing a non-leaf node with two children¶
Suppose we wanted to remove the 50 or the 30 from this tree.
What must we do so that the remaining nodes would still be a valid BST?
This is a hard case.
If we remove either the 50 or 30 nodes,
then we break the tree into pieces,
with no obvious place to put the now-detached subtrees.
There is an efficient solution to this problem. Instead of deleting the node when we find it, is there some other data value that we could put into that node that would preserve the BST property?
There are, in fact, two values that we could safely put in there:
the smallest value from the right subtree
the largest value from the left subtree
We can find the largest value on the left by
taking one step to the left
then running as far down to the right as we can go
We can find the smallest value on the right by
taking one step to the right
then running as far down to the left as we can go
At this point, we haven't deleted or created any nodes.
We simply copy a value from one node to another.
Now we have two nodes in our tree with the same value,
either 20 or 40,
depending on which approach we used.
We still need to delete the smallest right node or the largest left node. What makes this last step simple is that it falls under our previous case: it is by definition either a leaf, or has at most one child.
erase
Putting it all together.
Recall that std::unique_ptr is not copyable or copy constructible.
Moving a unique_ptr is allowed.
1template<class T>
2void erase(std::unique_ptr<tree_node<T>>& node, const T& value) {
3 if (node == nullptr) {
4 return;
5 }
6 if (value < node->value) {
7 erase(node->left, value);
8 return;
9 }
10 if (node->value < value) {
11 erase(node->right, value);
12 return;
13 }
14
15 if (node->left == nullptr) {
16 node = std::move(node->right);
17 return;
18 }
19 if (node->right == nullptr) {
20 node = std::move(node->left);
21 return;
22 }
23
24 const tree_node<T>* successor = node->right.get();
25 while (successor->left != nullptr) {
26 successor = successor->left.get();
27 }
28 node->value = successor->value;
29 erase(node->right, successor->value);
30}
31
32int main() {
33 std::unique_ptr<tree_node<int>> root;
34 for (int value : {50, 30, 70, 20, 40, 60, 80}) {
35 insert(root, value);
36 }
37
38 erase(root, 20); // leaf
39 erase(root, 60); // leaf
40 erase(root, 70); // one child: 80 remains
41 erase(root, 50); // two children
42 print_in_order(root.get());
43 std::cout << '\n';
44}
Lines 6-13 handle the search we discussed initially. Here we recursively search for our target value to remove.
The last if block handles the case with 2 children.
We find the smallest node in the right subtree
and assign its value to the current node.
Then we erase this value from the right subtree
of the current node.
The final block handles the leaf and the one child cases. This is the only case where a node is actually removed from the tree. This block will also ultimately get called when the case handling two child nodes needs to delete the smallest value from the right subtree.
More to Explore
The content on this page was adapted from Binary Search Trees, by Steven J. Zeil for his data structures course CS361.
MyCodeSchool video: Data structures: binary search trees
Wikipedia