listToTree in O(n): Why the Hash Table Beats the Brute-Force Double Loop
Foreword
Anyone who has written an admin dashboard has probably encountered this task:
The backend throws over a flat array, and the frontend needs to render it as a multi-level menu tree.
Address cascades, organizational charts, product category trees… Any feature involving "hierarchy" almost always boils down to the same pattern: listToTree.
This article breaks down two mainstream approaches and discusses their respective characteristics.
1. Why the backend always gives you a flat array
Many newcomers' first reaction is: why doesn't the backend just give me a tree? Giving me a one-dimensional array seems so unprofessional.
It's really not that the backend is lazy. It's because MySQL's table structure itself is one-dimensional.
| Dimension | Flat Array | Tree Structure |
|---|---|---|
| Storage method | One row per record, linked by a parentId field | Nested objects, children arrays |
| Database friendliness | ✅ Direct select * from |
❌ Relational databases are not good at nesting |
| Transmission size | Small | Large (parent node info stored redundantly) |
| Frontend processing | Needs to be converted to a tree | Ready to use |
| Real-world projects | Backend returns | Frontend assembles |
So the industry convention is: database stores flat, API returns flat, frontend converts to tree. The parentId field is the bridge between flat and tree structures.
2. First, look at the raw data
js
const flatList = [
{ id: 1, name: 'First-level Menu A', parentId: 0 },
{ id: 2, name: 'First-level Menu B', parentId: 0 },
{ id: 3, name: 'Second-level A-1', parentId: 1 },
{ id: 4, name: 'Third-level A-1-1', parentId: 3 },
{ id: 5, name: 'Second-level B-1', parentId: 2 }
]
Two key conventions:
idis the node's unique identifierparentIdpoints to the parent node's id;parentId: 0usually means "no parent node", i.e., a root node
Some projects use
parentId: nullorparentId: -1to indicate a root node. The convention differs, but the approach is the same.
3. The brute-force O(n²) approach: don't learn it, just know how bad it is
The most intuitive approach is: for each node, loop through the entire array again to find its parent node.
js
// Pseudocode, don't write this in production
list.forEach(item => {
const parent = list.find(n => n.id === item.parentId);
if (parent) parent.children.push(item);
else tree.push(item);
});
It works, but finding the parent for each node is O(n), and with n nodes, it's O(n²).
For 100 menu items, it doesn't matter. At 1000 items, it starts to lag. At 10000 items, the screen goes white.
| Approach | Time Complexity | Space Complexity | Verdict |
|---|---|---|---|
| Brute-force double loop | O(n²) | O(n) | Entry-level, don't use it |
| Hash table, two-pass | O(n) | O(n) | Production standard |
4. The core idea of O(n) optimization: enter the hash table
Where is the brute-force method slow? The step of finding the parent node is O(n).
If the parent node could be retrieved in O(1), the overall complexity drops to O(n).
What data structure offers O(1) lookup? A hash table. In JS, that's Map or a plain object {}.
Two steps:
- First pass: Put every node into a Map, with the key being the id and the value being the node itself (with an empty children array).
- Second pass: For each node, use parentId to retrieve the parent node from the Map in O(1), and push itself into the parent's children array; if no parent node is found (root node), push it into the tree array.
5. Code 1: Map + forEach approach
js
function listToTree(list) {
const map = new Map(); // ES6 HashMap data structure
const tree = [];
// First pass: build Map, each node gets a children array
list.forEach((item) => {
map.set(item.id, {
...item, // Spread existing fields
children: [] // Reserve children array
});
});
// Second pass: mount based on parentId
list.forEach(item => {
const current = map.get(item.id); // Current node
const parent = map.get(item.parentId); // Its parent node
if (parent) {
parent.children.push(current); // Mount to parent
} else {
tree.push(current); // No parent → root node
}
});
return tree;
}
Line-by-line breakdown:
| Line | Purpose | Why it's written this way |
|---|---|---|
new Map() |
Create hash table | Map lookup is O(1) |
...item |
Spread original fields | Doesn't mutate original data, shallow copy |
children: [] |
Reserve child array | Needs to be pushable later, must be an empty array |
map.get(item.parentId) |
O(1) parent lookup | The soul of the hash table |
if (parent) check |
Distinguish root/child | When parentId is 0, get returns undefined |
Note:
Map.get(0)returnsundefinedin our data (because there is no node with id 0), so root nodes enter theelsebranch and are pushed to tree.
6. Code 2: reduce approach
Same logic, different style:
js
function listToTree(list) {
// First pass: build map with reduce
const nodeMap = list.reduce((map, item) => {
map[item.id] = { ...item, children: [] };
return map;
}, {});
// Second pass: assemble tree with reduce
return list.reduce((tree, item) => {
const cur = nodeMap[item.id];
const parent = nodeMap[item.parentId];
if (parent) {
parent.children.push(cur);
} else {
tree.push(cur);
}
return tree;
}, []);
}
Differences from Code 1:
| Comparison Item | Code 1 | Code 2 |
|---|---|---|
| Hash table | new Map() |
Plain object {} |
| Iteration method | forEach |
reduce |
| Key access | map.get(id) / map.set(id, v) |
map[id] / map[id] = v |
| Style | Imperative, clear step-by-step | Functional, more compact chaining |
| Readability | High, beginner-friendly | Medium, requires understanding reduce |
Both approaches have identical logic, and performance is basically the same. It's purely a matter of style.
7. Map vs Plain Object: which hash table to use?
This is a common interview question, and the first choice to make when writing listToTree.
| Dimension | Map |
Plain Object {} |
|---|---|---|
| Key type | Any (including objects, numbers) | Only strings/Symbols |
| Iteration order | Insertion order | Integer keys sorted first |
| Size retrieval | map.size |
Object.keys(obj).length |
| Performance | Slightly better for frequent add/delete | Slightly better for static access |
| Prototype pollution | ❌ No | ⚠️ Possible (__proto__, etc.) |
| Serialization | ❌ No JSON support | ✅ JSON.stringify |
In a listToTree scenario, ids are usually numbers, so either works. But using
Mapis more "modern" and safer (no fear of keys like__proto__).Mapis recommended for production code.
8. forEach vs reduce: a battle of styles
| Dimension | forEach |
reduce |
|---|---|---|
| Return value | undefined |
Accumulator (any type) |
| Side effects | Mutates external variables | Hides state inside the accumulator |
| Readability | Straightforward, like a for loop | Functional, a bit mind-bending |
| Suitable for | Multi-step processes, pure iteration | Accumulation, reduction, building tables |
| Chainable | ❌ | ✅ Can chain .filter().map() |
I personally prefer the forEach approach, because listToTree is a "two-step" imperative process, and forEach is more straightforward. The reduce version suits teams that prefer a functional style.
9. Complexity analysis
| Metric | Complexity | Explanation |
|---|---|---|
| Time | O(n) | Two passes, each O(n), constant factor 2 |
| Space | O(n) | Map stores all nodes; tree also stores all nodes |
Compared to the brute-force O(n²), when n = 10000, O(n) is roughly 10000 times faster. That's the power of the hash table.
10. Warnings
Pitfall 1: parentId has no root node identifier
If parentId is null instead of 0 in the data, map.get(null) also returns undefined, and the logic works. But if a record's parentId points to a non-existent id, that record will be mistakenly treated as a root node. Add validation in production.
Pitfall 2: Reference issues
In the second pass, what's pushed into children is not a copy, but the same reference from the Map. So nodes in tree and map are the same objects; changing one changes both. This is usually what we want, but be aware of it.
Pitfall 3: Order dependency
If a parent node appears after its child node (parentId points to an id that appears later), the two-pass method still works correctly—because the first pass puts all nodes into the Map first, and the second pass does the mounting. This is also why "two-pass traversal" is more robust than "single-pass recursive mounting".
Pitfall 4: Root node judgment condition
The if (parent) check relies on parentId: 0 not being found in the Map. If you use parentId: null, the behavior is the same; but if someone carelessly writes a node with id: 0, the root node judgment breaks. Clarify the convention before coding.