面试必考:如何优雅地将列表转换为树形结构?

6 阅读15分钟

面试必考:如何优雅地将列表转换为树形结构?

前言

在前端开发中,我们经常会遇到这样一个场景:后端返回的是一个扁平的列表数据,但前端需要渲染成树形结构。比如:

  • 省市区三级联动
  • 组织架构树
  • 权限菜单树
  • 商品分类树

今天我们就来深入探讨这个经典面试题,从递归到优化,一步步掌握列表转树的精髓。

第一章:理解数据结构

1.1 什么是扁平列表?

想象一下,你有一张Excel表格,每一行都是一个独立的数据,它们之间通过某个字段(比如parentId)来表明谁是谁的“爸爸”:

// 这是一个扁平的列表
const list = [
    {id: 1, name: 'A', parentId: 0},  // A是根节点(parentId为0表示没有父节点)
    {id: 2, name: 'B', parentId: 1},  // B的爸爸是A(parentId=1)
    {id: 3, name: 'C', parentId: 1},  // C的爸爸也是A
    {id: 4, name: 'D', parentId: 2}   // D的爸爸是B
]

这种数据的特点:

  • 每条数据都有一个唯一的 id(就像每个人的身份证号)
  • 通过 parentId 来表示父子关系(就像你知道你爸爸的身份证号)
  • parentId: 0 表示根节点(没有爸爸,或者爸爸是“虚拟”的根)

1.2 什么是树形结构?

树形结构就像你家的家族族谱:爷爷下面有爸爸和叔叔,爸爸下面有你和你兄弟姐妹:

// 我们希望转换成的树形结构
[
  {
    id: 1,
    name: 'A',
    parentId: 0,
    children: [  // children表示“孩子”们
      {
        id: 2,
        name: 'B',
        parentId: 1,
        children: [
          { id: 4, name: 'D', parentId: 2 }  // D是B的孩子
        ]
      },
      { id: 3, name: 'C', parentId: 1 }  // C是A的孩子,但没有自己的孩子
    ]
  }
]

1.3 为什么要转换?

后端为什么给扁平列表?因为存数据方便(只需要一张表)。 前端为什么要树结构?因为展示数据方便(树形菜单、级联选择器等)。

第二章:递归法(最直观的思路)

2.1 什么是递归?

递归就像俄罗斯套娃:一个大娃娃里面套着一个小娃娃,小娃娃里面还套着更小的娃娃...用程序的话说,就是函数调用自身

2.2 思路分析

想象你在整理家族族谱:

  1. 先找到所有没有爸爸的人(parentId: 0),他们是第一代人
  2. 然后为每个人找他的孩子:遍历整个列表,找到所有爸爸ID等于这个人ID的人
  3. 对每个孩子重复第2步(递归!)

2.3 基础版代码实现(逐行解释)

function listToTree(list, parentId = 0) {
    // result用来存放最终的结果
    // 比如第一次调用时,它用来存放所有根节点
    const result = []
    
    // 遍历列表中的每一项
    list.forEach(item => {
        // 检查当前项的父亲是不是我们要找的那个父亲
        // 比如parentId=0时,我们就在找所有根节点
        if (item.parentId === parentId) {
            
            // ★ 关键递归:找当前项的孩子
            // 把当前项的id作为新的parentId,去找它的孩子
            const children = listToTree(list, item.id)
            
            // 如果找到了孩子(children数组不为空)
            if (children.length) {
                // 给当前项添加一个children属性,把孩子们放进去
                item.children = children
            }
            
            // 把处理好的当前项放进结果数组
            result.push(item)
        }
    })
    
    // 返回这一层找到的所有人
    return result
}

2.4 代码执行过程演示

假设我们有这样的数据:

const list = [
    {id: 1, name: 'A', parentId: 0},  // 爷爷
    {id: 2, name: 'B', parentId: 1},  // 爸爸
    {id: 3, name: 'C', parentId: 1},  // 叔叔
    {id: 4, name: 'D', parentId: 2}   // 孙子
]

