Array-Based Trees
This is a tree!
A tree is a directed acyclic graph (DAG) where each node has a single parent (except the root). What makes this one special, though, is that each of its nodes have at most two children – a binary tree.
Pointer-Based Tree
Binary trees are useful for all sorts of stuff. One way to implement them is by allocating an object for each node which stores a pointer to its parent and both its children. Kinda like this!
struct Node
{
int key;
Node *parent;
Node *left_child;
Node *right_child;
};
struct BinaryTree
{
int size;
Node *root;
};
Pros
- Intuitive. The pointer-based solution allows you to easily traverse the tree without further computation: simply follow the pointers stored in each
Node
! - Nice Memory Scaling. The total memory used by the pointer-based tree is proportional to the number of nodes.
Cons
- Potentially Slow Access. If you want to reach a node which is many levels deep, you have to traverse each child node one-by-one starting from the root.
- More Memory Per-Node. Each node must store the addresses of its parent and children. These days, C++ integers are bytes, while addresses are a whopping bytes. That means a (complete) pointer-based tree will use more memory than if they just stored their
key
! - Bad Locality. We have no guarantee on where each
Node
object will be allocated in memory. Modern CPUs load memory in contiguous chunks called cache blocks – withNode
objects scattered all over the address space, we’ll need to waste a lot of CPU cycles loading in different blocks.
Array-Based Tree
There’s another, super-cool way to represent binary trees, though: using an array!
To do so, we can index each node in level-order. That just means we order the nodes from top-to-bottom, then left-to-right. We end up with an array where the nodes in each level are stored contiguously, one level after another. Each element of the array just stores the key of the corresponding node.
It might look like a flat log, but it’s still a beautiful, branching tree!
How will we ever find our parents? Where did our children go? Fear not: there’s a simple formula. If we are node , then our parent and child nodes are given by the following:
Let’s see where these formulas actually come from. To do so, we need to establish a way to talk about specific nodes in the tree. Let be the level of the tree a node resides in, and call the index of the very top level. Let be the left-to-right position of a node in a given level. Let’s say is the index of the leftmost node. We’ll start by listing off where the first node in each level lands in the array.
- The node in level (the root node) is at index
- The node in level is at index
- The node in level is at index
- The node in level is at index
Do you see a pattern? The first () node in the level is at index
What about the indices of the other nodes in that level, where ? Well, since each level is stored contiguously in the array, all we have to do is add . So, the index for the node in level is given by
In a complete binary tree, where every parent node has two child nodes, the number of nodes doubles for each new level. This means there are nodes in level . So, note that here!
Let’s find the parent of node , which we know lives on the level of the tree. There are half as many nodes on this level. Thus, the left-to-right index of the parent in its level will be about half that of its child. Specifically, if node is a left-child, then it’s parent must be the node in it’s level. Thus,
On the other hand, if node is a right-child, then it’s parent is the node in level . Following similar steps as above, we see that in this case,
To reach the form we saw in the diagram above, we just have to realize that is odd when is a left child, and is even when it’s a right child. When is even, expression is a whole number. When is odd, expression is fractional. So, we can unite the two cases by taking the floor:
Whew, that was a lot of algebra! But we indeed found the desired formula for the parent of . Now, let’s find the left and right children of . There’s some more math here, but you’ll see that it follows a similar logic to the steps above.
The children of should live downstairs in the level of the tree. There are twice as many nodes in this level. Thus, the left-to-right positions of the children should be about two times as big as (the position of node in level ). There are other nodes that come before in level , each of which has two children. Thus, the positions of the left and right children in level should be and , respectively.
The index of the left child in the array is then given by
Following similar steps as above, we find the index of the right child in the array to be
which checks out with the diagram above! Yay!
Let’s see how we can implement an array-based tree in C++!
struct BinaryTree
{
int size;
int *array;
BinaryTree(int size) : size(size), array(new int[size]);
~BinaryTree() { delete []array; }
static int parent(int i)
{
// integer division takes care of the floor operation:)
return (i - 1) / 2;
}
static int left_child(int i)
{
return 2 * i + 1;
}
static int right_child(int i)
{
return 2 * i + 2;
}
};
// you might want to do some out-of-bounds error checking
Pros
- Fast Access. We can get the key of any node in our tree with an access to our array.
- Less Memory Per-Node. Each node just stores its key. (After all, it’s just an element in an array.)
- Great Locality. Arrays are guaranteed to be contiguous blocks of memory. That means we’ll need to load far fewer cache blocks, which makes our program run faster.
Cons
- Memory Scales with Levels. Array-based trees can support incomplete trees (in which some nodes are missing children). You can do so by storing a sentinal value in those children’s slots, say, . For a tree with many missing nodes, there may end up being more sentinal values than actual keys as the number of levels increases. So, array-based trees are best suited for complete trees (which is why they are often used for implementing binary heaps).
- Slow to Resize. Typically, arrays are fixed-sized. In the case that we need to add more nodes than we have space for, we’ll need to allocate an entirely new array and copy all the existing keys over one-by-one.
Array-based trees are so cool! But, when I first learned about them, I was really confused where the index formulas came from, and why they looked so simple given how unnatural it seemed to flatten a tree. Now, I hope you have a better understanding of how the math works out, as well as the costs and benefits of both tree representations! If you have any questions, or feedback on how I can improve this explanation, drop me a line!