分析教材與遞迴教材都把二元搜尋當成 (O(\log n)) 的例子,但沒有處理邊界、lower_bound / upper_bound,也沒有「對答案二分」——後者在課程與實作裡比在陣列裡找一個數更常出現。本篇把迴圈不變量寫死,避免 off-by-one。

相關教材:Chapter2_Algorithm_Analysis_Teaching_Materials.md、Divide_And_Conquer_Teaching_Materials.md、Dynamic_Programming_Teaching_Materials.md(LIS 的 (O(n\log n)) 版本用得到)。


目錄

  1. 前提:單調
  2. 半開區間迴圈
  3. lower_bound 與 upper_bound
  4. 手算
  5. 對答案二分
  6. 實數二分
  7. 複雜度
  8. 常見陷阱
  9. C++ 實作
  10. 練習題

1. 前提:單調

二元搜尋不是「陣列很長就可以用」。它要求搜尋空間上有一個單調性質:

  • 找一個值:陣列已依鍵排序。
  • 找一個最小可行答案:ok(x) 一旦為真,所有更大的 x 也為真(或相反)。

沒有單調性,砍掉一半可能砍掉答案。未排序陣列要先排序,排序的 (O(n\log n)) 通常比直接線性掃描還貴,除非同一陣列要查很多次。


2. 半開區間迴圈

維護半開區間 ([lo, hi)):答案若存在,落在這個範圍的某個索引。

lo = 0, hi = n
while lo < hi:
    mid = lo + (hi - lo) / 2     # 不要 (lo+hi)/2,lo 與 hi 很大時會溢位
    if a[mid] < target:
        lo = mid + 1             # 答案在 mid 的右邊
    else:
        hi = mid                 # a[mid] >= target,mid 仍可能是答案
# 結束時 lo == hi,是第一個 >= target 的位置;若 lo == n,表示都小於 target

不變量:([0, lo)) 裡的元素都 < target,([hi, n)) 裡的元素都 >= target。mid 的計算用減法,避免 lo+hi 溢位。

這個寫法結束時 lo 就是 lower_bound。若還要確認「真的找到」,再檢查 lo < n && a[lo] == target。


3. lower_bound 與 upper_bound

對已排序陣列:

函式 回傳的位置
lower_bound(target) 第一個 (\ge target)
upper_bound(target) 第一個 (> target)

upper_bound 只改一個比較方向:if a[mid] <= target: lo = mid+1 else hi = mid。

等於 target 的區間是 [lower_bound, upper_bound)。個數是兩者相減。區間是空的表示不存在。

插入位置也是 lower_bound:插在那裡仍然保持排序。


4. 手算

陣列索引 0..4,值 [1, 3, 3, 5, 8],找第一個 (\ge 3)。

步驟 lo hi mid a[mid] 動作
1 0 5 2 3 3 (\ge) 3,hi=2
2 0 2 1 3 3 (\ge) 3,hi=1
3 0 1 0 1 1 (<) 3,lo=1
結束 1 1 索引 1

第一個 3 在索引 1。upper_bound(3) 會停在索引 3(值 5)。3 出現了 3-1=2 次。

找 4:lower_bound 停在索引 3(值 5),a[3] != 4,所以 4 不存在,但 5 是第一個比 4 大的。


5. 對答案二分

問題不一定長得像「陣列裡有沒有這個數」。只要答案是一個整數、而且可行性單調,就可以二分答案,再用一個線性(或 (n\log n))的檢查。

問題。 工作時間 [2, 1, 5, 1, 2, 2] 必須依序切成至多 k=3 段連續區間。最小化「最忙那段的總時間」。

單調性:若最大負載 (X) 做得到,更大的 (X) 也做得到。若 (X) 做不到,更小的也做不到。

下界是最長的單一工作 5(一段至少要裝下它)。上界是全部加總 13。

檢查 ok(X):從左往右掃,目前這段再加下一件會超過 X 就開新段。段數 (\le 3) 則可行。

X 切法(一種) 段數 可行?
5 [2,1] [5] [1,2,2] 負載 3,5,5 3 是
4 5 本身放不進 — 否

所以最小可行 (X) 是 5。二分會從 [5, 13] 收到 5。

(k=2) 時 ok(5) 為假,ok(8) 為真:[2,1,5] 與 [1,2,2]。答案是 8。

