列表转树:一道题搞懂 HashMap 在算法里的价值

后端返回扁平数组,前端要展示多级菜单。这道经典题把 HashMap、reduce、引用类型三个知识点串到了一起。

本文把每一行代码、每一个关键字和函数的作用都讲一下,适合初学者从 0 搞懂。


一、问题背景:扁平数据 vs 树形数据

1.1 后端给的数据(扁平数组)

js 复制代码
const flatList = [
    { id: 1, name: '一级菜单A',     parentId: 0 },  // parentId=0 表示顶级
    { id: 2, name: '一级菜单B',     parentId: 0 },
    { id: 3, name: '二级菜单A-1',   parentId: 1 },  // 父亲是 id=1
    { id: 4, name: '三级菜单A-1-1', parentId: 3 },  // 父亲是 id=3
    { id: 5, name: '二级菜单B-1',   parentId: 2 }   // 父亲是 id=2
]

特点

  • 一维数组,每个元素是一条记录,没有嵌套
  • 每条记录有 id(自己编号)和 parentId(父亲编号)
  • parentId: 0 是约定俗成的"哨兵值",表示"我是顶级,没有父亲"

1.2 前端要的数据(树形结构)

less 复制代码
一级菜单A (id:1)
└── 二级菜单A-1 (id:3)
    └── 三级菜单A-1-1 (id:4)
一级菜单B (id:2)
└── 二级菜单B-1 (id:5)

特点 :子节点嵌套在父节点的 children 数组里。

1.3 转换目标

js 复制代码
listToTree(flatList)  →  [ 根节点1, 根节点2 ]  // 每个根节点带嵌套的 children

关键 :靠 parentId 字段串成父子关系。


二、朴素解法为什么不行

2.1 最直觉的写法:递归找孩子

js 复制代码
function listToTree(list, parentId = 0) {
    return list
        .filter(item => item.parentId === parentId)   // 每次扫整个 list 找孩子
        .map(item => ({
            ...item,
            children: listToTree(list, item.id)        // 递归往下找
        }))
}

2.2 痛点 1:慢(O(n²))

每个节点都要把整个 list 扫一遍找它的孩子。n 个节点就是 n × n = O(n²)

1000 个节点要算 100 万次,数据一多就卡。

2.3 痛点 2:怕数据顺序乱

如果数据是"孩子在父前面":

js 复制代码
[
    { id: 4, parentId: 3 },  // 孙子先出现
    { id: 1, parentId: 0 },  // 根最后出现
]

递归找爹时,爹还没出现,直接挂不上。


三、优化思路:HashMap 把"找父亲"降到 O(1)

3.1 核心洞察

朴素解法慢,是因为每次找父亲都要遍历整个数组

如果先把所有节点按 id 存成一个"字典",根据 id 一步就能拿到节点,找父亲就是 O(1) 了。

scss 复制代码
没索引:找父亲 → 遍历整个 list → O(n)
有索引:找父亲 → map.get(parentId) → O(1)

这就是 HashMap(哈希表) 的价值:用空间换时间

3.2 两步走

bash 复制代码
第一步:建索引
  遍历 list,把每个节点按 id 存进 Map
  → 得到 { id → 节点 } 的字典

第二步:串树
  再遍历 list,每个节点按 parentId 查字典找父亲
  → 找到父亲 → 挂到父亲的 children
  → 找不到父亲(parentId=0)→ 当根,进 tree

总复杂度:O(n) + O(n) = O(n),从 100 万次降到 2000 次。


四、解法一:forEach + Map

4.1 完整代码

js 复制代码
function listToTree(list) {
    const map = new Map();        // ① 创建 HashMap
    const tree = [];              // ② 准备装根节点的数组

    // ③ 第一次遍历:建索引
    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;                  // ⑤ 返回最终的树
}

4.2 ① const map = new Map();

做什么

创建一个空的 HashMap。

Map 是什么

ES6 新增的数据结构,本质是"键值对集合",类似对象 {},但更专业:

特性 普通对象 {} Map
key 类型 只能是字符串/Symbol 任意类型(数字、对象都行)
有 size Object.keys(obj).length map.size 直接拿
遍历顺序 ES2015+ 保证 按插入顺序
性能 一般 专为增删改查优化

常用 API

js 复制代码
map.set(key, value)   // 存
map.get(key)          // 取(不存在返回 undefined)
map.has(key)          // 判断有没有
map.delete(key)       // 删
map.size              // 元素个数