第一次调用listToTree(list, 0)

  • 找爸爸ID为0的人 → 找到A(id=1)
  • 调用listToTree(list, 1)找A的孩子

第二次调用listToTree(list, 1)

  • 找爸爸ID为1的人 → 找到B(id=2)和C(id=3)
  • 先处理B:调用listToTree(list, 2)找B的孩子
  • 再处理C:调用listToTree(list, 3)找C的孩子

第三次调用listToTree(list, 2)

  • 找爸爸ID为2的人 → 找到D(id=4)
  • 调用listToTree(list, 4)找D的孩子(没找到)
  • 返回[D],作为B的children

第四次调用listToTree(list, 3)

  • 找爸爸ID为3的人 → 没找到
  • 返回[],作为C的children(所以C没有children属性)

2.5 进阶版:使用ES6简化(逐行解释)

function listToTree(list, parentId = 0) {
    // 1. 先用filter过滤出当前层的所有节点
    // 比如找所有parentId等于0的根节点
    return list
        .filter(item => item.parentId === parentId)
        
        // 2. 然后用map对每个节点进行处理
        .map(item => ({
            // 这里用了三个点,后面会详细解释
            ...item,
            
            // 3. 递归找当前节点的孩子
            children: listToTree(list, item.id)
        }))
}

这段代码虽然简洁,但做了三件事:

  1. filter:从列表中筛选出符合条件的节点(比如所有根节点)
  2. map:对每个筛选出的节点进行处理
  3. 递归:为每个节点找它的孩子

2.6 递归法的优缺点

优点

  • 逻辑清晰,容易理解
  • 代码简洁优雅
  • 符合人的思维习惯

缺点

  • 时间复杂度 O(n²) - 每个节点都需要遍历整个列表
  • 列表越长,性能越差
  • 可能造成栈溢出(数据量极大时)

第三章:深入理解 ...item 的作用

3.1 如果不使用 ...item 会怎样?

很多初学者可能会这样写:

// 错误示例 ❌
map[item.id] = item
map[item.id].children = []  // 这样会修改原始数据!

3.2 为什么不能直接使用原对象?

让我们用一个生活例子来理解:

假设你有一张原始的家族成员名单

const originalList = [
    {id: 1, name: '爷爷'}
]

情况1:直接使用原对象(坏的做法)

const map = {}
map[1] = originalList[0]  // 把爷爷的原始记录放进map
map[1].children = ['孙子']  // 在原始记录上添加孙子信息

console.log(originalList[0])  
// 输出:{id: 1, name: '爷爷', children: ['孙子']}
// 哎呀!原始名单被修改了!

情况2:使用 ...item 复制(好的做法)

const map = {}
map[1] = { ...originalList[0] }  // 复制一份爷爷的记录
map[1].children = ['孙子']  // 在**副本**上添加孙子信息

console.log(originalList[0])  
// 输出:{id: 1, name: '爷爷'}
// 太好了!原始名单完好无损!

3.3 ...item 到底在做什么?

... 是JavaScript的扩展运算符,它的作用就像复印机:

const 原件 = { name: '张三', age: 18 }

// 用...复制一份
const 复印件 = { ...原件 }

// 现在原件和复印件是两份独立的数据
复印件.age = 19

console.log(原件.age)    // 18(没变)
console.log(复印件.age)  // 19(变了)

3.4 在列表转树中的应用

在我们的代码中:

map[item.id] = {
    ...item,        // 把item的所有属性复制过来
    children: []    // 再添加一个新的children属性
}

这相当于:

// 如果item是 {id: 1, name: 'A', parentId: 0}
// 那么新对象就是:
{
    id: 1,           // 从item复制来的
    name: 'A',       // 从item复制来的
    parentId: 0,     // 从item复制来的
    children: []     // 新添加的
}

3.5 什么时候必须用 ...item

必须用的场景:当你不想修改原始数据时

// 场景1:后端返回的数据,你不想污染它
const dataFromServer = [...]
const myCopy = { ...dataFromServer[0] }

