列表转树与爬楼梯:两种经典算法题的多角度解法解析

29 阅读5分钟

在前端开发和算法面试中,经常会遇到两类典型问题:一是将扁平列表结构转换为具有层级关系的树形结构;二是解决动态规划中的经典“爬楼梯”问题。这两类题目虽然应用场景不同,但都涉及对递归思想、时间复杂度优化以及数据结构选择的深入理解。本文将分别对这两种问题进行详细讲解,并展示多种实现方式及其优劣。


一、列表转树:从扁平结构构建层级关系

1. 问题描述

给定一个包含 idparentId 字段的扁平数组,要求将其转换成一棵或多棵以 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' }
    ]
  }
]

2. 递归解法(时间复杂度 O(n²))

最直观的方法是使用递归。对于每一个节点,若其 parentId 等于当前目标父 ID,则将其加入结果,并递归查找它的子节点。

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;
}

该方法逻辑清晰,但每次递归都需要遍历整个列表来查找子节点,导致时间复杂度为 O(n²),在数据量较大时性能较差。

3. 使用 filter + map 的简洁写法

借助 ES6 的高阶函数,可以写出更简洁的递归版本:

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

此写法语义更清晰,但本质上仍是 O(n²) 时间复杂度。

4. 哈希映射优化(时间复杂度 O(n))

为了提升效率,可采用“空间换时间”的策略:先用哈希表(如 Map 或普通对象)建立 id → 节点 的映射,再通过一次遍历构建父子关系。

function list2tree(list) {
  const nodeMap = new Map();
  const tree = [];

  // 第一步:构建 id -> 节点(带空 children 数组)的映射
  list.forEach(item => {
    nodeMap.set(item.id, { ...item, children: [] });
  });

  // 第二步:遍历原列表,根据 parentId 挂载到父节点
  list.forEach(item => {
    const node = nodeMap.get(item.id);
    if (item.parentId === 0) {
      tree.push(node);
    } else {
      const parentNode = nodeMap.get(item.parentId);
      if (parentNode) {
        parentNode.children.push(node);
      }
    }
  });

  return tree;
}

这种方法仅需两次线性遍历,时间复杂度降为 O(n),是生产环境中推荐的做法。

5. 实际应用场景

  • 多级菜单:后台管理系统中的侧边栏菜单常以树形结构组织。
  • 地区选择器:省市区三级联动的数据通常以 parentId 形式存储,前端需转为树以便渲染。
  • 评论嵌套:社交平台的评论回复结构也可用此类方式构建。

二、爬楼梯:斐波那契数列的动态规划演绎

1. 问题描述

假设你正在爬楼梯,每次可以爬 1 阶或 2 阶。问:爬到第 n 阶共有多少种不同的方法?

这是一个经典的动态规划入门题,其数学本质是斐波那契数列。

2. 朴素递归(指数时间复杂度)

最直接的思路是递归:到达第 n 阶的方法数等于到达 n-1 阶和 n-2 阶的方法数之和。

function climbStairs(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;
  return climbStairs(n - 1) + climbStairs(n - 2);
}

然而,这种写法存在大量重复计算。例如 climbStairs(5) 会多次计算 climbStairs(3),时间复杂度高达 O(2ⁿ),且容易因调用栈过深而爆栈。

3. 记忆化递归(自顶向下 + 缓存)

为避免重复计算,可引入缓存(memoization):

const memo = {};
function climbStairs(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;
  if (memo[n] !== undefined) return memo[n];
  memo[n] = climbStairs(n - 1) + climbStairs(n - 2);
  return memo[n];
}

此方法将时间复杂度降至 O(n),但依赖全局变量 memo,不够优雅。

4. 闭包封装缓存

为避免污染全局作用域,可使用闭包将缓存封装在函数内部:

const climbStairs = (function () {
  const memo = {};
  function climb(n) {
    if (n === 1) return 1;
    if (n === 2) return 2;
    if (memo[n] !== undefined) return memo[n];
    memo[n] = climb(n - 1) + climb(n - 2);
    return memo[n];
  }
  return climb;
})();

这种方式既保留了记忆化的优势,又保证了模块化和封装性。

5. 迭代法(自底向上,空间最优)

进一步优化,可以完全摒弃递归,采用迭代方式从底向上计算:

function climbStairs(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;
  let prevPrev = 1; // f(1)
  let prev = 2;     // f(2)
  let current;
  for (let i = 3; i <= n; i++) {
    current = prev + prevPrev;
    prevPrev = prev;
    prev = current;
  }
  return current;
}

该方法仅使用常数级别的额外空间(O(1)),时间复杂度为 O(n),是性能最优的解法。

6. 思维启示

  • 自顶向下:适合理解问题结构,但需处理重复子问题。
  • 自底向上:更适合实际编码,避免递归开销,易于优化。
  • 动态规划的核心在于:定义状态、找出转移方程、确定边界条件

结语

无论是将扁平列表转化为树形结构,还是求解爬楼梯的路径数,这两类问题都体现了算法设计中的核心思想:递归分解、避免重复、空间换时间、状态转移。在实际开发中,理解这些模式不仅能帮助我们高效解决问题,还能提升代码的可维护性与性能表现。掌握多种解法并能根据场景灵活选择,是工程师进阶的重要标志。