freeCodeCamp 每日编程挑战第 20 题:用 Python 双集合法求解数组重复元素(Array Duplicates)
【免费下载链接】freeCodeCampfreeCodeCamp.org's open-source codebase and curriculum. Learn math, programming, and computer science for free.项目地址: https://gitcode.com/GitHub_Trending/fr/freeCodeCamp
本篇技术指南围绕 freeCodeCamp 开源仓库中的 Python 每日编程挑战 "Challenge 20: Array Duplicates"(挑战文件位于 curriculum/challenges/english/blocks/daily-coding-challenges-python/6821ebee237de8297eaee796.md)展开。文章完整继承原挑战的题目描述、三组测试断言、种子代码与官方参考解法,并结合仓库中该功能块的结构配置、前端挑战渲染、API 数据链路与种子脚本源码,深入讲解题目背后的算法思路、challengeType: 29的运行机制,以及如何从源码层面理解这一每日一题的完整生命周期。读完本文,你将掌握"找出数组中出现多次的元素并按升序去重返回"的经典 Python 实现,并能独立分析、实现同类题型。
一、题目概览:找出数组中的重复元素
原挑战文档以 YAML frontmatter 定义了挑战元信息,正文包含四个关键部分:--description--(题目描述)、--hints--(测试断言)、--seed--(起始代码)与--solutions--(官方参考解)。题目原文如下:
Given an array of integers, return an array of integers that appear more than once in the initial array, sorted in ascending order. If no values appear more than once, return an empty array.
- Only include one instance of each value in the returned array.
翻译并拆解需求,可以得到三条必须同时满足的判定规则:
- 重复判定:某个整数在输入数组中出现的次数大于 1,才算"重复元素";
- 去重输出:返回数组中每个重复值只保留一份实例,不输出次数、不输出频次信息;
- 升序排序:最终返回的数组必须按数值升序排列;若输入中没有重复元素,则返回空数组
[]。
以题目给出的第三个测试用例为例,输入[2, 34, 0, 1, -6, 23, 5, 3, 2, 5, 67, -6, 23, 2, 43, 2, 12, 0, 2, 4, 4]中,2出现了 6 次、0、-6、23、5、4各出现 2 次,因此输出为[-6, 0, 2, 4, 5, 23]。注意2无论出现多少次都只输出一次,67、34等只出现一次的元素则被排除。
该挑战在块内的完整定义(含 id、dashedName、challengeType)可在 curriculum/structure/blocks/daily-coding-challenges-python.json 中确认,其challengeOrder数组将本挑战(id 为6821ebee237de8297eaee796)列为第 20 题。
二、题目在 freeCodeCamp 中的定位:每日编程挑战(Daily Coding Challenge)体系
这道题并不是孤立的一道练习题,而是 freeCodeCamp 每日编程挑战(Daily Coding Challenge)功能块中的一员。从 curriculum/structure/blocks/daily-coding-challenges-python.json 可以看到该块的配置:
{ "isUpcomingChange": true, "dashedName": "daily-coding-challenges-python", "usesMultifileEditor": true, "helpCategory": "Python", "blockLayout": "legacy-challenge-list" }其中关键字段的含义:
isUpcomingChange: true:表示该功能块属于"即将上线"的新功能,默认不会在正式站点对外展示;usesMultifileEditor: true:挑战使用多文件编辑器渲染;helpCategory: "Python":本题归类为 Python 帮助类别;blockLayout: "legacy-challenge-list":使用经典挑战列表布局。
该块的challengeOrder从 "Challenge 1: Vowel Balance" 一直排到 Challenge 365,而本挑战恰好是第 20 题。Python 版块与 JavaScript 版块(curriculum/structure/blocks/daily-coding-challenges-javascript.json)一一对应、逐日交替,二者共用同一份题目描述,仅在测试断言与语言写法上不同——在 tools/daily-challenges/helpers.ts 的combineChallenges函数中,会强制校验 JavaScript 与 Python 两个版本的title与description完全一致,否则抛错。
从客户端渲染源码 client/src/client-only-routes/show-daily-coding-challenge.tsx 可以看出,同一道挑战在运行时会被拆分为 JavaScript(challengeType: 28)与 Python(challengeType: 29)两套挑战数据,本挑战对应的challengeType: 29正是 Python 版每日挑战的专属类型标识。前端通过fetch(${apiLocation}/daily-coding-challenge/day/${monthDay})按 "MM-DD" 日期拉取当天挑战,并经validateDailyCodingChallengeSchema(见 client/src/utils/daily-coding-challenge-validator.ts)校验数据完整性后,将challengeType: 29的 Python 数据渲染为main.py文件供学习者作答。
三、测试断言(Hints)逐条解析
原挑战文档在--hints--区块中给出了三条由runPython包装的单元测试,测试框架使用 Python 标准库unittest的TestCase().assertEqual进行断言。逐条解析如下。
3.1 无重复元素场景
({test: () => { runPython(` from unittest import TestCase TestCase().assertEqual(find_duplicates([1, 2, 3, 4, 5]), [])`) }})输入[1, 2, 3, 4, 5],五个整数各出现一次,没有任何重复,因此期望返回空列表[]。这条用例验证的是"无重复时的边界行为"。
3.2 存在多个重复元素的场景
({test: () => { runPython(` from unittest import TestCase TestCase().assertEqual(find_duplicates([1, 2, 3, 4, 1, 2]), [1, 2])`) }})输入[1, 2, 3, 4, 1, 2],其中1与2各出现两次,3、4出现一次。期望输出[1, 2]——注意1、2在输入中本就按升序排列,但题目要求与实现都必须显式保证升序,不能依赖输入顺序的巧合。
3.3 乱序、负数与高频重复的复合场景
({test: () => { runPython(` from unittest import TestCase TestCase().assertEqual(find_duplicates([2, 34, 0, 1, -6, 23, 5, 3, 2, 5, 67, -6, 23, 2, 43, 2, 12, 0, 2, 4, 4]), [-6, 0, 2, 4, 5, 23])`) }})输入数组包含 21 个整数,混有负数(-6)、零、大数(67、43)以及高频重复(2出现 6 次)。期望输出[-6, 0, 2, 4, 5, 23]。这条用例同时验证了三件事:负数参与排序(-6排在最前)、只出现一次的元素(如34、1、3、67、43、12)被排除、同一值无论重复多少次都只输出一次。
这三组用例共同构成了对"重复判定 + 去重 + 升序排序 + 空数组边界"的完整覆盖。仓库中 JavaScript 同款挑战(curriculum/challenges/english/blocks/daily-coding-challenges-javascript/6821ebee237de8297eaee796.md)使用assert.deepEqual对findDuplicates做了完全等价的断言,二者测试数量必须一致(combineChallenges中对此有显式校验),这保证了每日挑战的 JS / Python 双版本难度与判定完全对齐。
四、起始代码(Seed)与作答要求
原挑战给出的种子代码如下:
def find_duplicates(arr): return arr学习者需要在保持函数签名find_duplicates(arr)不变的前提下填充实现,并保证:
- 函数接收一个整数列表
arr,返回一个新的列表; - 返回结果不得修改原数组(原文档虽未显式禁止原地修改,但返回新数组是更安全的惯例);
- 输出满足三条判定规则(重复、去重、升序)。
这套挑战运行于 freeCodeCamp 的多文件 Python 环境(challengeType: 29对应main.py文件,文件键为mainpy,见 client/src/client-only-routes/show-daily-coding-challenge.tsx 中fileKey: 'mainpy'、ext: 'py'、name: 'main'的配置)。
五、官方参考解:双集合(Two-Set)法
原挑战在--solutions--中给出的参考实现是经典的双集合扫描法:
def find_duplicates(arr): seen = set() duplicates = set() for num in arr: if num in seen: duplicates.add(num) else: seen.add(num) return sorted(duplicates)5.1 算法思路逐步拆解
- 维护两个集合:
seen记录"已经出现过至少一次"的元素,duplicates记录"已经确认重复"的元素; - 单次线性扫描:遍历
arr中的每个元素num:- 若
num已经在seen中,说明这是第二次(或更多次)出现,属于重复元素,将其加入duplicates; - 否则是首次出现,将其加入
seen;
- 若
- 返回前排序:
duplicates是集合(无序),用sorted()转换为按数值升序排列的列表作为最终返回值。
以用例三为例模拟执行:
| 遍历元素 | seen(处理后) | duplicates(处理后) |
|---|---|---|
| 2 | {2} | {} |
| 34 | {2, 34} | {} |
| 0 | {2, 34, 0} | {} |
| 1 | {2, 34, 0, 1} | {} |
| -6 | {2, 34, 0, 1, -6} | {} |
| 23 | {…, 23} | {} |
| 5 | {…, 5} | {} |
| 3 | {…, 3} | {} |
| 2(第二次) | 不变 | {2} |
| 5(第二次) | 不变 | {2, 5} |
| … | 不变 | 持续追加 |
| 4(第二次) | 不变 | {-6, 0, 2, 4, 5, 23} |
最终sorted(duplicates)得到[-6, 0, 2, 4, 5, 23],与测试断言完全一致。
5.2 复杂度分析
- 时间复杂度:
O(n)用于线性扫描(集合的in判断与add均为平均O(1)),加上O(k log k)用于对k个重复元素排序(k ≤ n),整体为O(n log n)(最坏情况下全部元素都重复时退化为O(n log n)); - 空间复杂度:
O(n),最坏情况下seen与duplicates合计需要存储至多n个不同元素。
该解法同时天然满足"只保留一份实例"的要求——duplicates是集合,add操作对同一值重复调用不会产生重复项,无需额外去重逻辑。
六、其他可行解法与对比
除官方双集合法外,本题还有多种等价实现,可作为面试或教学中的延伸讨论。
6.1 基于collections.Counter的频次统计法
from collections import Counter def find_duplicates(arr): return sorted(num for num, count in Counter(arr).items() if count > 1)Counter一次遍历完成频次统计,再用生成器筛选频次大于 1 的键并排序。代码最简洁,但需要额外引入collections模块,且内部同样要维护完整频次字典,空间开销与双集合法同级。
6.2 排序后相邻比较法
def find_duplicates(arr): arr = sorted(arr) result = [] for i in range(1, len(arr)): if arr[i] == arr[i - 1] and (not result or result[-1] != arr[i]): result.append(arr[i]) return result先整体排序,再扫描相邻元素是否相等,利用"结果列表最后一个元素"来避免连续重复值被多次记录。该方法空间开销小(原地排序时O(1)额外空间),但排序本身是O(n log n),且逻辑分支比集合法略复杂。
6.3 暴力双重循环(不推荐)
def find_duplicates(arr): result = [] for i in range(len(arr)): if arr[i] in result: continue for j in range(i + 1, len(arr)): if arr[i] == arr[j]: result.append(arr[i]) break return sorted(result)时间复杂度为O(n²),仅适合教学演示"为什么需要更高效解法",不应作为实际提交方案。
三种方案对比小结:
| 方案 | 时间复杂度 | 空间复杂度 | 代码简洁度 |
|---|---|---|---|
| 双集合法(官方解) | O(n log n) | O(n) | 高 |
| Counter 频次法 | O(n log n) | O(n) | 最高 |
| 排序相邻比较法 | O(n log n) | O(1)(原地排序) | 中 |
| 暴力双重循环 | O(n²) | O(n) | 低 |
七、从源码理解:这道题如何进入每日挑战并完成评测
在 freeCodeCamp 仓库中,本挑战的完整生命周期由以下环节构成,理解这条链路有助于你把握challengeType: 29与runPython背后的真实运行机制。
7.1 种子写入:从课程文件到数据库
tools/daily-challenges/seed-daily-challenges.ts 是每日挑战的种子脚本:
- 它通过 GraphQL 端点
http://localhost:8000/___graphql(客户端需以"显示即将上线内容"模式运行)从dev-playground超级块中按block: {eq: "daily-coding-challenges-python"}过滤拉取全部 Python 挑战(对应脚本fetchChallenges('python')),JavaScript 同理; - 脚本强制校验 JS 与 Python 挑战数量一致,且总数必须等于
EXPECTED_CHALLENGE_COUNT = 365,否则直接抛错; - 以
2025-08-11为起始日期,按天递增为每个挑战分配date,调用combineChallenges(tools/daily-challenges/helpers.ts)将同一题号的 JS/Python 双版本合并为一条记录(含tests与challengeFiles),_id直接复用挑战 id,并写入 MongoDB 的DailyCodingChallenges集合; - 起始日期通过硬编码断言
2025-08-11T00:00:00.000Z保护,防止发布后被无意修改。
7.2 API 读取:按日期暴露挑战数据
api/src/daily-coding-challenge/routes/daily-coding-challenge.ts 提供六条公开 GET 路由:
| 路由 | 参数格式 | 说明 |
|---|---|---|
/daily-coding-challenge/date/:date | YYYY-MM-DD | 按完整日期取当天挑战,未来日期返回 404 |
/daily-coding-challenge/day/:day | MM-DD | 按月-日取挑战(前端主用) |
/daily-coding-challenge/today | 无 | 取美国中部时间"今天"的挑战 |
/daily-coding-challenge/month/:month | YYYY-MM | 取某月全部挑战(仅返回 id/编号/日期/标题) |
/daily-coding-challenge/all | 无 | 取截至今日的全部挑战元信息 |
/daily-coding-challenge/newest | 无 | 返回最新一条挑战的日期 |
其中日期解析与"源日期映射"逻辑在 api/src/daily-coding-challenge/utils/helpers.ts:
dateStringToUtcMidnight严格校验YYYY-MM-DD格式并转为 UTC 零点;非法格式返回null,路由随即返回 400;monthDayStringToUtcDate以 2000 年(闰年)为占位年解析MM-DD,利用Date.UTC的滚动特性检测 2 月 30 日等非法日期;getSourceDate将任意日期映射回 2025-08-11 至 2026-08-10 这一年的原始挑战周期——8 月 11 日及之后取 2025 年,之前取 2026 年,且 2 月 29 日统一映射到 2 月 28 日,实现"每天一道、循环复用"的效果;- 所有查询都限定
date <= today(US Central),保证未来的挑战不会被提前泄露。
响应体结构由 api/src/daily-coding-challenge/schemas/daily-coding-challenge.ts 中的 TypeBox schema 严格约束:单条挑战响应包含id、date、challengeNumber、title、description,以及javascript与python两个语言对象,每个语言对象又含tests(text+testString)与challengeFiles(contents+fileKey)。本挑战的testString正是第三节中那些包裹着unittest断言的runPython代码。
7.3 前端渲染与评测
client/src/client-only-routes/show-daily-coding-challenge.tsx 完成最后的组装:拉取数据后补上<section id="description">包裹的描述、构造challengeType: 29的 Python 挑战节点与main.py文件,最终交给ShowClassic组件渲染。评测时,平台将学习者提交的find_duplicates函数与testString中的runPython测试代码合并执行——from unittest import TestCase意味着每次提交都会实例化一个真实unittest.TestCase并通过assertEqual严格比对输出列表。
此外,api/src/daily-coding-challenge/routes/daily-coding-challenge.test.ts 通过vi.useFakeTimers固定"当前时间",对上述路由的 400(非法格式)、404(未找到 / 未来日期)、200(正常返回)等场景做了完整覆盖,例如非法日期列表['invalid-format', '2025-07', '07-18-2025', ...]全部应返回 400,这从侧面印证了YYYY-MM-DD参数的严格性。
八、小结与延伸练习
"Array Duplicates" 是数组 + 集合 + 排序三类基础数据结构的综合应用题:它要求开发者正确区分"出现次数统计"与"去重集合"两种语义,并在一次线性扫描内完成状态维护。官方双集合法在正确性、可读性与性能之间取得了良好平衡,可作为同类题型(如"找出唯一未重复元素""返回出现次数最多的元素")的通用模板。
如果你想继续深入研究,建议阅读以下仓库文件:
- curriculum/challenges/english/blocks/daily-coding-challenges-python/6821ebee237de8297eaee796.md:本题原始定义(描述、断言、种子、官方解);
- curriculum/challenges/english/blocks/daily-coding-challenges-javascript/6821ebee237de8297eaee796.md:同题的 JavaScript 版本(
findDuplicates); - curriculum/structure/blocks/daily-coding-challenges-python.json:Python 挑战块的完整 365 题编排;
- tools/daily-challenges/seed-daily-challenges.ts:每日挑战入库脚本;
- api/src/daily-coding-challenge/routes/daily-coding-challenge.ts:挑战数据 API;
- client/src/client-only-routes/show-daily-coding-challenge.tsx:前端渲染与语言切换逻辑。
延伸练习建议:在不使用集合的情况下实现本题(要求空间复杂度 O(1) 且允许修改输入数组);或将本题推广为"返回出现次数超过n/3的元素",体会摩尔投票等进阶算法的适用边界。
【免费下载链接】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),仅供参考