列表转树:高频前端面试题

0 阅读8分钟

列表转树:高频前端面试题

前端面试里总有一道看似简单、却容易写错的题:给你一段扁平数据,怎么转成树?这节课从这道高频题出发,把哈希映射、Reduce、递归这些基础概念串了一遍;然后切换到 AI 方向,用 Prompt 驱动一个完整的 NLP 翻译任务。两部分看似不相关,但其实都在讲同一件事:如何把现实问题抽象成可复用的代码结构

列表转树:为什么它是前端高频题

后台返回的数据通常是扁平的,但前端渲染菜单、组织架构、评论回复时,需要的是树形结构。列表转树(list2tree)就是把这样的数据:

const flatList = [
  { id: 1, name: '一级菜单A', parentId: 0 },
  { id: 2, name: '一级菜单B', parentId: 0 },
  { id: 3, name: '二级A-1', parentId: 1 },
  { id: 4, name: '二级B-1', parentId: 2 },
  { id: 5, name: '三级A-1-1', parentId: 3 }
];

转成这样的嵌套结构:

一级菜单A
└── 二级A-1
    └── 三级A-1-1
一级菜单B
└── 二级B-1

这里的关键字段是 parentId:它记录了每个节点的父节点是谁。parentId === 0 表示这个节点没有父节点,也就是根节点。

基础实现:用 Map 建立父子关系

最直观的思路是两次遍历:

  1. 第一次遍历:用 Map 把每个 id 映射到节点对象,同时给每个节点初始化一个 children 数组;
  2. 第二次遍历:根据 parentId 把当前节点挂到父节点的 children 里。
function list2Tree(list) {
  const map = new Map();
  const tree = [];

  // 第一步:建立 id -> 节点的映射
  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);
    }

    if (item.parentId === 0) {
      tree.push(current);
    }
  });

  return tree;
}

Map 的好处是查找父节点的时间复杂度是 O(1),整体两次遍历下来,时间复杂度是 O(n),比嵌套循环查找父节点高效得多。

更优雅的写法:用 reduce 一次聚合

在写 list2tree 之前,先回顾一下 reduce 这个方法。它是数组的高阶函数之一,作用是把一个数组"归约"成单个值。基本签名长这样:

arr.reduce((accumulator, currentItem, currentIndex, originalArray) => {
  // 返回新的 accumulator
}, initialValue);
  • accumulator:累积值,也就是上一次回调返回的结果;
  • currentItem:当前遍历到的元素;
  • currentIndex:当前元素的索引;
  • originalArray:原数组本身;
  • initialValue:累积值的初始值,可选,但通常建议显式传入。

看个最简单的例子,求和:

const nums = [1, 2, 3, 4];
const sum = nums.reduce((acc, cur) => acc + cur, 0);
console.log(sum); // 10

这里的 acc 一开始是 0,然后依次变成 13610。每次回调把当前元素加到累积值上,最后返回总和。

reduce 的强大之处在于,累积值不一定是数字,也可以是对象、数组、Map 等任何数据结构。这让它非常适合"把数组聚合成另一个数据结构"的场景,比如把列表聚合成树。

如果熟悉 reduce,可以把上面的 list2tree 逻辑写得更紧凑:

function list2Tree(list) {
  const nodeMap = list.reduce((map, item) => {
    map[item.id] = { ...item, children: [] };
    return map;
  }, {});

  return list.reduce((tree, item) => {
    const node = nodeMap[item.id];
    const parent = nodeMap[item.parentId];

    if (item.parentId === 0) {
      tree.push(node);
    } else {
      parent.children.push(node);
    }

    return tree;
  }, []);
}

两种写法的核心思路完全一致,区别只在于用 forEach 还是用 reducereduce 更适合"把数组聚合成另一个数据结构"的场景;forEach 则更容易阅读,适合对代码可读性要求更高的项目。

列表转树与递归的关系

list2tree 本身没有显式递归,但它处理的问题本质上是树形的。每个节点知道自己的孩子,孩子又知道自己的孩子,这就形成了和递归遍历相同的结构。

这也是为什么面试官喜欢考这道题:它看起来是数组操作,实际上考察的是对"树形结构"和"哈希映射"的理解。写出 O(n) 的解法,说明你已经能把"找父节点"这个重复操作优化成一次查找。

进入 AI 部分:用 Prompt 做 NLP 翻译任务

列表转树练的是基础数据结构能力,而 NLP 翻译任务练的是如何把大模型能力封装成可复用的工程模块

这节课的项目结构和之前情感分析、信息提取的示例保持一致:

文件职责
client.mjs提供 LLM Client 对象,隔离配置
completions.mjs封装调用逻辑,提供 getCompletions 方法
index.mjs / main.mjs业务入口,写具体 Prompt 和调用流程

client.mjs:把配置隔离出去

import 'dotenv/config';
import OpenAI from 'openai';

