43. Serialize Pipeline Config Tree
Problem
A pipeline's stage-fallback config is modeled as a binary tree: each stage has at most a primary child (the next stage to try) and a fallback child (the stage to fall back to if the primary path fails). Persist a config tree to a single string, and reconstruct it from that string — e.g. to store and diff config versions.
This is the classic LC297 preorder-with-null-markers approach: deserialize(serialize(root))
must reconstruct a tree structurally identical to the original root. Because trees
aren't trivially comparable with ==, the test harness uses two helpers alongside
serialize/deserialize: build_tree, which builds a
TreeNode tree from a nested (val, left_spec, right_spec) tuple spec
(or None), and trees_equal, which recursively compares two trees
node-by-node for structural and value equality.
Source: src/43_serialize_pipeline_config_tree.py
class TreeNode:
def __init__(self, val: str, left: "TreeNode | None" = None, right: "TreeNode | None" = None): ...
def serialize(root: "TreeNode | None") -> str: ...
def deserialize(data: str) -> "TreeNode | None": ...
>>> root = TreeNode("primary", TreeNode("fallback"), None)
>>> data = serialize(root)
>>> trees_equal(root, deserialize(data))
True
>>> serialize(None)
'#'
>>> deserialize('#') is None
True
Step-by-Step Approach
- Serialize: do a preorder traversal (visit node, then left, then right).
At every
Nonechild, append a null marker ("#") to the output instead of recursing further — this is what lets deserialize later know exactly where each subtree ends without needing extra length/count metadata. - Collect all visited values (including the
"#"markers) into a list, then join them with a delimiter (e.g. a comma) that can't appear inside a value, producing the final string. - Deserialize: split the string back into tokens by the same delimiter and wrap them in an iterator, so tokens can be consumed one at a time without index bookkeeping.
- Write a recursive helper that pulls the next token: if it's the null marker, return
None. Otherwise create aTreeNodewith that value, then recursively build its left subtree (consuming more tokens), then its right subtree — in that order, mirroring the preorder serialization. - Because preorder is (root, left-subtree-tokens, right-subtree-tokens) with every subtree's boundary self-delimited by its own null markers, the recursive consumer always knows exactly how many tokens belong to the left subtree before it needs to start the right subtree — no separate boundary index is needed.
The key insight is that null markers make preorder self-delimiting: without them, you can't tell where a subtree ends just from its values, but with them, a single shared token stream is enough to unambiguously rebuild the exact tree shape in one recursive pass.
Reference solution
class TreeNode:
def __init__(self, val: str, left: "TreeNode | None" = None, right: "TreeNode | None" = None):
self.val = val
self.left = left
self.right = right
def serialize(root: "TreeNode | None") -> str:
# preorder traversal with "#" null markers, O(n) time/space (LC297)
vals: list[str] = []
# closure over vals, no explicit param threading
def _preorder(node: "TreeNode | None") -> None:
if node is None:
vals.append("#")
return
vals.append(node.val)
# recursion order mirrors traversal: root, left, right
_preorder(node.left)
_preorder(node.right)
_preorder(root)
# str.join, faster than repeated += concatenation
return ",".join(vals)
def deserialize(data: str) -> "TreeNode | None":
# consume the preorder token stream, rebuilding null markers as None, O(n) time/space
# iterator lets _build pull tokens without index bookkeeping
tokens = iter(data.split(","))
def _build() -> "TreeNode | None":
# advances and permanently consumes the next token
val = next(tokens)
if val == "#":
return None
node = TreeNode(val)
# same order as _preorder wrote tokens, so no boundary tracking needed
node.left = _build()
node.right = _build()
return node
return _build()
Key Functions & Tricks
"TreeNode | None"— forward-reference string type hint, avoidsNameErrorbefore the class is fully defined.",".join(vals)— concatenates a list into a delimited string.iter(data.split(","))— tokenizes on",", wraps in an iterator for one-at-a-time consumption.next(tokens)— advances and consumes the iterator's next value.- Nested closures —
_preorder/_buildsharevals/tokenswithout passing them as params. - Recursion order mirrors traversal order — root, then left, then right, matching serialization.
How to Recognize This Pattern
Signal words to watch for: "serialize/deserialize a tree," "persist a tree to a string/file and reconstruct it," "save and reload a hierarchical structure exactly." The tell is needing a lossless round-trip of an arbitrary (possibly unbalanced, possibly sparse) tree shape, not just its values. Common variations: level-order (BFS) serialization instead of preorder, which some systems prefer because it's easier to reason about layer-by-layer, but still needs null markers per level; or, for a tree known to be a binary *search* tree, dropping the null markers entirely and reconstructing structure purely from value order (since BST insertion order is enough to recover shape), which saves space when applicable. A common pitfall is forgetting the null markers and only recording non-null values — that loses the exact shape information whenever a node has only one child, since "value list" alone is ambiguous about which side a lone child was on.