写在前面:今天学了一个在实际工作中超级常用的算法------列表转树。后端从数据库里
select * from menu查出来的数据是一维的扁平列表,但前端要展示的是树状结构(多级菜单、地址选择器、组织架构......)。怎么转?老师教了两招:Map 法和 reduce 法。听完我只想说:原来这么简单,我之前居然手写递归转了半天!
一、为什么需要"列表转树"?
1.1 后端给的数据长这样
老师举了一个非常真实的例子:
javascript
const flatList = [
{ id: 1, name: '一级菜单A', parentId: 0 },
{ id: 2, name: '一级菜单B', parentId: 0 },
{ id: 3, name: '二级A-1', parentId: 1 },
{ id: 4, name: '三级A-1-1', parentId: 3 },
{ id: 5, name: '二级B-1', parentId: 2 },
]
这是一堆扁平数据 ------所有项都在同一个数组里,没有嵌套关系。每一项有一个 parentId,表示它的父节点是谁。
parentId: 0表示"我是根节点,没有父节点"。parentId: 1表示"我的爸爸是 id 为 1 的节点"。
1.2 前端想要的数据长这样
但前端组件(比如 Element UI 的 Tree、Ant Design 的 Cascader)需要的是树状结构:
javascript
[
{
"id": 1,
"name": "一级菜单A",
"parentId": 0,
"children": [
{
"id": 3,
"name": "二级A-1",
"parentId": 1,
"children": [
{
"id": 4,
"name": "三级A-1-1",
"parentId": 3,
"children": []
}
]
}
]
},
{
"id": 2,
"name": "一级菜单B",
"parentId": 0,
"children": [
{
"id": 5,
"name": "二级B-1",
"parentId": 2,
"children": []
}
]
}
]
从扁平到树状,这就是"列表转树"要解决的问题。
老师提到,这种需求在管理后台特别常见:
- 多级菜单
- 地址三连弹(省 → 市 → 区)
- 组织架构树
- 商品分类
MySQL 存储的是扁平结构,select * from 取出来就是一维数组。 所以列表转树是前后端分离项目中几乎必做的数据处理。
二、方法一:Map 法------空间换时间
2.1 核心思路
老师教的第一种方法,用到了 ES6 新增的 Map 数据结构:
"ES6 新增数据结构 HashMap"
核心思路:先把所有节点存到 Map 里(id → 节点),再遍历一遍,把每个节点挂到对应的父节点下。
2.2 代码实现
javascript
function listToTree(list) {
const map = new Map(); // HashMap,id -> 节点
const tree = [];
// 第一轮:把所有节点放入 Map,并初始化 children
list.forEach((item) => {
map.set(item.id, {
...item, // 展开原有属性
children: [] // 添加一个空数组
});
});
// 第二轮:把每个节点挂到父节点下
list.forEach((item) => {
const current = map.get(item.id); // 当前项
const parent = map.get(item.parentId); // 当前项的父节点
if (parent) {
parent.children.push(current); // 有父节点,挂上去
} else {
tree.push(current); // 没有父节点,就是根节点
}
});
return tree;
}
2.3 执行过程拆解
以示例数据为例,看看代码是怎么跑的:
第一轮:构建 Map
yaml
Map {
1 → { id: 1, name: '一级菜单A', parentId: 0, children: [] }
2 → { id: 2, name: '一级菜单B', parentId: 0, children: [] }
3 → { id: 3, name: '二级A-1', parentId: 1, children: [] }
4 → { id: 4, name: '三级A-1-1', parentId: 3, children: [] }
5 → { id: 5, name: '二级B-1', parentId: 2, children: [] }
}
第二轮:挂到父节点
| 当前项 | parentId | 父节点存在? | 操作 |
|---|---|---|---|
| id: 1 | 0 | map.get(0) = undefined | tree.push(节点1) |
| id: 2 | 0 | map.get(0) = undefined | tree.push(节点2) |
| id: 3 | 1 | map.get(1) = 节点1 | 节点1.children.push(节点3) |
| id: 4 | 3 | map.get(3) = 节点3 | 节点3.children.push(节点4) |
| id: 5 | 2 | map.get(2) = 节点2 | 节点2.children.push(节点5) |
最终结果:
ini
tree = [节点1, 节点2]
节点1.children = [节点3]
节点3.children = [节点4]
节点2.children = [节点5]
2.4 时间复杂度:O(n)
两轮遍历,每轮都是 O(n),总时间复杂度 O(n)。 用了一个 Map 做空间换时间,查找父节点是 O(1)。
这就像你整理家谱:
- 先把所有人按身份证号(id)排好队(Map)。
- 再一个一个问"你爸是谁?"(parentId),然后站到爸爸后面(children.push)。
三、方法二:reduce 法------函数式编程的优雅
3.1 核心思路
老师教的第二种方法,用 reduce 实现,更函数式、更简洁:
javascript
function listToTree(list) {
// 第一轮 reduce:构建 nodeMap
const nodeMap = list.reduce((map, item) => {
map[item.id] = { ...item, children: [] };
return map;
}, {});
// 第二轮 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;
}, []);
}
3.2 reduce 的妙用
reduce 是数组的"万能折叠器"------把数组折叠成一个值。
第一轮 reduce:
- 初始值是空对象
{}。 - 每次迭代把
item.id作为 key,把{ ...item, children: [] }作为 value,存入 map。 - 最终得到
nodeMap = { 1: 节点1, 2: 节点2, ... }。
第二轮 reduce:
- 初始值是空数组
[]。 - 每次迭代判断当前节点是否有父节点:
- 有父节点 →
parent.children.push(cur)。 - 没有父节点 →
tree.push(cur)。
- 有父节点 →
- 最终返回组装好的树。
3.3 Map vs reduce:两种风格,一样高效
| 对比项 | Map 法 | reduce 法 |
|---|---|---|
| 代码风格 | 命令式(forEach) | 函数式(reduce) |
| 可读性 | 直观,步骤清晰 | 简洁,一行一个操作 |
| 时间复杂度 | O(n) | O(n) |
| 空间复杂度 | O(n)(Map) | O(n)(对象) |
| 适用场景 | 团队偏好命令式 | 团队偏好函数式 |
两种方法的核心思想完全一样:先建索引,再挂父子关系。 只是实现方式不同。
四、关键知识点:parentId 是树的"DNA"
4.1 为什么 parentId 这么重要?
老师强调:
"
parentId是树状的关键。"
在数据库里存储树状结构,通常有两种方案:
| 方案 | 存储方式 | 优点 | 缺点 |
|---|---|---|---|
| 邻接表(Adjacency List) | 每个节点存 parentId |
简单直观,插入删除方便 | 查询子树需要递归 |
| 嵌套集(Nested Set) | 存 left 和 right |
查询子树快 | 插入删除麻烦 |
parentId 就是邻接表方案的核心。 绝大多数管理后台都用这个方案,因为简单、直观、好维护。
4.2 扁平 vs 树状:两种视角
css
扁平视角(数据库): 树状视角(前端):
[id:1, parentId:0] 一级菜单A
[id:2, parentId:0] ├── 二级A-1
[id:3, parentId:1] │ └── 三级A-1-1
[id:4, parentId:3] └── 二级B-1
[id:5, parentId:2]
数据库喜欢扁平(查询快、存储省),前端喜欢树状(展示直观、递归方便)。 列表转树就是在这两种视角之间做转换。
五、JSON.stringify 的妙用:打印树状结构
老师还展示了一个调试技巧:
javascript
console.log(JSON.stringify(listToTree(flatList), null, 2))
JSON.stringify(obj, null, 2) 可以把对象格式化成带缩进的字符串。
- 第一个参数:要序列化的对象。
- 第二个参数:
null(不替换)。 - 第三个参数:
2(缩进 2 个空格)。
这样在控制台看树状结构特别清晰,比直接 console.log 一堆 [object Object] 强多了。
这就像你写文章:
console.log(obj):把所有文字挤在一行,看着头疼。JSON.stringify(obj, null, 2):自动分段缩进,像排版好的文章,一目了然。
六、总结:列表转树,前端必会的"基本功"
| 知识点 | 说明 |
|---|---|
| 列表转树 | 把扁平数组转成嵌套树状结构 |
parentId |
标识父节点的关键字段 |
| Map 法 | 用 HashMap 做索引,两轮遍历 |
| reduce 法 | 用 reduce 折叠数组,函数式风格 |
| 时间复杂度 | O(n),线性遍历 |
| 应用场景 | 多级菜单、地址选择、组织架构 |
JSON.stringify(obj, null, 2) |
格式化打印对象 |
列表转树是前端面试的高频题,也是实际工作中天天要用的技能。 掌握 Map 法和 reduce 法,无论面试官问哪种风格,你都能从容应对。
写在最后
今天最大的收获,是理解了"空间换时间"的思想。用 Map 做索引,把 O(n²) 的嵌套查找优化成 O(n) 的线性遍历------这个思路在很多算法题里都能用到。
下次面试官问你:"怎么把扁平数组转成树状结构?"
你可以淡定地说:
"我用 Map 做索引,两轮遍历。第一轮把所有节点存入 Map(id → 节点),并初始化 children 数组。第二轮遍历,根据 parentId 找到父节点,把当前节点 push 到父节点的 children 里。如果 parentId 找不到对应节点,说明是根节点,直接 push 到结果数组。时间复杂度 O(n)。也可以用 reduce 实现,更函数式。"
然后看着面试官满意的表情,心里默念:这波,又稳了。
本文所有代码示例均来自课堂学习资料,真实可运行。