为什么用 Map 不用对象

  • 数字 key 保持数字类型(对象会把 1 转成字符串 "1"
  • 语义更清晰:map.set / map.get 一看就是哈希表操作
  • 性能更好

4.3 ② const tree = [];

做什么

创建一个空数组,用来装最终的根节点

为什么是数组不是对象

因为可能有多个顶级菜单(一级菜单A、一级菜单B),tree 要支持"森林"(多棵树并列)。数组正好能放多个根节点。

4.4 ③ 第一次遍历:建索引

js 复制代码
list.forEach((item) => {
    map.set(item.id, { ...item, children: [] })
});

做什么

把每个节点按 id 存进 map,建立"id → 节点"的字典。

拆解 list.forEach((item) => { ... })

forEach 是什么:数组的遍历方法,对每个元素执行一次回调。

js 复制代码
数组.forEach((当前项, 下标, 原数组) => { ... })

item 是什么forEach 的回调参数,每轮循环拿到的当前元素

ini 复制代码
list 有 5 个元素 → forEach 循环 5 次:
第1轮:item = {id:1, name:'一级菜单A', parentId:0}
第2轮:item = {id:2, name:'一级菜单B', parentId:0}
第3轮:item = {id:3, name:'二级菜单A-1', parentId:1}
第4轮:item = {id:4, name:'三级菜单A-1-1', parentId:3}
第5轮:item = {id:5, name:'二级菜单B-1', parentId:2}

item 在哪声明的 :在回调函数的参数列表 里声明的(形参),不用写 let/const。forEach 内部循环时自动把 list[i] 当作实参传进来。

=> 是什么:箭头函数语法,等价于:

js 复制代码
list.forEach(function (item) { ... })

拆解 map.set(item.id, { ...item, children: [] })

map.set(key, value) :往 Map 里存一对键值,key 是 item.id,value 是 {...item, children:[]}

...item 展开运算符

... 是什么 :ES6 的展开运算符(spread operator),把对象的所有属性"倒"进新对象。

js 复制代码
// 原 item
{ id: 1, name: '一级菜单A', parentId: 0 }

// { ...item } 等价于
{ id: 1, name: '一级菜单A', parentId: 0 }   // 完整拷贝一份

// { ...item, children: [] } 等价于
{ id: 1, name: '一级菜单A', parentId: 0, children: [] }
//    ↑ 从 item 拷贝来的           ↑ 新加的属性

为什么用 ...item 不直接存原 item

js 复制代码
// ❌ 直接存原 item(会污染原数组)
map.set(item.id, item)
item.children = []   // flatList 里的原始数据也被改了!

// ✅ 用 ...item 拷贝新对象(解耦)
map.set(item.id, { ...item, children: [] })
// flatList 里的原数据不受影响

展开运算符的作用:浅拷贝对象,生成一份独立的新对象。

children: []

做什么 :给每个节点预先准备一个空的 children 数组。

为什么先加空数组 :后面第二步要 parent.children.push(孩子),得先有 children 这个数组才能 push。预先加好,后面直接 push,代码干净。

如果不预先加,push 时还要临时判断:

js 复制代码
// 不预先加 children 的麻烦写法
if (!parent.children) parent.children = []
parent.children.push(cur)

第一次遍历结束后的 map

bash 复制代码
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:[] }
}

此时所有 children 都还是空的,父子关系还没串起来。Map 只是"按 id 存好的节点仓库"。

4.5 ④ 第二次遍历:串树

js 复制代码
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);
    }
});

做什么

每个节点按 parentId 找父亲,找到就挂到父亲的 children,找不到就是根节点进 tree

const current = map.get(item.id);

map.get(key):根据 key 从 Map 里取值。

这里用 item.id 当 key,拿到当前节点在 map 里存的那个对象(带空 children 的)。

为什么不用原 item,而要从 map 里取 :因为 map 里存的是带 children 的拷贝 ,而原 item 没有 children。后续要 push 孩子到 current.children,必须用 map 里的版本。

const parent = map.get(item.parentId);

做什么 :用当前节点的 parentId 当 key,去 map 里找它的父亲。

关键 :如果 parentId=0,map 里没有 key=0 的节点,map.get(0) 返回 undefined

if (parent) { ... } else { ... }

if (parent) 是什么意思 :JavaScript 的隐式类型转换 ,把 parent 当布尔值判断。

