☰
Advent of Code Python解题骨架:模块化、自动解析与验证
2026/10/17 12:00:57 网站建设 项目流程

简介:本资源是面向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 替代 argparse:AoC 每年都有新需求——2018 年要加--benchmark输出耗时分布,2019 年要支持--submit自动 POST 到官网(需 token),2020 年要--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 而是自建 validator?
    Pytest 擅长断言,但 AoC 样例验证有特殊性:Day 1 Part 1 的样例输入可能对应多个输出(如不同起始条件),且需区分example.txt(官网给的)和test_part1.txt(你自测的)。common.validator.validate_against_examples(2019, 4, part=1)会自动查找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", type=click.IntRange(2017, 2020), required=True, help="年份 (2017-2020)") @click.option("--day", type=click.IntRange(1, 25), required=True, help="日期 (1-25)") @click.option("--part", type=click.Choice(["1", "2"]), default=None, help="指定 Part 1 或 2,留空则运行全部") @click.option("--benchmark", is_flag=True, help="启用性能计时") def run(year, day, part, benchmark): # 1. 动态导入对应模块:aoc.years._2018.day05 module_name = f"aoc.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(f"Part 1: {result1} ({elapsed1:.4f}ms)") if benchmark: validate_against_examples(year, day, part=1, result=str(result1)) if part in ["2", None]: result2, elapsed2 = timeit(day_module.solve_part2)(input_data) click.echo(f"Part 2: {result2} ({elapsed2:.4f}ms)") if benchmark: validate_against_examples(year, day, part=2, result=str(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__(f"aoc.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) / f"day{day:02d}.txt",自动创建目录。grid_from_lines()将[".#..", "#..."]转为[[".", "#", ".", "."], ["#", ".", ".", "."]]。这种「模型优先 → 启发式规则 → 原始行」三级 fallback,既保证了强类型安全(有模型时),又保留了灵活性(无模型时不过度约束)。

3.3 验证器:为什么样例比对必须在运行时而非测试阶段做

aoc/common/validator.py的validate_against_examples()设计初衷是解决 AoC 的一个独特痛点:样例输入/输出是解题过程的一部分,而非测试边界。例如 2018 Day 5(聚合物反应)的样例:

  • 输入:dabAcCaCBAcCcaDA
  • Part 1 期望输出:10
  • Part 2 期望输出:4

但你在写solve_part1()时,很可能先用这个样例调试,确认逻辑正确后再跑真实输入。如果验证只放在pytest里,意味着你得先写完代码、再写测试、再pytest,而 CLI 命令aoc run --year 2018 --day 5 --benchmark应该在你第一次执行时就告诉你:“样例通过 ✅”,而不是等你手动去跑测试。

其实现关键在于样例文件的约定式存放:

  • tests/2018/day05/test_part1.txt内容为10
  • tests/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) / f"day{day:02d}" / f"test_part{part}.txt" if not test_file.exists(): click.secho(f"⚠️ 未找到样例文件 {test_file},跳过验证", fg="yellow") return True # 不阻断执行,仅警告 expected = test_file.read_text().strip() if result.strip() == expected: click.secho(f"✅ Part {part} 样例验证通过", fg="green") return True else: click.secho(f"❌ Part {part} 样例验证失败", fg="red") click.secho(f" 期望: {expected}", dim=True) click.secho(f" 实际: {result.strip()}", dim=True) return False

注意dim=True让期望/实际值以灰色显示,避免干扰主流程输出。这种「轻量级、即时反馈」的设计,让验证成为开发流的一部分,而非额外负担。

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(timestamp=timestamp, buses=buses)

并在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(digits=lines[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 获取详细耗时", fg="blue")。

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 年为例,执行以下三步:

  1. 创建年份包结构(终端命令):

    mkdir -p aoc/years/_2021 touch aoc/years/_2021/__init__.py
  2. 生成 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,表示暂未定义模型,后续可升级。

  3. 更新 CLI 年份范围(aoc/cli.py):

    @click.option("--year", type=click.IntRange(2017, 2021), required=True, help="年份 (2017-2021)") # 同时更新文档字符串中的范围说明

完成这三步后,aoc run --year 2021 --day 1即可执行(返回占位结果),无需修改任何其他文件。这就是模块化设计的威力——新增年份的成本趋近于零。

5.2 为特定题目定制输入模型:以 2021 Day 5(水下喷口)为例

2021 Day 5 输入是坐标线段列表,如0,9 -> 5,9。我们为其创建强类型模型,提升可读性与安全性:

  1. 创建aoc/years/_2021/day05/models.py:

    from 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(start=Point(x=sx, y=sy), end=Point(x=ex, y=ey))) return cls(segments=segments)
  2. 修改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
  3. 更新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 = f"Advent 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 = f"aoc/years/_{year}/day{day:02d}.py" content.append(f"```python\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/目录,按日期顺序浏览。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询