總時間 (O(n\log S)),(S) 是答案的範圍。檢查若不是線性,把檢查的成本乘上去。


6. 實數二分

答案是實數時沒有「lo==hi」的那一天。改成固定迭代次數(例如 80 次)或 hi-lo 小於公差。

每次仍砍一半。80 次之後區間長度是最初的 (2^{-80}),對多數幾何或工程公差足夠。不要用 while (lo != hi) 比對浮點數。


7. 複雜度

每次丟掉一半,(n) 個元素最多 (\lfloor \log_2 n\rfloor + 1) 次比較。主定理:(T(n)=T(n/2)+\Theta(1)=\Theta(\log n))。

對答案二分是 (O(\log S \cdot C)),(C) 是一次檢查的成本。(S) 若是 (10^{18}),(\log S) 大約 60,通常可以接受;檢查本身是 (O(n^2)) 時,整體可能比另一種演算法差。


8. 常見陷阱

  • 閉區間 while (lo <= hi) 卻忘記 mid+1 / mid-1。 會在 lo==hi==mid 時無限迴圈。半開區間只需要 lo < hi,少一個邊界案例。
  • (lo+hi)/2 溢位。 lo 與 hi 都超過 INT_MAX/2 時,和變成負數。用 lo + (hi-lo)/2。
  • 找不到時回傳 lo 卻不去看值。 lo 是插入點,不一定等於 target。
  • 對非單調的 ok 二分。 例如「段數恰好等於 k」不是單調的,「段數不超過 k」才是。
  • 整數答案用實數迴圈。 結束時要決定取 lo 還是 hi。半開區間的整數版沒有這個歧義。
  • 排序是不穩定鍵。 二元搜尋假設比較鍵的順序。用次要鍵排序後,只對主要鍵二分會切到錯誤的一半。

9. C++ 實作

#include <vector>

// 第一個 >= target 的索引;不存在則回傳 n。
int lower_bound_index(const std::vector<int>& a, int target) {
    int lo = 0, hi = static_cast<int>(a.size());
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (a[mid] < target) lo = mid + 1;
        else hi = mid;
    }
    return lo;
}

int upper_bound_index(const std::vector<int>& a, int target) {
    int lo = 0, hi = static_cast<int>(a.size());
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (a[mid] <= target) lo = mid + 1;
        else hi = mid;
    }
    return lo;
}

標準函式庫的 std::lower_bound / std::upper_bound 就是這兩個契約。自己寫之前先確認不變量,不要再發明第三種邊界。


10. 練習題

  1. 陣列 [2, 4, 4, 4, 7],lower_bound(4) 與 upper_bound(4) 的索引?4 出現幾次?
  2. lower_bound(1) 與 lower_bound(8) 呢?
  3. 工作 [4, 2, 3, 6] 切成至多 2 段,最小的最大負載是多少?ok(8) 與 ok(9) 分別為何?
  4. 為什麼「找出山峰陣列的頂點」(上升到頂再下降)仍然可以二分,即使它不是全域排序?
  5. LIS 的 (O(n\log n)) 做法在維護「長度為 k 的最小結尾」。為什麼對這個結尾陣列二分是合法的?

參考答案

  1. lower 是索引 1,upper 是索引 4,次數 4-1=3。
  2. lower_bound(1) 是 0(第一個元素 2 已經 (\ge 1),但值不是 1)。lower_bound(8) 是 5,等於長度,表示全部更小。
  3. 三種切法的最大負載分別是 [4]|[2,3,6] 為 11、[4,2]|[3,6] 為 9、[4,2,3]|[6] 為 9。答案是 9。ok(8) 為假:貪婪裝填會得到三段。ok(9) 為真:[4,2] 與 [3,6]。
  4. 頂點左邊滿足 a[i] < a[i+1],右邊滿足 a[i] > a[i+1](嚴格山峰)。「中點的右側比較大嗎」是單調的,可以決定往哪一半走。
  5. 最小結尾陣列本身是遞增的:長度更長的子序列,其最小可能結尾不會比更短的那個更小。遞增才允許二分。

小結

二元搜尋維護一個半開區間,每次用單調性丟掉一半。lower_bound 是第一個大於等於,upper_bound 是第一個大於。對答案二分搜的不是陣列元素,而是「最小的可行參數」,檢查函式必須單調。溢位、無限迴圈、以及在非單調謂詞上二分,是三個固定會出現的 bug。