const client = new OpenAI({
  apiKey: process.env.DASHSCOPE_API_KEY,
  baseURL: process.env.DASHSCOPE_BASE_URL,
  model: process.env.DASHSCOPE_MODEL
});

export default client;

把 API Key、Base URL、模型名放到环境变量里,代码本身不暴露敏感信息。这是从"能跑"到"能上线"的第一步。

completions.mjs:把调用逻辑封装成函数

import client from './client.mjs';

export async function getCompletions(prompt) {
  const response = await client.chat.completions.create({
    model: process.env.DASHSCOPE_MODEL,
    messages: [
      { role: 'user', content: prompt }
    ]
  });

  return response.choices[0].message.content;
}

getCompletions 只做一件事:接收一个 Prompt,返回模型输出。后续情感分析、翻译、总结都可以复用它。

翻译任务的多种形态

这节课重点讲了翻译(Translation),它是 NLP 中最常见、也最容易落地的任务之一。但翻译不是简单地把 A 语言变成 B 语言,它有很多细分形态。

1. 中英互译

const prompt = `
将以下三个反引号中的中文翻译成西班牙语:
\`\`\`您好,我想订购一个搅拌机。\`\`\`
`;

2. 语种识别

const prompt = `
请告诉我以下文本是什么语种:
\`\`\`Combien coûte le lampadaire?\`\`\`
`;

3. 多目标语言翻译

const prompt = `
请将以下文本分别翻译成中文、英文、法语和西班牙语:
\`\`\`I want to order a basketball.\`\`\`
`;

4. 语气转换

const prompt = `
请将以下文本翻译成中文,分别展示正式语气和非正式语气:
\`\`\`Would you like to order a pillow?\`\`\`
`;

同一个英文句子,翻译结果会根据语气场景变化。这说明 Prompt 不仅要说明"翻成什么语言",还要说明"用什么风格"。

实战:批量识别语种并翻译

课程里的 index.mjs 实现了一个通用翻译器:先识别输入文本的语种,再把它翻译成中文和英文。

const user_messages = [
  "La performance du système est plus lente que d'habitude.",
  "Mi monitor tiene píxeles que no se iluminan.",
  "Il mio mouse non funziona",
  "Mój klawisz Ctrl jest zepsuty",
  "我的屏幕在闪烁"
];

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

const main = async () => {
  for (let message of user_messages) {
    await sleep(1000);

    const prompt1 = `
    告诉我以下文本是什么语种,直接输出语种,
    如法语,无需输出标点符号:
    \`\`\`${message}\`\`\`
    `;
    const lang = await getCompletions(prompt1);
    console.log(lang, "\n");

    const prompt2 = `
    请将以下文本分别翻译成中文和英文,并写成
    中文:xxx
    英文:xxx
    的格式:
    \`\`\`${message}\`\`\`
    `;
    const result = await getCompletions(prompt2);
    console.log(result, "================");
  }
};

main();

这里有两个值得注意的点:

  1. 输出格式约束:翻译结果要求写成"中文:xxx / 英文:xxx"的固定格式,方便后续代码解析;
  2. 批量调用时加入延迟sleep(1000) 避免短时间内高频请求触发限流。

大模型接口调用的意义:从"会用"到"能封装"

这节课让我意识到,调用大模型和调用普通 HTTP 接口的本质区别不大,但工程化思路上有很大不同。

普通接口的输入输出通常是结构化的:传什么参数、返回什么字段,文档写得很清楚。而大模型接口的输入是"自然语言 Prompt",输出也是自然语言。这就需要我们多做一层"约束":

  • 用角色设定控制回答风格;
  • 用 Few-shot 示例固定输出格式;
  • 用 JSON / 固定分隔符让输出可被程序解析;
  • 把调用逻辑封装成 getCompletions 这样的函数,方便复用。

当这些封装做得足够好时,我们就可以像搭积木一样,把"翻译"、"总结"、"情感分析"这些能力组合成更复杂的业务系统。

我现在怎么理解这节课

这节课把两个看起来不相关的内容放在了一起:前端面试题 list2tree 和 AI 翻译任务。

list2tree 让我重新理解了"把扁平结构变成树形结构"这件事。它考察的不是你会不会写递归,而是你能不能通过哈希映射把 O(n²) 的嵌套查找优化成 O(n)。这种"用空间换时间"的思路,在面试和工程里都很常见。

而 NLP 翻译任务让我看到,AI 能力的落地不只是"调一个 API",而是要把 Prompt 设计、调用封装、输出解析、批量调度这些环节串起来。client.mjs 管配置,completions.mjs 管调用,业务文件专注写 Prompt——这种分层结构,就是从小 demo 走向可维护项目的关键。

两部分的共同点在于:好的代码结构,能让复杂问题变得可管理。无论是把数组转成树,还是把大模型能力封装成业务接口,最终目的都是让下一层调用者不必关心实现细节。