Learning/Trees/Serialize and Deserialize Binary Tree
Hard LeetCode 297 · 11 min read

Serialize and Deserialize Binary Tree

1. Problem & Core Objective

Design an algorithm to serialize a binary tree to a string and deserialize that string back into the identical tree.

Java
String serialize(TreeNode root);
TreeNode deserialize(String data);

There is no prescribed format — only that deserialize(serialize(tree)) reproduces the original.

      1
     / \        →  "1,2,#,#,3,4,#,#,5,#,#"   →  the same tree
    2   3
       / \
      4   5

Constraints: 0 <= nodes <= 10^4 · -1000 <= Node.val <= 1000

What's actually being tested: that a traversal alone is ambiguous, and that adding null markers makes it unique. It's the same insight as question 6's serialization approach, now as the whole problem. The elegance is that deserialization consumes the string in exactly the order serialization produced it.

2. First-Principles Thought Process

Why a plain traversal isn't enough

Preorder of a tree with values 1, 2, 3 might be 1,2,3 — but that's consistent with several different trees:

Preorder alone is ambiguous; null markers fix it
Preorder alone is ambiguous; null markers fix it

A left-leaning chain and a right-leaning chain produce identical preorder output. The values are the same; only the shape differs, and plain preorder records no shape.

What's missing is where the structure stops

The ambiguity is about absent children. If the output recorded not just the nodes but also the nulls, the two trees would separate:

left-leaning:   1,2,3,#,#,#,#
right-leaning:  1,#,2,#,3,#,#

Now they're distinguishable, and the format is unique for every tree. Null markers are the whole idea.

Why preorder in particular

Preorder emits the root first, which means deserialization can build top-down: read a token, create the node, then recursively build its left subtree and its right subtree from the tokens that follow.

Critically, the recursion consumes tokens in exactly the order serialization emitted them. A single moving pointer through the token list is all the state you need — the same trick as preIndex in question 13.

Inorder does not work: you'd have to know where the root is before reading it. Postorder and level-order both work, with adjustments.

Two format details that bite

Delimiters between values. Without a separator, 1 and 2 adjacent become 12. Comma-separate every token.

Negative numbers. Values can be negative, so a token may start with -. Splitting on commas handles this; anything that parses character-by-character does not.

3. Solution Paths

Approach 1 — Level-order with null markers

Java
public String serialize(TreeNode root) {
    if (root == null) return "";
    StringBuilder sb = new StringBuilder();
    Queue<TreeNode> q = new LinkedList<>();       // allows nulls
    q.offer(root);

    while (!q.isEmpty()) {
        TreeNode n = q.poll();
        if (n == null) { sb.append("#,"); continue; }
        sb.append(n.val).append(',');
        q.offer(n.left);                           // nulls enqueued deliberately
        q.offer(n.right);
    }
    return sb.toString();
}

public TreeNode deserialize(String data) {
    if (data.isEmpty()) return null;
    String[] tokens = data.split(",");
    TreeNode root = new TreeNode(Integer.parseInt(tokens[0]));

    Queue<TreeNode> q = new LinkedList<>();
    q.offer(root);
    int i = 1;
    while (!q.isEmpty() && i < tokens.length) {
        TreeNode parent = q.poll();
        if (!tokens[i].equals("#")) {
            parent.left = new TreeNode(Integer.parseInt(tokens[i]));
            q.offer(parent.left);
        }
        i++;
        if (i < tokens.length && !tokens[i].equals("#")) {
            parent.right = new TreeNode(Integer.parseInt(tokens[i]));
            q.offer(parent.right);
        }
        i++;
    }
    return root;
}
  • Time O(n) both ways · Space O(w) queue, O(n) string

Counter-questions on this approach

⭐ "This is noticeably longer than the preorder version. Why?"

Because deserialization has to pair each dequeued parent with two consecutive tokens and manage the index by hand, with bounds checks on both. The preorder version's recursion consumes tokens implicitly, so there's no index arithmetic at all.

It's O(n) either way; the preorder version is simply less code and fewer places to be off by one.

"Why LinkedList here rather than ArrayDeque?"

Because I deliberately enqueue null children as markers, and ArrayDeque throws on null elements. LinkedList permits them. That's a real constraint driving the choice, not a default — see 02.

"Is there any advantage to level-order?"

The output is human-readable and matches LeetCode's own input notation, which is convenient for debugging. And it's O(w) rather than O(h) in auxiliary space, so it's better on very deep trees. Otherwise preorder is cleaner.

