当 reduce 遇到二维数据:从"聚合直觉"到"复合 Map"的思维跃迁

3 阅读8分钟

刷题的时候踩了一个坑,但仔细想想,这个坑挺值得记录的。

题目要求把一组 { 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

每一步单独看还好,但问题在于:

  1. 步骤之间强依赖:Step 3 依赖 Step 2 的结构,Step 2 依赖 Step 1 的结果,调试时需要在三层中间结构里反复跳跃。
  2. 补零逻辑在错误的地方:我把"月份不存在时初始化为 0"放在 Step 2(预分配阶段),但实际上这是一个"查找时兜底"的逻辑,预分配让结构变得臃肿。
  3. 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"

为什么用 | 作为分隔符?

  1. 视觉上清晰销售|2026-01销售2026-01 更易读
  2. 避免冲突:部门和月份本身不会包含 |,确保唯一性
  3. 约定俗成:类似 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,让它做自己擅长的事。


参考资料