列表转树:一道面试题的层层优化之路
在前端开发中,我们经常会遇到这样一种数据处理需求:将一个扁平化的列表结构转换为具有层级关系的树形结构。这类问题常见于菜单管理、组织架构展示、省市区三级联动等场景,也是技术面试中的高频考点。它不仅考察对递归和数据结构的理解,更考验性能优化意识与工程思维。
本文将从最直观的解法出发,逐步深入,探讨该问题的多种实现方式及其背后的优化逻辑。
问题描述
给定一个数组,每个元素包含 id、parentId 和其他属性(如 name),其中 parentId === 0 表示该节点为根节点。目标是将其转换为嵌套的树形结构,每个节点可包含一个 children 数组。
输入示例:
const list = [
{ id: 1, parentId: 0, name: 'A' },
{ id: 2, parentId: 1, name: 'B' },
{ id: 3, parentId: 1, name: 'C' },
{ id: 4, parentId: 2, name: 'D' }
];
期望输出:
[
{
id: 1,
parentId: 0,
name: 'A',
children: [
{
id: 2,
parentId: 1,
name: 'B',
children: [{ id: 4, parentId: 2, name: 'D' }]
},
{ id: 3, parentId: 1, name: 'C' }
]
}
]
解法一:朴素递归(时间复杂度 O(n²))
最直接的思路是使用递归:从根节点开始,逐层查找其子节点。
function list2tree(list, parentId = 0) {
const result = [];
list.forEach(item => {
if (item.parentId === parentId) {
const children = list2tree(list, item.id);
if (children.length) {
item.children = children;
}
result.push(item);
}
});
return result;
}
该方法逻辑清晰:外层遍历所有节点,若其 parentId 匹配当前目标,则递归构建其子树。但每次递归都需要遍历整个列表,导致时间复杂度为 O(n²) 。当数据量较大时(如省市区数据达数千条),性能会急剧下降。
解法二:函数式递归(代码更简洁,复杂度不变)
借助现代 JavaScript 的高阶函数,可以写出更简洁的版本:
function list2tree(list, parentId = 0) {
return list
.filter(item => item.parentId === parentId)
.map(item => ({
...item,
children: list2tree(list, item.id)
}));
}
此写法利用 filter 筛选当前层级节点,再通过 map 递归挂载子树。虽然代码更紧凑、无副作用,但本质上仍需在每层递归中遍历全部数据,时间复杂度依然是 O(n²),仅适用于小规模数据。
解法三:哈希表优化(时间复杂度 O(n))
要突破 O(n²) 的瓶颈,关键在于避免重复遍历。核心思想是:用空间换时间——预先建立 id 到节点的映射,后续通过哈希查找直接定位父节点。
实现步骤如下:
- 遍历一次列表,将所有节点存入
Map,并初始化children数组; - 再次遍历,根据
parentId将当前节点挂载到对应父节点的children中; - 收集所有
parentId === 0的节点作为最终结果。
function listToTree(list) {
const map = new Map();
const roots = [];
// 构建 id -> node 映射
for (const item of list) {
map.set(item.id, { ...item, children: [] });
}
// 建立父子关系
for (const item of list) {
if (item.parentId === 0) {
roots.push(map.get(item.id));
} else {
const parent = map.get(item.parentId);
if (parent) {
parent.children.push(map.get(item.id));
}
}
}
return roots;
}
该方案仅需两次线性遍历,时间复杂度降至 O(n) ,空间复杂度为 O(n)。在真实项目中(如后台管理系统菜单渲染、地址选择器),这是推荐的生产级解法。
实际应用场景
这种“扁平转树”的需求广泛存在于各类业务中:
- 省市区三级联动:后端通常返回扁平列表,前端需构建成树以支持级联选择;
- 后台菜单系统:权限菜单常以
parentId形式存储,前端需转换为树用于侧边栏渲染; - 文件目录或分类体系:如电商商品类目、知识库章节结构等。
在这些场景中,数据量可能从几十条到上万条不等。若采用 O(n²) 方案,轻则页面卡顿,重则浏览器崩溃。因此,理解并掌握 O(n) 优化方案至关重要。
面试中的延伸思考
面试官常会在此基础上追问:
- 如果存在多个根节点?→ 当前方案天然支持。
- 如果数据中有环(如 A 的父是 B,B 的父是 A)?→ 需增加环检测机制。
- 能否支持非 0 的根标识?→ 可将根标识作为参数传入,提升通用性。
- 如何反向操作(树转扁平列表)?→ 使用 DFS 或 BFS 遍历收集节点即可。
这些问题考察的不仅是编码能力,更是对边界条件、健壮性和扩展性的思考。
总结
| 解法 | 时间复杂度 | 空间复杂度 | 特点 | 适用场景 |
|---|---|---|---|---|
| 朴素递归 | O(n²) | O(h) | 逻辑直观 | 教学、小数据 |
| 函数式递归 | O(n²) | O(h) | 代码简洁 | 快速原型 |
| 哈希表优化 | O(n) | O(n) | 高效稳定 | 生产环境 |
从暴力递归到哈希优化,这道题的演进过程体现了算法设计的核心思想:识别瓶颈、权衡时空、贴近实际。在面试中,能清晰阐述这一优化路径,往往比直接写出最优解更能赢得认可。
毕竟,优秀的工程师不仅会写代码,更懂得为什么这样写。