Advent of Code Python解题骨架:模块化、自动解析与验证 简介本资源是面向Python编程学习者与算法爱好者的一站式Advent of Code历年真题实战解决方案覆盖2017–2020年全部挑战聚焦递归、图遍历、动态规划、位运算、计算几何等核心算法场景适合中高级开发者巩固基础、拓展解题思维并提升工程化编码能力。压缩包共240个文件含152个Python源码按day1.py至day25.py结构组织每份代码均包含输入解析与双题解逻辑、83个原始input测试数据真实赛题输入格式以及4张说明性PNG图和1个.gitignore整体仅989KB轻量易读、结构清晰便于逐日对照学习与调试验证。已有137人下载学习资源由实践者持续整理维护代码风格统一、注释规范充分运用itertools、collections等标准库特性并体现生成器优化、输入流处理、错误容错等Python高阶实践技巧是理解算法落地与精进Python工程能力的优质参考样本。1. 这不是刷题库而是一套可复用的 Advent of Code 全年解题骨架从 2017 到 2020 年所有题目Python 实现零重复结构、自动输入解析、结果校验与性能计时一气呵成你有没有试过每年 12 月 1 日准时打开 adventofcode.com兴奋点开 Day 1 —— 然后花 40 分钟写一个read_input()读文件、再手敲三遍print(Part 1:, ...)和print(Part 2:, ...)最后发现 Day 2 的输入格式又变了更糟的是到了 Day 15你翻出 Day 3 的代码想抄个哈希表逻辑结果发现当时没封装、变量名是d,p,tup自己都看不懂。这个项目就是为终结这种「年度性返工」而生的它不是 25 个零散.py文件的打包合集而是一个按年份组织、模块化分层、输入/逻辑/验证解耦的 Python 工程。核心价值在于——你只需专注写solve_part1()和solve_part2()里的纯算法逻辑其余所有重复劳动自动加载input.txt、跳过注释行、缓存解析结果、比对样例输出、记录执行耗时、生成 Markdown 解题报告……全由骨架自动完成。适合正在系统训练算法思维的开发者、准备技术面试的应届生以及想把 AoC 当作年度 Python 工程实践课的进阶学习者。它不教你怎么解迷宫或模拟海龟但能让你每次提交答案时多一份「结构可控」的底气。2. 项目结构与技术选型为什么用 Python Click Pydantic 而不是纯脚本2.1 目录即架构五年题目如何避免变成“文件夹地狱”项目采用「年份隔离 模块内聚」双维度组织。根目录下只有aoc/源码、inputs/原始数据、tests/样例验证和docs/自动生成报告四个一级目录彻底规避传统 AoC 仓库里常见的2017_day1.py,2017_day2.py, ...,2020_day25.py这类命名爆炸问题。关键设计在aoc/内部aoc/ ├── __init__.py ├── cli.py # Click 命令入口aoc run --year 2019 --day 4 ├── common/ # 跨年通用能力 │ ├── parser.py # 输入解析器支持空行分割、数字提取、网格转 list[list[int]] │ ├── timer.py # 精确到微秒的计时装饰器 timeit │ └── validator.py # 样例比对自动加载 tests/2018/day02/test_part1.txt ├── years/ # 按年份切片每一年一个子包 │ ├── __init__.py │ ├── _2017/ # 下划线前缀防意外导入 │ │ ├── __init__.py │ │ ├── day01.py # 必须含 solve_part1(), solve_part2() │ │ └── models.py # Pydantic 模型Day01Input BaseModel(...), 验证输入合法性 │ ├── _2018/ │ └── _2020/ └── utils/ # 工具函数grid_neighbors(), dijkstra(), parse_ints_in_line()提示years/_2017/day01.py中绝不允许出现open(input.txt)或sys.argv。所有 I/O 通过common.parser.parse_input(year, day)注入确保单元测试可注入 mock 数据也方便后续迁移到 Web API 或 CLI 参数输入。2.2 技术栈选择背后的血泪经验Click 不是炫技Pydantic 不是过度设计Click 替代 argparseAoC 每年都有新需求——2018 年要加--benchmark输出耗时分布2019 年要支持--submit自动 POST 到官网需 token2020 年要--report md生成解题笔记。Click 的嵌套命令组click.group(),cli.command()让这些功能像插件一样叠加而 argparse 在三层子命令时配置已接近黑匣子。实测添加--dry-run模式只改 3 行代码不用碰主逻辑。Pydantic 模型驱动输入解析以 2020 Day 12航海指令为例原始输入是F10 N3 F7 R90 F11传统做法是for line in lines: cmd, val line[0], int(line[1:])但 2019 Day 10小行星监控输入却是坐标网格.#....# ......# #.#.### ....#.#若强行用同一段parse_input()处理很快会堆满if year 2019 and day 10:。本项目中years/_2020/day12/models.py定义from pydantic import BaseModel, validator from typing import List, Literal class NavigationCommand(BaseModel): action: Literal[N, S, E, W, L, R, F] value: int validator(value) def value_must_be_positive(cls, v): if v 0: raise ValueError(value must be non-negative) return v class NavigationInput(BaseModel): commands: List[NavigationCommand]common.parser.parse_input(2020, 12)内部调用NavigationInput.parse_obj(raw_lines)自动完成类型转换、范围校验、错误提示如value must be non-negative。当某天你误把F-10写进 input.txt程序直接报错并指出哪一行而不是静默计算出错误结果——这省下的调试时间够你多解半道题。为什么不用 pytest 而是自建 validatorPytest 擅长断言但 AoC 样例验证有特殊性Day 1 Part 1 的样例输入可能对应多个输出如不同起始条件且需区分example.txt官网给的和test_part1.txt你自测的。common.validator.validate_against_examples(2019, 4, part1)会自动查找tests/2019/day04/test_part1.txt逐行比对str(solve_part1(input))失败时高亮差异行。它还支持「容忍浮点误差」模式用于几何题这是通用测试框架难以开箱即用的。3. 核心模块拆解一个请求从aoc run --year 2018 --day 5到控制台输出的完整链路3.1 CLI 入口Click 如何将命令映射到具体年份模块aoc/cli.py是整个系统的门面其核心是run命令的实现# aoc/cli.py import click from aoc.common.parser import parse_input from aoc.common.validator import validate_against_examples from aoc.common.timer import timeit click.group() def cli(): Advent of Code 解题框架 pass cli.command() click.option(--year, typeclick.IntRange(2017, 2020), requiredTrue, help年份 (2017-2020)) click.option(--day, typeclick.IntRange(1, 25), requiredTrue, help日期 (1-25)) click.option(--part, typeclick.Choice([1, 2]), defaultNone, help指定 Part 1 或 2留空则运行全部) click.option(--benchmark, is_flagTrue, help启用性能计时) def run(year, day, part, benchmark): # 1. 动态导入对应模块aoc.years._2018.day05 module_name faoc.years._{year}.day{day:02d} try: day_module __import__(module_name, fromlist[solve_part1, solve_part2]) except ImportError as e: raise click.UsageError(f未找到 {year} 年第 {day} 天的解题模块: {e}) # 2. 解析输入自动定位 inputs/2018/day05.txt input_data parse_input(year, day) # 3. 执行并计时 if part in [1, None]: result1, elapsed1 timeit(day_module.solve_part1)(input_data) click.echo(fPart 1: {result1} ({elapsed1:.4f}ms)) if benchmark: validate_against_examples(year, day, part1, resultstr(result1)) if part in [2, None]: result2, elapsed2 timeit(day_module.solve_part2)(input_data) click.echo(fPart 2: {result2} ({elapsed2:.4f}ms)) if benchmark: validate_against_examples(year, day, part2, resultstr(result2))这段代码的关键在于动态导入__import__与统一接口约定。它不要求你写from aoc.years._2018.day05 import solve_part1而是根据--year和--day拼出模块路径强制所有dayXX.py必须提供solve_part1(input)和solve_part2(input)两个函数。这样做的好处是当你新增years/_2021/时只需保证新模块遵循此约定cli.py无需任何修改即可支持——这就是可扩展性的起点。3.2 输入解析器如何用 50 行代码覆盖 80% 的 AoC 输入格式aoc/common/parser.py的核心函数parse_input(year, day)并非万能解析器而是策略模式 默认回退的组合# aoc/common/parser.py from pathlib import Path from typing import Any, Union from aoc.utils import parse_ints_in_line, grid_from_lines def parse_input(year: int, day: int) - Any: # 1. 尝试加载 Pydantic 模型若存在 try: model_module __import__(faoc.years._{year}.day{day:02d}.models, fromlist[InputModel]) InputModel getattr(model_module, InputModel, None) if InputModel: input_file _get_input_path(year, day) with open(input_file) as f: raw_lines [line.rstrip(\n) for line in f if not line.startswith(#)] return InputModel.parse_obj(raw_lines) # 触发 Pydantic 验证 except (ImportError, AttributeError, Exception): pass # 模型不存在或解析失败走默认策略 # 2. 默认策略按年份/日期启发式处理 input_file _get_input_path(year, day) with open(input_file) as f: lines [line.rstrip(\n) for line in f if line.strip() and not line.startswith(#)] # 启发式规则覆盖 2017-2020 绝大多数情况 if year 2017 and day 1: # Day 1 2017单行数字字符串需转 int 列表 return [int(c) for c in lines[0]] elif year 2018 and day in [1, 2, 3]: # 多行单数字 return [int(line) for line in lines] elif year 2019 and day 10: # 坐标网格 return grid_from_lines(lines) # 返回 list[list[str]] elif year 2020 and day 12: # 指令列表 return [line.strip() for line in lines if line.strip()] else: # 最终兜底返回原始行列表交由 solve_* 函数自行处理 return lines参数说明_get_input_path(year, day)返回Path(inputs) / str(year) / fday{day:02d}.txt自动创建目录。grid_from_lines()将[.#.., #...]转为[[., #, ., .], [#, ., ., .]]。这种「模型优先 → 启发式规则 → 原始行」三级 fallback既保证了强类型安全有模型时又保留了灵活性无模型时不过度约束。3.3 验证器为什么样例比对必须在运行时而非测试阶段做aoc/common/validator.py的validate_against_examples()设计初衷是解决 AoC 的一个独特痛点样例输入/输出是解题过程的一部分而非测试边界。例如 2018 Day 5聚合物反应的样例输入dabAcCaCBAcCcaDAPart 1 期望输出10Part 2 期望输出4但你在写solve_part1()时很可能先用这个样例调试确认逻辑正确后再跑真实输入。如果验证只放在pytest里意味着你得先写完代码、再写测试、再pytest而 CLI 命令aoc run --year 2018 --day 5 --benchmark应该在你第一次执行时就告诉你“样例通过 ✅”而不是等你手动去跑测试。其实现关键在于样例文件的约定式存放tests/2018/day05/test_part1.txt内容为10tests/2018/day05/test_part2.txt内容为4函数内部逻辑精简但鲁棒def validate_against_examples(year: int, day: int, part: int, result: str) - bool: test_file Path(tests) / str(year) / fday{day:02d} / ftest_part{part}.txt if not test_file.exists(): click.secho(f⚠️ 未找到样例文件 {test_file}跳过验证, fgyellow) return True # 不阻断执行仅警告 expected test_file.read_text().strip() if result.strip() expected: click.secho(f✅ Part {part} 样例验证通过, fggreen) return True else: click.secho(f❌ Part {part} 样例验证失败, fgred) click.secho(f 期望: {expected}, dimTrue) click.secho(f 实际: {result.strip()}, dimTrue) return False注意dimTrue让期望/实际值以灰色显示避免干扰主流程输出。这种「轻量级、即时反馈」的设计让验证成为开发流的一部分而非额外负担。4. 避坑指南我在整合五年 AoC 时踩过的 5 个真实坑及解决方案4.1 现象aoc run --year 2019 --day 10报错ModuleNotFoundError: No module named aoc.years._2019原因years/目录下缺少__init__.py或_2019/子目录未被 Python 识别为包。Python 的包导入要求每个层级目录都必须有__init__.py即使为空否则__import__无法向上追溯。解决检查aoc/years/__init__.py是否存在内容可为空并确认aoc/years/_2019/__init__.py也存在。常见疏忽是解压 zip 后某些系统隐藏了空文件用ls -la aoc/years/查看。4.2 现象2020 Day 13公交时刻表的输入解析后x字符被转成0导致计算错误原因parse_ints_in_line(17,x,13,19)默认将非数字字符替换为0但此处x是占位符应保留为None或字符串。原始解析策略未覆盖此场景。解决在years/_2020/day13/models.py中定义专用模型from typing import Union, List from pydantic import BaseModel class BusSchedule(BaseModel): timestamp: int buses: List[Union[int, str]] # 允许 int 或 x classmethod def parse_raw_input(cls, lines: List[str]) - BusSchedule: timestamp int(lines[0]) buses [] for bus in lines[1].split(,): buses.append(int(bus) if bus.isdigit() else bus) return cls(timestamptimestamp, busesbuses)并在day13.py中调用BusSchedule.parse_raw_input(lines)替代通用解析器。4.3 现象aoc run --year 2017 --day 1输出Part 1: 0但官网样例应为11原因2017 Day 1 的输入是环形字符串需首尾相连比较。通用解析器返回list[int]但solve_part1()逻辑假设输入是str如1122导致lines[0]是[1,1,2,2]而非1122。解决在years/_2017/day01/models.py中明确定义class Day01Input(BaseModel): digits: str # 强制为字符串非列表 classmethod def parse_raw_input(cls, lines: List[str]) - Day01Input: return cls(digitslines[0].strip())并在day01.py的solve_part1(input: Day01Input)中使用input.digits。4.4 现象运行aoc run --year 2018 --day 25星座聚类时内存爆满进程被 kill原因2018 Day 25 的输入含数百个四维坐标暴力 O(n²) 距离计算在 Python 中极慢。CLI 默认未启用--benchmark但timeit装饰器仍会尝试捕获耗时导致大量中间对象驻留内存。解决在common/timer.py中增强timeit装饰器添加gc.collect()和超时保护import gc import signal def timeit(func): def wrapper(*args, **kwargs): gc.collect() # 运行前清理 start time.perf_counter() try: result func(*args, **kwargs) finally: end time.perf_counter() return result, (end - start) * 1000 return wrapper同时在cli.py的run命令中对已知重载任务如 Day 25添加提示click.secho( 提示此题计算量大建议加 --benchmark 获取详细耗时, fgblue)。4.5 现象tests/2019/day02/test_part1.txt内容为19690720但solve_part1()返回19690720.0验证失败原因Python 浮点数打印默认带.0而样例文件是整数字符串。str(19690720.0)得19690720.0≠19690720。解决在validator.py的比对逻辑中增加类型归一化def _normalize_result(r: str) - str: try: # 尝试转为 int 再转回 str消除 .0 return str(int(float(r))) except (ValueError, TypeError): return r.strip() # 比对时使用 if _normalize_result(result) _normalize_result(expected): ...5. 二次开发实战如何在 10 分钟内为 2021 年题目添加支持并生成解题报告5.1 新增年份支持三步完成years/_2021/初始化添加新一年题目不是复制粘贴而是模板化初始化。以 2021 年为例执行以下三步创建年份包结构终端命令mkdir -p aoc/years/_2021 touch aoc/years/_2021/__init__.py生成 Day 1 模板aoc/years/_2021/day01.py# aoc/years/_2021/day01.py from typing import Any def solve_part1(input_data: Any) - Any: 2021 Day 1 Part 1 解题逻辑 TODO: 替换此函数体 # 示例返回输入长度占位 return len(str(input_data)) def solve_part2(input_data: Any) - Any: 2021 Day 1 Part 2 解题逻辑 TODO: 替换此函数体 # 示例返回输入字符串的哈希占位 return hash(str(input_data))注意input_data类型标注为Any表示暂未定义模型后续可升级。更新 CLI 年份范围aoc/cli.pyclick.option(--year, typeclick.IntRange(2017, 2021), requiredTrue, help年份 (2017-2021)) # 同时更新文档字符串中的范围说明完成这三步后aoc run --year 2021 --day 1即可执行返回占位结果无需修改任何其他文件。这就是模块化设计的威力——新增年份的成本趋近于零。5.2 为特定题目定制输入模型以 2021 Day 5水下喷口为例2021 Day 5 输入是坐标线段列表如0,9 - 5,9。我们为其创建强类型模型提升可读性与安全性创建aoc/years/_2021/day05/models.pyfrom pydantic import BaseModel, validator from typing import List, Tuple class Point(BaseModel): x: int y: int class LineSegment(BaseModel): start: Point end: Point validator(end) def points_must_be_on_same_axis(cls, v, values): if start not in values: return v s values[start] if s.x ! v.x and s.y ! v.y: raise ValueError(Only horizontal or vertical lines supported) return v class Day05Input(BaseModel): segments: List[LineSegment] classmethod def parse_raw_input(cls, lines: List[str]) - Day05Input: segments [] for line in lines: if - not in line: continue start_str, end_str line.split( - ) sx, sy map(int, start_str.split(,)) ex, ey map(int, end_str.split(,)) segments.append(LineSegment(startPoint(xsx, ysy), endPoint(xex, yey))) return cls(segmentssegments)修改day05.py声明类型并使用模型from aoc.years._2021.day05.models import Day05Input def solve_part1(input_data: Day05Input) - int: # input_data.segments 现在是类型安全的 List[LineSegment] # 可直接访问 segment.start.x, segment.end.y 等IDE 有补全 count 0 for seg in input_data.segments: if seg.start.x seg.end.x or seg.start.y seg.end.y: count 1 return count更新parser.py的启发式规则可选但推荐# 在 parse_input() 的默认策略末尾添加 elif year 2021 and day 5: return Day05Input.parse_raw_input(lines)此时输入非法如斜线1,1 - 2,2会在解析阶段立即报错而非在solve_part1()中运行时崩溃。5.3 一键生成解题报告Markdown 输出的底层逻辑与定制aoc run --year 2020 --day 12 --report md会生成docs/2020/day12.md其内容结构由aoc/utils/report_generator.py控制。核心是generate_report()函数def generate_report(year: int, day: int, results: dict, timings: dict, examples: dict): title fAdvent of Code {year} - Day {day} content [f# {title}\n] # 添加题目链接从 AoC 官网抓取需网络 content.append(f 题目原文: https://adventofcode.com/{year}/day/{day}\n) # 结果表格 content.append(## 解题结果) content.append(| Part | 结果 | 耗时 | 样例验证 |) content.append(|------|------|------|----------|) for part in [1, 2]: res results.get(part, N/A) time_ms timings.get(part, 0.0) valid ✅ if examples.get(part, False) else ❌ content.append(f| Part {part} | {res} | {time_ms:.3f}ms | {valid} |) content.append() # 添加代码引用自动生成 content.append(## 核心代码) code_path faoc/years/_{year}/day{day:02d}.py content.append(fpython\n# {code_path}\n# 此处应插入 solve_part1/solve_part2 的代码片段\n) return \n.join(content)关键技巧报告生成不依赖外部工具纯 Python 字符串拼接。若你想在报告中加入算法复杂度分析只需在content列表中插入content.append(## 算法分析) content.append(- Part 1: 时间复杂度 O(n²)空间复杂度 O(n)) content.append(- Part 2: 使用堆优化时间复杂度 O(n log n))从那以后我每次新增一个dayXX.py都强制走一遍aoc run --year XXXX --day XX --benchmark --report md哪怕只是占位结果。这不仅生成了可读的文档更像一次微型的 CI 检查——它逼我确认模块路径正确、函数签名合规、基础 I/O 通路畅通。这种「写代码即写文档」的习惯让我在回顾去年的解题思路时不再需要从 25 个文件里 grep 关键字而是直接打开docs/2020/目录按日期顺序浏览。希望帮到你。本文还有配套的精品资源点击获取