parent 的值 if(parent) 结果 含义
对象(找到父亲了) true 走 if 分支
undefined(没找到) false 走 else 分支
null false 走 else
0 / '' / NaN false 走 else

falsy 值 (判为 false):false0''nullundefinedNaN

其他都是 truthy(判为 true)。

所以 if (parent) 等价于 if (parent !== undefined && parent !== null),写起来更简洁。

parent.children.push(current);

push 是什么 :数组方法,把元素加到数组末尾

js 复制代码
const arr = [1, 2]
arr.push(3)   // arr 变成 [1, 2, 3]

这里把当前节点 current push 到父亲的 children 数组里,完成"挂娃"

为什么能这样挂 :因为 parentcurrent 都是 map 里的同一个对象引用 。改 parent.children 就是改 map 里那个对象的 children。

tree.push(current);(else 分支)

做什么 :找不到父亲(parentId=0),说明是根节点,push 进 tree 数组。

为什么 parentId=0 会走到 else :map 里只有 key [1,2,3,4,5]没有 key=0

js 复制代码
map.get(0)   // → undefined

parent 是 undefined → if(parent) 为 false → 走 else。

parentId=0 是故意设计的"哨兵值",表示"我是顶级项没父亲节点"。这个值在 map 里找不到,自动区分根节点,代码极其简洁。

4.6 ⑤ return tree;

把最终拼好的 tree 数组返回给调用者。

4.7 第二次遍历结束后的 tree

js 复制代码
[
    {
        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: []
        }]
    }
]

关键理解 :第 3、4、5 轮,tree 数组本身没新增元素,但 parent.children.push(cur) 改的是 map 里的对象 。而 tree 里存的就是 map 里的同一个对象引用------所以 tree 虽然没长,但它内部节点的 children 已经变长了。

这就是 JS 引用类型的特性:改对象属性,所有指向它的变量都看得到。


五、解法二:reduce + 对象

逻辑完全相同,只是把 forEach 换成 reduce,更紧凑、函数式,是在es6之前没有Map的常用写法。

5.1 完整代码

js 复制代码
function listToTree(list) {
    // 第一次 reduce:建 nodeMap
    const nodeMap = list.reduce((map, item) => {
        map[item.id] = { ...item, children: [] }
        return map;
    }, {});

    // 第二次 reduce:串 tree
    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;
    }, []);
}

5.2 reduce 是什么

reduce 把数组"浓缩"成一个值。每轮拿到上次的累积结果 + 当前项,返回新的累积结果。

语法

js 复制代码
数组.reduce((累积器, 当前项, 下标, 原数组) => {
    return 新的累积器
}, 初始值)

最经典的例子------求和

js 复制代码
[10, 20, 30].reduce((acc, cur) => {
    return acc + cur
}, 0)
// 初始 acc = 0
// 第1轮:acc=0,  cur=10 → return 10
// 第2轮:acc=10, cur=20 → return 30
// 第3轮:acc=30, cur=30 → return 60
// 结果:60

acc(累积器):第一轮是初始值,之后是上一轮 return 的东西。每轮传回来让你接着算。

5.3 第一次 reduce:建 nodeMap

js 复制代码
const nodeMap = list.reduce((map, item) => {
    map[item.id] = { ...item, children: [] }
    return map;
}, {});

做什么

把 list 浓缩成一个 { id: 节点 } 的对象。

拆解 (map, item) => { ... }

  • map:累积器(第一轮是初始值 {},之后是上一轮 return 的 map)
  • item:当前项(list 里的每个元素)

map[item.id] = { ...item, children: [] }

用普通对象当 HashMap :用 item.id 当 key,往 map 对象上挂属性。

等价于:

js 复制代码
map[1] = { id:1, children:[] }
map[2] = { id:2, children:[] }
// ...

和 Map 的区别 :普通对象的 key 会被自动转成字符串(1"1"),但用的时候 map[1]map["1"] 都能取到,不影响使用。

return map;(关键!)

reduce 的回调必须每轮 return 累积器 ,不然下一轮 map 就变成 undefined,直接报错。

初始值 {}

js 复制代码
list.reduce((map, item) => { ... }, {})
//                                ↑ 这个 {} 是初始值
//       (map, item) => { ... }
//                    ↑ 这个 {} 是函数体

注意区分 :箭头函数 () => {}{} 是函数体,不是初始值。初始值写在 reduce 的第二个参数。

第一次 reduce 结束后的 nodeMap

