嘿,小伙伴们!今天我们要聊的是前端面试中出镜率极高、甚至可以说是“必考题”的一个算法场景:列表转树(List to Tree) 。
你可能在面试题库里见过它,也可能在实际开发联调接口时被后端同学丢过来的“扁平数据”折磨过。别担心,今天我们就从零开始,把这个知识点拆解得干干净净,让你不仅能写出代码,还能跟面试官聊出“深度”。
一、 为什么我们要“转”?—— 洞察数据结构
在深入代码之前,我们先得搞清楚:为什么原始数据是扁平的?
1. 扁平数据的魅力(存储友好)
在数据库(如 MySQL)中,我们通常采用关联列表的形式存储具有层级关系的数据。每一行就是一个对象,通过一个 parentId 字段来标记自己的“亲爹”是谁。
2. 树状结构的必要(展示友好)
到了前端界面,我们需要展示的是多级菜单、折叠面板、地址联动选择器。这些 UI 组件需要的结构是嵌套的:
JavaScript
{
id: 1,
name: '根节点',
children: [
{ id: 2, name: '子节点', children: [] }
]
}
这就是我们今天要攻克的任务:利用 id 和 parentId 的对应关系,把数组拼装成树。
二、 方案一:最直观的递归法(初学者必会)
当我们看到“层级”、“嵌套”这些词同时结构是树时,脑子里跳出的第一个单词应该是:递归(Recursion) 。
1. 经典解法:两层逻辑的循环
这是最传统的思维方式。我们要找根节点,然后针对每个根节点,再去列表里找它的“孩子们”。
JavaScript
function list2tree(list, parentId = 0) {
const result = [];
list.forEach(item => { // 【关键点 1】:外层遍历,寻找当前层级的节点
// 判断当前项的父级 ID 是否等于我们要找的 parentId
if(item.parentId === parentId){
// 【关键点 2】:递归调用,去找当前节点的子节点
// 这一步是核心:我们假设 list2tree 已经能帮我们处理好下一层了
const children = list2tree(list, item.id);
// 【关键点 3】:如果找到了孩子,就挂载到当前节点下
if(children.length > 0){
item.children = children;
}
// 将拼装好的节点推入结果数组
result.push(item);
}
})
return result;
}
🔍 技术细节解析:
- 重复的事:每次都在数组里翻找谁是我的孩子。
- 退出条件:当
list2tree在数组里找不到任何一个parentId匹配的项时,返回空数组,递归自然停止。 - 复杂度分析:这是一个典型的 算法。因为每深入一层递归,我们都要全量遍历一遍数组。如果树很深,性能会明显下降。
2. 方案二:结合 ES6 的优雅姿势
面试官通常会看你对新语法(其实也不新了)的掌握程度。我们可以利用 filter 和 map 把上面的逻辑写得非常“函数式”。
JavaScript
function list2tree2(list, parentId = 0) {
return list
.filter(item => item.parentId === parentId) // 【关键点】:过滤出当前层的节点
.map(item => ({
...item, // 解构原对象,保持数据不可变性(Immutable)
// 【关键点】:递归寻找子节点并直接赋值
// 注意:这里使用 () 包裹对象字面量,是因为箭头函数后面直接跟 {} 会被解析为函数体
children: list2tree2(list, item.id)
}))
}
💡 面试加分项:
这种写法简洁明了,体现了你对 JavaScript 数组 API 的熟练运用。但要记住,它的时间复杂度依然是 ,属于“用性能换简洁”。
三、 方案三:空间换时间,冲向
面试官如果问:“如果列表有 10 万条数据,递归太慢了怎么办?”
这时候,你的 哈希表(Map/Object) 就要登场了。
递归之所以慢,是因为每次找孩子都要遍历全表。如果我们能一次遍历就把所有节点的引用存起来,寻找过程不就变成 了吗?
1. 使用对象 Object 实现 Map
在 ES6 普及前,我们习惯用对象来充当字典。
JavaScript
function list2tree(list) {
const map = {}; // 临时仓库,存储所有节点的引用
const result = [];
// 第一步:先把所有节点丢进 map
list.forEach(item => {
map[item.id] = {
...item,
children: [] // 预留好 children 数组
}
})
// 第二步:再次遍历,直接从 map 里精准找爹
list.forEach(item => {
// 取出我们在第一步创建的具有 children 属性的对象
const node = map[item.id];
if(item.parentId === 0){
// 如果是根节点,直接进结果数组
result.push(node);
} else {
// 【关键点】:通过 parentId 直接在 map 中找到父节点
// 使用可选链 ?. 避免父节点不存在时报错
map[item.parentId]?.children.push(node);
}
})
return result;
}
🚀 为什么它快?
因为我们只遍历了两次数组。第一次建立映射,第二次完成拼接。这种 空间换时间 的做法,将复杂度降到了线性的 。
2. 方案四:ES6 Map 正统解法
虽然 Object 可以当 Map 用,但 ES6 提供的 Map 数据结构在频繁增删和键名查找上性能更优,且语义更明确。
JavaScript
function list2tree2(list) {
const nodeMap = new Map(); // 【关键点】:使用 ES6 原生 Map
const tree = [];
// 第一次循环:初始化映射表
list.forEach(item => {
nodeMap.set(item.id, {
...item,
children: []
});
});
// 第二次循环:利用 Map.get() 快速组装
list.forEach(item => {
// 获取当前节点的最新引用(带 children 的那个)
const currentNode = nodeMap.get(item.id);
if(item.parentId === 0){
tree.push(currentNode);
} else {
// 【关键点】:通过 get 获取父节点引用
const parentNode = nodeMap.get(item.parentId);
// 只要父节点存在,直接 push 进去
// 因为是引用类型,这里 push 进去的节点,后续如果有子节点加入,依然会保持更新
if(parentNode) {
parentNode.children.push(currentNode);
}
}
})
return tree;
}
四、 深度进阶:你在开发中哪里用到了它?
如果面试官问:“你这个算法在实际项目中怎么用的?” 千万别只说“我就写着玩”。你可以从以下三个真实的业务场景切入:
1. 行政区划级联
这是最经典的例子。中国省、市、区、街道的级别关系。
- 后端表结构:
id, name, parent_id。
id parentId name
1 0 北京
2 1 东城区
3 1 朝阳区
....
121 0 江西
....
155 121 抚州
156 155 临川
- 前端需求:点击“江西省”,自动联想出“抚州市”,点击“抚州”再联想出“临川区”。这种三连弹组件底层就是一棵树。
2. 侧边栏菜单权限管理
在后台管理系统中,不同角色看到的菜单不同。
- 后端会返回这个用户拥有的所有权限按钮和页面列表(扁平的)。
- 前端需要根据
parentId动态渲染侧边栏。如果权限有三层,你就得转成树。
3. 文件系统/组织架构
类似飞书或企业微信的部门组织架构,或者像 VS Code 左侧的文件夹目录。
这些数据在数据库底层一定是扁平存储的(为了方便移动部门、修改名称),但展示时必须是树状的。
五、 总结与重点复盘
好了,总结一下今天掌握的“通关密码”:
| 方案 | 核心思想 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|---|
| 递归法 | 找根节点 -> 递归找子节点 | (递归栈) | 数据量小,逻辑简单 | |
| Map 映射法 | 两次遍历,哈希查找 | 大数据量,追求性能 |
💡 最后的面试小贴士:
- 注意引用关系:在 Map 解法中,我们操作的是对象的引用。这意味着当你把一个子节点
push到父节点的children数组时,它指向的是内存中同一个对象。 - 数据清洗:面试时可以主动问面试官:“如果
parentId指向了一个不存在的节点,需要特殊处理吗?” 这种对异常边界的思考非常加分。 - 递归深度:如果树特别深(几千层),递归可能会导致栈溢出(Stack Overflow) 。虽然前端场景很少见,但意识到这一点说明你对底层原理有敬畏心。