
简介本资源是面向Python编程学习者与算法爱好者的一站式Advent of Code历年真题实战解决方案覆盖2017至2020年全部挑战题目聚焦递归、图遍历、动态规划、位运算、计算几何等核心算法场景适合中高级开发者巩固基础、拓展解题思维并提升工程化编码能力。压缩包共240个文件含152个Python源码按dayX.py规范命名每份代码均包含输入解析与双题解逻辑、83个原始input测试数据直接支撑本地调试验证以及4张说明性PNG图和1个.gitignore配置文件整体仅989KB轻量易用、结构清晰。目前已有137人学习下载。读者可直接运行各日代码复现官方题目求解过程深入理解Python内置模块itertools/collections/math与高效写法生成器、推导式、函数式风格的实际应用并通过目录层级直观掌握历年题型演进脉络与代码组织范式。1. 这不是刷题集而是一套可复用的 Advent of Code 全年解题骨架从 2017 到 2020 年所有题目Python 实现开箱即跑、模块可插拔、测试即写即验你有没有试过年底打开 Advent of Code 网站点开 Day 1 的题目兴奋地敲下def solve_part1(input_data):结果第二天就卡在 Day 2 的输入解析上第三天发现每个年份的输入格式不统一——2017 是纯文本块2019 突然加了 JSON 片段2020 又混入表格结构更别提测试手写assert solve_part1(1,2,3) 6跑一次要 3 秒全年 25×250 道题全手动测这套「代码问世我对代码问世的解决方案所有年份」就是为这种真实翻车现场写的。它不是一份答案合集而是一个按年份组织、按日拆分、输入自动归一化、解法函数隔离、测试用例内建、运行命令统一的 Python 工程化解题框架。适合两类人一是想系统练算法工程规范的新手每天一个 commit结构清晰不怕断更二是需要快速验证某道题思路、或把 AoC 题目嵌入教学/面试题库的熟手直接 import day03_2019调用 solve_part1 即可。它不依赖任何在线服务不调用外部 API所有逻辑都在本地 Python 文件里——你下载解压后cd aoc-skeleton python -m aoc.run 2019 3就能跑通 Day 3 的全部逻辑连输入文件都不用自己建。2. 项目结构与技术选型为什么是 Python 模块化目录 pytest而不是 Jupyter 或单文件脚本2.1 目录结构即设计哲学年份 → 日 → 解法 测试 输入三件套项目根目录下没有main.py也没有solutions/这种模糊命名。取而代之的是严格按年份分层的aoc/包aoc/ ├── __init__.py ├── run.py # 入口python -m aoc.run year day ├── common/ # 跨年份通用工具输入解析器、坐标类、网格工具等 │ ├── __init__.py │ ├── parser.py # 核心parse_input(text: str) → List[Union[str, int, dict, list]] │ └── grid.py # 常用二维操作Grid.from_text(), grid.get_neighbors() ├── years/ # 每年一个子包完全隔离 │ ├── y2017/ # 注意命名y2017 而非 2017避免数字开头导致 import 失败 │ │ ├── __init__.py │ │ ├── day01.py # 必含 solve_part1() / solve_part2() │ │ ├── test_day01.py # pytest 自动发现含示例输入和预期输出 │ │ └── input.txt # 示例输入非真实数据用于本地验证 │ ├── y2018/ │ │ ├── day02.py │ │ └── test_day02.py │ └── y2020/ │ ├── day10.py │ └── test_day10.py └── data/ # 可选存放你从官网复制的真实输入按年份/日组织 ├── 2017/ │ └── day01.txt └── 2020/ └── day10.txt这个结构不是为了“看起来专业”而是解决三个实际痛点年份隔离2017 的day05.py用的是字符串切片2020 的day05.py用的是itertools.groupby互不污染解法与测试紧耦合每个test_dayXX.py文件里test_part1_example()和test_part1_real()分开前者用题目给的示例后者读data/下真实输入若存在失败时一眼看出是逻辑错还是输入错common 层抽象合理比如parser.py里的parse_as_ints(text)会自动处理1,2,3、1\n2\n3、1 2 3三种常见格式不用每道题重复写map(int, line.split())。提示aoc/run.py是唯一命令入口它动态 import 对应年份日模块调用solve_part1()并打印结果。你不需要改它——除非你想加-v显示详细测试过程那就在run.py里subprocess.run([pytest, ...])加参数。2.2 技术栈选择为什么不用 Jupyter为什么坚持 pytest 而非 unittest新手常问“Jupyter 写 AoC 不是更直观”——直观但不可维护。当你第 15 天发现day15.ipynb里有 200 行 cell其中 3 个 cell 在做同样的坐标转换第 18 天想复用这段逻辑时你得手动复制粘贴还容易漏掉边界条件。而本项目用纯.py模块common.grid.Grid类定义一次y2019.day03.solve_part1()和y2020.day12.solve_part2()都能from aoc.common.grid import GridIDE 还能跳转、重命名、自动补全。pytest 的选择更是血泪经验pytest.mark.parametrize(input_str,expected, [...])让你一行代码跑多个测试用例比如day02_2018的 checksum 题直接传[abc, abbc, abcc]三组输入比写三个assert清晰十倍pytest -k part1只跑所有 part1 测试pytest -x遇错即停省去看 49 个绿点再找那个红点的痛苦--tbshort输出精简堆栈错误定位到day07_2017.py:42而不是conftest.py:123的 pytest 内部路径。2.3 核心模块common/parser.py如何用 80 行代码统一处理 4 年 200 道题的输入格式这是整个骨架最值得细看的模块。它不追求“万能解析”而是覆盖 AoC 中 95% 的输入模式并留出扩展点。关键函数如下# aoc/common/parser.py from typing import List, Union, Dict, Any import re def parse_input(text: str) - List[Union[str, int, float, Dict, List]]: 主解析入口根据文本特征自动选择策略 返回扁平化列表元素类型取决于内容如数字行→intJSON行→dict空行分隔→list of list lines [line.strip() for line in text.strip().splitlines() if line.strip()] # 情况1全是数字常见于 2017 Day1, 2018 Day1 if all(re.fullmatch(r-?\d(\.\d)?, line) for line in lines): return [int(line) if . not in line else float(line) for line in lines] # 情况2含 JSON 片段2019 Day12 的行星位置 if any(line.startswith({) or line.startswith([) for line in lines[:3]): import json parsed [] for line in lines: try: parsed.append(json.loads(line)) except json.JSONDecodeError: parsed.append(line) # 降级为字符串 return parsed # 情况3空行分隔的区块2020 Day8 的指令集每块是多行指令 if \n\n in text: blocks text.strip().split(\n\n) return [parse_block(block) for block in blocks] # 默认返回原始行列表2017 Day3 的螺旋矩阵描述 return lines def parse_block(block: str) - List[str]: 解析单个区块去除空行保留原顺序 return [line.strip() for line in block.splitlines() if line.strip()]这段代码的价值不在“多高级”而在可预测性当你打开y2019/day12.py第一行一定是from aoc.common.parser import parse_inputsolve_part1(raw_input: str)函数里第二行就是data parse_input(raw_input)如果某天输入格式特殊比如 2020 Day10 的排序后差值统计你只需在y2020/day10.py里写data sorted(parse_input(raw_input))不碰parser.py若未来新增 2021 年且其输入全是 CSV你只需在parser.py里加一个elif , in lines[0]:分支所有旧年份不受影响。这就是模块化的真实收益修改局部不影响全局新增功能不破坏存量。3. 快速上手从零开始跑通 2019 年 Day 1 的完整流程含真实输入接入3.1 环境准备Python 3.8无需额外安装标准库足矣本项目刻意规避第三方依赖只用 Python 标准库re,json,itertools,math,collections。这意味着你不需要pip install advent-of-code-helper这种包venv不是必须但推荐避免污染全局环境Windows/macOS/Linux 全平台一致无pathlib兼容问题用os.path做兜底。验证环境是否 OK只需两行命令python -c import sys; print(sys.version_info (3,8)) python -c import json, re; print(OK)如果都输出True和OK说明环境就绪。没有pip install步骤没有requirements.txt因为根本不需要。3.2 下载与目录初始化三步完成本地工作区搭建假设你已下载 ZIP 包并解压到~/projects/aoc-skeletoncd ~/projects/aoc-skeleton # 步骤1创建 data/ 目录存放你从官网复制的真实输入 mkdir -p data/2019 # 步骤2从 Advent of Code 官网复制 Day 1 输入登录后右键“View Source”或“Copy Input” # 粘贴到 data/2019/day01.txt注意文件名必须是 day01.txt不是 input.txt # 步骤3确保 aoc/ 是合法 Python 包有 __init__.py ls aoc/__init__.py # 应输出 aoc/__init__.py注意data/目录是用户专属不会被 git 跟踪.gitignore已预设data/。你放什么输入就跑什么输入不放run.py会自动 fallback 到years/y2019/day01.py同目录下的input.txt示例输入。3.3 运行单日解法python -m aoc.run 2019 1的背后发生了什么执行命令后aoc/run.py会做以下事情构造模块路径aoc.years.y2019.day01动态 importmodule importlib.import_module(module_path)尝试读取真实输入input_path Path(data) / 2019 / day01.txt若存在则读取否则读aoc/years/y2019/day01/input.txt调用module.solve_part1(input_text)捕获返回值并打印同样调用module.solve_part2(input_text)。我们来看y2019/day01.py的典型结构# aoc/years/y2019/day01.py from aoc.common.parser import parse_input def solve_part1(input_text: str) - int: 2019 Day 1 Part 1: 燃料计算质量 // 3 - 2 masses parse_input(input_text) # 自动转为 int 列表 return sum(mass // 3 - 2 for mass in masses) def solve_part2(input_text: str) - int: 2019 Day 1 Part 2: 递归燃料需为燃料本身再算燃料 masses parse_input(input_text) def fuel_for_mass(m: int) - int: total 0 while (m : m // 3 - 2) 0: total m return total return sum(fuel_for_mass(m) for m in masses)注意两点solve_part1和solve_part2必须是纯函数只依赖输入参数不读文件、不打印、不调用input()返回值类型明确int、str、List[int]方便run.py统一格式化输出。执行后你会看到$ python -m aoc.run 2019 1 Part 1: 3361299 Part 2: 5039071这行输出来自run.py的print(fPart 1: {result1})干净利落没有调试信息没有 tracebacks除非你代码真错了。3.4 运行测试pytest aoc/years/y2019/test_day01.py验证你的解法是否正确测试文件test_day01.py是验证安全网# aoc/years/y2019/test_day01.py import pytest from aoc.years.y2019.day01 import solve_part1, solve_part2 def test_part1_example(): input_text 12\n14\n1969\n100756 assert solve_part1(input_text) 2 2 654 33583 # 34241 def test_part1_real(): # 读取 data/2019/day01.txt若不存在则跳过pytest.skip import os if not os.path.exists(data/2019/day01.txt): pytest.skip(Real input not provided) with open(data/2019/day01.txt) as f: input_text f.read() assert solve_part1(input_text) 3361299 # 你的真实答案 def test_part2_example(): assert solve_part2(14\n1969\n100756) 2 966 50346 # 51314运行测试# 只跑 Day 1 示例测试快 pytest aoc/years/y2019/test_day01.py::test_part1_example -v # 跑所有 Day 1 测试含真实输入 pytest aoc/years/y2019/test_day01.py -v # 跑整个 2019 年50 个测试约 2 秒 pytest aoc/years/y2019/ -v --tbshortpytest的-v参数显示每个测试名--tbshort只显示关键错误行。如果test_part1_real失败错误信息会是E AssertionError: assert 3361298 3361299 E where 3361298 function solve_part1 at 0x...(12345\n...)这比python -m aoc.run 2019 1输出一个错数然后你得手动 debug 强十倍。4. 避坑指南我在 4 年 AoC 实战中踩过的 5 个高频坑附修复代码4.1 坑parse_input返回str列表但你的solve_part1直接int(line)导致ValueError现象运行python -m aoc.run 2018 2报错ValueError: invalid literal for int()堆栈指向day02.py第 15 行。原因2018 Day 2 输入是abcdef\nbababc\nabcccdparse_input按默认规则返回[abcdef, bababc, abcccd]字符串列表但你的代码写了for line in lines: count len([c for c in line if ca])逻辑没错却误以为lines是int列表。解决永远先看parse_input的返回类型注释。修复方式有两种方案A推荐在solve_part1开头加类型断言让错误提前暴露def solve_part1(input_text: str) - int: lines parse_input(input_text) assert isinstance(lines, list) and all(isinstance(s, str) for s in lines), \ fExpected list[str], got {type(lines)} with elements {type(lines[0]) if lines else empty} # 后续逻辑...方案B用parse_input的as_type参数需先在parser.py扩展# 在 parser.py 加def parse_input(text: str, as_type: str None) - ... # 然后在 day02.py 用lines parse_input(input_text, as_typestr)4.2 坑data/2020/day05.txt里有 Windows 换行符\r\n导致line.strip()后仍残留\r现象2020 Day 5 的二分查找座位号本地测试通过但提交答案错误print(repr(line))显示FBFBBFFRL\r。原因从 Windows 浏览器复制输入时换行符是\r\nline.strip()只去掉首尾空白\r在末尾strip()默认不处理。解决在parser.py的parse_input开头统一 normalize 换行符text text.replace(\r\n, \n).replace(\r, \n) # 统一为 \n lines [line.rstrip(\r\n) for line in text.strip().splitlines() if line.strip()]4.3 坑pytest找不到test_dayXX.py报ModuleNotFoundError: No module named aoc现象pytest aoc/years/y2019/test_day01.py报错但python -m aoc.run 2019 1正常。原因pytest运行时当前工作目录cwd是aoc/years/y2019/而test_day01.py里有from aoc.years.y2019.day01 import ...Python 在aoc/years/y2019/下找不到顶层aoc包。解决两种方式任选其一方式1推荐始终在项目根目录运行 pytestcd ~/projects/aoc-skeleton pytest aoc/years/y2019/test_day01.py方式2用-p no:python关闭 pytest 的 import 机制改用python -m pytest它会把当前目录加入sys.pathcd ~/projects/aoc-skeleton python -m pytest aoc/years/y2019/test_day01.py4.4 坑solve_part2依赖solve_part1的中间结果但两个函数被独立调用状态丢失现象2017 Day 6 的内存重分配solve_part1找到循环起始点solve_part2需要知道该点之后多少步再次出现——但solve_part2重新解析输入从头跑耗时翻倍且逻辑冗余。原因函数设计违反“单一职责”solve_part1做了不该做的缓存。解决引入solve_all函数返回结构化结果# y2017/day06.py from typing import NamedTuple class Day06Result(NamedTuple): part1: int part2: int cycle_length: int # 供其他模块复用 def solve_all(input_text: str) - Day06Result: banks parse_input(input_text) seen {} steps 0 while tuple(banks) not in seen: seen[tuple(banks)] steps # ... 重分配逻辑 steps 1 first_seen seen[tuple(banks)] return Day06Result(part1first_seen, part2steps - first_seen, cycle_lengthsteps - first_seen) def solve_part1(input_text: str) - int: return solve_all(input_text).part1 def solve_part2(input_text: str) - int: return solve_all(input_text).part2这样solve_part1和solve_part2仍保持接口兼容但内部共享计算。4.5 坑common/grid.py的Grid.from_text()解析....#\n#....\n.....时坐标系 y 轴反向现象2018 Day 18 的生命游戏本地测试通过但提交答案错误调试发现(0,0)是最后一行而非第一行。原因Grid.from_text()默认y0是输入文本的第一行但 AoC 题目描述常以“顶部为 y0”为准而text.splitlines()[0]是顶部所以(0,0)应该是顶部左角——这没错。但 2018 Day 18 的示例图是....# #.... .....题目说“第 0 行第 0 列是.”而text.splitlines()[0][0]确实是.所以坐标系正确。真正的问题是你在get_neighbors()里写了for dy in (-1,0,1)但没考虑边界y-1越界返回None导致邻居数少算。解决在grid.py的get_neighbors加边界检查def get_neighbors(self, x: int, y: int, include_diagonals: bool True) - List[Tuple[int, int]]: neighbors [] for dy in (-1, 0, 1): for dx in (-1, 0, 1): if dx 0 and dy 0: continue if not include_diagonals and (dx ! 0 and dy ! 0): continue nx, ny x dx, y dy # 关键检查是否在网格内 if 0 nx self.width and 0 ny self.height: neighbors.append((nx, ny)) return neighbors5. 二次开发实战如何为 2021 年新增支持并把你的解法一键导出为 Jupyter Notebook 用于教学5.1 新增 2021 年支持4 个文件10 分钟搞定假设你要为尚未包含的 2021 年添加骨架。这不是“复制粘贴”而是遵循现有契约的增量开发创建年份包mkdir -p aoc/years/y2021 touch aoc/years/y2021/__init__.py生成 25 天模板用 Python 脚本避免手敲# gen_days.py template from aoc.common.parser import parse_inputdef solve_part1(input_text: str) - int: # TODO: Implement 2021 Day {day} Part 1 data parse_input(input_text) return 0def solve_part2(input_text: str) - int: # TODO: Implement 2021 Day {day} Part 2 data parse_input(input_text) return 0 for day in range(1, 26): day_str str(day).zfill(2) with open(faoc/years/y2021/day{day_str}.py, w) as f: f.write(template.format(dayday)) with open(faoc/years/y2021/test_day{day_str}.py, w) as f: f.write(fimport pytest from aoc.years.y2021.day{day_str} import solve_part1, solve_part2def test_part1_example(): assert solve_part1() 0def test_part2_example(): assert solve_part2() 0 )运行 python gen_days.py自动生成 25 个 dayXX.py 和 test_dayXX.py。 3. **更新 aoc/run.py 的年份白名单**防止 import 错误 python # aoc/run.py 中 SUPPORTED_YEARS {y2017, y2018, y2019, y2020, y2021} # 新增 y2021验证骨架可用python -m aoc.run 2021 1 # 应输出 Part 1: 0 和 Part 2: 0 pytest aoc/years/y2021/test_day01.py # 应全绿至此2021 年骨架完成。你没改任何核心逻辑只是遵守了solve_part1(input_text)接口和years/yYYYY/目录约定。未来加 2022 年同样流程。5.2 导出为 Jupyter Notebook用nbformat自动生成带解题笔记的 .ipynb教学场景常需把解法转成 Notebook有 Markdown 题目描述、代码、可视化。本项目提供export_notebook.py脚本一键生成# export_notebook.py import nbformat as nbf from pathlib import Path def create_notebook(year: str, day: str, title: str): nb nbf.v4.new_notebook() # Cell 1: Markdown 题目标题 nb.cells.append(nbf.v4.new_markdown_cell(f# {title}\n\n来源Advent of Code {year} Day {day})) # Cell 2: 代码从 dayXX.py 复制 day_file Path(faoc/years/{year}/day{day}.py) if day_file.exists(): with open(day_file) as f: code f.read() nb.cells.append(nbf.v4.new_code_cell(code)) # Cell 3: 运行示例 nb.cells.append(nbf.v4.new_code_cell(f # 运行示例 from aoc.years.{year}.day{day} import solve_part1, solve_part2 example 12\\n14\\n1969\\n100756 print(Part 1:, solve_part1(example)) print(Part 2:, solve_part2(example)) )) # 写入文件 output_path Path(fnotebooks/{year}_day{day}.ipynb) output_path.parent.mkdir(exist_okTrue) with open(output_path, w) as f: nbf.write(nb, f) print(fNotebook exported to {output_path}) if __name__ __main__: import sys if len(sys.argv) ! 4: print(Usage: python export_notebook.py year day title) sys.exit(1) create_notebook(sys.argv[1], sys.argv[2], sys.argv[3])使用方式python export_notebook.py y2019 01 2019 Day 1: The Tyranny of the Rocket Equation # 生成 notebooks/y2019_day01.ipynb用 Jupyter Lab 打开即可教学生成的 Notebook 包含可编辑的 Markdown 标题你可补充题目截图、思路图原始.py代码保留注释IDE 友好即时可运行的示例example 12\n14...学生改输入就能看输出无任何外部依赖nbformat是标准库外唯一依赖pip install nbformat即可。5.3 改造common/parser.py以支持 2022 年新格式CSV 表格输入2022 年某天输入是 CSV 表格如name,age,city\nAlice,30,NYC\nBob,25,LAparse_input默认不识别。扩展它只需 10 行# aoc/common/parser.py import csv from io import StringIO def parse_input(text: str) - List[Union[str, int, float, Dict, List]]: # ... 原有逻辑略 # 新增CSV 表格检测含逗号且含表头行 lines [line.strip() for line in text.strip().splitlines() if line.strip()] if len(lines) 1 and , in lines[0] and all(, in line for line in lines[:3]): # 尝试 CSV 解析 try: reader csv.DictReader(StringIO(text)) return list(reader) # 返回字典列表[{name:Alice,age:30,...}, ...] except Exception: pass # 降级为原始行 # ... 其他逻辑然后在y2022/dayXX.py中def solve_part1(input_text: str) - int: data parse_input(input_text) # 自动得到 [{name:Alice,age:30}, ...] ages [int(row[age]) for row in data] return sum(ages)这种扩展不破坏任何旧代码y2017到y2020的所有parse_input调用依然走原有分支。从那以后我每次新增年份或格式都强制走一遍“最小改动验证”先写一个空day01.py确保python -m aoc.run YYYY 1不报 import 错再加一行return 0确保输出是0最后才填真实逻辑。这三步花不了 2 分钟但省去 90% 的路径错误、拼写错误、缩进错误。希望帮到你。本文还有配套的精品资源点击获取