The Tree Fits in the Parentheses
A pointer tree is a map with street signs at every intersection.
That is comfortable. It is also expensive. Every child pointer says where to go next using enough bits to name a memory address. For a large static tree, those pointers can dominate the thing being represented.
Succinct data structures ask a sharper question:
how close can the representation get to the information-theoretic minimum
without losing the operations?
For rooted ordered trees, one of the cleanest answers is almost comic. Walk the tree in depth-first order. Write an open parenthesis when you enter a node and a close parenthesis when you leave it.
enter node -> (
leave node -> )
Encode ( as 1 and ) as 0. A tree with \(n\) nodes becomes exactly
\(2n\) bits of topology.
The string is not a serialization side effect. It is the data structure.
The Tape Is The Shape
Take this small ordered tree:
root
src
lexer
parser
types
tests
unit
golden
docs
api
notes
Its depth-first parenthesis tape is:
((()()())(()())(()()))
There are 11 nodes and 22 parentheses. The first ( is root. The second (
is src. The three little () pairs inside src are lexer, parser, and
types. When the excess returns to the level before src, the src subtree is
over.
Define the excess after position \(i\) as:
\[\mathrm{excess}(i) = \#\text{opens up to }i - \#\text{closes up to }i.\]This is just stack height. It is also the bridge from parentheses to tree navigation.
If a node opens at position \(p\), then:
- its preorder number is
rank1(p) - 1; - its matching close is the first later position where excess returns to
excess_before(p); - its subtree size is
(close(p) - p + 1) / 2; - its first child, if any, opens at
p + 1; - its next sibling, if any, opens just after its matching close.
The identities are small. The hard part is answering them fast without reintroducing a pointer table.
Jacobson’s 1989 FOCS paper made this tradeoff explicit for static trees and graphs: use far fewer bits than pointer structures while keeping traversal operations efficient.1 Munro and Raman later gave balanced-parentheses and static-tree representations using space within a lower-order term of the information-theoretic minimum, with a rich set of navigation operations in constant time.2
That phrase “lower-order term” is the important accounting line. The raw parentheses carry the topology. Extra index bits carry speed.
Rank And Select Are The First Index
For a bitvector \(B\):
rank1(i) = number of 1 bits in B[0..i]
select1(k) = position of the k-th 1 bit
On a balanced-parentheses tree, opens are nodes. That means select1(k) jumps
to the open parenthesis of preorder node k, and rank1(open) - 1 recovers a
node’s preorder number.
A real succinct rank/select structure uses hierarchical summaries and word-level tables so most queries do not scan. The lab below uses a deliberately visible two-level directory:
superblock total + block total + tiny local scan
That directory is oversized for the toy tree. It is there to show the invariant, not to win a compression contest.
What The Default Run Checks
For the default catalog tree:
nodes = 11
balanced parens = 22 bits
rooted-tree floor = log2(C_10) = 14.0 bits
visible rank directory = 40 bits
pointer-ish ledger = 132 bits
The directory is larger than the topology because this is a small teaching case. The point is the split: topology bits describe the tree; directory bits make chosen operations fast.
The focused node is src, preorder node 1:
open position = 1
matching close = 8
depth = 1
parent = root
first child = lexer
next sibling = tests
subtree nodes = 4
At rank position 15, the visible directory computes:
rank1(15) = superblock rank 0 + block rank 7 + local tail 2 = 9
Move the focus slider and watch the green interval expand and contract on the
tape. A leaf is just (). An internal node is a balanced substring whose pairs
are exactly the nodes in the subtree.
Reproducibility Check
The browser lab is also a CommonJS module. From the repository root:
node - <<'NODE'
const lab = require("./assets/js/succinct-tree-lab.js");
const EXPECTED_TOTALS = {
rows: 180,
shapes: 4,
passed: 180,
total: 180,
passedChecks: 1620,
totalChecks: 1620
};
const EXPECTED_BY_SHAPE = [
"catalog:44:11:22:40:44:396/396",
"chain:44:11:22:40:44:396/396",
"bushy:48:12:24:40:48:432/432",
"trie:44:11:22:40:44:396/396"
];
const EXPECTED_FOCUS_SHAPE = [
"catalog:44:112:11:0,3,11,21:0:11|1:4|2:1|3:1|4:1|5:3|6:1|7:1|8:3|9:1|10:1",
"chain:44:152:11:0,3,11,21:0:11|1:7|2:1|3:5|4:4|5:3|6:1|7:1|8:3|9:1|10:1",
"bushy:48:116:12:0,3,12,23:0:12|1:3|2:1|3:4|4:1|5:2|6:1|7:1|8:1|9:1|10:1|11:1",
"trie:44:124:11:0,3,11,21:0:11|1:4|2:3|3:1|4:1|5:3|6:2|7:1|8:3|9:1|10:1"
];
const EXPECTED_CHECK_NAMES = [
"balanced parentheses finish at zero",
"two bits per node",
"rank directory matches direct rank",
"select(rank(open)) returns each open",
"matching closes recover DFS intervals",
"scanned parents match source tree",
"first child navigation matches source tree",
"next sibling navigation matches source tree",
"focus interval contains full subtree"
];
const EXPECTED_CHECK_ROW = EXPECTED_CHECK_NAMES.map((name) => `${name}=true`);
function sameJson(actual, expected) {
return JSON.stringify(actual) === JSON.stringify(expected);
}
const audit = lab.auditGrid();
const failed = audit.cases.filter(
(row) => !row.passed || row.passedChecks !== row.totalChecks
);
const totals = {
rows: audit.cases.length,
shapes: audit.byShape.length,
passed: audit.passed,
total: audit.total,
passedChecks: audit.passedChecks,
totalChecks: audit.totalChecks
};
const byShapeRows = audit.byShape.map((row) => [
row.shape,
row.cases,
row.nodes,
row.topologyBits,
row.directoryBits,
row.passed,
`${row.passedChecks}/${row.totalChecks}`
].join(":"));
const focusGroups = new Map();
for (const row of audit.cases) {
if (!focusGroups.has(row.shape)) {
focusGroups.set(row.shape, {
maxSubtree: 0,
rankKeys: new Set(),
rows: 0,
subtreeSum: 0,
focusKeys: new Set()
});
}
const group = focusGroups.get(row.shape);
group.rows += 1;
group.subtreeSum += row.subtreeNodes;
group.maxSubtree = Math.max(group.maxSubtree, row.subtreeNodes);
group.rankKeys.add(String(row.rankPos));
group.focusKeys.add(`${row.focus}:${row.subtreeNodes}`);
}
const focusShapeRows = audit.byShape.map((row) => {
const group = focusGroups.get(row.shape);
return [
row.shape,
group.rows,
group.subtreeSum,
group.maxSubtree,
Array.from(group.rankKeys).join(","),
Array.from(group.focusKeys).join("|")
].join(":");
});
const badCheckRows = audit.cases
.map((row, index) => ({
index,
shape: row.shape,
focus: row.focus,
rankPos: row.rankPos,
checks: row.checks.map((check) => `${check.name}=${check.ok}`)
}))
.filter((row) => !sameJson(row.checks, EXPECTED_CHECK_ROW));
const auditShape = {
totals,
byShapeRows,
focusShapeRows,
badCheckRows
};
const shapeErrors = [
sameJson(totals, EXPECTED_TOTALS) ? null : "totals",
sameJson(byShapeRows, EXPECTED_BY_SHAPE) ? null : "byShapeRows",
sameJson(focusShapeRows, EXPECTED_FOCUS_SHAPE) ? null : "focusShapeRows",
badCheckRows.length === 0 ? null : "checkRows"
].filter(Boolean);
console.table(audit.byShape);
if (shapeErrors.length) {
throw new Error(
`succinct-tree audit shape drifted in ${shapeErrors.join(", ")}:\n` +
JSON.stringify(auditShape, null, 2)
);
}
const summary =
`${audit.passed}/${audit.total} cases and ` +
`${audit.passedChecks}/${audit.totalChecks} checks passed`;
if (
failed.length ||
!audit.ok ||
audit.passed !== audit.total ||
audit.passedChecks !== audit.totalChecks
) {
throw new Error(`${summary}: ${JSON.stringify(failed, null, 2)}`);
}
console.log(summary);
NODE
The current audit grid has 180 cases and 1620 named checks. It checks every focus node in all four tree shapes and several rank positions. The checks include:
- final excess is zero and never goes negative;
- exactly two topology bits are emitted per node;
- the two-level rank directory matches direct rank;
select1(rank1(open) - 1)returns each node’s open position;- matching closes recover DFS subtree intervals;
- scanned parent, first-child, and next-sibling operations match the source tree; and
- the focus interval contains exactly the recursive subtree count.
The implementation is intentionally small:
assets/js/succinct-tree-lab.js.
Where The Toy Stops
The lab’s findClose and parent search are readable scans. That is not the
full succinct-tree theorem. Production representations add more indexing over
the excess sequence so matching parentheses, ancestors, descendants, degree,
subtree size, and level-ancestor style operations can be answered quickly.
Navarro and Sadakane’s range min-max tree is a useful modern lens: many tree operations reduce to a small set of primitives over the parenthesis excess sequence, and the static representation can stay at \(2n + o(n)\) bits while supporting a large operation set.3
So the mental model is:
balanced parentheses: the topology
rank/select: addresses for opens
excess/min-max indexes: navigation without pointers
The miracle is not that parentheses can serialize a tree. Anyone can serialize a tree. The miracle is that the string can remain close to the information minimum while still behaving like a data structure.
-
Guy Jacobson, “Space-efficient static trees and graphs”, FOCS, 1989. DOI: 10.1109/SFCS.1989.63533. ↩
-
J. Ian Munro and Venkatesh Raman, “Succinct Representation of Balanced Parentheses and Static Trees”, SIAM Journal on Computing 31(3), 762-776, 2001. ↩