← 返回手写题目录

🔁 业务算法进阶 — 对象数组互转

建树 · 拍平 · 表头行转换 · Map 索引 · 笛卡尔积 · 楼中楼 · 多字段聚合
每题带完整输入输出 · 多解法对比 · 复杂度分析 · 笔试工作高频

📌 这类题是前端笔试+业务开发的双重高频:笔试考思路,工作天天写。重点掌握 Map 建索引(O(n)) 和 reduce 聚合。

🌳 树与列表互转(最高频)

几乎每场前端笔试都考。工作里后端返回扁平数组,前端要构建成树给组件用。核心是用 Map 建索引实现 O(n)。

1列表转树(扁平数组 → 树结构)
必考菜单/部门/评论

场景:后端返回部门扁平数组(带 pid),前端要构建成树给 el-tree / TreeView 组件。核心难点:怎么 O(n) 完成,而不是 O(n²)。

📥 输入(扁平数组,带 pid)
[ { id: 1, name: '总部', pid: null }, { id: 2, name: '技术部', pid: 1 }, { id: 3, name: '产品部', pid: 1 }, { id: 4, name: '前端组', pid: 2 }, { id: 5, name: '后端组', pid: 2 } ]
📤 输出(嵌套树,带 children)
[{ id: 1, name: '总部', pid: null, children: [ { id: 2, name: '技术部', pid: 1, children: [ { id: 4, name: '前端组', pid: 2, children: [] }, { id: 5, name: '后端组', pid: 2, children: [] } ]}, { id: 3, name: '产品部', pid: 1, children: [] } ] }]
✅ 最优解:Map 建索引 + 两次遍历(O(n))
function listToTree(list) { const map = {} // id → 节点(含 children) const tree = [] // ① 第一遍:给每个节点挂上 children,存入 map(O(n)) list.forEach(item => { map[item.id] = { ...item, children: [] } }) // ② 第二遍:根据 pid 把节点挂到父节点的 children(O(n)) list.forEach(item => { const node = map[item.id] if (item.pid != null && map[item.pid]) { map[item.pid].children.push(node) // 有父 → 挂到父下面 } else { tree.push(node) // 无父 → 顶层节点 } }) return tree }
时间 O(n)空间 O(n)
❌ 暴力解:双层循环找父节点(O(n²),面试别写)
// 反面教材:每个节点都遍历一遍数组找父节点,n 个节点 = n² 次查找 list.forEach(item => { const parent = list.find(p => p.id === item.pid) // ← 每次都 O(n) 全表扫描 if (parent) { parent.children = parent.children || [] parent.children.push(item) } }) // 问题:① find 每次扫全表,n 个节点就是 O(n²); // ② 还要额外处理 item 本身的 children,容易漏
🔥 关键点:① 先建索引再挂父,顺序不能反(否则父节点可能还没创建);② pid 为 null/0/undefined 都是顶层;③ 引用挂载是关键——map 里存的是引用,挂到 children 后改 node 等于改 map 里的对象。
边界处理:① 数据顺序乱(pid 指向的父节点在子节点后面)也能正确处理,因为先全部建索引;② pid 指向不存在的父(脏数据)会被当顶层处理。
2树转列表(树 → 扁平数组,带 pid 和层级)
高频导出/搜索

场景:把树形菜单存数据库要扁平化;或要做"搜索框过滤树"时先拍平再过滤再重建。

📥 输入(嵌套树)
[{ id: 1, name: '总部', children: [{ id: 2, name: '技术部', children: [{ id: 4, name: '前端组', children: [] }] }] }]
📤 输出(扁平数组,带 level 层级)
[ { id:1, name:'总部', pid:null, level:0 }, { id:2, name:'技术部', pid:1, level:1 }, { id:4, name:'前端组', pid:2, level:2 } ]
✅ 解法一:递归 DFS(最直观)
function treeToList(tree) { const result = [] function dfs(nodes, pid = null, level = 0) { for (const node of nodes) { const { children, ...rest } = node result.push({ ...rest, pid, level }) // 解构剔除 children,补 pid/level if (children?.length) dfs(children, node.id, level + 1) } } dfs(tree) return result }
🔁 解法二:flatMap 递归(一行核心,但只能拍一层)
// flatMap 适合"树→列表"方向(展开),不适合反向建树 function treeToListFlat(nodes, pid = null, level = 0) { return nodes.flatMap(node => { const { children, ...rest } = node return [ { ...rest, pid, level }, ...(node.children ? treeToListFlat(node.children, node.id, level + 1) : []) ] }) }
📋 解法三:BFS 队列(按层遍历,适合"逐层加载")
function treeToListBFS(tree) { const result = [] const queue = tree.map(n => ({ ...n, pid: null, level: 0 })) while (queue.length) { const node = queue.shift() const { children, ...rest } = node result.push(rest) if (children) { children.forEach(c => queue.push({ ...c, pid: node.id, level: node.level + 1 })) } } return result }
三种解法对比
DFS 递归:最常用,代码清晰
flatMap:函数式风格,一行核心逻辑
BFS 队列:按层输出(先所有一级,再所有二级),适合"逐层渲染"
三者时间都是 O(n),区别在输出顺序。

📊 表格数据转换(导出/导入高频)

Excel 导出导入、CSV 处理、动态表单生成的核心算法。后端给对象数组,前端要转成表格;用户填的表格要转回对象数组提交。

3表头+行数据 ↔ 对象数组(双向转换)
笔试高频Excel/CSV

场景:导出 Excel 时要把对象数组转成"表头+行";读取 CSV 文件时要把"表头+行"转回对象数组。本质就是 map + 解构

📥 输入(表头 + 行数据)
headers: ['姓名', '年龄', '城市'] rows: [ ['张三', 25, '北京'], ['李四', 30, '上海'], ['王五', 28, '广州'] ]
📤 输出(对象数组)
[ { 姓名:'张三', 年龄:25, 城市:'北京' }, { 姓名:'李四', 年龄:30, 城市:'上海' }, { 姓名:'王五', 年龄:28, 城市:'广州' } ]
✅ 正向:表头+行 → 对象数组(reduce 按表头聚合)
function tableToObject(headers, rows) { return rows.map(row => row.reduce((obj, val, i) => { obj[headers[i]] = val // 用表头做 key,当前值做 value return obj }, {}) ) } // 进阶版:支持字段映射(中英文表头转换) function tableToObjectPro(headers, rows, keyMap = {}) { return rows.map(row => row.reduce((obj, val, i) => { const key = keyMap[headers[i]] || headers[i] // 表头映射,如 {'姓名':'name'} obj[key] = val return obj }, {}) ) } // tableToObjectPro(headers, rows, {'姓名':'name','年龄':'age','城市':'city'}) // → [{name:'张三', age:25, city:'北京'}, ...]
🔁 反向:对象数组 → 表头+行(Object.keys 取表头,map 取值)
function objectToTable(arr) { const headers = Object.keys(arr[0]) // 第一个对象的 keys 当表头 const rows = arr.map(item => headers.map(h => item[h])) // 每个对象按表头顺序取值 return { headers, rows } }
实战场景
导出 CSV:objectToTable → join 成 CSV 字符串
导入 CSV:split 成行 → 第一行当表头 → tableToObject
动态表单:后端配置字段 → 生成表头 → 渲染表格
字段映射:中文表头 ↔ 英文字段名的桥梁

🗂️ 数组与对象/Map 互转(性能优化)

把对象数组转成以 id 为 key 的 Map/对象,查找从 O(n) 降到 O(1)。这是前端性能优化最常用的技巧。

4对象数组 → Map 索引(arrayToMap,查找 O(1))
性能必考列表关联

场景:列表展示商品,要关联显示分类名。每个商品去 categories 数组里 find 一次分类 = O(n²);先建 map 再查 = O(n)。

📥 输入(对象数组)
[ { id: 101, name: 'iPhone', catId: 1 }, { id: 102, name: 'iPad', catId: 1 }, { id: 103, name: 'MacBook', catId: 2 } ]
📤 输出(以 id 为 key 的对象)
{ '101': { id:101, name:'iPhone', catId:1 }, '102': { id:102, name:'iPad', catId:1 }, '103': { id:103, name:'MacBook', catId:2 } }
✅ 解法:reduce 累积成对象
function arrayToMap(arr, key = 'id') { return arr.reduce((map, item) => { map[item[key]] = item return map }, {}) } // 更简洁:Object.fromEntries(推荐) const arrayToMap2 = (arr, key = 'id') => Object.fromEntries(arr.map(item => [item[key], item])) // 用真 Map(键可以是任意类型,不只是字符串) const arrayToRealMap = (arr, key = 'id') => new Map(arr.map(item => [item[key], item]))
🔥 实战:关联两个数组(消除 N+1 查找)
// 商品列表 + 分类列表,要展示"商品名 - 分类名" const products = [ { id: 1, name: 'iPhone', catId: 10 }, { id: 2, name: 'iPad', catId: 10 }, { id: 3, name: 'MacBook', catId: 20 } ] const categories = [ { id: 10, name: '数码' }, { id: 20, name: '电脑' } ] // ❌ 慢:每个商品 find 一次分类 = O(n×m) products.map(p => ({ ...p, catName: categories.find(c => c.id === p.catId)?.name })) // ✅ 快:先建分类 map,查找 O(1) = O(n+m) const catMap = arrayToMap(categories) // { '10':{...}, '20':{...} } const result = products.map(p => ({ ...p, catName: catMap[p.catId]?.name // O(1) 查找! })) // [{id:1,name:'iPhone',catId:10,catName:'数码'}, ...]
❌ find 法:O(n×m)✅ map 法:O(n+m)
核心价值:把"每次都遍历查找"变成"一次建索引,后续 O(1)"。1000 个商品关联 100 个分类,find 法 10 万次操作,map 法 1100 次。这是消除 N+1 查询的标准手段

📐 矩阵与组合运算

笔试中的"数学味"题。转置是表格行列互换,笛卡尔积是规格组合。

5二维数组转置(行列互换,矩阵 transpose)
笔试常考表格/Excel

场景:表格"行列互换"功能(Excel 的转置粘贴);笔试常考的矩阵操作。

📥 输入(2行3列)
[ [1, 2, 3], [4, 5, 6] ]
📤 输出(3行2列,行列互换)
[ [1, 4], [2, 5], [3, 6] ]
✅ 解法一:map 嵌套(最简洁)
function transpose(matrix) { // 用第一行的长度作为新数组的行数 return matrix[0].map((_, colIndex) => matrix.map(row => row[colIndex]) // 取每一行的第 colIndex 列 ) }
🔁 解法二:reduce 累积(更通用)
function transposeReduce(matrix) { return matrix.reduce((result, row) => row.map((_, i) => [...(result[i] || []), row[i]]) , []) }
理解技巧:转置就是 result[j][i] = matrix[i][j]。map 版的外层遍历列号,内层遍历每个行取该列。行列长度不必相等也能用。
6笛卡尔积(多维规格组合,SKU 生成)
电商必考SKU/规格

场景:电商 SKU 规格组合。商品有"颜色×尺码×版本"三个维度,每个维度多个选项,要生成所有组合。如颜色[红,蓝] × 尺码[S,M] = [(红,S),(红,M),(蓝,S),(蓝,M)]。

📥 输入(多维度规格)
[ ['红', '蓝'], // 颜色 ['S', 'M', 'L'], // 尺码 ['标准版', 'Pro版'] // 版本 ]
📤 输出(所有组合,2×3×2=12 种)
[ ['红','S','标准版'], ['红','S','Pro版'], ['红','M','标准版'], ... ['蓝','L','Pro版'] // 共 12 种 ]
✅ 解法:reduce 逐步累积(经典)
function cartesian(arrays) { // 初始 [[]],每加一个维度,把已有组合都扩展一遍 return arrays.reduce((acc, curr) => { return acc.flatMap(combo => // 已有的每个组合 curr.map(item => [...combo, item]) // 都拼上新维度的每个选项 ) }, [[]]) // ← 初始值必须是 [[]],不是 [] } // 执行过程演示(颜色×尺码): // 第1步:acc=[[]],加['红','蓝'] → [['红'],['蓝']] // 第2步:acc=[['红'],['蓝']],加['S','M'] // → [['红','S'],['红','M'],['蓝','S'],['蓝','M']]
🔥 易错点:初始值必须是 [[]](包含一个空数组的数组),不是 []!因为第一次 reduce 要把空组合和第一个维度拼接,[] 会导致 flatMap 没东西可扩展,结果一直是空。
进阶:带规格名的对象版(实际电商用的)
// 输入带规格名,输出每个 SKU 的规格键值对 function skuCombine(specs) { // specs: [{ name:'颜色', options:['红','蓝'] }, { name:'尺码', options:['S','M'] }] return specs.reduce((acc, spec) => acc.flatMap(combo => spec.options.map(opt => ({ ...combo, [spec.name]: opt })) ) , [{}]) // 初始 [{}](一个空对象) } // 输出:[{颜色:'红',尺码:'S'}, {颜色:'红',尺码:'M'}, {颜色:'蓝',尺码:'S'}, {颜色:'蓝',尺码:'M'}]

💬 评论与聚合(综合应用)

把前面学的树转换、聚合组合起来,解决真实业务场景。

7评论楼中楼(扁平评论 → 嵌套回复树)
大厂高频微博/掘金/B站

场景:微博/掘金/B站的评论系统。后端返回扁平评论数组(带 parentId 和 replyTo),前端要构建成"楼中楼"嵌套结构展示。本质还是 listToTree,但要处理回复对象谁

📥 输入(扁平评论,带 parentId)
[ { id:1, user:'A', text:'文章不错', parentId:null }, { id:2, user:'B', text:'同意', parentId:1 }, { id:3, user:'C', text:'不同意B', parentId:2 }, { id:4, user:'D', text:'我也觉得', parentId:1 }, { id:5, user:'E', text:'新话题', parentId:null } ]
📤 输出(楼中楼嵌套)
[ { id:1, user:'A', text:'文章不错', replies:[ { id:2, user:'B', text:'同意', replies:[{id:3,user:'C',text:'不同意B',replies:[]}]}, { id:4, user:'D', text:'我也觉得', replies:[] } ]}, { id:5, user:'E', text:'新话题', replies:[] } ]
✅ 解法:建树 + 字段名映射(comments 而非 children)
function buildCommentTree(comments) { const map = {} const roots = [] // ① 建索引:每条评论加 replies 数组 comments.forEach(c => { map[c.id] = { ...c, replies: [] } }) // ② 挂载:根据 parentId 把回复挂到父评论 comments.forEach(c => { const node = map[c.id] if (c.parentId != null && map[c.parentId]) { map[c.parentId].replies.push(node) } else { roots.push(node) // 顶层评论(parentId 为 null) } }) return roots }
与普通建树的区别:① 字段名是 parentId 而非 pid、replies 而非 children(面试官可能故意换字段名考你);② 评论区通常还要算"总回复数"、"最新回复",可以在建树时顺便用 reduce 累加。核心算法和 listToTree 完全一样
8多字段分组聚合统计(groupBy + 聚合函数)
报表必考数据统计

场景:销售报表,要把订单按"部门+月份"分组,统计每组订单数、总金额、平均金额。类似 SQL 的 GROUP BY dept, month

📥 输入(订单流水)
[ { dept:'华东', month:'1月', amount:100 }, { dept:'华东', month:'1月', amount:200 }, { dept:'华东', month:'2月', amount:150 }, { dept:'华南', month:'1月', amount:300 } ]
📤 输出(按 部门+月份 分组聚合)
{ '华东|1月': { count:2, total:300, avg:150 }, '华东|2月': { count:1, total:150, avg:150 }, '华南|1月': { count:1, total:300, avg:300 } }
✅ 解法:reduce + 复合 key(用分隔符拼多字段)
function groupByAgg(arr, groupKeys, aggField) { return arr.reduce((groups, item) => { // ① 生成复合 key:多字段用 '|' 拼接 const key = groupKeys.map(k => item[k]).join('|') // ② 初始化分组 if (!groups[key]) { groups[key] = { count: 0, total: 0 } } // ③ 累加聚合 groups[key].count++ groups[key].total += item[aggField] return groups }, {}) } // 调用:按 dept + month 分组,聚合 amount const result = groupByAgg(orders, ['dept', 'month'], 'amount') // 后处理:算平均值、展开 key Object.entries(result).forEach(([key, val]) => { val.avg = Math.round(val.total / val.count) val.dept = key.split('|')[0] val.month = key.split('|')[1] })
复合 key 技巧:多字段分组时,把多个字段值用分隔符(如 |_)拼成一个字符串当 key。注意分隔符不能出现在字段值里,否则会冲突。这是 SQL GROUP BY 的 JS 版本

🔢 排序与去重(业务细节)

前端笔试和工作都高频。多字段排序是列表"先按状态再按时间"的核心;对象去重要会用 Map。

9对象数组多字段排序(先按 A,A 相同按 B)
列表必考订单/任务列表

场景:订单列表"先按状态(待支付→已支付→已完成)升序,状态相同时按时间降序"。规则:第一个字段不同就按它走,相同才看第二个字段。

📥 输入(订单数组)
[ {id:1, status:2, time:'2024-01-02'}, {id:2, status:1, time:'2024-01-03'}, {id:3, status:1, time:'2024-01-01'}, {id:4, status:2, time:'2024-01-01'} ]
📤 输出(status 升序,相同则 time 降序)
[ {id:3, status:1, time:'2024-01-01'}, // status 1 优先 {id:2, status:1, time:'2024-01-03'}, // 同 status,time 降序 {id:4, status:2, time:'2024-01-01'}, // status 2 {id:1, status:2, time:'2024-01-02'} ]
✅ 解法:比较函数中先比第一字段,相同再比第二字段
function multiSort(arr) { return [...arr].sort((a, b) => { // ① 第一关键字:status 升序 if (a.status !== b.status) return a.status - b.status // ② 第二关键字:time 降序(注意 b - a) return new Date(b.time) - new Date(a.time) }) } // 通用版:传入排序规则数组 function sortBy(arr, rules) { // rules: [{ key:'status', order:'asc' }, { key:'time', order:'desc' }] return [...arr].sort((a, b) => { for (const { key, order } of rules) { const dir = order === 'desc' ? -1 : 1 if (a[key] > b[key]) return dir if (a[key] < b[key]) return -dir // 相等就继续看下一个规则 } return 0 // 所有规则都比完了还相等 }) } // 调用:sortBy(orders, [{key:'status',order:'asc'}, {key:'time',order:'desc'}])
核心套路:比较函数里 if (a.x !== b.x) return a.x - b.x; return 比第二字段。这就是"字典序"——先看第一字段,相同才看第二。支持任意多个字段,串联 if 即可。
10对象数组按字段去重(保留首次/末次出现)
高频列表清洗

场景:合并多个接口的数据后去重;用户重复点击产生的重复记录要清理。Set 只能去重基本类型,对象必须用 Map

📥 输入(有重复 id)
[ { id:1, name:'张三', score:80 }, { id:2, name:'李四', score:90 }, { id:1, name:'张三', score:95 }, // id=1 重复 { id:3, name:'王五', score:70 } ]
📤 输出(按 id 去重,保留首次)
[ { id:1, name:'张三', score:80 }, // 保留首次 { id:2, name:'李四', score:90 }, { id:3, name:'王五', score:70 } ]
✅ 解法一:Map(保留首次出现,推荐)
function uniqueBy(arr, key) { const map = new Map() arr.forEach(item => { if (!map.has(item[key])) { // 已有就跳过 → 保留首次 map.set(item[key], item) } }) return [...map.values()] }
🔁 解法二:Map(保留末次出现,覆盖式)
function uniqueByLast(arr, key) { const map = new Map() arr.forEach(item => map.set(item[key], item)) // 直接覆盖 → 保留末次 return [...map.values()] } // 输出:id=1 的会变成 {id:1,name:'张三',score:95}(用最后一次覆盖)
📋 解法三:filter + Set(一次性,代码短)
function uniqueByFilter(arr, key) { const seen = new Set() return arr.filter(item => { if (seen.has(item[key])) return false seen.add(item[key]) return true }) }
三种都 O(n)空间 O(n)
🔥 易错点:① Set 不能对对象去重——new Set([{id:1},{id:1}]) 还是两个,因为对象引用不同;② 保留首次还是末次要讲清楚,业务上常常是要"末次覆盖"(最新数据);③ 多字段去重把复合 key 拼起来:item.id + '_' + item.type

🎯 专题总结

这 10 题背后的 3 个核心套路:

① Map 建索引(Q1 建树、Q4 arrayToMap、Q7 评论、Q10 去重)
把"查找"从 O(n) 降到 O(1)。前端性能优化第一招。

② reduce 聚合(Q3 表头转换、Q6 笛卡尔积、Q8 多字段聚合)
把数组"压缩"成对象/单值。灵活度最高,能写分组、累加、转换。

③ 解构 + 扩展运算符(Q2 拍平树、Q3 字段映射、Q7 评论结构)
{ ...item, children: [] } 拷贝+改字段的标准写法。
面试答题主线(背下来):
• 被问"列表转树" → 先说"用 Map 建索引 O(n)",再写两遍 forEach
• 被问"对象去重" → 先说"Set 不行,用 Map 按 key 去重"
• 被问"多字段排序" → 先说"比较函数里串联 if,第一个不同就 return"
• 被问"关联两个数组" → 先说"先建 map,消除 N+1"

一句话:所有对象数组互转题,本质都是 "用 Map/对象当索引 + 一两次遍历"。把这个思路刻在脑子里,这类题就通了。