面试官:请实现列表转树。我:这题我有 N 种解法!

54 阅读7分钟

嘿,小伙伴们!今天我们要聊的是前端面试中出镜率极高、甚至可以说是“必考题”的一个算法场景:列表转树(List to Tree)

你可能在面试题库里见过它,也可能在实际开发联调接口时被后端同学丢过来的“扁平数据”折磨过。别担心,今天我们就从零开始,把这个知识点拆解得干干净净,让你不仅能写出代码,还能跟面试官聊出“深度”。


一、 为什么我们要“转”?—— 洞察数据结构

在深入代码之前,我们先得搞清楚:为什么原始数据是扁平的?

1. 扁平数据的魅力(存储友好)

在数据库(如 MySQL)中,我们通常采用关联列表的形式存储具有层级关系的数据。每一行就是一个对象,通过一个 parentId 字段来标记自己的“亲爹”是谁。

2. 树状结构的必要(展示友好)

到了前端界面,我们需要展示的是多级菜单、折叠面板、地址联动选择器。这些 UI 组件需要的结构是嵌套的:

JavaScript

{
  id: 1,
  name: '根节点',
  children: [
    { id: 2, name: '子节点', children: [] }
  ]
}

这就是我们今天要攻克的任务:利用 idparentId 的对应关系,把数组拼装成树。


二、 方案一:最直观的递归法(初学者必会)

当我们看到“层级”、“嵌套”这些词同时结构是树时,脑子里跳出的第一个单词应该是:递归(Recursion)

1. 经典解法:两层逻辑的循环

这是最传统的思维方式。我们要找根节点,然后针对每个根节点,再去列表里找它的“孩子们”。

JavaScript

function list2tree(list, parentId = 0) {
    const result = [];
    list.forEach(item => { // 【关键点 1】:外层遍历,寻找当前层级的节点
        // 判断当前项的父级 ID 是否等于我们要找的 parentId
        if(item.parentId === parentId){
            // 【关键点 2】:递归调用,去找当前节点的子节点
            // 这一步是核心:我们假设 list2tree 已经能帮我们处理好下一层了
            const children = list2tree(list, item.id); 
            
            // 【关键点 3】:如果找到了孩子,就挂载到当前节点下
            if(children.length > 0){
                item.children = children;
            }
            // 将拼装好的节点推入结果数组
            result.push(item);
        }
    })
    return result;
}

🔍 技术细节解析:

  • 重复的事:每次都在数组里翻找谁是我的孩子。
  • 退出条件:当 list2tree 在数组里找不到任何一个 parentId 匹配的项时,返回空数组,递归自然停止。
  • 复杂度分析:这是一个典型的 O(n2)O(n^2) 算法。因为每深入一层递归,我们都要全量遍历一遍数组。如果树很深,性能会明显下降。

2. 方案二:结合 ES6 的优雅姿势

面试官通常会看你对新语法(其实也不新了)的掌握程度。我们可以利用 filtermap 把上面的逻辑写得非常“函数式”。

JavaScript

function list2tree2(list, parentId = 0) {
    return list
      .filter(item => item.parentId === parentId) // 【关键点】:过滤出当前层的节点
      .map(item => ({ 
        ...item, // 解构原对象,保持数据不可变性(Immutable)
        // 【关键点】:递归寻找子节点并直接赋值
        // 注意:这里使用 () 包裹对象字面量,是因为箭头函数后面直接跟 {} 会被解析为函数体
        children: list2tree2(list, item.id) 
      }))
}

💡 面试加分项:

这种写法简洁明了,体现了你对 JavaScript 数组 API 的熟练运用。但要记住,它的时间复杂度依然是 O(n2)O(n^2),属于“用性能换简洁”。


三、 方案三:空间换时间,冲向 O(n)O(n)

面试官如果问:“如果列表有 10 万条数据,递归太慢了怎么办?”

这时候,你的 哈希表(Map/Object) 就要登场了。

递归之所以慢,是因为每次找孩子都要遍历全表。如果我们能一次遍历就把所有节点的引用存起来,寻找过程不就变成 O(1)O(1) 了吗?

1. 使用对象 Object 实现 Map

在 ES6 普及前,我们习惯用对象来充当字典。

JavaScript

