真实面经题目 · 原创解析
给你一个数组,其中元素是节点值以及父节点值,要求去重并还原树
这道题考察扁平数组转树的实现能力。关键不是只会 parentId 挂 children,而是先用 Map 按唯一 id 去重和建索引,再用第二遍挂载,明确重复 id、父节点后出现、多根、孤儿节点和循环引用的处理策略。
真实面经题目 · 原创解析
这道题考察扁平数组转树的实现能力。关键不是只会 parentId 挂 children,而是先用 Map 按唯一 id 去重和建索引,再用第二遍挂载,明确重复 id、父节点后出现、多根、孤儿节点和循环引用的处理策略。
我会先把输入约定清楚:每条记录有唯一 id、parentId 和节点值,parentId 为 null、undefined 或空字符串时视为根,0 是否为根要看题目约定。实现上第一遍遍历用 Map 按 id 去重并创建带 children 的节点,保留第一次出现的顺序;第二遍再按 parentId 找父节点,找到就 push 到父节点 children,根节点放 roots,父节点缺失放 orphans。重复 id 默认第一条决定父子关系,后续只补缺失的非结构字段;如果 parentId 冲突要记录冲突或直接报错。最后补环检测,避免 A 的父是 B、B 的父又是 A。这样时间复杂度 O(n),空间复杂度 O(n)。
先把记录形态定成 { id, parentId, value }。id 是节点唯一键,parentId 是父节点 id。根节点只能用明确的空值判断,比如 null、undefined、空字符串;如果 parentId 可能是 0,不能用 if (!parentId) 判断根。
遍历原数组时先查 Map。第一次遇到某个 id 就创建 { ...raw, children: [] } 并记录顺序;再次遇到同 id 时不创建新节点。默认策略是第一条决定 parentId,后续重复条只补齐缺失的非结构字段,parentId 冲突进入 conflicts 或抛错。
第二遍只遍历去重后的节点顺序。parentId 是根值就放入 roots;parentId 能在 Map 中找到就 push 到父节点 children。因为第一遍已经收集完所有节点,所以父节点出现在子节点之后也不会丢。
多个根说明结果是森林,直接返回 roots 数组,或在展示层额外挂虚拟根。父节点不存在的节点不建议静默当根,否则脏数据会被伪装成合法根;更稳的是返回 orphans,让调用方决定报错、补数据还是单独展示。
只要 parentId 关系里出现 self-loop 或祖先回指,就不是树。可以在挂载前对 id -> parentId 这张父指针图做 DFS 三色标记:访问中再次遇到同 id 说明有环,检测到环后直接报错或把环上节点放 invalidNodes。
如果按第一次出现顺序保存去重节点,根节点顺序和兄弟节点顺序都能稳定复现原输入相对顺序。Map 查询让父节点查找从嵌套遍历的 O(n²) 降到均摊 O(1),整体 O(n) 时间、O(n) 空间。
function buildTree(items) {
const nodeMap = new Map();
const order = [];
const conflicts = [];
const isRootParent = (parentId) =>
parentId === null || parentId === undefined || parentId === "";
for (const raw of items) {
const { id, parentId } = raw;
if (id === null || id === undefined || id === "") {
throw new Error("node id is required");
}
if (!nodeMap.has(id)) {
nodeMap.set(id, { ...raw, children: [] });
order.push(id);
continue;
}
const node = nodeMap.get(id);
if (node.parentId !== parentId) {
conflicts.push({ id, firstParentId: node.parentId, duplicateParentId: parentId });
}
}
const roots = [];
const orphans = [];
for (const id of order) {
const node = nodeMap.get(id);
if (isRootParent(node.parentId)) {
roots.push(node);
continue;
}
const parent = nodeMap.get(node.parentId);
if (parent) parent.children.push(node);
else orphans.push(node);
}
return { roots, orphans, conflicts };
} 不要单遍边读边挂。第一遍先把所有去重节点放入 Map,第二遍再挂载,所以父节点出现顺序不影响查找。
必须先定义策略。可以选择第一条决定结构,后续重复条不改 parentId,只补非结构字段;如果 parentId 冲突,就记录 conflicts 或直接抛错。
不建议默认当根。题目说没有父节点是根,通常指 parentId 为空;parentId 指向不存在的 id 是孤儿节点,应进入 orphans 或报错。
返回 roots 数组,也就是森林。如果业务接口必须返回单棵树,可以额外创建虚拟根,但虚拟根是展示层包装,不应改变原始 parentId 语义。
把每个节点看成 id -> parentId 的父指针边,用 DFS 三色标记或当前路径集合检测。访问中再次遇到同一个 id,就是环,包括 parentId 等于自身的 self-loop。
第一遍记录每个 id 第一次出现顺序,第二遍按这个顺序 push 到父节点 children。每个节点和父指针最多处理常数次,所以时间 O(n),额外 Map、children 和标记集合空间 O(n)。