
簡介這是一份洛谷LuoguOJ離線題庫資源包面向編程初學(xué)者、算法競賽選手以及需要系統(tǒng)刷題的學(xué)習(xí)者解決在無網(wǎng)絡(luò)或集中訓(xùn)練場景下瀏覽題目、鉆研算法的需求。壓縮包共包含2000個文件以HTML頁面為主要格式整體約44.6MB。每個HTML對應(yīng)一道洛谷原題完整呈現(xiàn)題目ID、標(biāo)題、問題描述、輸入輸出格式、示例測試用例、數(shù)據(jù)范圍與限制部分頁面還附帶社區(qū)題解或討論區(qū)內(nèi)容便于對照學(xué)習(xí)與自我檢驗。目前該資源已有5900余人學(xué)習(xí)下載適合離線搭建個人題庫、按專題分類整理也適合錯題復(fù)盤與賽前強化訓(xùn)練。通過逐一研讀這些題目頁面可以覆蓋數(shù)據(jù)結(jié)構(gòu)、算法、數(shù)學(xué)、邏輯等眾多領(lǐng)域幫助用戶快速理解不同題型的核心邏輯無論是入門練習(xí)還是備賽沖刺都能從中獲取有價值的學(xué)習(xí)素材。1. 洛谷 OJ 題庫下載你到底是在找文件還是在找一套刷題體系搜“洛谷題庫下載”的人真正缺的不是一個壓縮包。有人上課沒網(wǎng)想離線刷題有人帶競賽隊想把題庫篩成自己的題單還有人自己搭OJ需要一批題目做數(shù)據(jù)源。這些需求的共同點在于大家缺的是一套能從洛谷OJ里把題目按自己需要篩出來、存下來、持續(xù)更新的方法。先說結(jié)論洛谷沒有官方離線題庫包網(wǎng)上那些“洛谷題庫大全”基本是某個時間點的網(wǎng)頁快照題目是死的TAG是亂的按知識點刷的時候根本篩不動。更靠譜的做法是把洛谷 OJ 的公開題目列表抓成本地索引再配合刷題狀態(tài)一起維護(hù)。這篇文章從洛谷題庫的結(jié)構(gòu)講起給抓取和入庫的代碼再聊怎么用這份題庫刷題最后把提交時的典型翻車點列清楚。讀這篇的人要么在準(zhǔn)備 CSP、藍(lán)橋杯、華為 OD 這類機(jī)考要么是需要給學(xué)生布置 OJ 作業(yè)的信息學(xué)老師。前者需要按難度和知識點篩選的題單后者需要一份能持續(xù)更新的題目索引。下面對應(yīng)展開。2. 洛谷題庫的結(jié)構(gòu)題號、難度、TAG 與“下載”的正確姿勢2.1 洛谷題庫里有什么題號、難度檔位與 TAG 的檢索價值洛谷OJ的題庫主體是按題號排列的題目集。題號以P開頭后面跟一串?dāng)?shù)字比如“洛谷1843”在搜索框里就是P1843無論是日常討論還是題解引用大家習(xí)慣直接用編號說話。這個編號就是題目在本地的唯一主鍵抓取時直接拿它做去重比用標(biāo)題去重可靠得多。洛谷對題目的難度有一套自己的評檔。從入門、普及、提高到省選、NOI中間還有“普及/提高-”“普及/提高”這類過渡檔位。同一檔里的題難度也差不少所以只按檔位篩選會篩出風(fēng)格差異很大的題。這就是TAG的作用洛谷為每道題標(biāo)了算法標(biāo)簽比如模擬、貪心、搜索、圖論、動態(tài)規(guī)劃。做題時你可以不認(rèn)同某個TAG但作為篩選維度它足夠好用。這一節(jié)其實在回答一個問題為什么“下載題庫”不能只下載標(biāo)題列表。只拿到題號和標(biāo)題你只是擁有一份目錄擁有目錄之后還得能按知識點檢索這份數(shù)據(jù)才有實際價值。后面第4章的刷題策略全部依賴這里的難度TAG組合篩選。2.2 洛谷的公開數(shù)據(jù)口子列表頁、內(nèi)嵌 JSON 與抓取邊界洛谷沒有提供離線題庫包這點是確定的。常見的做法是抓公開的題目列表頁。瀏覽器打開洛谷的題目列表地址頁面會向后端要一份數(shù)據(jù)再把題目渲染成列表這份數(shù)據(jù)在頁面源碼里以初始化片段的形式存在。解析它就能拿到當(dāng)前頁的題號、標(biāo)題、難度、TAG等信息。抓取邊界要提前講清楚。第一公開列表頁不需要登錄但這不是讓你高并發(fā)去掃的你本地整理題庫一秒一個請求足夠觸發(fā)驗證碼就說明頻率太高立刻停下來等一段時間。第二題解區(qū)、提交記錄和用戶數(shù)據(jù)需要登錄不在“下載題庫”的范圍內(nèi)也別去碰。第三善用列表頁自身的篩選參數(shù)按難度排序、按關(guān)鍵字搜索都有現(xiàn)成參數(shù)抓的時候直接讓服務(wù)端篩好比自己抓全量再篩輕松得多。不同OJ平臺的題庫組織差別很大。華為OD機(jī)考的題庫按考試批次組織PTA按課程章節(jié)組織而洛谷更接近“競賽題庫”的形態(tài)按題號累加、按難度分檔。你如果是從華為OD或PTA轉(zhuǎn)過來的會發(fā)現(xiàn)“按TAG篩題”這個操作在洛谷上尤其順手因為題目量大每個TAG下都有足夠多不同難度的題。這個差異決定了本地題庫的維護(hù)方式洛谷的數(shù)據(jù)值得做本地索引而以批次為主的題庫更適合直接刷在線列表?!癙14258題解”“P1928洛谷”這類搜索詞能反映出一種常見做法大家在搜索引擎里直接找具體題目的題解。這沒有問題但題解是單點答案題庫是結(jié)構(gòu)數(shù)據(jù)結(jié)構(gòu)數(shù)據(jù)才能真正幫到你做刷題計劃。所以下面我用一個最小腳本演示怎么把列表頁解析成字典列表這是整個本地題庫的第一層原料。2.3 本地存儲選型JSON、SQLite 還是 Markdown 索引拿到題目數(shù)據(jù)后的存法有幾種常見選擇我分別試過直接給結(jié)論。小規(guī)模個人用JSON最省事存成數(shù)組放在項目目錄里隨手改順手讀缺點是題目到幾百條以后篩選全靠遍歷要寫很多臨時Python代碼。SQLite適合想長期維護(hù)刷題進(jìn)度的人一條SQL按難度和TAG篩題增量更新也方便。Markdown索引適合做閱讀型題單但數(shù)據(jù)更新時要重新生成維護(hù)成本高。存儲方式適合場景缺點JSON 文件個人快速整理數(shù)據(jù)多后篩選麻煩SQLite長期維護(hù)、按條件篩題需要掌握基本SQLMarkdown 索引直接閱讀的題單更新成本高我的選擇是JSON做中間層、SQLite做最終存儲抓下來的原始數(shù)據(jù)先落成JSON方便檢查字段確認(rèn)沒問題后再導(dǎo)入SQLite后續(xù)所有查詢走SQL。這個流程在下一章展開。這里要提醒的是不管選哪種題面不要試圖整包下載。洛谷的題面是渲染后的HTML圖片和公式混在一起存下來既難讀又容易因頁面結(jié)構(gòu)變化而失效。本地題庫存“元數(shù)據(jù)”題面在線看這是最不容易踩坑的組合。3. 把洛谷題庫拉到本地列表抓取、SQLite 入庫與離線索引查詢3.1 寫一個最小列表抓取腳本requests 解析公開頁面這段代碼是給第2章的思路落地的。腳本只做一件事請求公開的題目列表頁把頁面里內(nèi)嵌的初始化數(shù)據(jù)解析成字典列表。字段名在不同時期可能變化所以我在代碼里加了調(diào)試輸出跑之前先看一次真實結(jié)構(gòu)。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, # 按難度排序方便后續(xù)分檔 } if keyword: params[keyword] keyword resp requests.get( https://www.luogu.com.cn/problem/list, paramsparams, headersHEADERS, timeout10, ) resp.raise_for_status() # 列表頁里有一段以 window._feIni 開頭的初始化數(shù)據(jù) text resp.text start text.find(window._feIni) if start -1: # 頁面結(jié)構(gòu)調(diào)整時先打印開頭確認(rèn)新的數(shù)據(jù)藏在哪個變量里 print(text[:2000]) return [] return parse_problems(text) def parse_problems(text): start text.find(window._feIni) seg text[start:] # 右側(cè)是一個 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, []) # 調(diào)試用第一次跑看看拿到的字段名 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()腳本核心參數(shù)是orderdifficulty讓服務(wù)端先按難度排好解析后所有題都保證難度從低到高。keyword參數(shù)用于按標(biāo)題搜題比如想找和“數(shù)字”相關(guān)的題可以搜數(shù)字。timeout10秒網(wǎng)絡(luò)抖動時避免卡死。這里故意留了兼容邏輯原因是每次頁面結(jié)構(gòu)調(diào)整后字段路徑會變。這個腳本的價值是給你一個起點跑第一次時務(wù)必把result[0]打印出來看一遍確認(rèn)哪些字段有值再決定后面怎么清洗。別拿著網(wǎng)上兩年前的舊代碼硬套洛谷頁面結(jié)構(gòu)改過不止一次。3.2 入庫 SQLite表結(jié)構(gòu)、去重與難度字段處理JSON 是中間層SQLite 是最終存儲。表結(jié)構(gòu)要簡單但夠用主鍵用題號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標(biāo)題會改TAG 會調(diào)整運行兩次腳本是“更新”而不是“重復(fù)插入”。TAG 統(tǒng)一用逗號分隔的字符串存儲避免 SQLite 沒有原生數(shù)組類型的麻煩。difficulty字段保留字符串不要轉(zhuǎn)成 INT。洛谷的難度檔位是幾個枚舉值字符串寫在查詢條件里直觀得多要按檔位排序就額外加一列映射成數(shù)字這個映射我放在第 4.1 節(jié)講。3.3 離線索引查詢按難度、TAG 和關(guān)鍵詞篩題的 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;按關(guān)鍵詞搜題SELECT id, title, difficulty, tags FROM problems WHERE title LIKE %數(shù)字% OR title LIKE %最大% ORDER BY id;LIKE匹配對逗號拼接的 TAG 字符串足夠用缺點是同時篩兩個 TAG 時要多用一條AND或者先查出來再在 Python 里過濾。ORDER BY difficulty在 SQLite 里是字典序不是難度檔位順序所以下一章我會在表里加一個level整數(shù)列把難度映射成數(shù)字后排序才準(zhǔn)確。到這里“洛谷題庫下載”的核心動作就完成了你已經(jīng)把一份會過期的網(wǎng)頁數(shù)據(jù)變成了本地可查、可持續(xù)更新的索引。4. 題庫不是用來囤的用 Python 字典管刷題狀態(tài)按 TAG 刷題單4.1 從“全題庫亂刷”到“知識點刷題”按 TAG 拉題單題庫一旦入庫最怕的就是“今天隨便點一道”。刷題更像按知識點逐個擊破而不是靠隨機(jī)題號碰運氣。我的常見做法是把難度映射成數(shù)字然后按“希望練習(xí)的TAG 目標(biāo)難度”組合拉題單。DIFF_MAP { 入門: 1, 普及-: 2, 普及: 3, 普及/提高-: 4, 普及: 5, 提高: 6, 省選: 7, NOI: 8, }這個映射表導(dǎo)入時可以直接更新到 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的意思是“從普及-到普及/提高-”這個區(qū)間適合還在打基礎(chǔ)、沒進(jìn)省選難度的人。每拉出來一個題單做題順序按 level 再按題號遞增同一難度從小題號開始做能避免情緒被“一道難題卡死”影響整體節(jié)奏。洛谷本身也提供官方題單直接在線用也方便。但本地題單的好處是可以組合自己的弱項TAG比如“圖論 普及難度 我還沒AC的”這是固定題單給不了的篩選維度。拿別人排好的題單練手沒問題想補短板還是得自己拉數(shù)據(jù)。4.2 用 Python 字典維護(hù)刷題狀態(tài)AC、WA、待復(fù)習(xí)題庫是死的刷題進(jìn)度是活的。我用一個 Python 字典存自己的刷題狀態(tài)字段就三個狀態(tài)、提交次數(shù)、最后做的時間。字典的好處是維護(hù)代碼短改動直觀也方便直接寫成 JSON 落盤。import json from datetime import date progress_path my_progress.json # 結(jié)構(gòu){ 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()在每次提交后調(diào)用。status只允許 AC、WA、SKIP 三個值SKIP 代表看了題解才寫出來。嘗試次數(shù)記錄的是 WA 次數(shù)不是總提交次數(shù)這樣能看出“一道題磨了多少次才過”。這個字典和 SQLite 是配合關(guān)系SQLite 回答“題庫里有什么”字典回答“我做到哪了”。每周復(fù)習(xí)時我從 SQLite 篩出目標(biāo)難度和 TAG 的題再拿字典過濾掉狀態(tài)為 AC 且提交次數(shù)小于 2 的剩下的就是本周復(fù)習(xí)隊列。兩套數(shù)據(jù)各管一頭不混在一起。4.3 讀題先畫邊界P14258 這類題的樣例之外很多人刷題翻車不在算法在讀題?!皹永^了但 WA 一串”是最典型的信號。我要求自己做題前先把三樣?xùn)|西圈出來數(shù)據(jù)范圍、輸入格式里的多組數(shù)據(jù)、以及特殊輸出的描述。數(shù)據(jù)范圍決定復(fù)雜度能放寬到多少多組數(shù)據(jù)決定循環(huán)讀取的寫法特殊輸出決定邊界值。搜“洛谷P14258題解”的同學(xué)大概率不是不知道這題的做法而是題目里某個邊界條件沒吃透。這個習(xí)慣直接改變刷題效率讀完題不急著開寫先在題目序號旁邊寫一句話“這題考的是什么”。寫不出來就說明還沒讀明白寫出來了再動手。這句話也會進(jìn)到我自己的復(fù)視線索里復(fù)習(xí)時先看這句話再看代碼比直接看題解區(qū)節(jié)省大量時間。給本地題庫加一列my_note存這句話也是好做法。更新代碼和mark()類似一條UPDATE的事。有了這列你的本地題庫才真正從“洛谷的題庫”變成“你的題庫”。5. 洛谷 OJ 提交與本地題庫的避坑TLE、RE、MLE 和數(shù)據(jù)不完整5.1 TLE輸入輸出沒關(guān)同步、Python 讀入太慢現(xiàn)象本地編譯運行幾乎秒出一提交就提示 TLE。第一次遇到時完全摸不著頭腦后來發(fā)現(xiàn)大部分問題出在輸入輸出。原因C 的cin/cout默認(rèn)要兼容 C 的 stdio每次讀入都有同步開銷Python 的input()逐行讀在數(shù)據(jù)量大時也很慢。還有一種情況是算法復(fù)雜度本身超了但那種通常本地也能感覺到慢。解決C 在 main 開頭加兩行ios::sync_with_stdio(false); cin.tie(nullptr);并且不要混用cin和scanf。Python 改用整塊讀入再切分import sys data list(map(int, sys.stdin.buffer.read().split()))如果你已經(jīng)開始用sys.stdin.bufferTLE 大部分時候就不再是 IO 問題而是回頭檢查復(fù)雜度的信號。把循環(huán)里可以提前退出的條件補上比繼續(xù)優(yōu)化讀入更有效。5.2 RE 與 MLE數(shù)組越界、遞歸深度、內(nèi)存超限的排查順序現(xiàn)象本地跑得好好的交上去偶爾 RE有時 MLE。這種偶發(fā)問題最難查因為不是每次都能復(fù)現(xiàn)。原因數(shù)組開小了評測數(shù)據(jù)帶了一組剛好踩線的輸入遞歸寫法在一條深鏈上爆棧二維數(shù)組開了全量大小但只用了一小塊MLE 就是這么來的。解決先做最小驗證——把數(shù)組按題目數(shù)據(jù)范圍上限再加大 5大概率 RE 消失如果還是 RE檢查所有下標(biāo)計算尤其是循環(huán)里用字符串長度減一之類的地方。遞歸場景換成顯式?;虻?。MLE 優(yōu)先檢查是否有對整塊數(shù)據(jù)建全量二維數(shù)組的“偷懶”寫法能改成滾動數(shù)組就改不想改就先確認(rèn)時限是否允許你省內(nèi)存的代價。5.3 題庫數(shù)據(jù)不完整TAG 為空、更新滯后、增量抓取的補救現(xiàn)象從本地題庫篩題發(fā)現(xiàn)某幾道新題沒有 TAG難檔次序和自己印象不一致。折騰半天發(fā)現(xiàn)是抓取日期太早數(shù)據(jù)落后了。原因列表頁的初始化數(shù)據(jù)里 TAG 可能只給了部分字段題目列表本身也在持續(xù)更新你抓的是某個時間點的快照。解決把抓取腳本設(shè)計成“增量更新”每次跑只處理更新時間晚于本地最大updated_at的題目。具體實現(xiàn)是在導(dǎo)入前先查庫里的MAX(updated_at)過濾掉更早的數(shù)據(jù)再走INSERT OR REPLACE。TAG 為空的事我一般會單獨跑一次“只補 TAG”的任務(wù)按 id 列表逐個請求題目頁解析 TAG同樣控制在一秒一個。這活兒無聊但值得做因為 TAG 是刷題計劃的地基地基缺一塊后面篩題單就會漏一批該練的題。5.4 題解依賴癥搜題解之前先給自己設(shè)一個 20 分鐘鬧鐘現(xiàn)象搜索記錄里全是“洛谷P1928”這類題號加“題解”二字。這個習(xí)慣放在題庫整理上會變成災(zāi)難搜了十道題的題解一道也沒真正會。原因刷題量上去了獨立建模能力沒跟上。每次卡住立刻看題解大腦的“讀題到建?!辨溌肪捅惶^了。解決給自己定一條鐵律卡住 20 分鐘還沒思路才允許看題解看完題解 AC 了再把狀態(tài)標(biāo)成 SKIP一周后重做。這條規(guī)矩一開始很難受但執(zhí)行兩個月后你能明顯感覺到獨立寫出的題變多。題庫里“SKIP 率”這個參數(shù)比 AC 數(shù)量更能反映真實水平我會在每周復(fù)習(xí)時專門去看它SKIP 率超過三成說明最近刷的題超出能力區(qū)間該降一檔難度了。6. 給本地題庫加一個“對拍”工作流數(shù)據(jù)生成器加暴力驗證的復(fù)現(xiàn)方法6.1 對拍腳本的組成生成器、暴力程序、被測程序本地題庫配合對拍是驗證新題解最可靠的手段。對拍的本質(zhì)是一個隨機(jī)數(shù)據(jù)生成器不斷造數(shù)據(jù)一個一定正確的暴力程序和被測程序同時跑輸出不一致就停。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數(shù)組 的隨機(jī)數(shù)據(jù) import random n random.randint(1, 10) print(n) print( .join(str(random.randint(-100, 100)) for _ in range(n)))gen.py的參數(shù)要覆蓋邊界n 取 1、取上限元素取負(fù)數(shù)這些場景隨機(jī)跑一萬次都不一定觸發(fā)建議在生成器里固定寫幾組邊界 case 再隨機(jī)。暴力程序一定要夠“笨”越直譯題意越不容易錯被測程序才是你想驗證的那個。對拍不是只做一次而是每次改完題解都跑一輪一千次沒掛再提交就踏實得多。6.2 對拍結(jié)果的解讀樣例全過但 WA 的題先查這四處對拍找到一個反例后查錯順序依次是數(shù)據(jù)讀取對不對、邊界下標(biāo)有沒有越、long long有沒有漏、算法是否忽略了題目里“保證互不相同”這類約束。前三個查完沒有結(jié)果就得回到第四項重新讀題。我現(xiàn)在的習(xí)慣是每道 WA 的題先不碰代碼打開題面把數(shù)據(jù)范圍再讀一遍——有一半的問題都是在“我以為讀過了”的地方。這份本地題庫帶給我的最大改變是刷題終于有了復(fù)盤入口。每周日跑一次查詢把 SKIP 狀態(tài)的題拉出來重做做完的從 SKIP 改成 AC再順手把本周 WA 超過三次的題加入下周隊列。速度快慢不重要重要的是每道題都留下了一條可追蹤的記錄。希望我的這套整理和刷題方法能幫你的洛谷刷題之旅少走點彎路。本文還有配套的精品資源點擊獲取