freeCodeCamp 每日编程挑战解析:Unique Characters —— 字符串字符唯一性判定的实现与工程化组织
【免费下载链接】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)」系列的第 30 题Unique Characters展开:完整继承原题的问题描述、测试断言与参考答案,逐行剖析其解法思路与时间复杂度,并结合仓库源码说明这道挑战在 freeCodeCamp 课程体系中是如何被定义、校验与分发的。读完后,你既能独立解出这道字符唯一性判定题,也能理解 freeCodeCamp 用challengeType、Joi Schema 和块结构文件组织编程题的完整机制。
问题定义:判断字符串中所有字符是否唯一
该挑战的题目文件位于 curriculum/challenges/english/blocks/daily-coding-challenges-javascript/68af0687ef34c76c28ffa547.md,其核心描述为:
Given a string, determine if all the characters in the string are unique.
- Uppercase and lowercase letters should be considered different characters.
即:给定一个字符串,判断其中所有字符是否互不重复;同时明确了一条边界规则——大写与小写字母视为不同字符,因此"aA"是合法的唯一字符串。需要实现一个allUnique(str)函数,返回布尔值。
完整的测试断言与期望行为
原题给出了 7 组测试用例(# --hints--段),它们同时覆盖了「唯一」与「不唯一」两类输入,以及字母、大小写混合、纯数字符号等不同字符集。这些断言是解法正确性的验收标准:
| 输入 | 期望结果 | 考察点 |
|---|---|---|
"abc" | true | 最基础的唯一字符场景 |
"aA" | true | 大小写视为不同字符(题目关键规则) |
"QwErTy123!@" | true | 字母、数字、符号混合且全部唯一 |
"~!@#$%^&*()_+" | true | 纯特殊符号,含+、_等易被转义/忽略的字符 |
"hello" | false | 字母l重复 |
"freeCodeCamp" | false | 大小写混用但e、C、o等有重复 |
"!@#*$%^&*()aA" | false | 符号重复(*、^、%、&等出现两次),而非常见的大小写陷阱 |
对应的断言写法为:
assert.isTrue(allUnique("abc")); assert.isTrue(allUnique("aA")); assert.isTrue(allUnique("QwErTy123!@")); assert.isTrue(allUnique("~!@#$%^&*()_+")); assert.isFalse(allUnique("hello")); assert.isFalse(allUnique("freeCodeCamp")); assert.isFalse(allUnique("!@#*$%^&*()aA"));这 7 组用例值得注意的一点是:"!@#*$%^&*()aA"这个反例中字母部分是唯一的,重复发生在符号上,因此解法必须对任意字符(而不只是字母)做统一的重复检测,不能对字符做「归一化」或按类别分开处理。
种子代码:从模板出发
挑战提供给学习者的种子(# --seed--段)是一个空壳函数:
function allUnique(str) { return str; }其中return str只是占位返回,学习者的任务是在函数体内实现判定逻辑并返回true/false。这种「函数签名固定 + 测试先行」的模式是整个 Daily Coding Challenges 系列的统一结构:题面给出函数名与参数,hints 段给出断言作为测试骨架。
官方参考解法逐行剖析
原题# --solutions--段给出的参考实现如下,采用的是「已见字符累积 + 提前终止」策略:
function allUnique(str) { let seen = ''; for (let char of str) { if (seen.includes(char)) { return false; } seen += char; } return true; }逐行拆解其工作机制:
let seen = '';—— 用一个字符串累积所有「尚未重复出现」的字符,充当字符登记表。for (let char of str)—— 使用for...of逐字符遍历(相对于for (let i = 0; i < str.length; i++)下标访问,for...of对含多字节字符的字符串按码点/码元迭代,更符合「逐字符」语义)。if (seen.includes(char)) { return false; }—— 一旦当前字符已在seen中出现过,立即返回false。这是关键优化:发现第一个重复字符即终止,最坏情况不需要遍历完整字符串。seen += char;—— 字符唯一时,将其追加进登记表,继续下一轮。return true;—— 遍历结束仍未发现重复,返回true。空字符串""会直接走到这一步返回true(空串中不存在重复字符,逻辑自洽)。
以allUnique("aA")为例走一遍:'a'不在seen中,追加得seen = "a";'A'不在"a"中(String.prototype.includes是大小写敏感的),追加得seen = "aA";遍历结束返回true。这正好验证了题目「大小写视为不同字符」的规则——解法没有调用toLowerCase()之类的归一化,是故意为之。
再以allUnique("hello")为例:h、e、l依次进入seen,第二个l被seen.includes("l")命中,函数在遍历到第 4 个字符时就提前返回false。
复杂度分析与替代写法
从源码结构看,参考解法的时间复杂度为 O(n²):每轮调用includes需要在长度为 O(n) 的seen上做一次线性扫描,共 n 轮。在 Daily Coding Challenges 这类输入较短的题设下完全够用,但将其作为算法练习,值得对比更优实现——使用Set:
function allUnique(str) { const seen = new Set(); for (const char of str) { if (seen.has(char)) return false; seen.add(char); } return true; }Set.prototype.has的平均查找为 O(1),整体降到 O(n) 时间、O(n) 空间。两种写法语义完全等价,且都保持大小写敏感。也可以进一步用new Set(str).size === str.length一行式表达,但它无法提前终止、会先物化整个集合,属于「简洁优先」的取舍;在本题的测试断言下同样全部通过。三种写法共同的前提是:不得对输入做任何大小写折叠或字符过滤,否则allUnique("aA")这类断言会失败。
这道挑战在 freeCodeCamp 仓库中如何被组织
理解了题目本身后,从仓库源码看它如何被 freeCodeCamp 的课程系统定义、校验与分发,可以补全「一道题从 Markdown 到学员编辑器」的完整链路。
challengeType 28:dailyChallengeJs 的类型注册
该挑战的 frontmatter 中写有challengeType: 28。在 packages/shared/src/config/challenge-types.ts 中可以看到类型常量表:
const dailyChallengeJs = 28; const dailyChallengePy = 29;并据此定义了视图类型与提交方式:
[dailyChallengeJs]: 'classic', // viewTypes:使用 classic 挑战视图(编辑器 + 测试面板) [dailyChallengeJs]: 'tests', // submitTypes:提交时执行测试断言同时该文件提供getIsDailyCodingChallenge(challengeType)与getDailyCodingChallengeLanguage(challengeType)两个工具函数,后者将28映射为'javascript'、29映射为'python'。客户端与 API 侧据此区分每日挑战的语言与展示逻辑(客户端相关组件位于 client/src/components/daily-coding-challenge/)。
块结构文件:挑战的排序与元信息
这道题在课程块中的位置由 curriculum/structure/blocks/daily-coding-challenges-javascript.json 声明。其中该挑战以id精确对应到题目文件:
{ "id": "68af0687ef34c76c28ffa547", "title": "Challenge 30: Unique Characters" }challengeOrder数组的先后顺序即课程展示顺序,本文件同时声明了块的级元信息:"blockLayout": "legacy-challenge-list"(列表式导航)、"usesMultifileEditor": true、"disableLoopProtectTests": true(禁用死循环保护测试,允许解法中使用循环)。可以推断,题目文件中的dashedName: challenge-30与标题Challenge 30: Unique Characters正是该块内第 30 位的编号体现。
Joi Schema:挑战文件的字段级校验
每道挑战在入库前需通过 curriculum/schema/challenge-schema.js 中 Joi Schema 的校验。与本挑战相关的约束包括:
challengeType: Joi.number().min(0).max(33).required()—— 类型必须是 0–33 的整数,28合法;description对普通类型(含 dailyChallengeJs)为Joi.string().required(),因此题目文件必须包含# --description--段;disableLoopProtectTests: Joi.boolean().required()、helpCategory限定枚举值(本块使用'JavaScript')、id: Joi.objectId().required()等。
这意味着题目 Markdown 的 frontmatter(id、title、challengeType、dashedName)与正文小节(# --description--、# --hints--、# --seed--、# --solutions--)共同构成一份被 Schema 约束的结构化数据,而不是纯自由文本。
挑战数据的落库:daily-challenges 种子脚本
每日挑战还需要同步到后端数据库供「每日推荐」功能使用。tools/daily-challenges/README.md 描述了种子流程:复制sample.env为.env、安装依赖后,在tools/daily-challenges目录执行pnpm seed-daily-challenges,脚本会从 GraphQL 拉取挑战数据,写入freecodecamp数据库的DailyCodingChallenges集合。这解释了同一道挑战在仓库中存在「静态 Markdown 题面(课程块内按序练习)」与「数据库记录(每日挑战日历分发)」两种形态的原因。
小结与自检清单
Unique Characters 这道题的完整学习闭环可以概括为:
- 读题:明确「全部字符唯一 + 大小写敏感」两条语义;
- 对照断言:用 7 组 hints 断言覆盖正例(纯字母、大小写混合、纯符号)与反例(字母重复、符号重复);
- 实现:先写「已见字符累积 +
includes提前返回」的 O(n²) 参考解法,再尝试Set的 O(n) 写法; - 验证约束:确认没有调用任何大小写折叠方法,
"aA"必须返回true; - 追溯工程链路:从题目 Markdown → 块结构 JSON(排序/元信息)→ Joi Schema(字段校验)→
challengeType 28的视图与提交映射 → 种子脚本落库,理解 freeCodeCamp 如何将一道算法题完整工程化。
完成上述五步后,你不仅解出了这道字符串处理题,也掌握了 freeCodeCamp 课程库中每日挑战从定义到运行的组织方式,可以继续按challengeOrder顺序进入 Challenge 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
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考