刷题的时候踩了一个坑,但仔细想想,这个坑挺值得记录的。
题目要求把一组 { department, yearMonth, amount } 的记录,转成 ECharts 折线图的 option 配置。我的第一反应是:输入是数组,输出是整合后的结构——那就用 reduce 嘛。结果写了三个互相依赖的 reduce 串联,越写越乱,最后连自己都看不懂自己在干什么。
参考答案用一个 Map 解决了,代码量少了一半,逻辑却清晰得多。
这让我开始重新思考:reduce 的直觉是怎么来的?它到底适合解决什么问题?我又是在哪一步走偏的?
一、reduce 的直觉从哪里来?
reduce 是一个很强大的工具,强大到有些开发者(包括曾经的我)会把它当成处理数组的"万能锤"。
这个直觉并不是无根之水。reduce 的本质是线性聚合:把一个数组,按照某个规则,"折叠"成另一种结构。
// 环境:Node.js / 浏览器
// 场景:最经典的聚合用法
// 求和
const total = [1, 2, 3, 4].reduce((acc, val) => acc + val, 0);
// → 10
// 分组(按单个维度)
const grouped = ['Alice', 'Bob', 'Anna'].reduce((acc, name) => {
const key = name[0]; // 按首字母分组
if (!acc[key]) acc[key] = [];
acc[key].push(name);
return acc;
}, {} as Record<string, string[]>);
// → { A: ['Alice', 'Anna'], B: ['Bob'] }
这两个场景,reduce 都很自然:一个维度,一次聚合,结构清晰。
当我看到"把 ExpenseRecord[] 转成 ECharts option"这个需求时,触发了相同的模式识别:数组 → 复杂结构,reduce 来做。
但问题出在我没有继续分析下去:这个"复杂结构",到底复杂在哪里?
二、这道题真正的问题模型
让我们先把需求翻译成数据关系,而不是直接想"怎么写代码"。
输入数据:
销售 2026-01 12000
销售 2026-02 8000
技术 2026-01 5000
技术 2026-02 9000
行政 2026-02 3000 ← 行政在 2026-01 没有数据
期望输出的 series 中,每个部门需要一个与 xAxis(月份列表)完全对齐的数据数组:
xAxis: ['2026-01', '2026-02']
销售: [12000, 8000 ]
技术: [5000, 9000 ]
行政: [0, 3000 ] ← 缺失的位置必须补 0,不能是 undefined
这个结构,用一张表来理解会更直观:
| 2026-01 | 2026-02
---------|---------|--------
销售 | 12000 | 8000
技术 | 5000 | 9000
行政 | 0 | 3000 ← 原始数据中不存在这一格
这是一个二维交叉查值的问题,本质上是一张稀疏矩阵。行是部门,列是月份,值是金额。原始数据只包含有值的格子,其余的需要补零。
一旦用这个模型来看问题,就会发现:reduce 并不是最合适的工具,因为 reduce 擅长的是线性聚合,而不是二维索引。
三、我的三个 reduce 为什么难以推理
我当时的思路大概是这样的:
Step 1: reduce → 提取月份列表(去重 + 排序)
Step 2: reduce → 构建 initGroup(每个部门都有完整的月份占位)
Step 3: reduce → 把金额填进 initGroup
每一步单独看还好,但问题在于:
- 步骤之间强依赖:Step 3 依赖 Step 2 的结构,Step 2 依赖 Step 1 的结果,调试时需要在三层中间结构里反复跳跃。
- 补零逻辑在错误的地方:我把"月份不存在时初始化为 0"放在 Step 2(预分配阶段),但实际上这是一个"查找时兜底"的逻辑,预分配让结构变得臃肿。
find/filter在循环里出现了:为了在 Step 3 里找到对应的部门和月份,我在 reduce 内部用了find,导致整体复杂度从 O(n) 退化到了 O(n²)。
三个 reduce 串联不是"聚合了三次",而是"绕着一个二维关系转了三圈"。
四、复合 Key Map:把二维坐标压缩成一维索引
参考答案的核心洞察是:不要用线性结构来模拟二维关系,直接建立二维索引。
// 环境:浏览器 / Node.js
// 场景:用复合 Key 建立 O(1) 查找表
// Step 1: 去重 + 排序,提取所有月份和部门
const months = [...new Set(records.map(r => r.yearMonth))].sort();
const departments = [...new Set(records.map(r => r.department))];
// Step 2: 建查找表,key = "部门|月份",value = 金额
const amountMap = new Map<string, number>();
records.forEach(r => {
amountMap.set(`${r.department}|${r.yearMonth}`, r.amount);
});
// Step 3: 直接组装 series,缺失的用 ?? 0 补零
const series = departments.map(dept => ({
name: dept,
type: 'line' as const,
data: months.map(month => amountMap.get(`${dept}|${month}`) ?? 0),
}));
"${r.department}|${r.yearMonth}" 这个复合 Key,做的事情很简单:把二维坐标 (部门, 月份) 压缩成一个唯一字符串,用作 Map 的索引。
我来解释一下这里的 | 符号:
| 只是普通的字符串拼接,不是特殊运算符
amountMap.set(`${r.department}|${r.yearMonth}`, r.amount)
这里的 | 就是竖线字符(管道符),纯粹作为字符串分隔符使用,没有任何特殊语法含义。
拆解来看:
| 部分 | 含义 |
|---|---|
`${r.department}` | 部门名称,如 "销售" |
"|" | 字面量竖线字符 |
`${r.yearMonth}` | 月份,如 "2026-01" |
| 最终 Key | "销售|2026-01" |
为什么用 | 作为分隔符?
- 视觉上清晰:
销售|2026-01比销售2026-01更易读 - 避免冲突:部门和月份本身不会包含
|,确保唯一性 - 约定俗成:类似 CSV 的字段分隔思路
你也可以用其他分隔符:
// 以下都是等效的,只是风格不同
`${r.department}#${r.yearMonth}` // "销售#2026-01"
`${r.department}_${r.yearMonth}` // "销售_2026-01"
`${r.department}::${r.yearMonth}` // "销售::2026-01"
核心目的
把二维坐标 (部门, 月份) 压缩成一维字符串,让 Map 可以用 O(1) 时间查找。
(销售, 2026-01) ──→ "销售|2026-01" ──→ Map.get() ──→ 12000
这就是"复合 Key"的本质——用字符串拼接模拟多维度索引。
建好这张查找表之后,Step 3 的逻辑就变成了:对每个 (部门, 月份) 组合,直接查 Map,查不到就补 0。整个"填充稀疏矩阵"的过程,收敛在 ?? 0 这一行里。
// 环境:浏览器 / Node.js
// 场景:完整实现
function buildExpenseTrendOption(records: ExpenseRecord[]): EChartsOption {
const months = [...new Set(records.map(r => r.yearMonth))].sort();
const departments = [...new Set(records.map(r => r.department))];
const amountMap = new Map<string, number>();
records.forEach(r => {
amountMap.set(`${r.department}|${r.yearMonth}`, r.amount);
});
const series = departments.map(dept => ({
name: dept,
type: 'line' as const,
data: months.map(month => amountMap.get(`${dept}|${month}`) ?? 0),
}));
return {
tooltip: { trigger: 'axis' },
legend: { data: departments },
xAxis: { type: 'category', data: months },
yAxis: { type: 'value' },
series,
};
}
整个函数只有一次遍历建表,后面全是 map,复杂度 O(n + d×m)(n = 记录数,d = 部门数,m = 月份数),没有嵌套查找。
五、什么时候用 reduce,什么时候用 Map?
这是我在这道题上真正想搞清楚的问题。
我的理解是,一个粗略的判断维度是:这次转换操作,涉及几个维度?
单维度聚合 → reduce 通常合适
// 环境:浏览器 / Node.js
// 场景:按部门汇总总金额(单维度)
const totalByDept = records.reduce((acc, r) => {
acc[r.department] = (acc[r.department] ?? 0) + r.amount;
return acc;
}, {} as Record<string, number>);
// → { 销售: 20000, 技术: 14000, 行政: 3000 }
只按一个维度分组,每个 key 只需要维护一个累加值——这是 reduce 的主场。
多维度交叉查值 → 先建 Map,再填充
// 场景:日历热力图(日期 × 事件类型 → 数量)
// 场景:用户行为漏斗(用户ID × 步骤 → 是否完成)
// 场景:本题(部门 × 月份 → 金额)
// 共同特征:需要"同时按两个维度定位"某个值
// 解法模式:复合 Key Map → 遍历填充 → ?? 处理缺失
判断方式可以这样问自己:
"我在填充目标结构的时候,需要同时按几个维度去原始数据里查找?"
如果答案是一个,reduce 很自然。如果答案是两个或更多,先考虑 Map 建索引。
六、一个延伸思考:如果有三个维度呢?
这道题是「部门 × 月份」,如果需求变成「部门 × 月份 × 费用类型」(比如机票/酒店/餐饮分开展示),怎么办?
复合 Key 的思路可以直接扩展:
// 环境:浏览器 / Node.js
// 场景:三维复合 Key 示例
amountMap.set(`${dept}|${month}|${category}`, r.amount);
// 查找
amountMap.get(`${dept}|${month}|${category}`) ?? 0
Key 可以继续扩展,只要能保证唯一性,格式怎么定都行。这是复合 Key 方案的可扩展性:不需要改数据结构,只需要在 Key 里加一个维度。
当然,维度继续增加到四五个的时候,可能就需要考虑嵌套 Map 或者更结构化的方案了。这个问题可以留着以后再探索。
小结
回头看这道题,我踩坑的根本原因不是不会用 reduce,而是识别了"数组转换"这个操作形态,但没有识别"二维交叉查值"这个数据模型。
工具选择应该跟着数据关系走,而不是跟着操作外形走。
一个可以带走的判断框架:
- 问题是"把 n 个东西聚合成 1 个结果" → reduce
- 问题是"在两个维度的交叉点上查值" → 复合 Key Map +
?? 0
reduce 是好工具,但它是线性的。二维问题交给 Map,让它做自己擅长的事。
参考资料
- MDN - Map - Map 数据结构基础用法
- MDN - Array.prototype.reduce() - reduce 的定义与使用场景
- MDN - Nullish coalescing operator (??) -
?? 0的语义说明