面试官最爱问的‘列表转树’,你真的会优化吗?

41 阅读4分钟

列表转树:一道面试题的层层优化之路

在前端开发中,我们经常会遇到这样一种数据处理需求:将一个扁平化的列表结构转换为具有层级关系的树形结构。这类问题常见于菜单管理、组织架构展示、省市区三级联动等场景,也是技术面试中的高频考点。它不仅考察对递归和数据结构的理解,更考验性能优化意识与工程思维。

本文将从最直观的解法出发,逐步深入,探讨该问题的多种实现方式及其背后的优化逻辑。


问题描述

给定一个数组,每个元素包含 idparentId 和其他属性(如 name),其中 parentId === 0 表示该节点为根节点。目标是将其转换为嵌套的树形结构,每个节点可包含一个 children 数组。

输入示例:

const list = [
  { id: 1, parentId: 0, name: 'A' },
  { id: 2, parentId: 1, name: 'B' },
  { id: 3, parentId: 1, name: 'C' },
  { id: 4, parentId: 2, name: 'D' }
];

期望输出:

[
  {
    id: 1,
    parentId: 0,
    name: 'A',
    children: [
      {
        id: 2,
        parentId: 1,
        name: 'B',
        children: [{ id: 4, parentId: 2, name: 'D' }]
      },
      { id: 3, parentId: 1, name: 'C' }
    ]
  }
]

解法一:朴素递归(时间复杂度 O(n²))

最直接的思路是使用递归:从根节点开始,逐层查找其子节点。

function list2tree(list, parentId = 0) {
  const result = [];
  list.forEach(item => {
    if (item.parentId === parentId) {
      const children = list2tree(list, item.id);
      if (children.length) {
        item.children = children;
      }
      result.push(item);
    }
  });
  return result;
}

该方法逻辑清晰:外层遍历所有节点,若其 parentId 匹配当前目标,则递归构建其子树。但每次递归都需要遍历整个列表,导致时间复杂度为 O(n²) 。当数据量较大时(如省市区数据达数千条),性能会急剧下降。


解法二:函数式递归(代码更简洁,复杂度不变)

借助现代 JavaScript 的高阶函数,可以写出更简洁的版本:

function list2tree(list, parentId = 0) {
  return list
    .filter(item => item.parentId === parentId)
    .map(item => ({
      ...item,
      children: list2tree(list, item.id)
    }));
}

此写法利用 filter 筛选当前层级节点,再通过 map 递归挂载子树。虽然代码更紧凑、无副作用,但本质上仍需在每层递归中遍历全部数据,时间复杂度依然是 O(n²),仅适用于小规模数据。


解法三:哈希表优化(时间复杂度 O(n))

要突破 O(n²) 的瓶颈,关键在于避免重复遍历。核心思想是:用空间换时间——预先建立 id 到节点的映射,后续通过哈希查找直接定位父节点。

实现步骤如下:

  1. 遍历一次列表,将所有节点存入 Map,并初始化 children 数组;
  2. 再次遍历,根据 parentId 将当前节点挂载到对应父节点的 children 中;
  3. 收集所有 parentId === 0 的节点作为最终结果。
function listToTree(list) {
  const map = new Map();
  const roots = [];

  // 构建 id -> node 映射
  for (const item of list) {
    map.set(item.id, { ...item, children: [] });
  }

  // 建立父子关系
  for (const item of list) {
    if (item.parentId === 0) {
      roots.push(map.get(item.id));
    } else {
      const parent = map.get(item.parentId);
      if (parent) {
        parent.children.push(map.get(item.id));
      }
    }
  }

  return roots;
}

该方案仅需两次线性遍历,时间复杂度降至 O(n) ,空间复杂度为 O(n)。在真实项目中(如后台管理系统菜单渲染、地址选择器),这是推荐的生产级解法。


实际应用场景

这种“扁平转树”的需求广泛存在于各类业务中:

  • 省市区三级联动:后端通常返回扁平列表,前端需构建成树以支持级联选择;
  • 后台菜单系统:权限菜单常以 parentId 形式存储,前端需转换为树用于侧边栏渲染;
  • 文件目录或分类体系:如电商商品类目、知识库章节结构等。

在这些场景中,数据量可能从几十条到上万条不等。若采用 O(n²) 方案,轻则页面卡顿,重则浏览器崩溃。因此,理解并掌握 O(n) 优化方案至关重要。


面试中的延伸思考

面试官常会在此基础上追问:

  • 如果存在多个根节点?→ 当前方案天然支持。
  • 如果数据中有环(如 A 的父是 B,B 的父是 A)?→ 需增加环检测机制。
  • 能否支持非 0 的根标识?→ 可将根标识作为参数传入,提升通用性。
  • 如何反向操作(树转扁平列表)?→ 使用 DFS 或 BFS 遍历收集节点即可。

这些问题考察的不仅是编码能力,更是对边界条件、健壮性和扩展性的思考。


总结

解法时间复杂度空间复杂度特点适用场景
朴素递归O(n²)O(h)逻辑直观教学、小数据
函数式递归O(n²)O(h)代码简洁快速原型
哈希表优化O(n)O(n)高效稳定生产环境

从暴力递归到哈希优化,这道题的演进过程体现了算法设计的核心思想:识别瓶颈、权衡时空、贴近实际。在面试中,能清晰阐述这一优化路径,往往比直接写出最优解更能赢得认可。

毕竟,优秀的工程师不仅会写代码,更懂得为什么这样写。