js 复制代码
{
    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:[] }
}

和解法一 Map 内容一模一样,只是类型从 Map 变成了普通对象。

5.4 第二次 reduce:串 tree

js 复制代码
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;
}, []);

做什么

找爹挂娃,找不到爹的进 tree。

关键点

  • (tree, item)tree 是累积器(初始值 []),item 是当前项
  • nodeMap[item.id] / nodeMap[item.parentId]:用普通对象的中括号语法 取值,等价于 Map 版的 map.get()
  • return tree;必须 return,理由同上
  • []:reduce 的初始值是空数组,最终浓缩成一个装根节点的 tree 数组

六、forEach vs reduce 对比

6.1 两种写法对比

维度 forEach + Map(1.js) reduce + 对象(2.js)
建索引类型 new Map() 普通 {}
存取方式 map.set() / map.get() map[id] = / map[id]
遍历方式 forEach reduce
要 return? 不要 必须 return 累积器
代码风格 直观,调试方便 紧凑,函数式,面试加分
结果 完全相同 完全相同

6.2 forEach vs reduce 本质区别

特性 forEach reduce
返回值 undefined(不返回) 累积器(最终结果)
用途 遍历执行副作用 把数组浓缩成一个值
要 return? 不需要 必须
适合场景 "对每个元素做点事" "把数组归纳成一个结果"

listToTree 用 reduce 的好处

  • 第一次 reduce 直接"产出"nodeMap 对象(不用在外面先声明)
  • 第二次 reduce 直接"产出"tree 数组(直接 return)
  • 代码更紧凑,一个表达式搞定

七、执行流程:5 个节点走一遍

7.1 第一次遍历:建索引

轮次 item.id 执行 map/nodeMap 新增
1 1 map.set(1, {id:1,children:[]}) 1 → 节点1
2 2 map.set(2, {...}) 2 → 节点2
3 3 map.set(3, {...}) 3 → 节点3
4 4 map.set(4, {...}) 4 → 节点4
5 5 map.set(5, {...}) 5 → 节点5

7.2 第二次遍历:串树

轮次 item parentId 找爹结果 走哪条? 执行动作 tree 状态
1 id=1 0 undefined else tree.push(节点1) [节点1]
2 id=2 0 undefined else tree.push(节点2) [节点1, 节点2]
3 id=3 1 节点1 if 节点1.children.push(节点3) 不变
4 id=4 3 节点3 if 节点3.children.push(节点4) 不变
5 id=5 2 节点2 if 节点2.children.push(节点5) 不变

7.3 最终 tree 结构

ini 复制代码
[ 节点1, 节点2 ]
    │      │
    │      └── children: [ 节点5 ]
    │
    └── children: [ 节点3 ]
                      │
                      └── children: [ 节点4 ]

八、复杂度分析

8.1 对比表

方法 时间复杂度 空间复杂度 数据顺序敏感?
朴素递归 O(n²) O(n) ❌ 敏感(孩子在父前面挂不上)
Map 索引法 O(n) O(n) ✅ 不敏感

8.2 为什么 Map 索引法不敏感

第一次遍历已建好全部索引,第二次遍历时所有爹都在 map 里了,不管数据顺序怎样都能找到。


九、五个常见坑

9.1 坑 1:reduce 忘了 return 累积器

js 复制代码
list.reduce((map, item) => {
    map[item.id] = { ...item, children: [] }
    // ❌ 忘了 return map!
}, {})
// 下一轮 map 变 undefined → Cannot set property of undefined

修复 :每轮都 return map

9.2 坑 2:直接存原 item,污染原数组

js 复制代码
// ❌ 直接存原对象
map.set(item.id, item)
item.children = []   // flatList 里的原数据也被改了

// ✅ 用 ...item 拷贝新对象
map.set(item.id, { ...item, children: [] })

9.3 坑 3:parentId 用 0 还是 null

两种都能跑(map.get(0)map.get(null) 都是 undefined,走 else)。但项目里要统一,别一半 0 一半 null。

9.4 坑 4:朴素递归法遇到乱序数据挂不上

yaml 复制代码
// 数据顺序乱(孩子在父前面):
[
    { id: 4, parentId: 3 },  // 孙子先出现
    { id: 1, parentId: 0 },  // 根最后出现
]

朴素递归会挂不上。Map 索引法完全不受影响

9.5 坑 5:用 for...in 遍历普通对象 nodeMap

