freeCodeCamp 每日编程挑战实战:Challenge 117 "Symmetric Difference"(对称差集)完整解法剖析
【免费下载链接】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 Challenges(JavaScript)题库的第 117 道题目展开,围绕“对称差集(Symmetric Difference)”的定义、顺序约束、去重陷阱与官方参考实现进行源码级拆解。读完你将掌握这道题的标准解法、边界条件的推导方法,并理解这类 Markdown 题目在 freeCodeCamp 仓库中从题库、测试断言到线上每日挑战的完整流转链路,从而能够举一反三地解决题库中其余 300 多道同类题目。
挑战档案:题目在仓库中的位置与元信息
本题目对应的源文件为 69162d64f96574d9bb629f02.md,其 frontmatter 如下:
--- id: 69162d64f96574d9bb629f02 title: "Challenge 117: Symmetric Difference" challengeType: 28 dashedName: challenge-117 ---几个值得注意的元数据点:
- 从 daily-coding-challenges-javascript.json 的
challengeOrder数组可以看出,该文件在块内按标题序列号排在第 117 位(前一题是 Challenge 116: Permutation Count,后一题是 Challenge 118: Date Formatter),因此题目以challenge-117作为dashedName,这也是挑战文件的命名约定。 - 邻近题目(如 Challenge 118 Date Formatter)同样声明
challengeType: 28且使用完全相同的文档结构,说明这是该题库统一的“编码挑战”题型模板。 - 该块同时是 dev-playground.json 中
daily-coding-challenges-javascript与daily-coding-challenges-python两个并行的每日挑战块之一,且块配置中标有isUpcomingChange: true,属于仍在迭代开发期的实验性课程内容。
题目理解:到底要算什么
原文档给出的任务定义是:
Given two arrays, return a new array containing the symmetric difference of them.
- The symmetric difference between two sets is the set of values that appear in either set, but not both.
- Return the values in the order they first appear in the input arrays.
把集合论的对称差概念翻译成可执行的规则,共有三层要求:
- 取并集中“恰好属于其中一个集合”的元素。对称差集的符号记法为 A △ B = (A \ B) ∪ (B \ A),也就是“出现在任一数组中、但不同时出现在两个数组中的值”。出现在两个数组中的交集部分必须被剔除。
- 结果去重。由于把数组当作“集合”看待,输入数组中某个值的多次重复出现,最终输出里只能保留一次。换言之,输出是一个“值集合”,而不是重复值的列表。
- 保持出现顺序。输出数组按该值在两个输入数组中首次出现的先后次序排列:先扫描
arr1中符合条件的值,再追加arr2中符合条件的值。因为被剔除的都是交集值,剩下的每个值都只属于一边,这种“先扫arr1、再扫arr2”的顺序天然与全局首次出现顺序一致。
一个需要留意的隐含细节是数组比较使用严格相等语义(下文会看到官方实现用Array.prototype.includes,其内部基于SameValueZero比较,1与"1"会被视为不同值),因此不同类型元素会按原样保留。
官方测试用例逐条解读
原文档的# --hints--区块给出了四个断言,全部使用assert.deepEqual进行深度相等比较(即逐元素比较内容与顺序,而非引用比较):
| 调用 | 期望输出 | 设计意图 |
|---|---|---|
difference([1, 2, 3], [3, 4, 5]) | [1, 2, 4, 5] | 最基本场景,仅有一个公共值3 |
difference(["a", "b"], ["c", "b"]) | ["a", "c"] | 字符串数组,公共值为"b" |
difference([1, "a", 2], [2, "b", "a"]) | [1, "b"] | 混合数字与字符串,公共值为2与"a",验证严格相等语义与类型保留 |
difference([1, 3, 5, 7, 9], [1, 2, 3, 4, 5, 6, 7, 8, 9]) | [2, 4, 6, 8] | 大数据量场景,奇数全部在交集中被剔除,剩偶数 |
其中第三个用例对“严格相等 + 类型保留”的验证尤为关键:arr1中的1是数字、"a"是字符串;arr2中的2、"b"、"a"。公共元素为2(数字)与"a"(字符串),两者剔除后剩余1与"b",且各自保持原类型。如果你错误地使用宽松转换比较或先字符串化再比较,就会在此用例上失败。
第四个用例则验证顺序与批量剔除逻辑:arr1 = [1,3,5,7,9](全部奇数)在arr2中都存在,因此arr1无贡献;arr2中剔掉全部奇数后按剩余顺序输出偶数[2,4,6,8]。
题目给出的初始代码(seed)
# --seed--区块为学习者提供函数骨架:
function difference(arr1, arr2) { return arr1; }函数名固定为difference,接收两个数组参数arr1、arr2,要求返回一个新数组。注意骨架里的return arr1只是占位逻辑,直接提交仅能通过“两数组无交集且第一个数组较短”这类侥幸场景——例如它对前四个用例全部失败,因为它既不剔除交集值、也不追加第二个数组的独有值、还直接返回原数组引用。
官方参考实现逐行拆解
# --solutions--区块给出的参考解如下:
function difference(arr1, arr2) { const diff = []; for (const v of arr1) { if (!diff.includes(v) && !arr2.includes(v)) diff.push(v) } for (const v of arr2) { if (!diff.includes(v) && !arr1.includes(v)) diff.push(v) } return diff; }它采用“两次线性扫描 + 双保险判重”策略,整体思路非常直白:
第一阶段:处理arr1。用for...of遍历arr1的每个元素v,只有同时满足两个条件才推入结果:
!diff.includes(v):该值尚未出现在结果中。由于我们按arr1的原始顺序遍历,这确保同一值的重复出现不会重复入列,同时也保证了输出结果的有序性与唯一性;!arr2.includes(v):该值不在另一个数组中,即不是交集元素。
第二阶段:处理arr2。用同样的模式遍历arr2,条件为!diff.includes(v) && !arr1.includes(v)。这里的!arr1.includes(v)把交集元素挡在门外;而!diff.includes(v)承担两重职责:一方面清理arr2内部的重复值,另一方面兜底防止与第一阶段已加入的元素重复(虽然对一个“同时属于两边”的值而言它必然已被第二条件拦截,但保留此判断让逻辑更稳健、语义更明确)。
返回值。直接返回累积的新数组diff,不修改输入数组,符合“return a new array”的题目约束。
我们用第二个用例快速走一遍执行轨迹:difference(["a", "b"], ["c", "b"])。
diff = []。- 扫
arr1:"a"不在diff、不在arr2→ 推入,diff = ["a"];"b"不在diff,但arr2.includes("b")为true→ 跳过。 - 扫
arr2:"c"不在diff、不在arr1→ 推入,diff = ["a", "c"];"b"在arr1中 → 跳过。 - 返回
["a", "c"],与断言一致。
复杂度评估。由于includes()需要对数组做线性扫描,外层又是线性遍历,该实现的最坏时间复杂度是 O(n·(n + m) + m·(n + m))(n、m 分别为两数组长度),即平方级;空间上额外维护一个diff数组。对每日挑战这种以“值范围小、输入规模可控”为设计前提的入门到中级题目而言完全够用。
从题库到线上:挑战题在 freeCodeCamp 工程中的流转
这道 Markdown 题目并不是孤立的练习题,它背后是 freeCodeCamp 一整套“每日编程挑战”生产链路,仓库内可以交叉验证到多个环节的实现:
- 题库侧(本文件即其一):题目文档的 frontmatter 中
title、dashedName、challengeType由 daily-coding-challenges-javascript.json 的challengeOrder登记成序;两个语言块被归入 dev-playground.json 这个“开发试验场”超块。 - 取数与判题脚本:seed-daily-challenges.ts 通过 GraphQL 从 dev-playground 超块抓取挑战数据,并强制校验 JavaScript 与 Python 两个块的数量一致(
EXPECTED_CHALLENGE_COUNT = 365),随后以bulkWrite + replaceOne + upsert的方式写入 MongoDB 的DailyCodingChallenges集合。其配对逻辑在 helpers.ts 中实现:fetchChallenges(language)查询每个挑战的description、tests与challengeFiles,combineChallenges再校验两种语言的描述与测试数量一致,最后把测试用例与初始代码拼装进对应语言子对象——也就是说,本文件# --hints--里的assert.deepEqual断言会被收集为tests(含text与testString),# --seed--的内容则进入challengeFiles。 - 运行与部署说明:tools/daily-challenges/README.md 记载了完整跑法:把
sample.env复制为.env、安装依赖、以“显示 upcoming changes”的方式运行主客户端(使 GraphQL 可访问),进入tools/daily-challenges后执行pnpm seed-daily-challenges即可把挑战写入本地或生产库。 - 客户端校验侧:daily-coding-challenge-validator.ts 中的 Joi schema 印证了上述数据结构——它要求每日挑战对象必须同时包含
javascript与python两个语言子对象,每个子对象都要有tests(text+testString)和challengeFiles(fileKey+contents),challengeNumber须为正整数。这解释了为什么题库要求 JS 与 Python 双份题面:一份挑战在两个语言下保持平行。
由此可见,学习者在本挑战中填写的函数最终会成为数据库DailyCodingChallenges集合中某条记录tests.testString的评判目标,并与日期(首个挑战从 2025-08-11 UTC 起每日递增)绑定后呈现给用户。
扩展思考:更优与更简洁的替代实现
在动手前先明确官方解法的“护栏”逻辑其实等价于三个集合操作:A独有值 +B独有值 + 全局去重。基于这一洞察,可以用Set把两个“判断集合”与一个“去重集合”显式分离,将时间复杂度降到线性:
function difference(arr1, arr2) { const in1 = new Set(arr1); const in2 = new Set(arr2); const result = []; const seen = new Set(); for (const v of [...arr1, ...arr2]) { // 恰好只属于一个集合:in1.has(v) !== in2.has(v) if (in1.has(v) !== in2.has(v) && !seen.has(v)) { seen.add(v); result.push(v); } } return result; }这里用in1.has(v) !== in2.has(v)来判定“属于其一而不属于其二”,本质是对集合论中“异或”关系的直接编码,比“两次排除交集 + 判重”更贴近数学定义,也天然满足“按首次出现顺序”的输出要求(因为仍是对arr1、arr2的拼接顺序做单遍扫描)。
如果偏好函数式风格,也可以基于filter写一个可读版本,但务必注意去重边界——如果不做额外处理,输入数组内部的重复值会被filter原样输出多份,从而破坏“集合”语义:
function difference(arr1, arr2) { const in1 = new Set(arr1); const in2 = new Set(arr2); const once = new Set(); return [...arr1, ...arr2].filter(v => { const keep = in1.has(v) !== in2.has(v) && !once.has(v); if (keep) once.add(v); return keep; }); }对题目规模的输入而言,官方参考解与上述 Set 版在结果上完全等价;若要追求面试或实际工程中的稳健性(例如超长数组、需多次调用),优先选择 Set 线性版本。练习时建议先独立推演“先扫arr1、再扫arr2、双条件判重”的过程,再用四个官方断言逐一验证,随后对比includes与Set.has两种判重手段的复杂度差异,这道 Challenge 117 就真正吃透了。
【免费下载链接】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),仅供参考