freeCodeCamp Python 每日编程挑战第 31 关 Array Diff:对称差集与字母排序完整解析
【免费下载链接】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-python区块的第 31 个挑战Array Diff展开,完整拆解题目语义、5 个官方测试用例的判定逻辑、种子代码的改造路径与参考解法,并结合 挑战源文件、API 路由实现 与 种子脚本 说明这类挑战从 Markdown 文档到数据库记录、再到每日投递与自动判题的全链路机制。读完本文,你将掌握对称差集(symmetric difference)的多种 Python 实现、去重与排序的边界处理,以及 freeCodeCamp 每日编程挑战体系的源码级工作原理。
挑战定位:每日编程挑战系列中的一环
在 freeCodeCamp 课程仓库中,每日编程挑战(Daily Coding Challenge)由 JavaScript 与 Python 两套平行区块组成,分别位于daily-coding-challenges-javascript与daily-coding-challenges-python。区块结构文件 记录了该区块的元信息:
isUpcomingChange: true——该区块作为即将上线的新内容维护,需要通过环境变量开启展示;usesMultifileEditor: true——挑战使用多文件编辑器环境;helpCategory: "Python"——归类到 Python 帮助分类;blockLayout: "legacy-challenge-list"——采用传统挑战列表布局。
Array Diff 是该区块的第 31 个挑战,其挑战文档的 frontmatter 标注为:
id: 68af0687ef34c76c28ffa549——全局唯一挑战 ID,也是挑战完成记录与数据库中记录关联的主键;title: "Challenge 31: Array Diff";challengeType: 29——对应多文件编辑器的挑战类型;dashedName: challenge-31。
在 区块结构文件 的challengeOrder中,它紧随 Challenge 30: Unique Characters,后接 Challenge 32: Reverse Sentence,是整套每日挑战难度递进序列中的一环。
题目语义拆解:只出现在一个数组中的值
挑战的--description--部分给出了核心需求:
Given two arrays with strings values, return a new array containing all the values that appear in only one of the arrays. The returned array should be sorted in alphabetical order.
翻译为两层约束:
- 集合语义:给定两个仅含字符串的数组,返回"只出现在其中一个数组"的所有值。这正是数学上的对称差集(symmetric difference,记作 A △ B):
(A − B) ∪ (B − A),即属于 A 但不属于 B、以及属于 B 但不属于 A 的元素。 - 输出排序:返回数组必须按字母顺序(alphabetical order)排序。
需要注意题目的措辞 "appear in only one of the arrays"——它关注的是"值是否存在",因此重复元素也是隐含考察点:参考解法首先对两个输入数组分别去重,再去求对称差。这从 挑战源文件 的--solutions--段可以清楚看到:
def array_diff(arr1, arr2): unique1 = [] for v in arr1: if v not in unique1: unique1.append(v) unique2 = [] for v in arr2: if v not in unique2: unique2.append(v) only1 = [v for v in unique1 if v not in unique2] only2 = [v for v in unique2 if v not in unique1] return sorted(only1 + only2)参考解法分为三步:
- 分别去重:用
v not in unique1的线性查找方式,把arr1、arr2各自折叠成无重复序列unique1、unique2; - 双向过滤:
only1保留unique1中不在unique2的值,only2保留unique2中不在unique1的值,二者合并即为对称差集; - 排序返回:
sorted(...)对合并结果做字母序排序。
去重这一步是解题的关键:若不去重,对于含重复值的输入,输出中会残留重复项,无法精确满足"值只出现在一个数组中"的集合语义。
官方测试用例逐一验证
挑战源文件 的--hints--段内置了 5 个由runPython+unittest.TestCase().assertEqual执行的测试,它们共同界定了函数必须满足的行为:
| 输入 arr1 | 输入 arr2 | 期望输出 | 覆盖要点 |
|---|---|---|---|
["apple", "banana"] | ["apple", "banana", "cherry"] | ["cherry"] | 仅 arr2 多出的值应被返回 |
["apple", "banana", "cherry"] | ["apple", "banana"] | ["cherry"] | 仅 arr1 多出的值应被返回(方向对称) |
["one", "two", "three", "four", "six"] | ["one", "three", "eight"] | ["eight", "four", "six", "two"] | 多元素差集 + 输出必须字母排序 |
["two", "four", "five", "eight"] | ["one", "two", "three", "four", "seven", "eight"] | ["five", "one", "seven", "three"] | 输出与输入顺序完全无关,只按字母序 |
["I", "like", "freeCodeCamp"] | ["I", "like", "rocks"] | ["freeCodeCamp", "rocks"] | 区分大小写:大写I与小写单词不混淆 |
第 3、4 个用例重点校验排序:例如第三个用例中两数组的公共元素是"one"与"three",差集元素为"two"、"four"、"six"、"eight",期望输出按字母序为["eight", "four", "six", "two"],与输入数组中的出现顺序完全不同。第 5 个用例验证字符串比较是区分大小写的,"I"不会被当作与"like"之类的小写词相同。
在课程测试体系中,这些 hint 会被解析为text(展示给学习者的说明文本)与testString(实际执行的判定代码),对应 Prisma 模型中DailyCodingChallengeApiLanguageTests的两个字段,详见下文"从 Markdown 到数据库"一节。
从种子代码出发:最小改造思路
挑战的--seed--段给出了初始代码,学习者需要在此骨架之上完成实现:
def array_diff(arr1, arr2): return arr1种子代码目前直接返回arr1,显然无法通过任何用例。一个保持官方解法思路、但对学习者更友好的写法是在原函数内完成"去重—求差—排序"三步:
def array_diff(arr1, arr2): unique1 = list(dict.fromkeys(arr1)) # 保持顺序去重 unique2 = list(dict.fromkeys(arr2)) only1 = [v for v in unique1 if v not in unique2] only2 = [v for v in unique2 if v not in unique1] return sorted(only1 + only2)dict.fromkeys利用了字典键的唯一性,同时保留元素首次出现的顺序;由于最终结果要排序,顺序保持并非必需,但能让中间步骤更直观。官方参考解法使用的v not in unique1线性查找同样成立,二者都能通过全部 5 个测试。
进阶方案:用集合运算实现对称差集
既然题目本质是求对称差集,Python 内置的set类型提供了两种直接表达方式:
def array_diff(arr1, arr2): s1, s2 = set(arr1), set(arr2) return sorted(s1 ^ s2) # 对称差集运算符或使用集合方法:
def array_diff(arr1, arr2): return sorted(set(arr1).symmetric_difference(arr2))这两种写法更简洁,且set的哈希查找让整体复杂度从参考解法的 O(n·m) 降到 O(n + m)。需要注意一个行为差异:set会同时完成去重与去顺序,因此当输入含重复元素时,集合方案天然满足"值只出现一次"的集合语义,与官方参考解法的去重意图一致。
复杂度对比
| 方案 | 去重方式 | 差集计算 | 时间复杂度 | 空间复杂度 |
|---|---|---|---|---|
| 官方参考解法 | 线性查找(in于列表) | 双向列表过滤 | O(n·m)(n、m 为两数组长度) | O(n + m) |
dict.fromkeys+ 列表过滤 | 字典键去重 | 双向列表过滤 | O(n + m) 去重 + O(n·m) 过滤 | O(n + m) |
set对称差集 | 哈希集合 | ^运算符 | O(n + m) | O(n + m) |
对于字符串数组且规模不大的每日挑战场景,三种方案均能通过全部测试;官方解法刻意避开set,可能是为了让学习者练习列表推导与去重逻辑,这一点从源码可以确认——挑战源文件 的参考解法全程只使用列表与sorted。
常见陷阱与边界情况
结合题目约束与测试用例,容易踩坑的点包括:
- 忘记排序:只返回差集而不调用
sorted(),会直接导致第 3、4 个用例失败——期望输出严格按字母序。 - 忘记去重:若输入数组含重复值(题目未禁止),不去重会产生重复输出,破坏集合语义。
- 方向遗漏:只计算
arr1 - arr2而忽略arr2 - arr1,会让第 1 个用例("cherry"只在 arr2)返回空数组。 - 大小写混淆:字符串比较区分大小写,
"I"与"i"是不同值。 - 原地修改输入:
sorted()返回新列表,若误用list.sort()会修改原数组的引用;虽然测试只断言返回值,但保持函数无副作用是良好实践。
源码级原理:挑战如何从 Markdown 变成每日投递
Array Diff 这类挑战并不是简单静态页面,而是经由"课程内容 → GraphQL → 种子脚本 → MongoDB → API → 客户端组件"的完整流水线呈现在学习者面前。
数据库模型
Prisma Schema 定义了DailyCodingChallenges模型:
model DailyCodingChallenges { id String @id @default(auto()) @map("_id") @db.ObjectId challengeNumber Int date DateTime title String description String javascript DailyCodingChallengeApiLanguage python DailyCodingChallengeApiLanguage } type DailyCodingChallengeApiLanguage { tests DailyCodingChallengeApiLanguageTests[] challengeFiles DailyCodingChallengeApiLanguageChallengeFiles[] }每条记录同时携带 JavaScript 与 Python 两个语言版本(tests+challengeFiles),date字段决定挑战在哪一天对外可见——这正是"每日"机制的落点。
种子脚本与语言一致性校验
tools/daily-challenges/seed-daily-challenges.ts 负责把课程内容灌入数据库:它从运行中的客户端 GraphQL 端点(http://localhost:8000/___graphql)按superBlock: "dev-playground"、block: "daily-coding-challenges-python"(以及 JavaScript 版本)拉取全部挑战,逐条配对生成记录。脚本内置了严格的一致性断言:
- 期望挑战总数为 365(
EXPECTED_CHALLENGE_COUNT),JS 与 Python 数量必须相等; - 首条挑战的日期被固定为
2025-08-11,之后每天递增一天,且脚本会在起始日期被意外改动时直接抛错,防止生产环境已发布内容错位; - helpers.ts 的
combineChallenges在合并时校验 JS/Python 两版的title、description、tests数量完全一致,任一不匹配即拒绝写入; - 记录
_id直接取用 JS 挑战的 id(如本文挑战的68af0687ef34c76c28ffa549),保证挑战完成记录与课程内容可互相追踪。
与之对应的课程级校验在 curriculum/src/test/daily-challenges.test.js:通过getChallenges(CURRICULUM_LOCALE, { superBlock: 'dev-playground' })加载挑战后,逐一断言 JS/Python 两个区块的挑战数量、ID、标题、描述与测试数量匹配。
API 投递与客户端入口
挑战对外暴露的读取接口位于 api/src/daily-coding-challenge/routes/daily-coding-challenge.ts,共 6 个 GET 端点:
| 端点 | 参数格式 | 行为 |
|---|---|---|
/daily-coding-challenge/date/:date | YYYY-MM-DD | 按具体日期取挑战 |
/daily-coding-challenge/day/:day | MM-DD | 按月-日取(自动匹配到对应年份) |
/daily-coding-challenge/today | 无 | 返回美国中部时间当天的挑战 |
/daily-coding-challenge/month/:month | YYYY-MM | 返回某月挑战摘要列表 |
/daily-coding-challenge/all | 无 | 返回全部已发布挑战摘要 |
/daily-coding-challenge/newest | 无 | 返回最新挑战日期 |
值得注意的约束:所有端点都只返回"今天(美国中部时间)之前"的挑战(challenge.date > getUtcMidnight(getNowUsCentral())会被 404 拦截),保证未发布内容不可见。请求参数的模式校验由 api/src/daily-coding-challenge/schemas/daily-coding-challenge.ts 中的 TypeBox Schema 完成,例如日期必须是YYYY-MM-DD格式、单条响应的id必须是 24 位 ObjectId。每条成功/失败记录还会通过 Sentry metrics 上报dcc.challenge_viewed、dcc.challenge_not_found、dcc.request_failed等指标。
客户端侧,widget.tsx 中的DailyCodingChallengeWidget组件在课程地图与首页渲染两个入口按钮:进入"今日挑战"(链接由getMonthDayUsCentral()生成MM-DD格式)以及进入历史档案库。学习者在页面中编写array_diff并通过全部 5 个 hint 后,提交完成记录走的是主挑战完成流程(API README 明确说明"Daily challenge submission still lives in the main part of the API")。
总结
Array Diff 看似是一个简单的双数组求差题,但它在 freeCodeCamp 的每日编程挑战体系中承载了完整的学习闭环:理解对称差集的集合语义、掌握去重与字母排序的边界、对比set与列表推导两种实现风格。透过 挑战源文件 与 区块结构文件、Prisma 模型、API 路由、种子脚本 与 课程测试 的源码证据,还能看到一道 Markdown 挑战如何被校验、入库、按日投递并被自动判题——这正是把"刷题"升级为"理解平台机制"的进阶视角。
【免费下载链接】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),仅供参考