Approach 2 — Preorder with null markers (optimal)

Java
public String serialize(TreeNode root) {
    StringBuilder sb = new StringBuilder();
    build(root, sb);
    return sb.toString();
}

private void build(TreeNode n, StringBuilder sb) {
    if (n == null) { sb.append("#,"); return; }
    sb.append(n.val).append(',');
    build(n.left, sb);
    build(n.right, sb);
}

public TreeNode deserialize(String data) {
    Deque<String> tokens = new ArrayDeque<>(Arrays.asList(data.split(",")));
    return parse(tokens);
}

private TreeNode parse(Deque<String> tokens) {
    String token = tokens.poll();                 // consume exactly one
    if (token == null || token.equals("#")) return null;

    TreeNode node = new TreeNode(Integer.parseInt(token));
    node.left  = parse(tokens);                   // LEFT first — preorder demands it
    node.right = parse(tokens);
    return node;
}

Trace — serializing then deserializing [1,2,3,null,null,4,5]:

Serialization emits 1,2,#,#,3,4,#,#,5,#,#,

parse callToken consumedAction
11create 1; recurse left
22create 2; recurse left
3#null — 2's left
4#null — 2's right; node 2 complete
53create 3 (1's right); recurse left
64create 4
7, 8#, #4's children; node 4 complete
95create 5 (3's right)
10, 11#, #5's children

Tree reconstructed exactly ✓

  • Time O(n) both ways · Space O(n) string, O(h) stack

Counter-questions on this approach

⭐ "Why does a single shared token queue work? How does each call know which tokens are its own?"

Because preorder emits tokens in exactly the order the recursion consumes them: node, then the whole left subtree, then the whole right subtree. Each parse call takes exactly one token for itself, and its recursive calls consume precisely the tokens belonging to their subtrees.

The subtree boundaries never need to be computed — they're implicit in the # markers, which tell each branch when to stop descending.

It works only if I recurse left before right, matching the serialization order. Swapping those two lines silently builds a mirrored tree.

⭐ "Why can't you use inorder?"

Because inorder emits the root between its subtrees, so on reading the first token you don't know whether it's the root or the leftmost descendant. You'd have to locate the root first, and there's nothing in the stream that marks it.

Postorder works if you consume the tokens backwards and build right before left — it's the mirror of preorder. Level-order works too, as above.

⭐ "What if a node's value could be the string #?"

Then the marker is ambiguous and the format breaks. Here values are integers bounded by 1000, so # can never be a value — I verified that rather than assumed it. If values were arbitrary strings I'd escape them, or use a length-prefixed binary format where no sentinel is needed.

Same discipline as the −1 sentinel in question 4 and the long bounds in question 11: confirm the sentinel is outside the data's domain.

"Why StringBuilder and not string concatenation?"

Concatenation allocates a new string at every node, making serialization O(n²). StringBuilder appends in amortized O(1), keeping it O(n). Passing the builder down rather than returning strings is what makes that work.

"There's a trailing comma. Does that matter?"

String.split(",") discards trailing empty strings by default, so it's harmless. But it's sloppy — and if anyone switched to split(",", -1), which keeps them, an empty token would appear and parse would treat it as neither # nor a number and throw. I'd either trim it or rely on the token == null guard, which also covers a queue that runs dry.

"What about the empty tree?"

serialize(null) gives "#,", and deserialize reads # and returns null. It round-trips with no special case, which is worth checking rather than assuming.

"Stack depth at 10^4?"

A degenerate tree gives 10,000 frames in both directions. Survivable but close; the level-order version is O(w) and is the safer choice for a very deep tree.

Comparison

ApproachSerializeDeserializeAux spaceNotes
Level-order + markersO(n)O(n)O(w)Readable; more index arithmetic
Preorder + markersO(n)O(n)O(h)Least code; consumption order is implicit

4. Why the Optimal Wins

Both are O(n) in each direction, so this is about code you can get right under pressure.

The preorder version's deserialization is six lines with no index arithmetic, because the recursion consumes tokens in precisely the order serialization produced them. The level-order version manages an explicit index, pairs each parent with two tokens, and needs bounds checks in two places — each an opportunity for an off-by-one.

The idea underneath both:

The framing worth keeping:

A traversal records values; null markers record structure. You need both, and with preorder the reconstruction falls out because the consumption order matches the emission order.

That's the same realisation as question 6, and it's why that question's serialization approach was worth understanding rather than dismissing.

5. Java Prerequisites

StringBuilder passed down

Java
private void build(TreeNode n, StringBuilder sb) { ... }   // not String build(...)

Returning strings and concatenating makes it O(n²).

Splitting and consuming

Java
Deque<String> tokens = new ArrayDeque<>(Arrays.asList(data.split(",")));
String t = tokens.poll();     // returns null when empty, rather than throwing

String.split behaviour

Java
"a,b,,".split(",")       // ["a","b"]     — trailing empties dropped
"a,b,,".split(",", -1)   // ["a","b","",""] — kept

LinkedList vs ArrayDeque — only LinkedList accepts null elements, which the level-order version needs.

Integer.parseInt handles a leading -, so negative values survive a comma split. Character-by-character parsing would not.

6. Interview Communication Guide

Clarifying questions: Is the format up to me (yes — only the round-trip matters)? Can values be negative (yes — it rules out single-character parsing)? Can the tree be empty (yes)? Is there a size limit on the string (matters if you're asked to compact it)? Must it be human-readable (affects preorder vs level-order)?

The pitch

"The core issue is that a traversal alone is ambiguous. A root with only a left child and a root with only a right child produce the same preorder sequence of values — the values match, only the shape differs, and plain preorder records no shape.

What's missing is where the structure stops. So I emit a marker for every null child. Then the two trees separate: 1,2,#,#,# versus 1,#,2,#,#. Null markers are the whole idea.

I use preorder because it emits the root first, which lets deserialization build top-down: read a token, create the node, recurse for the left subtree, recurse for the right.

The nice part is that the recursion consumes tokens in exactly the order serialization produced them — node, whole left subtree, whole right subtree. So a single moving pointer through the tokens is all the state I need; subtree boundaries never have to be computed, because the # markers tell each branch when to stop. That works only if I recurse left before right, matching the emission order.

Two format details. I comma-separate so adjacent values don't run together, and I use # as the marker — which is safe here because values are integers bounded by 1000, so # can never be a value. I'd confirm that rather than assume it; if values were arbitrary strings I'd need escaping.

O(n) both directions, O(n) for the string and O(h) stack.

Inorder wouldn't work, incidentally — it emits the root between its subtrees, so you can't tell from the first token whether it's the root or the leftmost node. Postorder works if you consume backwards and build right before left."

Edge cases to volunteer:

InputTests
nullEmpty tree must round-trip
[1]Single node
[1,2] vs [1,null,2]The ambiguity the markers exist to resolve
[-1,-2,-3]Negative values — a leading - in a token
[1000,-1000]Multi-digit values; delimiters matter
Degenerate, 10^4 nodesStack depth both ways
Perfect treeRoughly half the tokens are #

Name [1,2] vs [1,null,2]. It's the pair that motivates the entire design — and the negative-value case is what breaks anyone parsing one character at a time.

7. Follow-Up Questions — Modified Constraints

⭐ "Serialize a BST more compactly."

LeetCode 449. A BST needs no null markers at all — preorder alone determines it, because the BST property supplies the ordering constraint that markers would otherwise provide. Deserialize by tracking value bounds during the descent: a token belonging to the current subtree must fall within (lo, hi), and the first token outside it terminates the subtree. That roughly halves the output, since a tree with n nodes has n+1 null slots.

⭐ "Make the encoding as small as possible."

Several directions. Drop the markers by pairing preorder with inorder (question 13) — no nulls, but two arrays. Use a succinct encoding: 2n+1 bits of structure via balanced-parenthesis or LOUDS representation, plus the values packed separately, which approaches the information-theoretic minimum. Or just gzip the current format — the # runs compress extremely well.

"Serialize an n-ary tree."

Null markers are no longer sufficient, because arity is variable — a node with two children followed by a leaf is indistinguishable from one with three. Emit the child count after each value, or use an explicit end-of-children marker. This is the point where the binary shortcut stops generalising.

"What if the tree were a general graph with cycles?"

Traversal alone would loop forever. Assign each node an id, emit (id, value, childIds) triples, and reconstruct by wiring ids in a second pass. That's what any real object serializer does, and it's why they all maintain an identity map.

"Deserialize without recursion."

Use an explicit stack, pushing nodes and tracking whether each still needs a left or right child. More code, but O(h) on the heap — which is the answer if the tree could be 10^6 deep.

"What if serialize and deserialize ran on different machines?"

The format becomes a wire protocol, so byte order, character encoding, and versioning all matter. You'd reach for Protobuf, Avro, or similar rather than a hand-rolled comma format — and you'd want the schema versioned, since a format change has to be readable by older consumers.