// 场景2:多次使用同一份数据
const baseConfig = { theme: 'dark' }
const user1Config = { ...baseConfig, name: '张三' }
const user2Config = { ...baseConfig, name: '李四' }
// user1Config和user2Config互不影响

// 场景3:需要添加新属性,又不影响原对象
const original = { x: 1, y: 2 }
const enhanced = { ...original, z: 3 }
// original还是{x:1,y:2},enhanced是{x:1,y:2,z:3}

第四章:Map优化法(空间换时间)

4.1 为什么要优化?

递归法虽然好理解,但有个严重的问题:太慢了

想象一下:

  • 100个节点:递归法要做100×100=10000次操作
  • 1000个节点:要做1000×1000=1000000次操作
  • 10000个节点:...算了,太可怕了!

这就是我们常说的时间复杂度O(n²),数据量越大越慢。

4.2 优化思路

就像你去图书馆找书:

  • 递归法:每次找一本书都要把整个图书馆逛一遍
  • 优化法:先做一个索引表,想看什么书直接查索引

4.3 基础版代码实现(逐行解释)

function listToTree(list) {
    // 1. 第一步:创建"索引表"(map)
    // 这个map就像一个电话簿,通过id能直接找到对应的人
    const map = {}
    
    // 2. 第二步:存放最终结果(根节点们)
    const result = []
    
    // 3. 第一次遍历:把所有人都放进"电话簿"
    list.forEach(item => {
        // 对每个人,都做一份复印件(用...item复制)
        // 并且给复印件加一个空的"孩子名单"(children数组)
        map[item.id] = {
            ...item,        // 复印个人信息
            children: []    // 准备一个空的孩子名单
        }
    })
    
    // 4. 第二次遍历:建立父子关系
    list.forEach(item => {
        // 判断:这个人是不是根节点(没有爸爸)?
        if (item.parentId === 0) {
            // 是根节点:直接放进最终结果
            result.push(map[item.id])
        } else {
            // 不是根节点:找到他爸爸,把自己加入爸爸的孩子名单
            
            // map[item.parentId] 通过爸爸的ID找到爸爸
            // ?. 是可选链操作符,意思是:如果爸爸存在,就执行后面的操作
            // .children.push() 把自己加入爸爸的孩子名单
            map[item.parentId]?.children.push(map[item.id])
        }
    })
    
    // 5. 返回最终结果
    return result
}

4.4 图解Map优化法

假设有这样的数据:

