freeCodeCamp 每日编程挑战精讲:用 JavaScript 实现 Inventory Update 库存更新算法
【免费下载链接】freeCodeCampfreeCodeCamp.org's open-source codebase and curriculum. Learn math, programming, and computer science for free.项目地址: https://gitcode.com/GitHub_Trending/fr/freeCodeCamp
本篇技术指南围绕 freeCodeCamp 每日编程挑战(Daily Coding Challenge)中的第 124 题「Inventory Update」展开,完整讲解问题定义、数据格式约束、四条合并规则、逐条测试用例与官方参考解法的源码级剖析,并结合仓库中 daily-coding-challenges-javascript 挑战块结构 与 每日挑战 API 路由实现 说明该挑战在真实平台中的落盘与分发机制。读完本文,你将掌握「以条目名为键、以数组下标为索引」的 O(n) 原地合并思路,并理解此类 2D 数组型算法题目的通用解题范式。
挑战全景:它处于每日编程挑战体系中的哪个位置
「Inventory Update」是 freeCodeCamp 课程中daily-coding-challenges-javascript挑战块的第 124 个挑战。从块配置文件 curriculum/structure/blocks/daily-coding-challenges-javascript.json 可以看到,该块标记为isUpcomingChange: true、helpCategory: "JavaScript"、usesMultifileEditor: true,且使用legacy-challenge-list的块布局——这意味着它运行在 freeCodeCamp 的多文件编辑器中,挑战文件本身即该挑战的英文原文:691b559495c5cb5a37b9b484.md。
每个挑战文件都以 YAML frontmatter 开头,声明id(固定 24 位十六进制)、title、challengeType与dashedName,随后用--description--、--hints--、--seed--与--solutions--四个区块组织内容:
| 区块 | 作用 |
|---|---|
--description-- | 问题陈述、输入格式约束与合并规则 |
--hints-- | 由assert断言组成的自动化测试用例 |
--seed-- | 提供给学习者的起始代码骨架 |
--solutions-- | 官方参考解法(在课程数据中被引用,不直接展示给学习者) |
该挑战编号与日期一一对应:挑战序列从 2025-08-11 开始、按天递增(挑战 124 约对应 2025-12 月中旬),这与 API 侧日期工具 中ORIGINAL_START_YEAR = 2025、ORIGINAL_START_MONTH = 8、ORIGINAL_START_DAY = 11的常量设定吻合。
问题定义:合并两份 2D 数组形式的库存清单
题目原文给出的核心任务是:
给定一个表示店铺当前库存的二维数组,以及一个表示新到货品的二维数组,返回更新后的库存。
输入数据格式
- 数组中的每个元素格式固定为
[quantity, "item"]:第一个位置quantity是整数(int),第二个位置"item"是字符串(string); - 第一个参数
inventory代表当前库存,第二个参数shipment代表新收到的货品。
例如库存为[[2, "apples"], [5, "bananas"]],新到货为[[1, "apples"], [3, "bananas"]],则更新后的库存为[[3, "apples"], [8, "bananas"]]——苹果 2 + 1 = 3,香蕉 5 + 3 = 8。
四条核心合并规则
- 匹配相加:对
shipment中每一项,如果该条目名已存在于inventory中,则将quantity累加到对应条目; - 新增追加:如果收到的条目名在当前库存中不存在,则将其作为新条目追加到库存末尾;
- 顺序保持:返回的库存必须保持原有顺序,新增条目按照它们在
shipment中出现的顺序排在末尾; - 原样返回:函数签名
updateInventory(inventory, shipment)直接返回更新后的数组,官方解法采用原地修改(mutate)方式,不创建新数组。
第 3 条是本题与「哈希表直接重建数组」做法的关键差异点:不能把库存重排为按字母序或按数量排序,必须严格保留inventory传入时的条目顺序。
规则推演:用三步把示例拆解清楚
以[[2, "apples"], [5, "bananas"]]与[[1, "apples"], [3, "bananas"], [4, "oranges"]]为例:
| 步骤 | 当前处理的货品 | 动作 | 更新后的库存 |
|---|---|---|---|
| 初始 | — | — | [[2, "apples"], [5, "bananas"]] |
| 1 | [1, "apples"] | apples 已存在 → 2 + 1 = 3 | [[3, "apples"], [5, "bananas"]] |
| 2 | [3, "bananas"] | bananas 已存在 → 5 + 3 = 8 | [[3, "apples"], [8, "bananas"]] |
| 3 | [4, "oranges"] | oranges 不存在 → 追加到末尾 | [[3, "apples"], [8, "bananas"], [4, "oranges"]] |
可见:前两项触发「匹配相加」,第三项触发「新增追加」,最终结果与测试断言的[[3, "apples"], [8, "bananas"], [4, "oranges"]]完全一致。
测试用例(hints)逐条拆解
文档共给出 4 组自动化测试,覆盖了三种典型场景:
用例 1:纯匹配合并
assert.deepEqual( updateInventory([[2, "apples"], [5, "bananas"]], [[1, "apples"], [3, "bananas"]]), [[3, "apples"], [8, "bananas"]] );shipment中所有条目在inventory中均已存在,不触发任何新增,验证「匹配相加」与「顺序保持」。
用例 2:匹配 + 新增
assert.deepEqual( updateInventory([[2, "apples"], [5, "bananas"]], [[1, "apples"], [3, "bananas"], [4, "oranges"]]), [[3, "apples"], [8, "bananas"], [4, "oranges"]] );在用例 1 基础上混入不存在的oranges,验证「新增条目追加到末尾、且保持 shipment 中的相对顺序」。
用例 3:空库存
assert.deepEqual( updateInventory([], [[10, "apples"], [30, "bananas"], [20, "oranges"]]), [[10, "apples"], [30, "bananas"], [20, "oranges"]] );当inventory为空数组时,所有货品都是新条目,结果应与shipment顺序完全一致。该用例专门检验新增路径在空输入下是否健壮。
用例 4:零数量条目与新条目交错
assert.deepEqual( updateInventory( [[0, "Bowling Ball"], [0, "Dirty Socks"], [0, "Hair Pin"], [0, "Microphone"]], [[1, "Hair Pin"], [1, "Half-Eaten Apple"], [1, "Bowling Ball"], [1, "Toothpaste"]] ), [[1, "Bowling Ball"], [0, "Dirty Socks"], [1, "Hair Pin"], [0, "Microphone"], [1, "Half-Eaten Apple"], [1, "Toothpaste"]] );这是最有含金量的用例,覆盖了三个易错点:
- 数量为 0 的条目依然存在于库存中:
Dirty Socks和Microphone数量为 0 但没有收到新货,必须原样保留,不能被删除; - shipment 与 inventory 的条目顺序不同:
Hair Pin在 shipment 中排第 1,但在 inventory 中排第 3,合并后必须回到 inventory 原本的位置,数量变为 0 + 1 = 1; - 新条目追加时保持 shipment 顺序:
Half-Eaten Apple在 shipment 中位于Hair Pin之后、Toothpaste之前,追加到末尾后也必须维持该相对顺序。
官方解法:Map 映射下标 + 原地累加
文档--solutions--区块给出了官方参考实现:
function updateInventory(inventory, shipment) { const inventoryMap = new Map(); inventory.forEach(([qty, item], index) => { inventoryMap.set(item, index); }); shipment.forEach(([qty, item]) => { if (inventoryMap.has(item)) { const index = inventoryMap.get(item); inventory[index][0] += qty; } else { inventory.push([qty, item]); inventoryMap.set(item, inventory.length - 1); } }); return inventory; }逐行剖析
第一阶段:建立「条目名 → 数组下标」的索引(第 2~5 行)
const inventoryMap = new Map(); inventory.forEach(([qty, item], index) => { inventoryMap.set(item, index); });遍历inventory,利用数组解构同时取出qty与item,将条目名映射到它在数组中的下标。这里的巧妙之处在于:Map 只存下标、不存数量,数量始终以inventory[index][0]为唯一事实来源(single source of truth),避免了 Map 与数组之间出现数据不同步。
第二阶段:遍历 shipment 完成合并(第 7~15 行)
shipment.forEach(([qty, item]) => { if (inventoryMap.has(item)) { const index = inventoryMap.get(item); inventory[index][0] += qty; } else { inventory.push([qty, item]); inventoryMap.set(item, inventory.length - 1); } });- 匹配分支:
Map.has命中后取出下标,直接对inventory[index][0]做加法累加; - 新增分支:
Array.prototype.push将新条目追加到末尾,新下标恰为inventory.length - 1,随即登记进 Map,保证后续shipment中再次出现同名条目时能走「匹配分支」继续累加(例如 shipment 中出现两条"apples"的情况也能正确处理)。
第三阶段:返回原数组(第 17 行)
return inventory;由于全程原地修改,直接返回inventory即符合「保持原顺序、新条目在末尾」的语义,也满足用例 3 中空库存退化为「按 shipment 顺序返回」的边界行为。
复杂度分析
| 指标 | 复杂度 | 说明 |
|---|---|---|
| 时间复杂度 | O(n + m) | 构建 Map 遍历库存 n 项,合并遍历 shipment m 项,Map 的查找/插入均为平均 O(1) |
| 空间复杂度 | O(n) | 仅额外维护一张条目名 → 下标的 Map |
若采用「每次用Array.find线性查找」的朴素写法,时间复杂度会退化为 O(n·m),因此官方解法在数据规模变大时优势明显。
边界情况与易错点清单
- 空库存:
inventory为空时不能崩溃,所有 shipment 条目都走新增分支(用例 3 覆盖); - 空 shipment:
shipment为空时应当原样返回库存;官方解法中forEach不执行任何逻辑,天然正确; - 数量为 0 的条目:0 数量不代表条目不存在,不能被误删(用例 4 覆盖);
- 条目名匹配是精确字符串比较:题目未要求忽略大小写,
"Apples"与"apples"被视为不同条目——这是日常编码挑战「以题目字面约束为准」的典型体现; - shipment 内重复条目:Map 在下标登记后,第二条同名货品会走匹配分支完成累加;
- 副作用语义:官方解法修改了调用方传入的
inventory数组,若调用方需要保留原始数据,应自行传入副本。
更多实现思路对比
除官方解法的「Map 存下标」外,还有几种常见思路,各有权衡:
思路一:Map 存数量,最后重建数组
function updateInventory(inventory, shipment) { const countMap = new Map(); for (const [qty, item] of inventory) countMap.set(item, qty); for (const [qty, item] of shipment) { countMap.set(item, (countMap.get(item) ?? 0) + qty); } return [...countMap.entries()].map(([item, qty]) => [qty, item]); }优点是利用了Map的插入序特性(新条目自动排在末尾);缺点是必须先按原始顺序遍历 inventory 写入 Map,再追加 shipment 新条目,才能复现题目要求的顺序。若直接[...countMap.entries()]展开,Map 的迭代顺序是「首次插入顺序」,需要额外保证 inventory 先于 shipment 插入。该写法更符合函数式风格、无副作用,但会生成新数组、空间开销略高。
思路二:Object作为字典
function updateInventory(inventory, shipment) { const indexByItem = {}; inventory.forEach(([qty, item], i) => { indexByItem[item] = i; }); shipment.forEach(([qty, item]) => { if (item in indexByItem) inventory[indexByItem[item]][0] += qty; else { inventory.push([qty, item]); indexByItem[item] = inventory.length - 1; } }); return inventory; }与官方解法等价,但需注意两点:Object的键会被强制转为字符串,且in操作符会沿着原型链查找(对"toString"、"constructor"这类键名存在误判风险)。官方解法选用Map正是为了规避这两个问题——这也解释了为什么仓库解法优先选择Map.has而不是in。
思路三:朴素线性查找(不推荐)
function updateInventory(inventory, shipment) { shipment.forEach(([qty, item]) => { const found = inventory.find(entry => entry[1] === item); if (found) found[0] += qty; else inventory.push([qty, item]); }); return inventory; }代码最简洁,但每次查找都是 O(n),整体退化为 O(n·m),仅在条目数量极少时可用。
平台侧支撑:挑战如何被检索与分发
「Inventory Update」这类每日挑战的题目内容与测试数据来自课程仓库,而运行时则由 freeCodeCamp API 提供检索服务。api/src/daily-coding-challenge/routes/daily-coding-challenge.ts 注册了 6 个公开 GET 路由:
| 路由 | 用途 |
|---|---|
/daily-coding-challenge/today | 返回今日(美国中部时区)的挑战全文 |
/daily-coding-challenge/date/:date | 按YYYY-MM-DD日期返回指定挑战 |
/daily-coding-challenge/day/:day | 按MM-DD(月份-日)返回,可跨年复用 |
/daily-coding-challenge/month/:month | 按YYYY-MM返回某月挑战的摘要列表(id、编号、日期、标题) |
/daily-coding-challenge/all | 返回全部已发布挑战的摘要列表 |
/daily-coding-challenge/newest | 返回最新挑战的日期 |
这些路由的响应结构与挑战内容直接对应:单挑战响应包含id、date、challengeNumber、title、description,以及javascript/python两个语言对象(各含tests与challengeFiles字段),其 TypeBox 校验定义见 api/src/daily-coding-challenge/schemas/daily-coding-challenge.ts。
两个与「日期语义」相关的实现细节值得注意:
- 时区锚定:
getNowUsCentral()通过date-fns-tz计算America/Chicago的时区偏移,确保「今天」以美国中部时间为准; - 日期回收与闰日兜底:
getSourceDate()将任意日期映射回原始挑战周期(2025-08-11 至 2026-08-10,见 helpers.ts 中ORIGINAL_*常量),并在请求 2 月 29 日时自动回退到 2 月 28 日的挑战——因为 2000 年被选为闰年占位年,Date.UTC不会把 2 月 29 日滚动到 3 月 1 日。
课程侧,挑战编号、日期与内容的对应关系由 daily-coding-challenges-javascript.json 中的challengeOrder数组维护;挑战正文则存放在 curriculum/challenges/english/blocks/daily-coding-challenges-javascript/ 目录下,每道题一个 Markdown 文件、以固定 24 位 id 命名。
总结与延伸
Inventory Update 是一道典型的「双数组合并 + 保序」问题,核心考点有三:
- 数据建模:用
Map建立「条目名 → 下标」的反向索引,以 O(1) 查找替代线性扫描; - 顺序语义:严格区分「匹配更新(保持原位)」与「新增追加(shipment 顺序末尾)」两条路径,这是与一般哈希合并题的最大差异;
- 边界健壮性:空库存、零数量、重复条目与 shipmnet 乱序等场景都要覆盖。
掌握这道题的 Map 索引思路后,你可以将其推广到同类的「合并 + 保序」场景,例如合并两份用户关注列表、按来源顺序聚合配置项、或实现表格数据的增量合并。若要继续练习,可在同目录挑战块中尝试相邻题目,如 Challenge 123: Roman Numeral Builder(贪心映射表思想)与 Challenge 125: Game of Life(二维数组遍历),形成对 2D 数组与映射表类题目的系统认知。
【免费下载链接】freeCodeCampfreeCodeCamp.org's open-source codebase and curriculum. Learn math, programming, and computer science for free.项目地址: https://gitcode.com/GitHub_Trending/fr/freeCodeCamp
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考