function list2tree(list) {
    const map = {}; // 临时仓库,存储所有节点的引用
    const result = [];

    // 第一步:先把所有节点丢进 map 
    list.forEach(item => {
        map[item.id] = {
            ...item,
            children: [] // 预留好 children 数组
        }
    })

    // 第二步:再次遍历,直接从 map 里精准找爹
    list.forEach(item => {
        // 取出我们在第一步创建的具有 children 属性的对象
        const node = map[item.id];
        
        if(item.parentId === 0){
            // 如果是根节点,直接进结果数组
            result.push(node);
        } else {
            // 【关键点】:通过 parentId 直接在 map 中找到父节点
            // 使用可选链 ?. 避免父节点不存在时报错
            map[item.parentId]?.children.push(node);
        }
    })
    return result;
}

🚀 为什么它快?

因为我们只遍历了两次数组。第一次建立映射,第二次完成拼接。这种 空间换时间 的做法,将复杂度降到了线性的 O(n)O(n)

2. 方案四:ES6 Map 正统解法

虽然 Object 可以当 Map 用,但 ES6 提供的 Map 数据结构在频繁增删和键名查找上性能更优,且语义更明确。

JavaScript

function list2tree2(list) {
    const nodeMap = new Map(); // 【关键点】:使用 ES6 原生 Map
    const tree = [];

    // 第一次循环:初始化映射表
    list.forEach(item => {
        nodeMap.set(item.id, {
            ...item,
            children: []
        });
    });

    // 第二次循环:利用 Map.get() 快速组装
    list.forEach(item => {
        // 获取当前节点的最新引用(带 children 的那个)
        const currentNode = nodeMap.get(item.id);

        if(item.parentId === 0){
            tree.push(currentNode);
        } else {
            // 【关键点】:通过 get 获取父节点引用
            const parentNode = nodeMap.get(item.parentId);
            // 只要父节点存在,直接 push 进去
            // 因为是引用类型,这里 push 进去的节点,后续如果有子节点加入,依然会保持更新
            if(parentNode) {
                parentNode.children.push(currentNode);
            }
        }   
    })
    return tree;
}

四、 深度进阶:你在开发中哪里用到了它?

如果面试官问:“你这个算法在实际项目中怎么用的?” 千万别只说“我就写着玩”。你可以从以下三个真实的业务场景切入:

1. 行政区划级联

这是最经典的例子。中国省、市、区、街道的级别关系。

  • 后端表结构:id, name, parent_id
id   parentId name
1    0        北京
2    1        东城区
3    1        朝阳区
....
121  0        江西
....
155  121      抚州
156  155      临川
  • 前端需求:点击“江西省”,自动联想出“抚州市”,点击“抚州”再联想出“临川区”。这种三连弹组件底层就是一棵树。

2. 侧边栏菜单权限管理

在后台管理系统中,不同角色看到的菜单不同。

  • 后端会返回这个用户拥有的所有权限按钮和页面列表(扁平的)。
  • 前端需要根据 parentId 动态渲染侧边栏。如果权限有三层,你就得转成树。

3. 文件系统/组织架构

类似飞书或企业微信的部门组织架构,或者像 VS Code 左侧的文件夹目录。

这些数据在数据库底层一定是扁平存储的(为了方便移动部门、修改名称),但展示时必须是树状的。


五、 总结与重点复盘

好了,总结一下今天掌握的“通关密码”:

方案核心思想时间复杂度空间复杂度适用场景
递归法找根节点 -> 递归找子节点O(n2)O(n^2)O(n)O(n) (递归栈)数据量小,逻辑简单
Map 映射法两次遍历,哈希查找O(n)O(n)O(n)O(n)大数据量,追求性能

💡 最后的面试小贴士:

  1. 注意引用关系:在 Map 解法中,我们操作的是对象的引用。这意味着当你把一个子节点 push 到父节点的 children 数组时,它指向的是内存中同一个对象。
  2. 数据清洗:面试时可以主动问面试官:“如果 parentId 指向了一个不存在的节点,需要特殊处理吗?” 这种对异常边界的思考非常加分。
  3. 递归深度:如果树特别深(几千层),递归可能会导致栈溢出(Stack Overflow) 。虽然前端场景很少见,但意识到这一点说明你对底层原理有敬畏心。