DSA Serializing a Binary Tree
Serializing a binary tree writes the whole tree as one string that can rebuild it exactly. The format here writes preorder values joined by commas and writes # for each empty child.
The problem
Serializing a binary tree writes the whole tree as one string. Deserializing reads that string and rebuilds the tree exactly. Exactly means the same values in the same positions, with the same shape.
The format in this article writes values in preorder, joined by commas. A missing child is written as the marker #. The test trees below are rebuilt from their strings alone, and the round trip is the walkthrough's job.
The test trees and their serial forms
class TreeNode:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
def serialize(node, tokens):
if node is None:
tokens.append("#")
return
tokens.append(str(node.value))
serialize(node.left, tokens)
serialize(node.right, tokens)
def to_string(root):
tokens = []
serialize(root, tokens)
return ",".join(tokens)
tree = TreeNode(5,
TreeNode(3, TreeNode(1), TreeNode(4)),
TreeNode(8, TreeNode(7), TreeNode(9)))
small = TreeNode(5, TreeNode(3), TreeNode(8))
for name, root in [("main tree", tree), ("small tree", small),
("single node", TreeNode(7)), ("empty tree", None)]:
print(name, "- serial:", to_string(root))Read the main string slowly. Its value tokens are the preorder walk of the tree. Its # tokens sit exactly where children are missing, and they carry the shape.
Intuition: preorder alone loses the shape
A first idea writes only the preorder values. The string is short and easy to produce. It also throws away every fact about where the missing children are.
Two different trees can share one preorder list. The run below builds two such trees. Their preorder lists match, and their inorder lists prove the trees are different.
Two trees, one preorder string
class TreeNode:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
def preorder_values(node, order):
if node is None:
return
order.append(node.value)
preorder_values(node.left, order)
preorder_values(node.right, order)
def inorder_values(node, order):
if node is None:
return
inorder_values(node.left, order)
order.append(node.value)
inorder_values(node.right, order)
tree_a = TreeNode(5, TreeNode(3), TreeNode(8))
tree_b = TreeNode(5, None, TreeNode(3, None, TreeNode(8)))
for name, root in [("tree A, root with two children", tree_a),
("tree B, a right chain", tree_b)]:
pre, ino = [], []
preorder_values(root, pre)
inorder_values(root, ino)
print(name, "- preorder:", pre, " inorder:", ino)A reader given only 5,3,8 cannot choose between these trees. The format needs extra information. Every fix in this article is a way of putting the shape back into the string.
Brute force: write two traversals instead
One working fix writes two lists, the preorder list and the inorder list, joined as one string. The reader rebuilds the tree from the pair. That rebuild is the split method from the building-a-tree article.
The fix works on unique values. Its weaknesses are real. It writes every value twice, and it fails when values repeat, because the inorder split needs one position per value.
The two-list format and its round trip
class TreeNode:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
def walks(node, pre, ino):
if node is None:
return
pre.append(str(node.value))
walks(node.left, pre, ino)
ino.append(str(node.value))
walks(node.right, pre, ino)
def build(preorder, index_map, pre_index, low, high):
if low > high:
return None
value = preorder[pre_index[0]]
pre_index[0] += 1
node = TreeNode(value)
split = index_map[value]
node.left = build(preorder, index_map, pre_index, low, split - 1)
node.right = build(preorder, index_map, pre_index, split + 1, high)
return node
def round_trip_two_lists(root):
pre, ino = [], []
walks(root, pre, ino)
values = [int(item) for item in pre]
inorder = [int(item) for item in ino]
index_map = {value: index for index, value in enumerate(inorder)}
rebuilt = build(values, index_map, [0], 0, len(inorder) - 1)
pre2, ino2 = [], []
walks(rebuilt, pre2, ino2)
return ",".join(pre) + "|" + ",".join(ino), pre2 == pre and ino2 == ino, len(pre) + len(ino)
tree = TreeNode(5,
TreeNode(3, TreeNode(1), TreeNode(4)),
TreeNode(8, TreeNode(7), TreeNode(9)))
serial, matched, tokens = round_trip_two_lists(tree)
print("two-list serial:", serial)
print("round trip matches:", matched)
print("tokens written:", tokens)The round trip matches on the main tree. The token count is 14 for seven nodes, two per node. The marker format below writes a comparable count and removes the uniqueness condition.
The optimal solution: one preorder stream with null markers
One preorder stream that writes # for every missing child carries both the values and the shape in a single pass. That one sentence is the whole method.
The reader consumes tokens in the same order the writer wrote them. A value token creates a node and then reads its left subtree and its right subtree. A # token returns an empty child at once, so the stream is never ambiguous.
The marker format on every test tree
class TreeNode:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
def serialize(node, tokens):
if node is None:
tokens.append("#")
return
tokens.append(str(node.value))
serialize(node.left, tokens)
serialize(node.right, tokens)
def to_string(root):
tokens = []
serialize(root, tokens)
return ",".join(tokens)
def deserialize(tokens):
token = next(tokens)
if token == "#":
return None
node = TreeNode(int(token))
node.left = deserialize(tokens)
node.right = deserialize(tokens)
return node
tree = TreeNode(5,
TreeNode(3, TreeNode(1), TreeNode(4)),
TreeNode(8, TreeNode(7), TreeNode(9)))
small = TreeNode(5, TreeNode(3), TreeNode(8))
for name, root in [("main tree", tree), ("small tree", small),
("single node", TreeNode(7)), ("empty tree", None)]:
serial = to_string(root)
rebuilt = deserialize(iter(serial.split(",")))
print(name, "- serial:", serial, " round trip matches:", to_string(rebuilt) == serial)Every test tree round-trips exactly. The check compares full serial strings, not just traversals. Equal serial strings mean equal values and equal shape, position by position.
Walkthrough: reading one string back, token by token
The walkthrough writes the small tree first. It then reads the string back with a traced reader. Each line shows one token being consumed and what that token builds.
The round trip, token by token
class TreeNode:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
def deserialize_traced(tokens, depth):
token = next(tokens)
if token == "#":
print(" " * depth + "token # - empty child, return None")
return None
print(" " * depth + "token", token, "- make node", token)
node = TreeNode(int(token))
node.left = deserialize_traced(tokens, depth + 1)
node.right = deserialize_traced(tokens, depth + 1)
return node
def inorder_values(node, order):
if node is None:
return
inorder_values(node.left, order)
order.append(node.value)
inorder_values(node.right, order)
small = TreeNode(5, TreeNode(3, TreeNode(1), TreeNode(4)), TreeNode(8))
tokens = []
def serialize(node, out):
if node is None:
out.append("#")
return
out.append(str(node.value))
serialize(node.left, out)
serialize(node.right, out)
serialize(small, tokens)
print("serial:", ",".join(tokens))
rebuilt = deserialize_traced(iter(tokens), 0)
order = []
inorder_values(rebuilt, order)
print("rebuilt inorder:", order)The first token makes the root. Each # closes one empty child slot and lets the reader move on. No token is ever reread, and no position is guessed.
The rebuilt inorder list is read off at the end. It matches the small tree's inorder walk. Combined with the matching serial check in the previous section, the shape is proven kept.
Complexity
Writing and reading each touch every token once, so both take O(n) time for a tree of n nodes. The two-list format has the same time, because it also touches each of its tokens once.
The serial string itself holds O(n) tokens under either format. A tree of n nodes writes n value tokens and n plus one null markers in the marker format, for a total of 2n plus one tokens.
Token counts at three sizes
class TreeNode:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
def serialize(node, tokens):
if node is None:
tokens.append("#")
return
tokens.append(str(node.value))
serialize(node.left, tokens)
serialize(node.right, tokens)
def to_string(root):
tokens = []
serialize(root, tokens)
return ",".join(tokens)
def complete_tree(values):
if not values:
return None
mid = len(values) // 2
return TreeNode(values[mid], complete_tree(values[:mid]),
complete_tree(values[mid + 1:]))
for n in [3, 7, 15]:
root = complete_tree(list(range(1, n + 1)))
tokens = []
serialize(root, tokens)
markers = sum(1 for token in tokens if token == "#")
print("nodes", n, "- two-list tokens:", 2 * n,
" marker tokens:", len(tokens), " of which null markers:", markers)The counts prove the token claim. Marker tokens grow by exactly the formula at each size. The gain over two lists is not size but strength, since one stream now handles repeated values.
Edge cases and pitfalls
An empty tree serializes to a single #. A single node writes its value and two markers. Both round-trip through the same code as the large trees, with no special cases.
Repeated values are safe in the marker format. The run below round-trips a tree of three copies of 5. The two-list format from the brute force section cannot rebuild that tree at all.
Edge cases, run
class TreeNode:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
def serialize(node, tokens):
if node is None:
tokens.append("#")
return
tokens.append(str(node.value))
serialize(node.left, tokens)
serialize(node.right, tokens)
def to_string(root):
tokens = []
serialize(root, tokens)
return ",".join(tokens)
def deserialize(tokens):
token = next(tokens)
if token == "#":
return None
node = TreeNode(int(token))
node.left = deserialize(tokens)
node.right = deserialize(tokens)
return node
print("empty tree serial:", to_string(None))
print("empty tree rebuilt:", deserialize(iter(["#"])))
dup = TreeNode(5, TreeNode(5), TreeNode(5))
serial = to_string(dup)
rebuilt = deserialize(iter(serial.split(",")))
print("duplicate values serial:", serial)
print("duplicate round trip matches:", to_string(rebuilt) == serial)
try:
deserialize(iter(["5", "3"]))
print("truncated input: no error")
except StopIteration:
print("truncated input fails with: StopIteration")A truncated string is the remaining pitfall. The reader trusts the stream to be complete and fails loudly when it ends early. Producers must write the markers, all of them, every time.
Exercise
How many tokens does the serial form of a tree with 4 nodes hold?