洛谷题库下载不只是文件:用SQLite搭建本地刷题索引
简介这是一份洛谷LuoguOJ离线题库资源包面向编程初学者、算法竞赛选手以及需要系统刷题的学习者解决在无网络或集中训练场景下浏览题目、钻研算法的需求。压缩包共包含2000个文件以HTML页面为主要格式整体约44.6MB。每个HTML对应一道洛谷原题完整呈现题目ID、标题、问题描述、输入输出格式、示例测试用例、数据范围与限制部分页面还附带社区题解或讨论区内容便于对照学习与自我检验。目前该资源已有5900余人学习下载适合离线搭建个人题库、按专题分类整理也适合错题复盘与赛前强化训练。通过逐一研读这些题目页面可以覆盖数据结构、算法、数学、逻辑等众多领域帮助用户快速理解不同题型的核心逻辑无论是入门练习还是备赛冲刺都能从中获取有价值的学习素材。1. 洛谷 OJ 题库下载你到底是在找文件还是在找一套刷题体系搜“洛谷题库下载”的人真正缺的不是一个压缩包。有人上课没网想离线刷题有人带竞赛队想把题库筛成自己的题单还有人自己搭OJ需要一批题目做数据源。这些需求的共同点在于大家缺的是一套能从洛谷OJ里把题目按自己需要筛出来、存下来、持续更新的方法。先说结论洛谷没有官方离线题库包网上那些“洛谷题库大全”基本是某个时间点的网页快照题目是死的TAG是乱的按知识点刷的时候根本筛不动。更靠谱的做法是把洛谷 OJ 的公开题目列表抓成本地索引再配合刷题状态一起维护。这篇文章从洛谷题库的结构讲起给抓取和入库的代码再聊怎么用这份题库刷题最后把提交时的典型翻车点列清楚。读这篇的人要么在准备 CSP、蓝桥杯、华为 OD 这类机考要么是需要给学生布置 OJ 作业的信息学老师。前者需要按难度和知识点筛选的题单后者需要一份能持续更新的题目索引。下面对应展开。2. 洛谷题库的结构题号、难度、TAG 与“下载”的正确姿势2.1 洛谷题库里有什么题号、难度档位与 TAG 的检索价值洛谷OJ的题库主体是按题号排列的题目集。题号以P开头后面跟一串数字比如“洛谷1843”在搜索框里就是P1843无论是日常讨论还是题解引用大家习惯直接用编号说话。这个编号就是题目在本地的唯一主键抓取时直接拿它做去重比用标题去重可靠得多。洛谷对题目的难度有一套自己的评档。从入门、普及、提高到省选、NOI中间还有“普及/提高-”“普及/提高”这类过渡档位。同一档里的题难度也差不少所以只按档位筛选会筛出风格差异很大的题。这就是TAG的作用洛谷为每道题标了算法标签比如模拟、贪心、搜索、图论、动态规划。做题时你可以不认同某个TAG但作为筛选维度它足够好用。这一节其实在回答一个问题为什么“下载题库”不能只下载标题列表。只拿到题号和标题你只是拥有一份目录拥有目录之后还得能按知识点检索这份数据才有实际价值。后面第4章的刷题策略全部依赖这里的难度TAG组合筛选。2.2 洛谷的公开数据口子列表页、内嵌 JSON 与抓取边界洛谷没有提供离线题库包这点是确定的。常见的做法是抓公开的题目列表页。浏览器打开洛谷的题目列表地址页面会向后端要一份数据再把题目渲染成列表这份数据在页面源码里以初始化片段的形式存在。解析它就能拿到当前页的题号、标题、难度、TAG等信息。抓取边界要提前讲清楚。第一公开列表页不需要登录但这不是让你高并发去扫的你本地整理题库一秒一个请求足够触发验证码就说明频率太高立刻停下来等一段时间。第二题解区、提交记录和用户数据需要登录不在“下载题库”的范围内也别去碰。第三善用列表页自身的筛选参数按难度排序、按关键字搜索都有现成参数抓的时候直接让服务端筛好比自己抓全量再筛轻松得多。不同OJ平台的题库组织差别很大。华为OD机考的题库按考试批次组织PTA按课程章节组织而洛谷更接近“竞赛题库”的形态按题号累加、按难度分档。你如果是从华为OD或PTA转过来的会发现“按TAG筛题”这个操作在洛谷上尤其顺手因为题目量大每个TAG下都有足够多不同难度的题。这个差异决定了本地题库的维护方式洛谷的数据值得做本地索引而以批次为主的题库更适合直接刷在线列表。“P14258题解”“P1928洛谷”这类搜索词能反映出一种常见做法大家在搜索引擎里直接找具体题目的题解。这没有问题但题解是单点答案题库是结构数据结构数据才能真正帮到你做刷题计划。所以下面我用一个最小脚本演示怎么把列表页解析成字典列表这是整个本地题库的第一层原料。2.3 本地存储选型JSON、SQLite 还是 Markdown 索引拿到题目数据后的存法有几种常见选择我分别试过直接给结论。小规模个人用JSON最省事存成数组放在项目目录里随手改顺手读缺点是题目到几百条以后筛选全靠遍历要写很多临时Python代码。SQLite适合想长期维护刷题进度的人一条SQL按难度和TAG筛题增量更新也方便。Markdown索引适合做阅读型题单但数据更新时要重新生成维护成本高。存储方式适合场景缺点JSON 文件个人快速整理数据多后筛选麻烦SQLite长期维护、按条件筛题需要掌握基本SQLMarkdown 索引直接阅读的题单更新成本高我的选择是JSON做中间层、SQLite做最终存储抓下来的原始数据先落成JSON方便检查字段确认没问题后再导入SQLite后续所有查询走SQL。这个流程在下一章展开。这里要提醒的是不管选哪种题面不要试图整包下载。洛谷的题面是渲染后的HTML图片和公式混在一起存下来既难读又容易因页面结构变化而失效。本地题库存“元数据”题面在线看这是最不容易踩坑的组合。3. 把洛谷题库拉到本地列表抓取、SQLite 入库与离线索引查询3.1 写一个最小列表抓取脚本requests 解析公开页面这段代码是给第2章的思路落地的。脚本只做一件事请求公开的题目列表页把页面里内嵌的初始化数据解析成字典列表。字段名在不同时期可能变化所以我在代码里加了调试输出跑之前先看一次真实结构。import requests import json import time HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64), Referer: https://www.luogu.com.cn/problem/list, Accept: text/html,application/xhtmlxml, } def fetch_problem_list(page_no1, keyword): params { page: page_no, order: difficulty, # 按难度排序方便后续分档 } if keyword: params[keyword] keyword resp requests.get( https://www.luogu.com.cn/problem/list, paramsparams, headersHEADERS, timeout10, ) resp.raise_for_status() # 列表页里有一段以 window._feIni 开头的初始化数据 text resp.text start text.find(window._feIni) if start -1: # 页面结构调整时先打印开头确认新的数据藏在哪个变量里 print(text[:2000]) return [] return parse_problems(text) def parse_problems(text): start text.find(window._feIni) seg text[start:] # 右侧是一个 JS 对象字面量取第一个 { 到最后一个 } brace_start seg.find({) brace_end seg.rfind(}) raw seg[brace_start:brace_end 1] data json.loads(raw) # 常见路径是 data.currentData.problems.result # 有些版本直接是 data.problems.result做兼容 node data.get(currentData, {}).get(problems, {}) if not node: node data.get(problems, {}) result node.get(result, []) # 调试用第一次跑看看拿到的字段名 if result: print(json.dumps(result[0], ensure_asciiFalse)[:500]) return result def main(): all_problems [] for page in range(1, 6): # 先抓 5 页验证流程 problems fetch_problem_list(page) if not problems: break all_problems.extend(problems) print(fpage {page}: {len(problems)} problems) time.sleep(1) # 一秒一个请求别赶 with open(luogu_raw.json, w, encodingutf-8) as f: json.dump(all_problems, f, ensure_asciiFalse, indent2) if __name__ __main__: main()脚本核心参数是orderdifficulty让服务端先按难度排好解析后所有题都保证难度从低到高。keyword参数用于按标题搜题比如想找和“数字”相关的题可以搜数字。timeout10秒网络抖动时避免卡死。这里故意留了兼容逻辑原因是每次页面结构调整后字段路径会变。这个脚本的价值是给你一个起点跑第一次时务必把result[0]打印出来看一遍确认哪些字段有值再决定后面怎么清洗。别拿着网上两年前的旧代码硬套洛谷页面结构改过不止一次。3.2 入库 SQLite表结构、去重与难度字段处理JSON 是中间层SQLite 是最终存储。表结构要简单但够用主键用题号TAG 用逗号拼接成字符串难度保留原始档位文本。import sqlite3 import json DB_PATH luogu_index.db def load_json(path): with open(path, encodingutf-8) as f: return json.load(f) def import_to_sqlite(problems): conn sqlite3.connect(DB_PATH) cur conn.cursor() cur.execute( CREATE TABLE IF NOT EXISTS problems ( id TEXT PRIMARY KEY, title TEXT NOT NULL, difficulty TEXT DEFAULT , tags TEXT DEFAULT , updated_at TEXT DEFAULT ) ) for item in problems: pid item.get(pid) or item.get(id) if not pid: continue tags item.get(tags, []) if isinstance(tags, list): tags ,.join(tags) cur.execute( INSERT OR REPLACE INTO problems (id, title, difficulty, tags, updated_at) VALUES (?, ?, ?, ?, ?) , ( pid, item.get(title, ), str(item.get(difficulty, )), tags, item.get(updatedAt, ), )) conn.commit() conn.close()主键用id配合INSERT OR REPLACE标题会改TAG 会调整运行两次脚本是“更新”而不是“重复插入”。TAG 统一用逗号分隔的字符串存储避免 SQLite 没有原生数组类型的麻烦。difficulty字段保留字符串不要转成 INT。洛谷的难度档位是几个枚举值字符串写在查询条件里直观得多要按档位排序就额外加一列映射成数字这个映射我放在第 4.1 节讲。3.3 离线索引查询按难度、TAG 和关键词筛题的 SQL入库后离线查询非常直接。按难度筛题SELECT id, title, difficulty, tags FROM problems WHERE difficulty 普及 ORDER BY id LIMIT 20;按 TAG 筛题SELECT id, title, difficulty, tags FROM problems WHERE tags LIKE %贪心% ORDER BY difficulty;按关键词搜题SELECT id, title, difficulty, tags FROM problems WHERE title LIKE %数字% OR title LIKE %最大% ORDER BY id;LIKE匹配对逗号拼接的 TAG 字符串足够用缺点是同时筛两个 TAG 时要多用一条AND或者先查出来再在 Python 里过滤。ORDER BY difficulty在 SQLite 里是字典序不是难度档位顺序所以下一章我会在表里加一个level整数列把难度映射成数字后排序才准确。到这里“洛谷题库下载”的核心动作就完成了你已经把一份会过期的网页数据变成了本地可查、可持续更新的索引。4. 题库不是用来囤的用 Python 字典管刷题状态按 TAG 刷题单4.1 从“全题库乱刷”到“知识点刷题”按 TAG 拉题单题库一旦入库最怕的就是“今天随便点一道”。刷题更像按知识点逐个击破而不是靠随机题号碰运气。我的常见做法是把难度映射成数字然后按“希望练习的TAG 目标难度”组合拉题单。DIFF_MAP { 入门: 1, 普及-: 2, 普及: 3, 普及/提高-: 4, 普及: 5, 提高: 6, 省选: 7, NOI: 8, }这个映射表导入时可以直接更新到 SQLitedef add_level_column(conn): cur conn.cursor() cur.execute(ALTER TABLE problems ADD COLUMN level INTEGER DEFAULT 0) conn.commit() def update_levels(conn, diff_map): cur conn.cursor() for name, level in diff_map.items(): cur.execute( UPDATE problems SET level ? WHERE difficulty ?, (level, name), ) conn.commit()配合一条查询SELECT id, title, difficulty, tags, level FROM problems WHERE tags LIKE %贪心% AND level BETWEEN 2 AND 4 ORDER BY level, id;BETWEEN 2 AND 4的意思是“从普及-到普及/提高-”这个区间适合还在打基础、没进省选难度的人。每拉出来一个题单做题顺序按 level 再按题号递增同一难度从小题号开始做能避免情绪被“一道难题卡死”影响整体节奏。洛谷本身也提供官方题单直接在线用也方便。但本地题单的好处是可以组合自己的弱项TAG比如“图论 普及难度 我还没AC的”这是固定题单给不了的筛选维度。拿别人排好的题单练手没问题想补短板还是得自己拉数据。4.2 用 Python 字典维护刷题状态AC、WA、待复习题库是死的刷题进度是活的。我用一个 Python 字典存自己的刷题状态字段就三个状态、提交次数、最后做的时间。字典的好处是维护代码短改动直观也方便直接写成 JSON 落盘。import json from datetime import date progress_path my_progress.json # 结构{ P1843: {status: AC, attempts: 2, last_done: 2025-01-01} } def load_progress(pathprogress_path): try: with open(path, encodingutf-8) as f: return json.load(f) except FileNotFoundError: return {} def today(): return date.today().isoformat() def mark(pid, status): progress load_progress() record progress.get(pid, {attempts: 0}) record[status] status # AC / WA / SKIP record[attempts] 1 record[last_done] today() progress[pid] record with open(progress_path, w, encodingutf-8) as f: json.dump(progress, f, ensure_asciiFalse, indent2)mark()在每次提交后调用。status只允许 AC、WA、SKIP 三个值SKIP 代表看了题解才写出来。尝试次数记录的是 WA 次数不是总提交次数这样能看出“一道题磨了多少次才过”。这个字典和 SQLite 是配合关系SQLite 回答“题库里有什么”字典回答“我做到哪了”。每周复习时我从 SQLite 筛出目标难度和 TAG 的题再拿字典过滤掉状态为 AC 且提交次数小于 2 的剩下的就是本周复习队列。两套数据各管一头不混在一起。4.3 读题先画边界P14258 这类题的样例之外很多人刷题翻车不在算法在读题。“样例过了但 WA 一串”是最典型的信号。我要求自己做题前先把三样东西圈出来数据范围、输入格式里的多组数据、以及特殊输出的描述。数据范围决定复杂度能放宽到多少多组数据决定循环读取的写法特殊输出决定边界值。搜“洛谷P14258题解”的同学大概率不是不知道这题的做法而是题目里某个边界条件没吃透。这个习惯直接改变刷题效率读完题不急着开写先在题目序号旁边写一句话“这题考的是什么”。写不出来就说明还没读明白写出来了再动手。这句话也会进到我自己的复视线索里复习时先看这句话再看代码比直接看题解区节省大量时间。给本地题库加一列my_note存这句话也是好做法。更新代码和mark()类似一条UPDATE的事。有了这列你的本地题库才真正从“洛谷的题库”变成“你的题库”。5. 洛谷 OJ 提交与本地题库的避坑TLE、RE、MLE 和数据不完整5.1 TLE输入输出没关同步、Python 读入太慢现象本地编译运行几乎秒出一提交就提示 TLE。第一次遇到时完全摸不着头脑后来发现大部分问题出在输入输出。原因C 的cin/cout默认要兼容 C 的 stdio每次读入都有同步开销Python 的input()逐行读在数据量大时也很慢。还有一种情况是算法复杂度本身超了但那种通常本地也能感觉到慢。解决C 在 main 开头加两行ios::sync_with_stdio(false); cin.tie(nullptr);并且不要混用cin和scanf。Python 改用整块读入再切分import sys data list(map(int, sys.stdin.buffer.read().split()))如果你已经开始用sys.stdin.bufferTLE 大部分时候就不再是 IO 问题而是回头检查复杂度的信号。把循环里可以提前退出的条件补上比继续优化读入更有效。5.2 RE 与 MLE数组越界、递归深度、内存超限的排查顺序现象本地跑得好好的交上去偶尔 RE有时 MLE。这种偶发问题最难查因为不是每次都能复现。原因数组开小了评测数据带了一组刚好踩线的输入递归写法在一条深链上爆栈二维数组开了全量大小但只用了一小块MLE 就是这么来的。解决先做最小验证——把数组按题目数据范围上限再加大 5大概率 RE 消失如果还是 RE检查所有下标计算尤其是循环里用字符串长度减一之类的地方。递归场景换成显式栈或迭代。MLE 优先检查是否有对整块数据建全量二维数组的“偷懒”写法能改成滚动数组就改不想改就先确认时限是否允许你省内存的代价。5.3 题库数据不完整TAG 为空、更新滞后、增量抓取的补救现象从本地题库筛题发现某几道新题没有 TAG难档次序和自己印象不一致。折腾半天发现是抓取日期太早数据落后了。原因列表页的初始化数据里 TAG 可能只给了部分字段题目列表本身也在持续更新你抓的是某个时间点的快照。解决把抓取脚本设计成“增量更新”每次跑只处理更新时间晚于本地最大updated_at的题目。具体实现是在导入前先查库里的MAX(updated_at)过滤掉更早的数据再走INSERT OR REPLACE。TAG 为空的事我一般会单独跑一次“只补 TAG”的任务按 id 列表逐个请求题目页解析 TAG同样控制在一秒一个。这活儿无聊但值得做因为 TAG 是刷题计划的地基地基缺一块后面筛题单就会漏一批该练的题。5.4 题解依赖症搜题解之前先给自己设一个 20 分钟闹钟现象搜索记录里全是“洛谷P1928”这类题号加“题解”二字。这个习惯放在题库整理上会变成灾难搜了十道题的题解一道也没真正会。原因刷题量上去了独立建模能力没跟上。每次卡住立刻看题解大脑的“读题到建模”链路就被跳过了。解决给自己定一条铁律卡住 20 分钟还没思路才允许看题解看完题解 AC 了再把状态标成 SKIP一周后重做。这条规矩一开始很难受但执行两个月后你能明显感觉到独立写出的题变多。题库里“SKIP 率”这个参数比 AC 数量更能反映真实水平我会在每周复习时专门去看它SKIP 率超过三成说明最近刷的题超出能力区间该降一档难度了。6. 给本地题库加一个“对拍”工作流数据生成器加暴力验证的复现方法6.1 对拍脚本的组成生成器、暴力程序、被测程序本地题库配合对拍是验证新题解最可靠的手段。对拍的本质是一个随机数据生成器不断造数据一个一定正确的暴力程序和被测程序同时跑输出不一致就停。for i in $(seq 1 1000); do python3 gen.py data.in python3 brute.py data.in ans.out python3 fast.py data.in out.out if ! diff -q ans.out out.out /dev/null; then echo case $i failed break fi done# gen.py 示例生成一个 n数组 的随机数据 import random n random.randint(1, 10) print(n) print( .join(str(random.randint(-100, 100)) for _ in range(n)))gen.py的参数要覆盖边界n 取 1、取上限元素取负数这些场景随机跑一万次都不一定触发建议在生成器里固定写几组边界 case 再随机。暴力程序一定要够“笨”越直译题意越不容易错被测程序才是你想验证的那个。对拍不是只做一次而是每次改完题解都跑一轮一千次没挂再提交就踏实得多。6.2 对拍结果的解读样例全过但 WA 的题先查这四处对拍找到一个反例后查错顺序依次是数据读取对不对、边界下标有没有越、long long有没有漏、算法是否忽略了题目里“保证互不相同”这类约束。前三个查完没有结果就得回到第四项重新读题。我现在的习惯是每道 WA 的题先不碰代码打开题面把数据范围再读一遍——有一半的问题都是在“我以为读过了”的地方。这份本地题库带给我的最大改变是刷题终于有了复盘入口。每周日跑一次查询把 SKIP 状态的题拉出来重做做完的从 SKIP 改成 AC再顺手把本周 WA 超过三次的题加入下周队列。速度快慢不重要重要的是每道题都留下了一条可追踪的记录。希望我的这套整理和刷题方法能帮你的洛谷刷题之旅少走点弯路。本文还有配套的精品资源点击获取