The depth-first order gives us the interesting property that each slice of the array can be interpreted as a sub-tree, and that your "relative index to the next sibling" corresponds to the size of that subtree. If a node has children, the first child will be at the next index.
However, this only lets us work forwards. It does not let us work backwards to the previous sibling, or upwards to the parent. There is also the issue that a node cannot know by itself whether it has a next sibling. Instead, we need to know the parent's size to know when the end of the tree is reached. For example, node D
knows that E
is the next sub-tree in the array, but cannot know whether this is a sibling of D
or some previous node.
Simple solution: track two sizes / relative indexes
You can avoid this by tracking two sizes in each node – one size to work forwards to find the next subtree, and one size to work backwards or upwards. Which choice to make here depends on which operations you expect to be frequent.
If the second size is the size of the left sibling tree, then you can move back to the left sibling in O(1) time. You can repeat this until the size is zero, in which case you have found the first sibling and know that the previous node is the parent. Thus, you can find the parent in O(siblings) time. Similarly, as you advance forwards, you know that the current subtree is the last sibling if the next tree indicates no left sibling size. Inserting or deleting a node requires you to update the right sibling as well, but that's just an O(1) cost. All in all, you can think of this as a doubly linked list.
If the second size is the combined size of all left siblings, then you can move back to the parent in O(1) time. You can use this to check the parent's size, which you can use to stop when moving rightwards to the next sibling. However, finding the left sibling would require an O(siblings) search. Inserting or deleting a node requires updates to all rightward siblings, and each ancestor, and each ancestor's rightward siblings. While this approach simplifies queries, it would make updates more expensive.
Example of the second encoding:
We have a struct with fields name
, size
, and parent
. Type offset
is an unsigned integral type that is as small as possible, but large enough to hold the size of the largest possible tree.
typedef struct {
char name;
offset size;
offset parent;
} node;
Your example tree would be encoded as:
{
{ 'A', 7, 0 },
{ 'B', 3, 0 },
{ 'C', 1, 0 },
{ 'D', 1, 1 },
{ 'E', 1, 3 },
{ 'F', 2, 4 },
{ 'G', 1, 0 },
}
Consider how these offsets change when a node X
is inserted as a child for E
:
{
{ 'A', 8, 0 }, // <- update size
{ 'B', 3, 0 },
{ 'C', 1, 0 },
{ 'D', 1, 1 },
{ 'E', 2, 3 }, // <-- update size
{ 'X', 1, 0 },
{ 'F', 2, 5 }, // <-- update parent link
{ 'G', 1, 0 },
}
That is, all ancestors must be updated, and each involved node's rightward siblings.
Relevant movement operations can be defined as follows:
/// Return index of parent node.
/// For root node, returns zero.
size_t parent(node const* data, size_t self) {
if (self == 0) return 0; // root has no parent
return self - data[self].parent - 1;
}
/// Return index of rightwards sibling.
/// If no rightward sibling exists, returns zero.
size_t right_sibling(node const* data, size_t self) {
if (self == 0) return 0; // root has no sibling
size_t p = parent(data, self);
size_t end = p + data[p].size;
size_t next = self + data[self].size;
// Return next sibling offset if it is within the parent's tree
return (next < end) ? next : 0;
}
/// Return index of first child.
/// If no child exists, returns zero.
size_t first_child(node const* data, size_t self) {
return (data[self].size > 1) ? self + 1 : 0;
}
Sacrifice speed for memory compactness by searching for the parent
It is also possible to go ahead with only one size, but that means finding the parent of the node involves a search that starts from the root. This makes all operations more expensive, since even just moving to the next sibling requires knowledge about the parent's subtree-size. All movements would now have a complexity around O(depth × left siblings), which should typically be around O(log size) in total, but could be as bad as O(size) for some trees.
It is convenient here to define a helper function to check whether a node is part of a parent's subtree:
bool is_in_parent(node const* data, size_t self, size_t parent) {
size_t end = parent + data[parent].size;
return parent <= self && self < end;
}
This simplifies the right_sibling()
API:
size_t right_sibling(node const* data, size_t self) {
if (self == 0) return 0; // root has no sibling
size_t p = parent(data, self);
size_t next = self + data[self].size;
return is_in_parent(data, next, p) ? next : 0;
}
However, the parent()
function is now much more complex since it must search for the given node from the root:
size_t parent(node const* data, size_t self) {
if (self == 0) return 0; // root has no parent
// loop through all ancestor levels
size_t parent = 0;
while (true) {
assert(is_in_parent(data, self, parent);
// loop through all siblings in this level
for (size_t subtree = first_child(data, parent); ; subtree += data[subtree].size) {
assert(subtree);
assert(is_in_parent(data, subtree, parent);
// found the target node
if (subtree == self) return parent;
// found a new parent
if (is_in_parent(data, self, subtree)) {
parent = subtree;
break;
}
}
}
assert(false); // unreachable
}