列表转树结构:从扁平列表到层级森林的魔法变身🔄
一、题目详细描述:扁平列表的 "树形梦想"
想象一下,你手里有一串平平无奇的列表数据,每个元素都带着id和parentId------ 就像一群知道自己 "爸爸是谁" 的小精灵。比如这样:
javascript
const list = [
{ id: 1, parentId: 0, name: 'A' }, // 老大,没有爸爸(parentId=0)
{ id: 2, parentId: 1, name: 'B' }, // 老二,爸爸是1号
{ id: 3, parentId: 1, name: 'C' }, // 老三,爸爸也是1号
{ id: 4, parentId: 2, name: 'D' } // 老四,爸爸是2号
];
而你的任务,是把它们变成一棵枝繁叶茂的 "树"🌳,让每个节点都带着自己的 "孩子":
javascript
[
{
id: 1,
parentId: 0,
name: 'A',
children: [ // A的孩子
{
id: 2,
parentId: 1,
name: 'B',
children: [ { id: 4, parentId: 2, name: 'D' } ] // B的孩子
},
{ id: 3, parentId: 1, name: 'C' } // A的另一个孩子
]
}
]
简单说,就是让扁平的 "线性关系" 变成有层级的 "父子关系",这就是列表转树结构的核心任务!
二、面试官想考这些知识点
1.树 🌳:数据结构的 "家族图谱"
树是一种经典的层级结构,有几个关键概念和这道题死死绑定:
- 根节点 :没有爸爸的节点(
parentId=0),是树的 "老祖宗"; - 子节点:被其他节点包含的节点,比如上例中 B 是 A 的子节点;
- 层级关系 :通过
parentId和id关联,形成 "爷爷→爸爸→儿子" 的链条; - children 属性:树结构的标志,每个节点用它收纳自己的 "后代"。
面试官就是想看看你对 "如何用代码表达层级关系" 的理解 ------ 毕竟树结构在前端太常见了!
2.递归 🔄:树结构的 "天然搭档"
一提到树,递归简直是 "条件反射" 般的存在。递归的核心是 "自己调用自己",刚好匹配树的 "每个节点的子节点也是一棵树" 的特性:
- 递归公式 :要构建一个节点的子树,只需找到所有
parentId等于该节点id的元素,再为这些元素递归构建它们的子树; - 退出条件 :当某个节点没有子节点时(找不到
parentId等于它id的元素),递归就可以 "刹车" 了。
说白了,递归就是让每个节点自己搞定 "找孩子" 的工作,非常省心~
三、解法一:递归法 ------ 简单直接的 "笨办法"
1.两层循环:暴力但有效
递归法的核心思路是 "先找根,再找子,子再找孙"。外层循环找当前层级的节点,内层循环帮这些节点找 "孩子",找不到就收手。
代码实现 :
javascript
// 列表转树的递归函数
// list:原始扁平列表;parentId:当前要找的父节点ID(默认0,即根节点)
function list2tree(list, parentId = 0) {
const result = []; // 存储当前层级的节点
// 外层循环:遍历所有节点,找"爸爸"是parentId的节点
list.forEach(item => {
// 如果当前节点的parentId等于目标parentId,说明它是当前层级的节点
if (item.parentId === parentId) {
// 递归调用:帮这个节点找它的子节点(子节点的parentId等于当前节点的id)
const children = list2tree(list, item.id);
// 如果有子节点,就给当前节点加个children属性存起来
if (children.length) {
item.children = children;
}
// 把处理好的节点放进结果数组
result.push(item);
}
});
return result; // 返回当前层级的节点(可能带children)
}
2.ES6 语法优化:代码瘦身术✨
ES6 的filter和map简直是为这种场景量身定做的!用它们可以把循环和判断 "浓缩" 成更优雅的代码。
ES6 API 详解:
filter:遍历数组,返回满足条件的元素组成的新数组(相当于 "筛选");map:遍历数组,对每个元素做处理后返回新数组(相当于 "改造")。
优化代码 :
javascript
function list2tree2(list, parentId = 0) {
// 1. 先用filter筛选出当前parentId的直接子节点
return list.filter(item => item.parentId === parentId)
// 2. 用map给每个子节点"装孩子"
.map(item => ({
...item, // 保留原始属性(id、parentId、name等)
children: list2tree2(list, item.id) // 递归找子节点,挂到children上
}));
}
3.时间复杂度:O (n²)------ 有点费时间的 "老实人"
为什么是 O (n²)?假设列表有 n 个节点,每个节点都要遍历一次列表找子节点(最坏情况下每个节点都要找 n 次),所以总操作次数是 n×n,即 O (n²)。
能不能优化?当然能!这种方法虽然简单,但数据量大的时候会很慢(比如有 10000 个节点,就要做 1 亿次操作)。这时候就得请出 "空间换时间" 的思路啦~
四、解法二:空间换时间 ------ 用 HashMap 加速⚡
1.用对象字面量代替 HashMap:给节点办 "身份证"
思路是先给每个节点 "拍个照" 存起来(存在对象里),需要找父节点时直接 "刷身份证" 调取,不用再遍历整个列表。
代码实现 :
javascript
function listToTree(list) {
const map = {}; // 用对象当"通讯录",key是节点id,value是带children的节点
const result = []; // 最终的树结构
// 第一步:给每个节点"办身份证",并初始化children
list.forEach(item => {
map[item.id] = {
...item, // 复制原始属性
children: [] // 先给每个节点空的children数组
};
});
// 第二步:给每个节点"找爸爸",挂到正确的位置
list.forEach(item => {
const node = map[item.id]; // 从通讯录里取出当前节点
if (item.parentId === 0) {
// 如果是根节点,直接放进结果数组
result.push(node);
} else {
// 不是根节点?查通讯录找到爸爸,把自己放进爸爸的children里
// 可选链?.避免爸爸不存在的情况(防止报错)
map[item.parentId]?.children.push(node);
}
});
return result;
}
2.ES6 的 Map 结构:更专业的 "通讯录"
ES6 的Map是专门做键值对存储的,比普通对象更灵活(键可以是任意类型),用它来实现更规范。
代码实现 :
javascript
function list2treeWithMap(list) {
const nodeMap = new Map(); // 用Map当通讯录
const tree = []; // 最终的树
// 第一步:给每个节点办身份证(存在Map里)
list.forEach(item => {
nodeMap.set(item.id, { // 用id当key
...item,
children: [] // 初始化children
});
});
// 第二步:认亲,挂到爸爸名下
list.forEach(item => {
const node = nodeMap.get(item.id); // 从Map里取节点
if (item.parentId === 0) {
tree.push(node); // 根节点进结果
} else {
// 找爸爸,把自己加进children
nodeMap.get(item.parentId)?.children.push(node);
}
});
return tree;
}
3.时间复杂度:O (n)------ 飞一般的速度
为什么这么快?因为只遍历了两次列表:第一次存节点(O (n)),第二次挂节点(O (n)),每次操作(存、取、加 children)都是 O (1)。总操作次数是 2n,忽略常数后就是 O (n)。大数据量下,这可比递归法快太多了!
五、面试官会问什么? 🤔
1.实际开发中哪里会用到列表转树?
太多啦!比如省市区三级联动(数据库里省、市、区存在一张表,用parentId关联)、后台管理系统的树状菜单(菜单父子层级)、评论区的嵌套回复(评论和子评论)等。
id parentId name
1 0 北京
2 1 东城区
3 1 朝阳区
...
12 0 江西
32 12 赣州
...
2.为什么列表要扁平化存储,而不是直接存成树?
扁平化列表(带parentId)在数据库中存储更方便,查询、增删节点时不用处理复杂的嵌套结构;需要展示层级关系时,再转成树结构就行("存的时候 flat ,用的时候 tree")。
3.递归法和 HashMap 法各有什么优缺点?
递归法代码简洁、容易理解,但数据量大时效率低(O (n²));HashMap 法效率高(O (n)),但需要额外空间存 map(空间复杂度 O (n))。实际开发中,数据量大选 HashMap,数据量小递归更直观。
六、结语:选对方法,事半功倍!
列表转树结构看似简单,却藏着数据结构(树)、算法思想(递归、空间换时间)和 JS API 的综合考察。递归法像 "笨鸟先飞",简单易懂但效率一般;HashMap 法则像 "聪明的懒汉",用空间换时间,适合大数据场景。
下次面试官再问这个问题,你可以先笑着说:"这题我会两种解法~" 😎 然后从递归讲到 Map 优化,再结合实际场景分析,保证让面试官眼前一亮!