原始列表:
[  {id:1, parentId:0, name:'A'},  // 根节点  {id:2, parentId:1, name:'B'},  // A的孩子  {id:3, parentId:1, name:'C'}   // A的孩子]

第一次遍历后(建立索引表):
map = {
  1: {id:1, name:'A', children:[]},
  2: {id:2, name:'B', children:[]},
  3: {id:3, name:'C', children:[]}
}

第二次遍历(建立关系):
- 处理item1: parentId=0 → result = [map[1]]
- 处理item2: parentId=1 → map[1].children.push(map[2])
- 处理item3: parentId=1 → map[1].children.push(map[3])

最终result:
[{
  id:1, name:'A',
  children: [
    {id:2, name:'B', children:[]},
    {id:3, name:'C', children:[]}
  ]
}]

4.5 使用ES6 Map版本(更专业的写法)

function listToTree(list) {
    // 使用ES6的Map数据结构代替普通对象
    // Map相比普通对象有更多优点:键可以是任何类型,有size属性等
    const nodeMap = new Map()
    const tree = []
    
    // 第一次遍历:初始化所有节点
    list.forEach(item => {
        nodeMap.set(item.id, {
            ...item,
            children: []
        })
    })
    
    // 第二次遍历:构建树结构
    list.forEach(item => {
        if (item.parentId === 0) {
            // 根节点直接加入树
            tree.push(nodeMap.get(item.id))
        } else {
            // 非根节点找爸爸
            const parentNode = nodeMap.get(item.parentId)
            if (parentNode) {
                // 把自己加入爸爸的孩子名单
                parentNode.children.push(nodeMap.get(item.id))
            }
        }
    })
    
    return tree
}

4.6 为什么返回result就是返回所有树的元素?

这是一个非常关键的知识点!很多初学者会疑惑:为什么最后返回result就能得到完整的树?

让我们用一个比喻来理解:

想象你是一个班主任,要整理全校学生的家族关系:

  1. 你有一张全校学生名单(list
  2. 你做了一个索引表(map),通过学号能快速找到每个学生
  3. 你有一个空的花名册(result),用来放每个家族的"族长"(根节点)

关键理解:当你把"族长"放进result时,这个"族长"已经通过索引表关联了所有的子孙后代!

// 实际内存中的关系
map[1] = { id:1, name:'A', children: [] }  // 族长A
map[2] = { id:2, name:'B', children: [] }  // A的儿子B
map[3] = { id:3, name:'C', children: [] }  // A的儿子C

// 建立关系后
map[1].children.push(map[2])  // 现在 map[1].children 里有 map[2] 的引用
map[1].children.push(map[3])  // 现在 map[1].children 里有 map[3] 的引用

// 把map[1]放入result
result.push(map[1])

// 此时的map[1]长这样:
{
    id: 1,
    name: 'A',
    children: [
        { id:2, name:'B', children:[] },  // 注意:这里是完整的B对象
        { id:3, name:'C', children:[] }   // 注意:这里是完整的C对象
    ]
}

重点来了:虽然我们只把A放进了result,但是A的children数组里直接存储了B和C对象的引用。也就是说:

  • result[0] 就是 A
  • result[0].children[0] 就是 B
  • result[0].children[1] 就是 C

所以通过result,我们就能访问到整棵树的所有节点!

如果有多个根节点:

// 假设还有另一个家族:D是族长,E是D的儿子
map[4] = { id:4, name:'D', children: [] }
map[5] = { id:5, name:'E', children: [] }
map[4].children.push(map[5])

// 把map[4]也放入result
result.push(map[4])

// 最终result:
[
    {  // 第一棵树
        id: 1, name:'A',
        children: [ { id:2, name:'B' }, { id:3, name:'C' } ]
    },
    {  // 第二棵树
        id: 4, name:'D',
        children: [ { id:5, name:'E' } ]
    }
]

所以返回result就是返回了所有的树,因为:

  1. 每个根节点都包含了它的所有子孙节点(通过引用)
  2. result数组收集了所有的根节点
  3. 通过这些根节点,我们可以访问到整个森林的所有节点

4.7 为什么说"空间换时间"?

  • 递归法:速度快吗?慢!占内存吗?少!(时间多,空间少)
  • Map优化法:速度快吗?快!占内存吗?多!(时间少,空间多)

就像搬家:

  • 递归法:每次需要什么都临时去买(耗时但省地方)
  • Map优化法:先把所有东西都买好放仓库(费地方但省时间)

第五章:两种方法的详细对比

对比维度递归法Map优化法通俗解释
时间复杂度O(n²)O(n)100个数据:递归法要查10000次,Map法只要查200次
空间复杂度O(1)O(n)递归法基本不占额外内存,Map法需要建一个索引表
代码长度短(3-5行)稍长(10-15行)递归法更简洁
可读性⭐⭐⭐⭐⭐⭐⭐⭐递归法更容易理解
适用场景小数据量(<100条)大数据量(>100条)根据数据量选择

第六章:实际应用场景(详细版)

6.1 省市区三级联动

// 实际开发中,后端通常只返回扁平列表
const areas = [
    {id: 1, parentId: 0, name: '中国'},
    {id: 2, parentId: 1, name: '北京'},
    {id: 3, parentId: 1, name: '上海'},
    {id: 4, parentId: 2, name: '东城区'},
    {id: 5, parentId: 2, name: '西城区'},
    {id: 6, parentId: 3, name: '黄浦区'}
]

// 转换后,就可以方便地实现三级联动选择器
const areaTree = listToTree(areas)
// 用户选择"中国"后,自动显示"北京"、"上海"
// 选择"北京"后,自动显示"东城区"、"西城区"

6.2 组织架构树

// 公司人员列表
const employees = [
    {id: 1, parentId: 0, name: '张总', position: 'CEO'},
    {id: 2, parentId: 1, name: '李经理', position: '技术总监'},
    {id: 3, parentId: 1, name: '王经理', position: '市场总监'},
    {id: 4, parentId: 2, name: '赵前端', position: '前端工程师'},
    {id: 5, parentId: 2, name: '钱后端', position: '后端工程师'},
    {id: 6, parentId: 3, name: '孙策划', position: '市场专员'}
]

// 转换后可以渲染出组织架构图
const orgTree = listToTree(employees)

6.3 权限菜单树

// 后台管理系统的菜单
const menus = [
    {id: 1, parentId: 0, name: '系统管理', icon: '⚙️'},
    {id: 2, parentId: 1, name: '用户管理', icon: '👤'},
    {id: 3, parentId: 1, name: '角色管理', icon: '🔑'},
    {id: 4, parentId: 2, name: '新增用户', permission: 'user:add'},
    {id: 5, parentId: 2, name: '编辑用户', permission: 'user:edit'}
]

// 转换后可以方便地渲染侧边栏菜单
const menuTree = listToTree(menus)

第七章:常见问题解答(FAQ)

Q1: 如果数据中有多个根节点怎么办?

A: 没问题!result 数组会包含所有根节点,形成一个"森林"(多棵树)。每个根节点都带着自己的子树。

Q2: 如果数据中有循环引用(A的爸爸是B,B的爸爸是A)怎么办?

A: 这会导致无限递归!需要先做数据校验,或者设置最大递归深度。

Q3: 什么情况下用递归法,什么情况下用Map法?

A:

  • 数据量小(<100条):用递归法,简单易懂
  • 数据量大(>100条):用Map法,性能好
  • 面试时:先说递归法展示思路,再说Map法展示优化能力

Q4: 为什么 map[item.parentId]?.children 要加问号?

A: 问号是可选链操作符,意思是:如果 map[item.parentId] 存在,才执行后面的 .children.push。防止出现"找不到爸爸"的错误。

Q5: 为什么返回result就能得到完整的树?

A: 因为每个根节点的children数组里存储的是子节点的引用,而不是复制品。当你通过result访问根节点时,实际上可以通过引用链访问到所有后代节点。

第八章:面试技巧

当面试官问到这个问题时,可以这样回答:

  1. 第一层(基础):"我可以用递归实现,先找根节点,再递归找每个节点的子节点。"

  2. 第二层(优化):"但递归的时间复杂度是O(n²),数据量大时会很慢。我可以用Map优化到O(n)。"

  3. 第三层(细节):"在实现时要注意用...item复制对象,避免修改原始数据。还要处理边界情况,比如找不到父节点的情况。"

  4. 第四层(原理):"Map优化法的核心是空间换时间,通过索引表实现O(1)查找。返回result之所以能得到完整的树,是因为JavaScript的对象引用机制——根节点的children里存储的是子节点的引用。"

  5. 第五层(应用):"这个算法在实际开发中很常用,比如我在做省市区选择器、后台管理系统菜单时都用到了。"

第九章:总结与思考

通过这篇文章,我们学习了:

  1. 什么是列表转树:把扁平数据变成树形结构
  2. 递归法:直观但性能较差
  3. ...item的作用:复制对象,避免修改原始数据
  4. Map优化法:性能好但稍微复杂
  5. 返回结果的原理:通过引用机制,根节点包含所有子孙节点
  6. 实际应用场景:省市区联动、组织架构、权限菜单等

掌握了这个算法,你不仅能应对面试,在实际开发中也能游刃有余地处理各种树形结构数据。


如果这篇文章对你有帮助,欢迎点赞收藏,也欢迎在评论区提出你的问题!