freeCodeCamp 每日编码挑战 321:Periodic Spelling 元素周期表拼词算法解析
【免费下载链接】freeCodeCampfreeCodeCamp.org's open-source codebase and curriculum. Learn math, programming, and computer science for free.项目地址: https://gitcode.com/GitHub_Trending/fr/freeCodeCamp
本篇技术指南以 freeCodeCamp 开源仓库中curriculum/challenges/english/blocks/daily-coding-challenges-javascript板块下的Challenge 321: Periodic Spelling(挑战文件 6a15cadf5f240d05a264955f.md)为核心,完整讲解题目约束、全部测试用例、官方参考解中的 Map 哈希与递归回溯实现,并沿仓库源码链路展示这道"每日一题"从课程 Markdown 到数据库种子脚本、再到前端组件的完整落地方式。读完本文,你将掌握一类"字典单词拆分"问题的通用解法,并理解 freeCodeCamp 每日编码挑战在仓库中的生产化流程。
一、挑战概述:用元素周期表符号拼写单词
Challenge 321 是 freeCodeCamp 每日编码挑战(Daily Coding Challenge)JavaScript 系列中的第 321 题,题目要求实现函数getPeriodicSpelling(word):
Given a word, determine if it can be spelled using element symbols from the periodic table.
给定一个单词,判断它能否用元素周期表中的元素符号拼写出来。核心约束如下:
- 忽略大小写:输入单词的大小写不影响判定,例如
"neon"可以拆解为符号"Ne"、"O"、"N"; - 返回原始大小写:结果数组中的元素符号必须保持其在周期表中的原始大小写形式(首字母大写、第二个字母小写),并按拼写顺序排列;
- 不可拼写返回空数组:若单词无法由元素符号完整拼出,返回
[]。
题目附带了完整的 118 个元素符号列表(从"H"、"He"到"Og"),这既是解题所需的"字典",也决定了题目的两个关键特征:
- 单字母与双字母符号并存:既有
"H"、"C"、"O"这样的单字母符号,也有"He"、"Ne"、"Ti"等双字母符号,因此同一单词可能存在多种拆分方式; - 映射表固定且无重复:每个符号在列表中是唯一的,可以直接用于建立大小写无关的查找结构。
["H","He","Li","Be","B","C","N","O","F","Ne","Na","Mg","Al","Si","P","S","Cl","Ar","K","Ca","Sc","Ti","V","Cr","Mn","Fe","Co","Ni","Cu","Zn","Ga","Ge","As","Se","Br","Kr","Rb","Sr","Y","Zr","Nb","Mo","Tc","Ru","Rh","Pd","Ag","Cd","In","Sn","Sb","Te","I","Xe","Cs","Ba","La","Ce","Pr","Nd","Pm","Sm","Eu","Gd","Tb","Dy","Ho","Er","Tm","Yb","Lu","Hf","Ta","W","Re","Os","Ir","Pt","Au","Hg","Tl","Pb","Bi","Po","At","Rn","Fr","Ra","Ac","Th","Pa","U","Np","Pu","Am","Cm","Bk","Cf","Es","Fm","Md","No","Lr","Rf","Db","Sg","Bh","Hs","Mt","Ds","Rg","Cn","Nh","Fl","Mc","Lv","Ts","Og"]挑战文件的--seed--节给出了初始脚手架:函数签名function getPeriodicSpelling(word) { return word; },学习者需要在此基础上补充算法实现。
二、测试用例逐条拆解:单一解与多重解并存
挑战的--hints--节共包含 8 条断言,是理解题目意图的最佳入口。前 3 条是单一解情形,后 5 条则引入了多重解(分支回溯)与无解情形:
| 输入 | 期望输出 | 特点 |
|---|---|---|
"neon" | ["Ne", "O", "N"] | 单一解,验证大小写归一化 |
"rational" | ["Ra", "Ti", "O", "N", "Al"] | 单一解,全部为双字母/单字母混合 |
"yarn" | ["Y", "Ar", "N"] | 单一解 |
"carbon" | ["C", "Ar", "B", "O", "N"]或["Ca", "Rb", "O", "N"] | 多重解,任选其一 |
"noisy" | ["N", "O", "I", "S", "Y"]或["No", "I", "S", "Y"] | 多重解 |
"bicycles" | ["B", "I", "C", "Y", "Cl", "Es"]或["Bi", "C", "Y", "Cl", "Es"] | 多重解 |
"optics" | ["O", "P", "Ti", "C", "S"]、["O", "P", "Ti", "Cs"]、["O", "Pt", "I", "C", "S"]或["O", "Pt", "I", "Cs"] | 四种合法解 |
"value" | [] | 无解,返回空数组 |
多重解的断言方式值得注意:hints 并没有要求返回"字典序最小"或"符号数最少"的解,而是通过JSON.stringify后与多个合法路径逐一比对(如carbon的path1/path2),只要命中任意一条合法拆分即通过。这大大放宽了实现自由度——递归时优先取双字母还是单字母都不会影响判题结果,只要回溯逻辑正确即可。
const result = JSON.stringify(getPeriodicSpelling("optics")); const path1 = JSON.stringify(["O", "P", "Ti", "C", "S"]); const path2 = JSON.stringify(["O", "P", "Ti", "Cs"]); const path3 = JSON.stringify(["O", "Pt", "I", "C", "S"]); const path4 = JSON.stringify(["O", "Pt", "I", "Cs"]); assert.isTrue(result === path1 || result === path2 || result === path3 || result === path4);"value"返回[]的用例则专门考察失败路径的终止条件:递归必须在所有拆分尝试均告失败时返回空数组(而不是抛错或返回中间状态)。
三、问题本质:一类"字典单词拆分"问题
将题目抽象后可以发现,它本质上是经典的Word Break(单词拆分)问题的变体,只是"字典"变成了 118 个元素符号,且每个符号长度只能是 1 或 2:
- 输入是长度为
n的字符串(小写归一化后); - 在每一步,游标
i处可以尝试取长度为1或2的前缀(因为周期表符号最短 1 个字母、最长 2 个字母); - 若该前缀是合法元素符号,则递归处理剩余部分
word[i+1:]或word[i+2:]; - 递归终点是
i === word.length,即整个单词恰好被符号序列覆盖完毕; - 若某条路径走不通,需要回溯到上一个决策点尝试另一种切分。
由于符号只有 1 或 2 两种长度,每个位置的分支因子最多为 2,因此递归树是二叉树形态。若不考虑剪枝,朴素递归的最坏情况时间复杂度为 O(2^n),但随着字符串变长,前缀查表会很快失败,实际分支远少于理论值。题目的输入为普通英文单词(长度通常不超过 12),参考解直接采用带失败返回的递归即可通过全部测试。
四、官方参考解:Map 哈希 + 递归回溯
挑战文件的--solutions--节提供了官方参考实现,其核心是用Map建立"小写符号 → 原始符号"的哈希映射,再以递归回溯完成拆分:
function getPeriodicSpelling(word) { const elements = ["H","He","Li","Be","B","C","N","O","F","Ne","Na","Mg","Al","Si","P","S","Cl","Ar","K","Ca","Sc","Ti","V","Cr","Mn","Fe","Co","Ni","Cu","Zn","Ga","Ge","As","Se","Br","Kr","Rb","Sr","Y","Zr","Nb","Mo","Tc","Ru","Rh","Pd","Ag","Cd","In","Sn","Sb","Te","I","Xe","Cs","Ba","La","Ce","Pr","Nd","Pm","Sm","Eu","Gd","Tb","Dy","Ho","Er","Tm","Yb","Lu","Hf","Ta","W","Re","Os","Ir","Pt","Au","Hg","Tl","Pb","Bi","Po","At","Rn","Fr","Ra","Ac","Th","Pa","U","Np","Pu","Am","Cm","Bk","Cf","Es","Fm","Md","No","Lr","Rf","Db","Sg","Bh","Hs","Mt","Ds","Rg","Cn","Nh","Fl","Mc","Lv","Ts","Og"]; const lower = new Map(elements.map(e => [e.toLowerCase(), e])); function spell(word, i) { if (i === word.length) return []; const one = word.slice(i, i + 1); const two = word.slice(i, i + 2); if (lower.has(two)) { const rest = spell(word, i + 2); if (rest !== null) return [lower.get(two), ...rest]; } if (lower.has(one)) { const rest = spell(word, i + 1); if (rest !== null) return [lower.get(one), ...rest]; } return null; } return spell(word.toLowerCase(), 0) ?? []; }逐行解读这段参考实现:
- 映射构建(O(118)):
elements.map(e => [e.toLowerCase(), e])将全部符号转为小写键,值保留原始大小写。这样lower.get("ne")返回"Ne",天然满足"返回原始大小写"的需求,也免去了对输入单词逐个字符做大小写判断; - 递归函数
spell(word, i):i是当前读取游标。终止条件i === word.length返回[]作为成功基底; - 优先尝试双字母:
two = word.slice(i, i + 2),若命中lower则递归推进 2 个字符。只有递归返回非null时才拼接结果并返回,这正是回溯的关键——双字母路径失败时不会污染结果; - 再尝试单字母:双字母失败后回退到
one = word.slice(i, i + 1)分支,同样先递归后拼接; - 失败信号用
null:两条路径都走不通时返回null,由?? []在入口处统一转换为空数组。??(空值合并)保证了[](真值语义上的"成功但为空")不会被误替换。
该实现的精巧之处在于:返回值本身兼作"成功标志"与"路径记录"——null表示失败,数组表示成功路径,从而省去了单独维护visited或path栈的开销。由于题目不要求返回所有解或最优解,找到任意一条可行路径即可提前回溯返回。
五、算法复杂度与边界讨论
- 时间复杂度:每次调用至多进行 2 次
Map.has()查询(每次 O(1))与 2 次递归调用,递归深度为 O(n)。最坏情况(如全由单字母符号组成的路径,例如"noy"→["N","O","Y"])下,递归树节点数为 O(2^n),但哈希查询失败会立即剪枝,实际英文单词场景下远低于该上界。若需处理超长输入,可引入记忆化(memoization):用Map<number, string[] | null>缓存i位置的失败/成功结果,将复杂度降至 O(n); - 空间复杂度:递归栈深度 O(n),符号映射表 O(118) 常数空间;
- 边界情况:
- 空字符串:
spell("", 0)立即命中i === word.length,返回[],可视为"空单词可拼写"的平凡情形; - 含数字或特殊字符的输入:既不在单字母也不在双字母表中,两条路径均失败,最终返回
[]; - 大小写混输入如
"NeON":入口统一toLowerCase()归一化,不影响判定。
- 空字符串:
值得补充的是,hints 中"bicycles"的路径["B", "I", "C", "Y", "Cl", "Es"]用到了符号"Es"(锿),"optics"的四种合法解用到了"Pt"(铂)、"Ti"(钛)、"Cs"(铯),说明题目有意覆盖了双字母符号与单字母符号交替、且存在"贪心优先双字母会走错"的情形——例如"carbon"若贪心取双字母"Ca"后剩余"rbon","rb"、"r"均不是合法符号,必须回溯改取"C"。这正是考验回溯能力的设计点。
六、从单道题到产品:每日挑战在仓库中的完整链路
Challenge 321 并非孤立存在的课程 Markdown,它在 freeCodeCamp 仓库中有一条完整的生产链路,理解它有助于把这道题放进真实项目的上下文中:
6.1 课程板块定义
挑战所属板块由 daily-coding-challenges-javascript.json 定义,该文件以challengeOrder数组登记了全部挑战(Challenge 1: Vowel Balance 起,按天递增),其中 JavaScript 与 Python 两个板块一一对应,challengeType标识了每日挑战的专用类型。
6.2 数据库种子脚本
seed-daily-challenges.ts 负责把每日挑战写入 MongoDB 的DailyCodingChallenges集合:
- 通过 helpers.ts 中的
fetchChallenges('javascript' | 'python')函数,向本地 Gatsby 客户端暴露的 GraphQL 端点http://localhost:8000/___graphql查询dev-playgroundsuperblock 下的daily-coding-challenges-javascript板块,并按challengeOrder升序取出description、tests(testString+text)、challengeFiles等字段; combineChallenges将同日期的 JS 与 Python 挑战配对,校验标题、描述、测试数量一致后,以 JS 挑战的id作为 MongoDB 文档_id,写入包含challengeNumber、date、javascript与python双语言的统一文档;- 脚本硬性断言挑战总数为365,起始日期为
2025-08-11T00:00:00.000Z,此后每天递增一天,并注释强调发布后不可更改起始日期(这是每日挑战"每日一题"节奏的正确性保证)。
运行方式在 tools/daily-challenges/README.md 中有明确说明:复制sample.env为.env、安装依赖、以"显示即将上线内容(upcoming changes shown)"模式启动主客户端,然后在tools/daily-challenges目录执行pnpm seed-daily-challenges完成播种。
6.3 API 与前端呈现
- API 侧:daily-coding-challenge.ts 与其测试文件 daily-coding-challenge.test.ts 提供按日期读取挑战数据的服务端接口;
- 前端侧:widget.tsx、calendar.tsx 及 helpers.ts 实现挑战展示组件与日期计算逻辑;
- E2E 侧:daily-coding-challenge.spec.ts 覆盖了
/learn/daily-coding-challenge/08-11等日期路由、无效日期重定向到 archive、以及通过种子命令构造测试数据等端到端场景。
这意味着 Challenge 321 这类题目的 Markdown 是"单一事实来源":它既驱动学习者页面上的题目与测试运行,又被种子脚本转化为按日期发布的每日挑战数据,最终通过 API 呈现给用户。对学习者而言,在本地运行pnpm seed-daily-challenges后即可在/learn/daily-coding-challenge/<日期>页面实际作答第 321 题。
七、总结:从拼词题到通用解题思维
Challenge 321: Periodic Spelling 表面是一道"元素符号拼词"趣味题,实际考察的是三类核心能力:
- 哈希映射的设计:把 118 个符号用小写键建立
Map,同时解决大小写归一化与"还原原始符号大小写"两个需求,是空间换时间的典型手法; - 递归回溯的终止与信号设计:用
null作为失败信号、以返回值同时承载"是否成功"与"路径内容",并用?? []兜底,是简洁且不易出错的分治写法; - 多解宽容的判题设计:hints 允许多条合法路径中的任意一条,启示我们在实现时不必追求"唯一最优解",正确回溯 + 任意可行解即可通过验证。
掌握这类"固定长度词元 + 查表 + 回溯"的套路后,你可以把它直接迁移到其他场景——如 IP 地址/电话号码分段、罗马数字解析、以及经典 Word Break 问题。结合 官方参考解 动手实现一遍,再对比仓库中每日挑战的完整生产链路,既能巩固算法功底,也能理解开源课程内容如何被工程化地构建、测试与发布。
【免费下载链接】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),仅供参考