二分的技巧 | 你的二分爲什麼死循環了

最近在答疑坊做志願者,很多大一小朋友來問我二分怎麼寫。據我觀察,類似的問題已經困擾過我和我的無數同學們了。爲了今後節省體力、保護嗓子,我決定寫一篇博客講一下二分的技巧,這樣下次我可以直接把博客轉給問問題的人(

樸素的二分相信大家都很熟悉,無非是每次循環取區間中點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\)是否可行

我見過的所有二分問題都可以只分兩種情況討論

  1. \(mid\)可能是答案;
  2. \(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;
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章