最近在答疑坊做志願者,很多大一小朋友來問我二分怎么寫。據我觀察,類似的問題已經困擾過我和我的無數同學們了。為了今后節省體力、保護嗓子,我決定寫一篇博客講一下二分的技巧,這樣下次我可以直接把博客轉給問問題的人(
朴素的二分相信大家都很熟悉,無非是每次循環取區間中點mid,再判斷答案是在mid左邊還是mid右邊,遞歸查找,從而在\(O(\log 初始區間長度)\)復雜度內找到答案。
但是在實現二分的時候,很多同學發現:自己的二分死循環了 / 自己搞不清楚自己的邏輯了。接下來我們用一道例題說明一下。
例題1:數組分段
已知一個長度為\(n\)的數組\(a\),把它切分成\(m\)個連續的段,使得每段之和的最大值最小。求這個最小值。
數據范圍:\(1 \le m \le n \le 10^5, 0 \le a_i \le 10^9\)。
二分的思路很簡單:二分答案\(mid\),定義一個min_segments(mid)函數,用來求每段和不超過\(mid\)時,最少划分幾段。划分的方法是:從左往右遍歷整個數組,如果當前段能放得下\(a_i\)(加入\(a_i\)之后不會讓當前段的和超出\(mid\)),則把\(a_i\)加到當前段中,否則新開一段,把\(a_i\)放進去。然后根據划分的段數,判斷答案在mid左邊還是右邊。
一個bug,改編自我正在debug的代碼
小明看完題,寫出了這樣一份代碼:
long long l = 0, r = 1e14, mid;
while (l < r) {
mid = (l + r) / 2;
if (min_segments(mid) >= m)
l = mid;
else
r = mid;
}
cout << l << endl;
運行之后,他驚奇地發現:自己的二分代碼死循環了。大家不妨先暫停閱讀,思考一下小明的bug出在哪里?
答案揭曉
問題一:死循環
先不考慮別的問題,只考慮二分的最后一步—— \(r = l + 1\) 的情況。此時\(mid = (l + r) / 2 = l\)。假如此時發現 \(\mathrm{min\_segments}(mid) \ge m\),那么代碼會執行到 \(l = mid\) 這一步,然后繼續循環——等等,這\(l\)不就沒改變嘛!怪不得死循環了!
問題二:邏輯問題
其實小明還有一個問題,就是在 if (min_segments(mid) >= m)
這一句。不妨思考一下,如果\(\mathrm{min\_segments}(mid) \ge m\)不成立(也就是說如果\(\mathrm{min\_segments}(mid) < m\)),意味着什么呢?意味着我們可以把數組分成小於\(m\)段,每段之和不超過\(mid\),所以答案大於等於\(mid\),看上去沒有錯。那么如果\(\mathrm{min\_segments}(mid) \ge m\)成立呢?它什么也不能說明!如果\(\mathrm{min\_segments}(mid) = m\),那么\(mid\)固然可能是答案,可是答案可不可能比\(mid\)還小?完全有可能,比如\(mid-1\),划分出的這\(m\)段完全可能每段之和都不超過\(mid-1\)。當然,答案也可能比\(mid\)還要大。所以這個不等式不能用來判斷答案是在\(mid\)左邊還是\(mid\)右邊。
很多同學在寫二分時都踩過上面這兩個坑。一些人為了避免邏輯錯誤,會分“大於m”、“等於m”、“小於m”三種情況討論,但是這樣並沒有必要,而且在別的二分題目中很可能無法分出三種情況、只能分出兩種。接下來我來講講二分到底怎么寫,才能盡量不出鍋。
所以二分到底怎么寫?
第一步:判斷\(mid\)是否可行
我見過的所有二分問題都可以只分兩種情況討論:
- \(mid\)可能是答案;
- \(mid\)不可能是答案。
例如這道題中,如果\(\mathrm{min\_segments}(mid) \le m\),則\(mid\)可能是答案;如果\(\mathrm{min\_segments}(mid) > m\)(也就是說不可能分\(m\)段使得每段和不超過\(mid\)),則\(mid\)不可能是答案。
第二步:判斷答案在\(mid\)哪一側。
在這道題里,如果\(mid\)可能是答案,則實際的答案\(\le mid\);如果\(mid\)不可能是答案,則實際的答案\(> mid\)。(而在其他題中,情況也可能是:如果\(mid\)可能是答案,則實際的答案\(\ge mid\);如果\(mid\)不可能是答案,則實際的答案\(< mid\)。)
於是我們的代碼就改成了:
long long l = 0, r = 1e14, mid;
while (l < r) {
mid = (l + r) / 2;
if (min_segments(mid) > m)
l = mid + 1;
else
r = mid;
}
cout << l << endl;
注意l = mid + 1
一句,意味着這種情況中,實際答案不僅在\(mid\)右邊,還不可能是\(mid\),也就是嚴格大於\(mid\)。這句代碼讓答案可能出現的區間從\([l, r]\)變成了\([mid + 1, r]\)。
第三步:考慮\((l + r) / 2\)的取整問題
最后一步也是關鍵的一步。雖然在這道題中,mid = (l + r) / 2
是對的,但是有的題中這樣卻可能導致死循環。例如,假如對另一道題,我們寫出了這樣的代碼:
long long l = 0, r = 1e14, mid;
while (l < r) {
mid = (l + r) / 2;
if (一些條件)
l = mid;
else
r = mid - 1;
}
cout << l << endl;
那么,仍然考慮\(r = l + 1\)的情況,此時\(mid = l\)。那么如果if中的“一些條件”成立,程序會執行l = mid
——得,又來了,\(l\)沒有改變,死循環了。
對於這種情況,我們不應該寫mid = (l + r) / 2
,而應該寫mid = (l + r + 1) / 2
,這句的效果就是\(mid = \lceil (l + r) / 2 \rceil\),即向上取整。無論是向下取整還是向上取整,都不會影響二分復雜度的正確性,但是這一個“+1”之差很可能決定你是否死循環。
例如下面這道題,就可以運用這個技巧:
例題2:x的前驅
已知一個長度為\(n\)的有序數組\(a\),每次詢問輸入一個\(x\),輸出\(a\)中最后一個嚴格小於\(x\)數的下標(下標從1開始,如果沒有比\(x\)小的數則輸出\(0\))。
數據范圍:\(1 \le n \le 10^5, 0 \le a_i, x \le 10^9\)。
正確的代碼:
int l = 0, r = n;
while (l < r) {
mid = (l + r + 1) / 2;
if (a[mid] < x)
l = mid;
else
r = mid - 1;
}
cout << l << endl;