列表转树算法深度解析:从 Map 到 Reduce 的两种实现,面试高频考点
管理后台的多级菜单、地址三连弹、组织架构树------这些常见功能背后,都依赖同一个核心算法:列表转树。本文深入解析两种实现方案(Map 版 vs 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 }
];
关键字段:
id:唯一标识name:显示名称parentId:父节点 ID(0表示根节点)
1.2 输出:树状结构
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: []
}
]
}
]
结构可视化:
less
一级菜单A (id:1)
└── 二级A-1 (id:3)
└── 三级A-1-1 (id:4)
一级菜单B (id:2)
└── 二级B-1 (id:5)
1.3 为什么后端存扁平结构?
sql
-- MySQL 表结构(统一的扁平存储)
CREATE TABLE menu (
id INT PRIMARY KEY,
name VARCHAR(50),
parentId INT,
FOREIGN KEY (parentId) REFERENCES menu(id)
);
-- 查询:简单直接
SELECT * FROM menu;
优势:
- 表结构简单统一
- 增删改查方便
- 支持无限层级(无需预先定义层级数)
劣势:前端需要额外处理,将扁平列表转为树状结构。
二、核心思路:两次遍历法
列表转树的核心算法是两次遍历法:
bash
第一次遍历:构建映射表(id → node)
┌─────────────────────────────────┐
│ 1 → {id:1, name:'一级菜单A', children:[]} │
│ 2 → {id:2, name:'一级菜单B', children:[]} │
│ 3 → {id:3, name:'二级A-1', children:[]} │
│ 4 → {id:4, name:'三级A-1-1', children:[]} │
│ 5 → {id:5, name:'二级B-1', children:[]} │
└─────────────────────────────────┘
第二次遍历:挂载到父节点
id:3 (parentId:1) → 挂载到 id:1 的 children
id:4 (parentId:3) → 挂载到 id:3 的 children
id:5 (parentId:2) → 挂载到 id:2 的 children
id:1 (parentId:0) → 根节点,放入 tree 数组
id:2 (parentId:0) → 根节点,放入 tree 数组
时间复杂度 :O(n) --- 两次遍历,每次 O(n) 空间复杂度:O(n) --- 映射表存储 n 个节点
三、方案一:Map 版实现(forEach)
3.1 代码实现
javascript
function listToTree(list) {
const map = new Map(); // ES6 Map:id → node 的映射
const tree = []; // 最终结果:根节点数组
// 第一次遍历:构建映射表
list.forEach((item) => {
map.set(item.id, {
...item, // 展开原始属性
children: [] // 初始化 children 数组
});
});
// 第二次遍历:挂载到父节点
list.forEach(item => {
const current = map.get(item.id); // 当前节点
const parent = map.get(item.parentId); // 父节点
if (parent) {
parent.children.push(current); // 有父节点 → 挂载到父节点的 children
} else {
tree.push(current); // 无父节点(parentId: 0)→ 根节点
}
});
return tree;
}
3.2 执行流程图解
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 }
];
// ========== 第一次遍历:构建 Map ==========
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: [] }
}
// ========== 第二次遍历:挂载到父节点 ==========
item: { id: 1, parentId: 0 }
parent = map.get(0) → undefined
→ tree.push(node_1)
tree = [node_1]
item: { id: 2, parentId: 0 }
parent = map.get(0) → undefined
→ tree.push(node_2)
tree = [node_1, node_2]
item: { id: 3, parentId: 1 }
parent = map.get(1) → node_1
→ node_1.children.push(node_3)
node_1.children = [node_3]
item: { id: 4, parentId: 3 }
parent = map.get(3) → node_3
→ node_3.children.push(node_4)
node_3.children = [node_4]
item: { id: 5, parentId: 2 }
parent = map.get(2) → node_2
→ node_2.children.push(node_5)
node_2.children = [node_5]
// ========== 最终结果 ==========
tree = [
node_1( children: [node_3( children: [node_4] )] ),
node_2( children: [node_5] )
]
3.3 为什么用 Map 而不是 Object?
| 特性 | Object | Map |
|---|---|---|
| 键类型 | 只能是 String/Symbol | 任意类型(Number, Object...) |
| 键的顺序 | 不保证 | 保证插入顺序 |
| 性能(大量数据) | 一般 | 更优 |
| 获取大小 | Object.keys(obj).length |
map.size |
| 迭代方式 | for...in |
forEach、for...of |
💡 列表转树场景 :
id通常是数字,Map 的键可以是任意类型,比 Object 更自然。
四、方案二:Reduce 版实现(函数式编程)
4.1 代码实现
javascript
function listToTree(list) {
// 第一次 reduce:构建映射表(对象形式)
const nodeMap = list.reduce((map, item) => {
map[item.id] = { ...item, children: [] };
return map;
}, {});
// 第二次 reduce:挂载到父节点,收集根节点
return list.reduce((tree, item) => {
const current = nodeMap[item.id];
const parent = nodeMap[item.parentId];
if (parent) {
parent.children.push(current);
} else {
tree.push(current);
}
return tree;
}, []);
}
4.2 Reduce 的执行过程
javascript
// 第一次 reduce:构建 nodeMap
list.reduce((map, item) => {
map[item.id] = { ...item, children: [] };
return map;
}, {});
// 初始值:{}
// 第1次:{ 1: node_1 }
// 第2次:{ 1: node_1, 2: node_2 }
// 第3次:{ 1: node_1, 2: node_2, 3: node_3 }
// ...
// 结果:{ 1: node_1, 2: node_2, 3: node_3, 4: node_4, 5: node_5 }
// 第二次 reduce:构建 tree
list.reduce((tree, item) => {
// ... 挂载逻辑
return tree;
}, []);
// 初始值:[]
// 处理 id:1 (parentId:0) → tree = [node_1]
// 处理 id:2 (parentId:0) → tree = [node_1, node_2]
// 处理 id:3 (parentId:1) → node_1.children = [node_3]
// 处理 id:4 (parentId:3) → node_3.children = [node_4]
// 处理 id:5 (parentId:2) → node_2.children = [node_5]
// 结果:[node_1, node_2]
4.3 Reduce 的核心概念
javascript
array.reduce((accumulator, currentValue) => {
// 处理逻辑
return accumulator; // 返回累积值
}, initialValue);
| 参数 | 含义 | 列表转树中的对应 |
|---|---|---|
accumulator |
累积值 | map / tree |
currentValue |
当前元素 | item |
initialValue |
初始值 | {} / [] |
💡 Reduce 的哲学:将数组"归约"为单一值(可以是对象、数组、数字等)。
五、两种方案对比
| 对比维度 | Map 版(forEach) | Reduce 版 |
|---|---|---|
| 代码行数 | 较多 | 较少 |
| 可读性 | ✅ 清晰直观 | ⚠️ 需要理解 reduce |
| 函数式风格 | ❌ 命令式 | ✅ 声明式 |
| 中间变量 | map + tree |
nodeMap |
| 性能 | O(n) | O(n) |
| 面试推荐 | 基础写法 | 进阶写法 |
5.1 面试时如何选择?
先写 Map 版(确保正确):
- 思路清晰,不容易出错
- 面试官能理解你的思路
再优化为 Reduce 版(展示深度):
- 体现函数式编程思维
- 代码更简洁优雅
- 展示对数组方法的深入理解
六、扩展:实际应用场景
6.1 多级菜单渲染
javascript
// 后端返回的菜单列表
const menuList = [
{ id: 1, name: '系统管理', parentId: 0, path: '/system' },
{ id: 2, name: '用户管理', parentId: 1, path: '/system/user' },
{ id: 3, name: '角色管理', parentId: 1, path: '/system/role' },
{ id: 4, name: '订单管理', parentId: 0, path: '/order' },
{ id: 5, name: '订单列表', parentId: 4, path: '/order/list' }
];
// 转为树后渲染
const menuTree = listToTree(menuList);
// Vue/React 中递归渲染
function renderMenu(items) {
return items.map(item => (
<li key={item.id}>
<a href={item.path}>{item.name}</a>
{item.children.length > 0 && (
<ul>{renderMenu(item.children)}</ul>
)}
</li>
));
}
6.2 地址选择器(省市区)
javascript
const addressList = [
{ id: 110000, name: '北京市', parentId: 0 },
{ id: 110100, name: '北京市', parentId: 110000 },
{ id: 110101, name: '东城区', parentId: 110100 },
{ id: 110102, name: '西城区', parentId: 110100 }
];
const addressTree = listToTree(addressList);
// 用于级联选择器(Cascader)组件
6.3 组织架构树
javascript
const orgList = [
{ id: 1, name: 'CEO', parentId: 0 },
{ id: 2, name: '技术总监', parentId: 1 },
{ id: 3, name: '产品总监', parentId: 1 },
{ id: 4, name: '前端组', parentId: 2 },
{ id: 5, name: '后端组', parentId: 2 }
];
const orgTree = listToTree(orgList);
// 用于组织架构图、汇报关系展示
七、常见面试题
Q1:列表转树的时间复杂度是多少?
答:O(n)。两次遍历列表,每次 O(n),总体 O(2n) = O(n)。
Q2:如果数据量很大(10万条),如何优化?
答:
- 当前算法已经是 O(n),理论上最优
- 实际优化:后端直接返回树结构(如果业务允许)
- 分页加载:只加载当前展开的节点
- Web Worker:在后台线程处理,不阻塞主线程
Q3:如何处理循环引用(A 的 parent 是 B,B 的 parent 是 A)?
答:增加 visited 集合,检测环:
javascript
function listToTreeSafe(list) {
const nodeMap = {};
list.forEach(item => {
nodeMap[item.id] = { ...item, children: [] };
});
const tree = [];
const visited = new Set(); // 检测环
list.forEach(item => {
if (visited.has(item.id)) return; // 已处理,跳过
visited.add(item.id);
const current = nodeMap[item.id];
const parent = nodeMap[item.parentId];
if (parent && !visited.has(item.parentId)) {
parent.children.push(current);
} else if (!parent) {
tree.push(current);
}
});
return tree;
}
Q4:Map 和 Object 作为映射表有什么区别?
答 :Map 的键可以是任意类型(包括数字),且保证插入顺序;Object 的键只能是 String/Symbol,且顺序不保证。列表转树中 id 通常是数字,Map 更自然。
八、知识图谱
scss
列表转树算法
├── 问题定义
│ ├── 输入:扁平列表(id, name, parentId)
│ ├── 输出:树状结构(嵌套 children)
│ └── 应用场景:菜单、地址、组织架构、评论
├── 核心思路:两次遍历法
│ ├── 第一次:构建映射表(id → node)
│ └── 第二次:挂载到父节点
├── 方案一:Map 版
│ ├── new Map() 构建映射
│ ├── forEach 两次遍历
│ └── 时间/空间复杂度 O(n)
├── 方案二:Reduce 版
│ ├── reduce 构建 nodeMap
│ ├── reduce 收集根节点
│ └── 函数式编程风格
├── 两种方案对比
│ ├── 可读性 vs 简洁性
│ ├── 命令式 vs 声明式
│ └── 面试策略
└── 扩展应用
├── 多级菜单渲染
├── 地址选择器
└── 组织架构树
九、总结
本文深入解析了列表转树这一前端高频算法:
- 问题本质:后端存储扁平列表(统一表结构),前端需要树状结构(嵌套展示)。
- 核心算法:两次遍历法 --- 第一次构建映射表,第二次挂载到父节点,时间复杂度 O(n)。
- Map 版 :使用
forEach+Map,思路清晰,适合基础写法。 - Reduce 版 :使用
reduce两次归约,代码简洁,体现函数式编程思维,适合进阶展示。 - 实际应用:多级菜单、地址选择器、组织架构树、评论嵌套等场景都离不开列表转树。
🚀 学习建议:面试时先写 Map 版确保正确,再展示 Reduce 版体现深度。理解两次遍历法的原理后,无论用哪种 API 都能快速实现。
参考资源
📌 标签:#列表转树 #算法 #面试题 #Map #Reduce #树结构 #前端算法 #数据结构
💬 互动:你在项目中遇到过哪些列表转树的变体需求?欢迎在评论区分享!