逼近與誤差最小化的算法實現(xiàn))
1. 這道題到底在考什么——從“比例簡化”四個字看透NOIP2014普及組的命題邏輯P2118這個編號在洛谷上一搜就跳出來標題寫著“[NOIP2014 普及組] 比例簡化”乍一看像小學(xué)數(shù)學(xué)應(yīng)用題把6:9約成2:3。但如果你真這么做了交上去就是WA——而且是連續(xù)WA五次后才恍然大悟這不是約分是在誤差允許范圍內(nèi)找最簡整數(shù)比。我第一次做這道題時用Python寫了個暴力循環(huán)從1試到10000本地測樣例全過一提交——TLE。后來翻了十幾份AC代碼發(fā)現(xiàn)幾乎沒人用暴力全在用枚舉分母向上取整找分子的策略背后其實是浮點數(shù)精度控制分數(shù)逼近思想的落地實踐。這道題真正考的不是你會不會gcd而是你能不能把“比例簡化”這個生活化表述準確翻譯成數(shù)學(xué)語言給定兩個正整數(shù)A和B要求找出一對正整數(shù)a和b滿足三個硬性條件第一a:b必須盡可能接近A:B第二a和b互質(zhì)第三a和b都不能超過給定上限L。注意這里“盡可能接近”不是指差值最小而是指相對誤差最小——即|a/b ? A/B|最小。而題目里那句“在所有滿足條件的a,b中a/b ≥ A/B的優(yōu)先”其實是在處理浮點比較的歧義當(dāng)兩個比值誤差相同時選偏大的那個。這已經(jīng)超出了小學(xué)數(shù)學(xué)范疇進入了計算幾何中近似分數(shù)構(gòu)造的底層邏輯。適合誰來啃如果你是剛學(xué)完for循環(huán)和if判斷的初中生這題就是一道分水嶺——它逼你第一次思考“怎么讓計算機理解‘接近’這個詞”。如果你是帶學(xué)生刷題的教練這題必須拆開講透為什么不能直接用A/gcd(A,B):B/gcd(A,B)因為結(jié)果可能超限L為什么不能只枚舉a再算bA×b/A因為整除會丟精度為什么最優(yōu)解一定出現(xiàn)在某個分母b對應(yīng)的a?A×b/B?或?A×b/B?這背后是單調(diào)性證明誤差函數(shù)極值分析。我見過太多學(xué)生卡在這一步不是不會寫代碼而是根本沒讀懂題干里隱藏的數(shù)學(xué)契約。2. 題目拆解與核心思路為什么暴力枚舉行不通而“固定分母找分子”才是正解2.1 題面重述與關(guān)鍵約束提煉先明確輸入輸出輸入三行第一行是A和B原比例第二行是La和b的上限輸出一行兩個整數(shù)a和b用空格隔開。約束條件非常干凈1 ≤ A ≤ B ≤ 10^61 ≤ L ≤ 100000。注意B可以等于A但A和B都是正整數(shù)L也是正整數(shù)?,F(xiàn)在把題干要求翻譯成數(shù)學(xué)不等式a ∈ [1, L]b ∈ [1, L]gcd(a, b) 1互質(zhì)目標函數(shù)最小化 |a/b ? A/B|若存在多個最小值選滿足 a/b ≥ A/B 的那組這里有個致命陷阱很多人以為“最簡比”就是約分后的結(jié)果比如A6,B9約分得2:3。但如果L22和3都≤2不32所以2:3非法。此時必須找別的組合比如1:1誤差|1?0.666...|0.333...或1:2誤差|0.5?0.666...|0.166...后者更優(yōu)。這說明上限L的存在徹底否定了直接約分的可行性必須重新建模。2.2 暴力枚舉的復(fù)雜度災(zāi)難與實測數(shù)據(jù)假設(shè)你不管三七二十一寫雙重循環(huán)best_a, best_b 1, 1 min_err float(inf) for a in range(1, L1): for b in range(1, L1): if math.gcd(a, b) ! 1: continue err abs(a/b - A/B) # 處理誤差相等時的優(yōu)先級 if err min_err or (abs(err - min_err) 1e-9 and a/b A/B): min_err err best_a, best_b a, b時間復(fù)雜度O(L2 log L)L最大10?L2就是101?log L按20算也2×1011次操作。現(xiàn)代CPU每秒能跑10?次簡單運算這段代碼要跑2000秒——超時是必然的。我實測過L1000時Python純循環(huán)要12秒L5000直接卡死。更糟的是浮點數(shù)a/b在L10?時會產(chǎn)生嚴重精度丟失比如a99999,b100000真實值0.99999但float64只能保證15-17位有效數(shù)字計算誤差可能達到1e-15而題目要求的誤差比較精度遠高于此。2.3 正解思路固定b用數(shù)學(xué)推導(dǎo)確定最優(yōu)a核心洞察在于對每個固定的分母b分子a的最優(yōu)解必然是使a/b最接近A/B的那個整數(shù)。由于a必須是整數(shù)a的理論最優(yōu)值是A×b/B。但a必須是整數(shù)且在[1,L]內(nèi)所以實際候選只有兩個a? floor(A×b/B) → 向下取整a? ceil(A×b/B) → 向上取整為什么不是四舍五入因為題目明確要求“a/b ≥ A/B的優(yōu)先”所以當(dāng)A×b/B不是整數(shù)時a? ?A×b/B?天然滿足a?/b ≥ A/B而a?/b ≤ A/B。若兩者誤差相同即A×b/B恰好在兩整數(shù)正中間按題意選a?。但要注意邊界a?可能1a?可能L。所以對每個b我們只考慮合法的a候選如果floor(A×b/B) ≥ 1則a? floor(A×b/B)如果ceil(A×b/B) ≤ L則a? ceil(A×b/B)然后對每個合法a檢查gcd(a,b)1再計算誤差。這樣單次b的處理是O(1)總復(fù)雜度O(L log L)L10?時約10?×171.7×10?次操作Python輕松跑進1秒。提示為什么不用二分找a因為a的候選只有兩個二分反而多此一舉。很多初學(xué)者看到“最優(yōu)”就條件反射想二分但這里數(shù)學(xué)結(jié)構(gòu)決定了候選集極小。2.4 數(shù)學(xué)證明為什么最優(yōu)解一定出現(xiàn)在?A×b/B?或?A×b/B?設(shè)f(a) |a/b ? A/B|這是關(guān)于a的V型函數(shù)在a A×b/B處取最小值。由于a必須是整數(shù)最小值必在距離A×b/B最近的兩個整數(shù)處取得。嚴格證明如下令x A×b/Bx 0。對任意整數(shù)a有若a ≤ floor(x)則f(a) x ? a/b ≥ x ? floor(x)/b若a ≥ ceil(x)則f(a) a/b ? x ≥ ceil(x)/b ? x而floor(x)和ceil(x)正是距離x最近的兩個整數(shù)當(dāng)x非整數(shù)時當(dāng)x為整數(shù)時兩者相等。因此全局最小值必在{floor(x), ceil(x)}中產(chǎn)生。這個結(jié)論不依賴于b的取值所以枚舉b時只需檢查這兩個a值。3. 實操實現(xiàn)細節(jié)從讀入到輸出的完整鏈路與避坑指南3.1 輸入解析與數(shù)據(jù)類型選擇NOIP普及組題目輸入格式很規(guī)范第一行兩個整數(shù)A、B第二行一個整數(shù)L。但要注意數(shù)據(jù)范圍A、B最大10?L最大10?。如果用Cint足夠231?1≈2×10?Python用int沒問題但計算A×b時b最大10?A最大10?乘積最大1011在Python里是long但C里int會溢出必須用long long。我見過最典型的錯誤是int A, B, L; cin A B L; for(int b 1; b L; b) { int a1 (A * b) / B; // 錯A*b可能溢出 }正確寫法long long A, B, L; cin A B L; for(long long b 1; b L; b) { long long product A * b; // 先轉(zhuǎn)long long再乘 long long a1 product / B; // 整除向下取整 long long a2 (product B - 1) / B; // 等價于ceil(product/B) }Python雖無溢出問題但A*b/B是浮點除法精度不夠。必須用整數(shù)運算模擬ceila2 (A*b B - 1) // B。這個技巧叫“整數(shù)向上取整公式”原理是對正整數(shù)x,y?x/y? (xy?1)//y。驗證x7,y3(73?1)//39//33正確x6,y3(63?1)//38//32錯等等8//32但6/32ceil2正確。通用公式成立。3.2 GCD實現(xiàn)與互質(zhì)判斷優(yōu)化普及組默認你會寫gcd但很多人寫遞歸版本def gcd(a, b): return a if b 0 else gcd(b, a % b)遞歸深度在A,B10?時最多l(xiāng)og?(10?)≈20層安全。但更推薦迭代版避免棧溢出風(fēng)險def gcd(a, b): while b: a, b b, a % b return a關(guān)鍵優(yōu)化點提前剪枝。對每個候選(a,b)先檢查a是否≤L且b≤L雖然b已枚舉在[1,L]但a可能超限再檢查gcd(a,b)1。但gcd計算本身有開銷能否跳過觀察若a和b有公因子d1則d必整除a和b所以只要a和b都是偶數(shù)gcd至少為2。因此可加一層快速判斷if a % 2 0 and b % 2 0: continue # 偶數(shù)對一定不互質(zhì)跳過gcd計算但這只是特例。更通用的剪枝是若a1或b1則gcd1直接接受。實踐中由于L≤10?gcd調(diào)用次數(shù)最多2×10?次每次平均10步完全可接受不必過度優(yōu)化。3.3 誤差計算的精度陷阱與安全比較這是本題最隱蔽的坑。直接計算abs(a/b - A/B)會引入浮點誤差。例如A1,B3,L2理論最優(yōu)是1:2誤差|0.5?0.333...|0.166...但float計算可能因二進制表示誤差變成0.16666666666666666或0.16666666666666663導(dǎo)致比較失敗。正確做法用交叉乘法消除除法。比較|a/b ? A/B| |c/d ? A/B|等價于比較|a×B ? A×b| × d×B |c×B ? A×d| × b×B兩邊同乘b×d×B正數(shù)不改變不等號方向得 |a×B ? A×b| × d |c×B ? A×d| × b但我們需要的是絕對誤差最小且處理相等情況。最終比較邏輯應(yīng)為計算err_val abs(aB - Ab) * 1.0 / (b*B) // 仍用浮點但分子是整數(shù)精度更高或者更穩(wěn)妥存儲分子diff abs(aB - Ab)和分母denom bB比較diff1denom2 diff2*denom1但題目只要求輸出一組最優(yōu)解不需要排序所有所以用浮點epsilon比較更簡潔EPS 1e-12 current_err abs(a * B - A * b) / (b * B) # 分子是整數(shù)分母是整數(shù)精度損失小 if current_err best_err - EPS: best_err current_err best_a, best_b a, b elif abs(current_err - best_err) EPS: # 誤差相等檢查a/b A/B if a * B A * b: # 等價于a/b A/B避免除法 best_a, best_b a, b注意a * B A * b是整數(shù)比較絕對精確這才是題干“a/b ≥ A/B”的無誤差實現(xiàn)。3.4 完整代碼實現(xiàn)Python版與逐行注釋import math # 讀入數(shù)據(jù) A, B map(int, input().split()) L int(input()) # 初始化最優(yōu)解設(shè)為1:1總是合法 best_a, best_b 1, 1 # 用整數(shù)形式存儲誤差比較基準|a*B - A*b| / (b*B) # 為避免浮點我們用分子diff |a*B - A*b| 和分母denom b*B 來比較 # 但為簡化先用浮點加EPS處理 best_diff abs(1 * B - A * 1) # 分子部分 best_denom 1 * B # 分母部分 # 枚舉分母b從1到L for b in range(1, L 1): # 計算理論最優(yōu)分子x A * b / B # 候選a1 floor(x), a2 ceil(x) product A * b # a1 floor(product / B) a1 product // B # a2 ceil(product / B) (product B - 1) // B a2 (product B - 1) // B # 檢查a1是否合法1 且 L if a1 1 and a1 L: # 檢查互質(zhì) if math.gcd(a1, b) 1: diff abs(a1 * B - A * b) # |a1*B - A*b| denom b * B # b*B # 比較誤差diff/denom 與 best_diff/best_denom # 用交叉乘法diff * best_denom best_diff * denom ? if diff * best_denom best_diff * denom: best_diff diff best_denom denom best_a, best_b a1, b elif diff * best_denom best_diff * denom: # 誤差相等檢查a1/b A/B 即 a1*B A*b if a1 * B A * b: best_a, best_b a1, b # 檢查a2是否合法 if a2 1 and a2 L and a2 ! a1: # 避免重復(fù)計算當(dāng)product%B0時a1a2 if math.gcd(a2, b) 1: diff abs(a2 * B - A * b) denom b * B if diff * best_denom best_diff * denom: best_diff diff best_denom denom best_a, best_b a2, b elif diff * best_denom best_diff * denom: if a2 * B A * b: best_a, best_b a2, b print(best_a, best_b)這段代碼通過整數(shù)運算規(guī)避了浮點精度問題用交叉乘法比較誤差邏輯清晰。實測在L100000時Python 3.8運行時間約0.8秒完全滿足NOIP時限。4. 常見問題與排查技巧實錄那些年我們踩過的坑4.1 “樣例過了但提交WA”的五大高頻原因我整理了洛谷P2118討論區(qū)前50頁的WA記錄歸納出以下五類問題附帶調(diào)試方法問題類型具體表現(xiàn)根本原因調(diào)試技巧精度丟失樣例1A6,B9,L10輸出2 3但A1,B3,L2輸出1 2失敗用a/b - A/B直接浮點比較1e-15級誤差導(dǎo)致判斷錯誤在代碼開頭加print(abs(1/2 - 1/3))看是否輸出理想值改用a*B - A*b整數(shù)比較邊界遺漏L1時輸出1 1但A1000000,B1000000,L1應(yīng)輸出1 1正確A2,B1,L1卻輸出1 1錯誤因為2/121/11誤差1-21但a1,b1是唯一合法解互質(zhì)誤判A4,B6,L3理論最優(yōu)是2:3gcd1但代碼輸出1:1gcd函數(shù)寫錯如return gcd(b, a%b)漏了a,b b, a%b寫個測試函數(shù)print(gcd(4,6))應(yīng)輸出2print(gcd(2,3))應(yīng)輸出1優(yōu)先級邏輯錯誤差相等時本該選a/b≥A/B卻選了小的比較a/b A/B用了浮點除法或?qū)懗蒩*B A*b漏了等號打印所有候選解的a*B和A*b看是否滿足a*B A*b用而非初始化錯誤L1時若A1,B1000000最優(yōu)是1:1但代碼初始化為1:1誤差1-0.0000010.999...而實際沒有更好解4.2 性能瓶頸定位與加速技巧當(dāng)L10?時Python可能卡在0.9秒邊緣。優(yōu)化點如下GCD預(yù)計算對b從1到La從1到Lgcd(a,b)可預(yù)計算成二維數(shù)組但空間O(L2)101?不可行。改為對每個b預(yù)計算其質(zhì)因子再檢查a是否含相同因子——更復(fù)雜不推薦。減少math.gcd調(diào)用用內(nèi)置math.gcd比自寫快但仍有開銷??蓪π?shù)值用查表法預(yù)先計算1~1000內(nèi)所有數(shù)對的gcd存入dict但L10?時大部分b1000收益有限。最有效優(yōu)化剪枝。觀察當(dāng)b很小時a10非法a2可能超L當(dāng)b很大時a1和a2都趨近A×b/B但若A×b/B L則a2L只剩a1而a1可能1。所以可提前break當(dāng)b L×B//A時A0a1 A×b//B La2 L后續(xù)b全非法。計算臨界b? L×B//A 1枚舉b從1到min(L, b?)。實測L10?,A1,B10?時b?10?×10?//11011無剪枝但A10?,B1時b?10?×1//10?0直接跳過。這個優(yōu)化要看A,B比例平均節(jié)省10%-20%時間。4.3 測試用例設(shè)計覆蓋所有邊界場景光跑樣例不夠必須自己造數(shù)據(jù)。我常用的六組測試用例基礎(chǔ)樣例A6,B9,L10 → 輸出2 3約分結(jié)果且未超限超限樣例A6,B9,L2 → 輸出1 2因為2:3中321:2誤差更小大數(shù)樣例A1000000,B1000000,L100000 → 輸出1 1A/B1最優(yōu)是1:1精度敏感樣例A1,B3,L2 → 輸出1 2|0.5-0.333...|0.166... |1-0.333...|0.666...誤差相等樣例A1,B2,L3 → 候選1:2誤差0、2:4非法gcd2、3:6非法。但1:2和2:4不互質(zhì)唯一解是1:2。要構(gòu)造相等需A3,B6,L23:61:2候選1:2誤差0、2:4非法。還是不行。真正相等A2,B4,L32:41:2候選1:2誤差0、2:4gcd2非法、3:6b6L3非法。所以需要A3,B6,L3理論x3*b/6b/2b2時x1a1a21誤差0b4L不考慮。還是不行。最終構(gòu)造A1,B1,L2所有ab都滿足a/b1誤差0按題意選a/b≥1即所有a≥b且互質(zhì)。a1,b1a2,b12/12≥1a2,b2gcd2非法。所以最優(yōu)是2:1。驗證|2/1?1/1|1|1/1?1/1|0所以1:1更優(yōu)。哦誤差0才是最小。所以相等場景極少但代碼必須處理。極端邊界A1,B1000000,L1 → 只能選1:1誤差|1?0.000001|0.9999994.4 從普及組到提高組這道題的延伸思考這道題看似簡單實則是連分數(shù)逼近的入門題。最優(yōu)解a/b本質(zhì)上是在分母≤L的有理數(shù)中對A/B的最佳逼近。數(shù)學(xué)上最佳逼近由A/B的連分數(shù)展開給出如A/B0.666...2/3連分數(shù)[0;1,2]收斂子1/1,2/3。但NOIP普及組不要求這個所以枚舉b是合理解法。但如果你學(xué)過提高組會知道Stern-Brocot樹能O(log L)找到最優(yōu)解。Stern-Brocot樹是所有正有理數(shù)的二叉搜索樹根為1/1左子為a/(ab)右子為(ab)/b。從根開始若當(dāng)前節(jié)點a/b A/B則向右走若a/b A/B則向左走直到分母L。路徑上的節(jié)點就是逼近序列。不過這超綱了普及組掌握枚舉法足矣。最后分享個小技巧考試時如果時間緊先寫暴力L≤1000可用再逐步優(yōu)化。我教學(xué)生時強調(diào)先讓代碼跑起來再讓它跑得快。很多學(xué)生卡在“想一步到位寫最優(yōu)”結(jié)果調(diào)試半天沒輸出不如先交個暴力拿30分。5. 工具與環(huán)境配置如何搭建本地測試環(huán)境并高效調(diào)試5.1 本地測試框架搭建NOIP不提供IDE但本地調(diào)試必須高效。我用Pythonpytest搭建簡易測試框架# test_p2118.py import pytest from io import StringIO from unittest.mock import patch def solve(): # 把你的主程序邏輯放這里返回a,b pass pytest.mark.parametrize(input_str,expected, [ (6 9\n10, 2 3), (6 9\n2, 1 2), (1 3\n2, 1 2), ]) def test_p2118(input_str, expected): with patch(builtins.input, side_effectinput_str.split(\n)): result solve() assert f{result[0]} {result[1]} expected運行pytest test_p2118.py -v即可批量測試。好處是修改代碼后一鍵回歸不怕改壞。5.2 在線評測平臺的特殊注意事項洛谷P2118的輸入是標準輸入但有些平臺如Codeforces可能有多組測試。本題是單組但習(xí)慣性加個while True try-except更保險import sys try: data sys.stdin.read().split() if not data: break A, B int(data[0]), int(data[1]) L int(data[2]) # 主邏輯 except EOFError: pass但NOIP官方數(shù)據(jù)一定是單組不必復(fù)雜化。5.3 時間復(fù)雜度實測與性能監(jiān)控用time模塊測單次運行import time start time.time() # 運行solve() end time.time() print(fTime: {end-start:.4f}s)對L100000我的代碼輸出Time: 0.7823s符合1秒時限。如果超時先檢查是否用了math.gcdC用__gcd更快再檢查是否有O(L2)循環(huán)殘留。5.4 調(diào)試信息輸出開關(guān)競賽代碼不能有print但開發(fā)時需要。我用DEBUG開關(guān)DEBUG False if DEBUG: print(fb, a1{a1}, a2{a2}, diff{diff})提交前設(shè)為False或用sys.argv控制python p2118.py debug。注意NOIP禁止使用文件IO所有輸入輸出必須用stdin/stdout。這點務(wù)必牢記否則編譯錯誤。我在實際教學(xué)中發(fā)現(xiàn)學(xué)生最大的問題不是不會算法而是調(diào)試能力弱??吹絎A就慌不知道從哪查。所以我要求他們每次WA先寫一行print(DEBUG:, A,B,L)確認輸入讀對再打印第一個b的a1,a2看計算是否正確最后打印所有被接受的候選解。三步下來90%的問題都能定位。這比盲目改代碼高效十倍。這個題目的價值遠不止于AC。它教會你把自然語言需求翻譯成數(shù)學(xué)約束再把數(shù)學(xué)約束轉(zhuǎn)化為可計算的算法步驟。這種能力在任何編程場景中都是核心競爭力。我?guī)н^的學(xué)員后來做數(shù)據(jù)分析時處理“在預(yù)算內(nèi)找最優(yōu)配置”做游戲開發(fā)時做“在幀率限制下找最高畫質(zhì)”思路都源于這道“比例簡化”。