js 复制代码
// ❌ 会带上原型链属性
for (let key in nodeMap) { ... }

// ✅ 用 Object.values() 或 entries()
Object.values(nodeMap).forEach(node => { ... })
for (let [id, node] of Object.entries(nodeMap)) { ... }

十、关键字与语法速查

10.1 ...(展开运算符)

js 复制代码
// 1. 展开对象(浅拷贝)
const copy = { ...obj }

// 2. 展开数组
const merged = [...arr1, ...arr2]

// 3. 合并属性
const obj = { ...old, newProp: 1 }

// 4. 函数剩余参数
function fn(...args) { ... }

10.2 Map 常用 API

js 复制代码
const map = new Map()
map.set(key, value)   // 存
map.get(key)          // 取(不存在返回 undefined)
map.has(key)          // 判断有没有
map.delete(key)       // 删一个
map.clear()           // 清空
map.size              // 元素个数

// 遍历
for (const [key, value] of map) { ... }
map.forEach((value, key) => { ... })

10.3 forEach vs reduce vs for...of

方法 返回值 能 break 能 await 适合场景
forEach undefined 简单遍历执行副作用
reduce 累积器 把数组浓缩成一个值
for...of 需要中断/异步的遍历

10.4 if (parent) 的真假判断

falsy 值 (判为 false):false0''nullundefinedNaN

truthy 值 (判为 true):其他所有值(包括 []{}'0'

js 复制代码
if (parent)   // 等价于 if (parent !== undefined && parent !== null)

10.5 push 数组方法

js 复制代码
const arr = [1, 2]
arr.push(3)        // [1, 2, 3],返回新长度
arr.push(4, 5)     // [1, 2, 3, 4, 5],可一次 push 多个

十一、延伸知识

11.1 树转列表(反向操作)

js 复制代码
function treeToList(tree) {
    const list = []
    tree.forEach(node => {
        list.push({ id: node.id, name: node.name, parentId: node.parentId })
        if (node.children?.length) {
            list.push(...treeToList(node.children))   // 递归拍扁子节点
        }
    })
    return list
}

11.2 查找树中某个节点

js 复制代码
function findNode(tree, id) {
    for (let node of tree) {
        if (node.id === id) return node
        if (node.children?.length) {
            const found = findNode(node.children, id)
            if (found) return found
        }
    }
    return null
}

11.3 渲染到页面(Vue 递归组件)

vue 复制代码
<!-- MenuItem.vue -->
<template>
    <div>
        <span>{{ node.name }}</span>
        <MenuItem
            v-for="child in node.children"
            :key="child.id"
            :node="child"
        />
    </div>
</template>

十二、总结

12.1 核心思路

ini 复制代码
列表转树三步走:
  ① 建 id → 节点 索引(Map 或 {})
  ② 按 parentId 找爹
  ③ 找到爹 → push 到爹的 children
     找不到爹(parentId=0)→ push 到 tree 当根

12.2 三个知识点

知识点 在本题的作用
HashMap Map/对象,把"找父亲"从 O(n) 降到 O(1)
reduce 函数式累积器,必须每轮 return
引用类型 共享对象,改 children 自动同步到 tree

12.3 一句话总结

列表转树的核心:用 HashMap 把"找父亲"从 O(n) 降到 O(1),两次线性遍历解决树形构建。

这是前端处理多级菜单、目录树、省市区联动的标准解法。

相关推荐
用户921080262861 小时前
5. 数据大屏实时通信第一步:为什么选择 WebSocket,以及如何接入 Socket.IO
前端
岁月留痕1681 小时前
17 实战项目二:实现“日记”项目多页面管理
前端
李顿波1 小时前
Chrome 插件弹窗一直停留在初始的小尺寸 —— 你看到的小方块
前端·javascript·chrome
悟空瞎说1 小时前
Cesium 与 Three.js 融合实战:在数字地球上渲染自定义 3D 场景
前端
渣波1 小时前
React 移动端首页架构实战:从并发请求到防御性编程的深度解析
前端·javascript
a1117761 小时前
原生 Markdown 阅读与编辑器 开源项目
前端·开源·软件
laity171 小时前
python发光表白爱心(从零到一实现)
前端·后端
程序员爱钓鱼1 小时前
Rust impl详解:为Struct定义方法与关联函数
前端·后端·rust
用户059540174461 小时前
Qdrant 召回不一致踩坑实录:跑了 300 次测试才发现是索引没刷新
前端·css