5.36 Serialize And Deserialize N-ary Tree
Source:
src/main/kotlin/tree/SerializeAndDeserializeNArrayTree.ktPattern: preorder with child-count encoding · Core page
The Problem
Design serialize(root) / deserialize(data) for an N-ary tree — each node has any number of children. Serialize to a single string; deserialize must reconstruct the identical tree.
- Constraints: node values fit in an
Int; any number of children per node; tree sizes up to $10^4$.
Examples
Tree:
1
/ | \
2 3 4
/ \ |
5 6 7
serialize -> "1:3,2:2,5:0,6:0,3:0,4:1,7:0"
deserialize -> identical tree
Intuition — preorder + “how many children” makes the shape unambiguous
A binary tree’s shape is implied by null markers (see 5.5). An N-ary tree has no fixed child count — so the marker becomes a count:
For every node, write
value:childCount, then recursively write its children. ThechildCounttells the deserializer exactly how many subtrees to parse next — no nulls, no ambiguity.
Why does this work? The format is self-delimiting: after reading "1:3", the parser knows it must consume exactly 3 child subtrees before 1’s encoding is complete. Recursion does the rest — each parse() call reads one node’s value:count, then loops count times calling parse() again. The string is fully consumed exactly when the tree is fully rebuilt.
The comma separator splits tokens; each token is "value:childCount". This is the preorder of the tree with degree information appended — the “encoding” equivalent of the 5.5 null-marker trick, generalized.
Approach 1 — Level-order with child counts (also works)
BFS with counts per node: same idea, queue-based. Preorder is shorter to write and matches the repo.
Approach 2 — Preorder + child-count (the repo’s version, optimal)
class SerializeAndDeserializeNArrayTree {
class Node(var `val`: Int) {
var children: List<Node?> = listOf()
}
class Codec {
// Encodes a tree to a single string.
fun serialize(root: Node?): String = when (root) {
null -> ""
else -> buildString {
fun dfs(node: Node?) {
node?.let {
append("${it.`val`}:${it.children.size}")
if (it.children.isNotEmpty()) append(",")
it.children.forEachIndexed { index, child ->
dfs(child)
if (index < it.children.size - 1) append(",")
}
}
}
dfs(root)
}
}
// Decodes your encoded data to a tree.
fun deserialize(data: String): Node? {
if (data.isEmpty()) return null
val tokens = data.split(",")
var index = 0
fun parse(): Node? {
if (index >= tokens.size) return null
val (valueStr, childCountStr) = tokens[index++].split(":")
val node = Node(valueStr.toInt())
node.children = List(childCountStr.toInt()) { parse() }
return node
}
return parse()
}
}
}
class Node:
def __init__(self, val, children=None):
self.val = val
self.children = children if children is not None else []
def serialize(root):
if not root:
return ""
parts = []
def dfs(node):
parts.append(f"{node.val}:{len(node.children)}")
for child in node.children:
dfs(child)
dfs(root)
return ",".join(parts)
def deserialize(data):
if not data:
return None
tokens = data.split(",")
idx = 0
def parse():
nonlocal idx
val_s, count_s = tokens[idx].split(":")
idx += 1
node = Node(int(val_s))
node.children = [parse() for _ in range(int(count_s))]
return node
return parse()
import java.util.*;
class SerializeAndDeserializeNaryTree {
static class Node {
public int val;
public List<Node> children = new ArrayList<>();
public Node(int val) { this.val = val; }
}
static class Codec {
/**
* @param root n-ary tree root
* @return "val:count,val:count,..." preorder encoding
*/
public String serialize(Node root) {
if (root == null) return "";
StringBuilder sb = new StringBuilder();
dfs(root, sb);
return sb.toString();
}
private void dfs(Node node, StringBuilder sb) {
sb.append(node.val).append(':').append(node.children.size());
for (Node child : node.children) {
sb.append(',');
dfs(child, sb);
}
}
/**
* @param data serialized string
* @return reconstructed n-ary tree
*/
public Node deserialize(String data) {
if (data.isEmpty()) return null;
String[] tokens = data.split(",");
int[] idx = {0};
return parse(tokens, idx);
}
private Node parse(String[] tokens, int[] idx) {
String[] parts = tokens[idx[0]++].split(":");
Node node = new Node(Integer.parseInt(parts[0]));
int count = Integer.parseInt(parts[1]);
for (int i = 0; i < count; i++) node.children.add(parse(tokens, idx));
return node;
}
}
}
Reading the code — what’s actually happening
Serialization (top-down):
append("${it.val}:${it.children.size}")writes the node’s header. One token per node: the value, a colon, and the child count. That count is the entire “shape information” — it replaces thenulls a binary-tree encoding needs.- The recursive
dfs(child)calls write children immediately after their parent’s header. This is preorder: parent, then each child subtree in order. TheforEachIndexedcomma logic just separates sibling tokens — a comma goes between every pair of tokens (the header of each child, and before each child subtree). - An empty tree serializes to
""— the null-input branch. Deserialization must checkdata.isEmpty()before splitting (splitting""would yield[""]).
Deserialization (bottom-up, and the elegant part):
tokens[index++].split(":")reads one header and advances the global cursor. Destructuring(valueStr, childCountStr)unpacks the two fields.List(childCountStr.toInt()) { parse() }is the recursion-with-a-cursor. It creates exactlycountchildren by callingparse()that many times. Eachparse()call itself reads a header and recursively builds its children — so the count acts as a self-delimiting contract: after"1:3", the next three tokens’ subtrees belong to node 1, no more and no less.- The shared
indexis what makes it work. It’s a cursor into the token list, mutated by every recursive call. Because preorder writes children immediately after parents, the cursor is always at the right place whenparse()is called — no backtracking, no lookahead.
Trace "1:3,2:2,5:0,6:0,3:0,4:1,7:0": parse reads 1:3 → three children needed → parse → 2:2 → two children → parse → 5:0 (leaf) → parse → 6:0 (leaf) → node 2 done → parse → 3:0 (leaf) → parse → 4:1 → one child → parse → 7:0 (leaf) → node 4 done → node 1 done. Every token consumed, tree rebuilt exactly.
Complexity
Time. Every node visited once in each direction:
$$ T(n) = O(n) $$
Space. The recursion depth (tree height) plus the token list:
$$ S(n) = O(n) $$
Variants & follow-ups
- Serialize And Deserialize Binary Tree (5.5) — the binary ancestor;
nullmarkers vs. child counts is the exact upgrade this page makes. - Serialize And Deserialize N-ary (LeetCode 428) — the same encoding, but the problem asks for
1,3,2,2,5,0,6,0,3,0,4,1,7,0style (counts inline, no colons) — same logic, different delimiters. - Preorder with degree (Prüfer-like thinking) — the child-count trick generalizes: any rooted tree can be encoded by a traversal plus per-node degree, which is the backbone of tree canonicalization problems.
- Interview follow-up: “Why do we need the count at all — can’t we infer it?” Without counts, the parser can’t know when one child subtree ends and the next begins (children have variable sizes). The count makes every subtree self-delimiting — that’s the same reason binary-tree encodings use
nullmarkers. Say that and the design is justified.