二分搜尋

搜尋與排序優先度 5/5 — 必備 — 幾乎每一輪面試都會出現必備 更新於 Oct 9, 2026
章節優先度優先度 5/5 — 必備 — 幾乎每一輪面試都會出現必備優先度 4/5 — 高價值 — 這裡有缺口就會掉關高價值優先度 3/5 — 值得會 — 多半是必備模式的變形值得會優先度 2/5 — 冷門 — 讀過一次即可,除非目標公司已知會問冷門只標在真正需要的章節上 —— 沒標的是背景/參考資料。

範圍 — 對單調搜尋空間做折半 — l <= r 與 l < r 背後的迴圈不變式推理、邊界(lower / upper bound)模板、旋轉陣列,以及浮點數與二維搜尋。 另見 — 從本文件拆出去的深入主題:binary_search_on_answer.md — 對答案空間做搜尋:canFinish / isValid 判定式、最小化最大值 vs 最大化最小值,以及值域計數;binary_search_examples.md — 題解存放處,每題一份標準解。 鄰近文件:patience_sorting.md — 把 §1.5 的掃描講成紙牌遊戲,附上重建、牌堆 / Dilworth 證明,以及可化約成 LIS 的題目;sort.md — 先把陣列排好序;advanced_divide_and_conquer.md — 折半外加合併步驟;bst.md — 同一個不變式做成資料結構;heap.md — 不用整體排序就取第 k 大元素;monotonic_stack.md — 位置性的「下一個更大元素」,也就是最常和 lower bound 搞混的模式。

LeetCode 題目清單

總覽

二分搜尋(Binary Search)是一個用雙指標在已排序的搜尋空間中尋找目標值的高效演算法。

關鍵性質

  • 時間複雜度:O(log n)
  • 空間複雜度:迭代 O(1),遞迴 O(log n)
  • 前提條件:已排序的陣列,或具備單調性
  • 搜尋空間:不限於完全排序的陣列,以下情況都適用:
    • 完全排序的陣列
    • 部分排序的陣列
    • 旋轉排序陣列
    • 任何具備單調性質的空間

核心演算法步驟

  1. 定義邊界:把 left 與 right 指標初始化成涵蓋所有可能情況
  2. 定義回傳值:想清楚要回傳什麼(索引、值、-1 等)
  3. 定義結束條件:選對迴圈條件(<=、< 或 < -1)
  4. 更新指標:依與 target 的比較結果移動邊界

何時使用二分搜尋

  • 已排序陣列:找確切值的經典場景
  • 單調函數:只要 condition(k) 能推得 condition(k+1),就能用二分搜尋
  • 搜尋邊界:找某個值第一次 / 最後一次出現的位置
  • 最佳化問題:找滿足限制條件的最小 / 最大值

參考資料

理解二分搜尋的指標行為

核心洞見:l 和 r 到底代表什麼?

這是讓二分搜尋成立的根本概念,也解釋了為什麼像 LC 35 這種求插入位置的題目回傳 l 是對的。

迴圈進行中:搜尋空間不變式

在整個 while (l <= r) 迴圈期間:

  • l 左邊的所有元素都嚴格 < target
  • r 右邊的所有元素都嚴格 > target
  • target 可能出現的位置永遠落在 [l, r] 之內

每次迭代砍掉一半搜尋空間,同時保持這個不變式。

java
// Standard binary search pattern
while (l <= r) {
    int mid = l + (r - l) / 2;

    if (nums[mid] == target) {
        return mid;
    } else if (nums[mid] < target) {
        l = mid + 1;  // All elements [0..mid] are < target
    } else {
        r = mid - 1;  // All elements [mid..end] are > target
    }
}

迴圈結束時:指標的位置

迴圈在 l > r 時結束,也就是 l == r + 1。

在這一瞬間,陣列被切成兩部分:

text
Visual Representation:

index:     0   ...   r   l   ...   n-1
value:   [< target]  gap  [> target]

迴圈結束時的關鍵性質:

  1. r 是最後一個小於 target 的元素
  2. l 是第一個大於等於 target 的元素
  3. r 和 l 之間不存在任何索引(因為 r = l - 1)

這就是為什麼 l 正是正確的插入位置!

圖解範例

我們來追蹤 nums = [1, 3, 5, 6], target = 4:

text
Initial:
l=0, r=3
[1, 3, 5, 6]
 l        r

Step 1:
mid = 1, nums[1] = 3
3 < 4, so l = mid + 1 = 2
[1, 3, 5, 6]
       l  r

Step 2:
mid = 2, nums[2] = 5
5 > 4, so r = mid - 1 = 1
[1, 3, 5, 6]
    r  l

Loop ends (l > r):
- r points to 3 (last element < 4)
- l points to 5 (first element > 4)
- Insertion position is l = 2

總結表

狀態 l 的位置 r 的位置 意義
迴圈進行中 第一個未檢查且 >= target 的索引 最後一個未檢查且 <= target 的索引 搜尋空間是 [l, r]
迴圈結束 第一個 >= target 的元素 最後一個 < target 的元素 l 就是插入點
圖示 ... r | l ... 兩者之間沒有空隙 l = r + 1

應用:Search Insert Position (LC 35)

java
// LC 35 - The cleanest solution using pointer behavior
public int searchInsert(int[] nums, int target) {
    if (nums == null || nums.length == 0) {
        return 0;
    }

    int l = 0;
    int r = nums.length - 1;

    while (l <= r) {
        int mid = l + (r - l) / 2;

        if (nums[mid] == target) {
            return mid;
        } else if (nums[mid] < target) {
            l = mid + 1;
        } else {
            r = mid - 1;
        }
    }

    // Key insight: l is always the correct insertion position
    return l;
}

為什麼不需要特判就能成立:

  1. 若 target 存在:迴圈中就直接回傳 mid
  2. 若 target 不存在:
    • 迴圈以 l > r 結束
    • 依不變式:nums[0..l-1] < target 且 nums[l..end] >= target
    • 因此 l 正是 target 該被插入的位置

該避開的常見錯誤

java
// ❌ WRONG: Trying to handle "between mid and mid+1" during the loop
while (l <= r) {
    int mid = l + (r - l) / 2;
    if (nums[mid] == target) return mid;

    // This is unnecessary and error-prone!
    // ❌ WRONG — can cause index out of bounds
    if (mid + 1 <= nums.length - 1 && target < nums[mid+1] && target > nums[mid]) {
        return mid + 1;
    }
    // ...
}

為什麼這樣寫是錯的:二分搜尋本來就會自然收斂到正確位置。相信指標不變式,迴圈結束後直接回傳 l 就好。


1) 二分搜尋的類型與模式

1.1) 類型速覽

基本二分搜尋 — LC 704(標準模板見 §2.1)

  • 目的:在已排序陣列中找出確切的目標值
  • 回傳:target 的索引,找不到則回傳 -1
  • 複雜度:O(log n)

遞迴版二分搜尋

  • 使用時機:遞迴寫法比較直觀時
  • 空間:因呼叫堆疊而為 O(log n)

旋轉陣列搜尋(§1.2)

  • 關鍵概念:判斷哪一半是有序的,再決定往哪邊搜尋
  • 應用:找 target、找最小元素

找邊界 — 左 / 右,即 lower / upper bound(§1.3)

  • 目的:找出第一個 / 最後一個滿足判定式的索引,而不是某個確切值
  • 回傳:分界點 — 絕不 return mid

二維矩陣搜尋(§2.3)

  • 做法 1:用 row = idx / cols、col = idx % cols 把矩陣攤平
  • 做法 2:逐列做二分搜尋
  • 時間:O(log(m×n))

對答案空間做二分搜尋(§1.4)

  • 目的:搜尋的是候選答案的範圍,而不是一個陣列
  • 回傳:單調可行性判定式的分界點

對自己維護的陣列做二分搜尋 — tails 模式(§1.5)

  • 目的:把「每個已達成長度的最佳值」這種 O(n²) DP 壓成一次 lower bound 查詢
  • 回傳:所維護陣列的長度(是長度,不是那條鏈本身)

1.2) 旋轉排序陣列 — 找出樞紐點

  • 關鍵概念:判斷哪一半是有序的,再決定往哪邊搜尋
  • 應用:找 target、找最小元素

Find Minimum in Rotated Sorted Array (LC 153) 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

模式:找出旋轉點

旋轉排序陣列一定長這個樣子:

text
[Left Higher Plateau] > [Right Lower Plateau]
e.g. [3, 4, 5, 1, 2]
      ^^^^^^^^  ^^^^
      left part  right part (contains minimum)

最小值永遠在旋轉點上 — 也就是陣列中唯一那個「下墜」的位置。

核心想法

判斷 mid 落在哪一段平台上,然後往沒有排序的那一側移動(最小值就在那裡):

text
Rotation examples (length 5):
[1, 2, 3, 4, 5]  → already sorted, min at index 0
[5, 1, 2, 3, 4]  → mid < r → right is sorted → go left (r = mid)
[4, 5, 1, 2, 3]  → mid < r → right is sorted → go left (r = mid)
[3, 4, 5, 1, 2]  → mid >= l → left is sorted → go right (l = mid + 1)
[2, 3, 4, 5, 1]  → mid >= l → left is sorted → go right (l = mid + 1)

判斷規則:

  • nums[mid] >= nums[l] → mid 在左平台上 → 最小值在右邊 → l = mid + 1
  • nums[mid] < nums[l] → mid 在右平台上 → 最小值就是 mid 或在其左邊 → r = mid - 1
推薦模板(閉區間邊界,追蹤 ans)
java
// LC 153 - Find Minimum in Rotated Sorted Array
// time = O(log N), space = O(1)
public int findMin(int[] nums) {
    int l = 0, r = nums.length - 1;
    int ans = nums[0];

    while (l <= r) {
        // Early exit: current window already sorted → minimum is at l
        if (nums[l] <= nums[r]) {
            ans = Math.min(ans, nums[l]);
            break;
        }

        int mid = l + (r - l) / 2;
        ans = Math.min(ans, nums[mid]);

        if (nums[mid] >= nums[l]) {
            l = mid + 1;  // left plateau → go right
        } else {
            r = mid - 1;  // right plateau → go left
        }
    }
    return ans;
}
替代模板(開區間邊界 r > l,不用 ans 變數)
java
// Cleaner: converges l == r to the minimum index
// time = O(log N), space = O(1)
public int findMin(int[] nums) {
    int l = 0, r = nums.length - 1;
    while (r > l) {
        int mid = l + (r - l) / 2;
        if (nums[mid] < nums[r]) {
            r = mid;       // right side sorted → min could be at mid
        } else {
            l = mid + 1;   // left side sorted → min is to the right
        }
    }
    return nums[l];  // l == r → minimum
}
視覺追蹤:nums = [3,4,5,1,2]
text
l=0, r=4: nums[l]=3 > nums[r]=2 → rotated
  mid=2, nums[2]=5 >= nums[0]=3 → left plateau → l=3

l=3, r=4: nums[l]=1 < nums[r]=2 → sorted → ans=min(ans,1), break

Answer = 1 ✓
模板比較
模板 迴圈條件 更新方式 回傳 適用時機
閉區間 l <= r + ans l <= r l=mid+1 / r=mid-1 ans 需要記錄候選答案時
開區間 r > l r > l r=mid / l=mid+1 nums[l] 最乾淨,會收斂到索引
相似題目
LC # 題目 關鍵差異
153 Find Minimum in Rotated Sorted Array 元素不重複,找最小值
154 Find Minimum in Rotated Sorted Array II 有重複值 — nums[mid]==nums[r] 時用 r--
33 Search in Rotated Sorted Array 找 target(不是最小值)— 要檢查 target 是否落在有序的那一半
81 Search in Rotated Sorted Array II 在有重複值的情況下找 target
189 Rotate Array 概念相關,但任務不同

Search in Rotated Sorted Array (LC 33, LC 81)

python
# LC 033. Search in Rotated Sorted Array
# LC 081. Search in Rotated Sorted Array II
# V0
# IDEA : BINARY SEARCH
#        -> CHECK WHICH PART IS ORDERING
#        -> CHECK IF TARGET IS IN WHICH PART
# CASES :
#  1) if mid is on the right of pivot -> array[mid:] is ordering
#     -> check if mid in on the left or right on mid
#     -> binary search on left or right sub array
#  2) if mid in on the left of pivot  -> array[:mid] is ordering
#     -> check if mid in on the left or right on mid
#     -> binary search on left or right sub array
### NOTE : THE NESTED IF ELSE CONDITION 
class Solution(object):
    def search(self, nums, target):
        if not nums: return -1
        left, right = 0, len(nums) - 1
        while left <= right:
            mid = (left + right) // 2
            if nums[mid] == target:
                return mid
            #---------------------------------------------
            # Case 1 :  nums[mid:right] is ordering
            #---------------------------------------------
            # all we need to do is : 1) check if target is within mid - right, and move the left or right pointer
            if nums[mid] < nums[right]:
                # mind NOT use (" nums[mid] < target <= nums[right]")
                # mind the "<="
                if target > nums[mid] and target <= nums[right]: # check the relationship with target, which is different from the default binary search
                    left = mid + 1
                else:
                    right = mid - 1
            #---------------------------------------------
            # Case 2 :  nums[left:mid] is ordering
            #---------------------------------------------
            # all we need to do is : 1) check if target is within left - mid, and move the left or right pointer
            else:
                # # mind NOT use (" nums[left] <= target < nums[mid]")
                # mind the "<="
                if target < nums[mid] and target >= nums[left]:  # check the relationship with target, which is different from the default binary search
                    right = mid - 1
                else:
                    left = mid + 1
        return -1     
java
// LC 33 - Search in Rotated Sorted Array
// IDEA: Binary search — identify sorted half, narrow range
// time = O(log N), space = O(1)
public int search(int[] nums, int target) {
    int l = 0, r = nums.length - 1;
    while (l <= r) {
        int mid = (l + r) / 2;
        if (nums[mid] == target) return mid;
        if (nums[l] <= nums[mid]) {           // left half is sorted
            if (nums[l] <= target && target < nums[mid]) r = mid - 1;
            else l = mid + 1;
        } else {                               // right half is sorted
            if (nums[mid] < target && target <= nums[r]) l = mid + 1;
            else r = mid - 1;
        }
    }
    return -1;
}

關鍵差異:

  • LC 153(找最小值):只需要判斷該往哪一側搜尋
  • LC 33/81(找 target):還必須檢查 target 是否落在有序的那一半裡

1.3) 找邊界 — Lower 與 Upper Bound (LC 34) 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

目的:回答排序陣列上的 bound 查詢 — 第一個 >= target 的索引、最後一個 <= target 的索引,以及所有能化約成它們的問題(第一次 / 最後一次出現、插入位置、 floor / ceiling 查詢)

辨識 — 「最小的 value >= target」就是 Lower Bound 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

選模板之前,先讀題目本身的措辭。凡是寫成 「最小的 value >= target」 的查詢 — 至少為 X 的最小值 — 都是 lower bound,不管題目怎麼包裝(區間、時間戳、咒語強度、LIS 的 tails)。

模式:排序一次,然後每個元素做一次 Bound 查詢
題目中的措辭 你真正在問的是 模板
「最小的值 >= X」、「第一個至少為 X 的」 lower bound findLeft → l(bisect_left)
「最小的值 > X」、「嚴格大於」 upper bound findRight → r + 1(bisect_right)
「最大的值 <= X」、「floor」、「X 之前最近的一筆」 upper bound − 1 findRight → r(bisect_right - 1)
「最大的值 < X」 lower bound − 1 findLeft → l - 1(bisect_left - 1)

形狀永遠是同樣的三行 — 而當答案必須是元素的原始位置時,要在排序之前 把值和它的索引配成一對,讓排序時兩者黏在一起:

python
# python - the generic "sort once, lower-bound each query" shape
# time = O(n log n) build + O(log n) per query, space = O(n)
import bisect

pairs = sorted((v, i) for i, v in enumerate(raw))   # NOTE !!! pair value WITH original idx
keys  = [v for v, _ in pairs]                       # bisect needs a plain sorted list

j = bisect.bisect_left(keys, x)                     # first value >= x
ans = pairs[j][1] if j < len(keys) else -1          # map sorted pos -> original idx
java
// java - the same query, hand-rolled (identical to findLeft in the template below)
// time = O(log N), space = O(1)
private int lowerBound(int[] keys, int x) {
    int l = 0, r = keys.length - 1;
    while (l <= r) {
        int mid = l + (r - l) / 2;
        if (keys[mid] < x) l = mid + 1;   // strict < → equality falls right, pushing l left
        else r = mid - 1;
    }
    return l;                              // l == keys.length → no value >= x
}
核心概念:Lower Bound vs 單調堆疊

這兩者經常被搞混,因為聽起來都像是「找下一個更大的東西」。 但它們回答的是不同的問題:

text
Monotonic stack : for each i, the FIRST element to its LEFT / RIGHT that is > (or <) nums[i]
                  -> a POSITIONAL neighbour; array order is the whole point

Lower bound     : for each query x, the SMALLEST value >= x in the WHOLE set
                  -> a VALUE ranking; position is irrelevant, so sort first

判斷方法:如果重新排列輸入會改變答案,那就是單調堆疊(最近鄰掃描)。 如果不會 — 你想要的仍然是同一個最小值 — 那就是 lower bound,排序一次再二分搜尋。

LC 436 (Find Right Interval) 是後者的乾淨範例:它要的是在所有區間中, start 為**>= end_i 的最小 start** 的那個區間,而不是輸入陣列中緊鄰在右邊的區間 — 所以它是二分搜尋,不是單調堆疊。

text
current.end
     ↓
all intervals' starts (sorted)
     ↓
first start >= end          → lower bound

LC 436 的完整題解 — 包括「連同索引一起排序」的做法 — 放在 binary_search_examples.md §16。

相似題目:有序集合上的 Bound 查詢
LC # 題目 改寫成 bound 形式的查詢
436 Find Right Interval 最小的 start >= end_i → lower bound,答案是原始索引
35 Search Insert Position 最小的索引使 nums[i] >= target → lower bound,不做驗證
34 Find First and Last Position of Element in Sorted Array lower bound 與 upper bound − 1 一起用
744 Find Smallest Letter Greater Than Target 嚴格 > → upper bound,再用 % n 繞回開頭
981 Time Based Key-Value Store 最大的 timestamp <= query → upper bound − 1,每個 key 各自查
1146 Snapshot Array 同樣的 floor 查詢,作用在每個索引的版本清單上
300 Longest Increasing Subsequence (O(N log N)) 最小的 tail >= x,然後覆寫它 — §1.5
2300 Successful Pairs of Spells and Potions 最小的 potion >= ceil(success / spell),再計算後綴長度
1170 Compare Strings by Frequency of the Smallest Character freq > 查詢值的單字數 → n - upperBound

如果集合在查詢之間會變動(隨時間陸續插入),排序陣列加二分搜尋就不夠了 — 改用 SortedList / BST / BIT。

模式:兩次獨立的邊界搜尋

有重複值的排序陣列可以看成三個區塊:

text
nums = [5, 7, 7, 8, 8, 10],  target = 8

  [ < target ] [ == target ] [ > target ]
   5  7  7       8  8          10
   0  1  2       3  4          5
                 ^  ^
              first  last

因為相等的那個區塊是連續的,一次「找確切值」的二分搜尋毫無用處(它會停在區塊內的任意位置)。正確做法是跑兩次獨立的搜尋,各自去找一個區塊邊緣:

  • findLeft → 第一個滿足 nums[i] >= target 的索引(相等區塊的起點)
  • findRight → 最後一個滿足 nums[i] <= target 的索引(相等區塊的終點)
核心想法 ⭐⭐⭐⭐⭐

關鍵想法:不要去找那個值 — 去找「太小」與「夠大」之間的分界點。絕不提早 return mid;持續縮小範圍,把指標擠到邊緣上。

這兩個輔助函式是只差一個字元的同一段程式碼(< vs <=),而且它們完全不做相等判斷:

輔助函式 移動 l 的條件 相等時走哪條路 回傳
findLeft nums[mid] < target 走進 else → r = mid - 1(往左推) l
findRight nums[mid] <= target 走進 if → l = mid + 1(往右推) r

為什麼這樣成立 — 結束時 l 與 r 已經交錯成 r == l - 1,正好夾住分界點:

text
after findLeft :  everything left of l is  < target   → l = first index >= target
after findRight:  everything right of r is > target   → r = last  index <= target

為什麼 [l, r] 同時也是有效性檢查(不必再回頭讀 nums):

text
target present  → l = first equal idx, r = last equal idx  → l <= r  ✅
target absent   → both searches collapse to the same gap:
                  l = insertion point p,  r = p - 1        → l >  r  ❌ return [-1,-1]

與 Python bisect 的對應關係 — 把這個對照記起來,兩個輔助函式就再也忘不掉:

python
findLeft(nums, target)  ==  bisect.bisect_left(nums, target)       # count of elements < target
findRight(nums, target) ==  bisect.bisect_right(nums, target) - 1  # count of elements <= target, minus 1
推薦模板(兩個輔助函式,閉區間 l <= r)
python
# python - LC 34 Find First and Last Position of Element in Sorted Array
# IDEA: two binary searches — left boundary + right boundary
# time = O(log N), space = O(1)
class Solution:
    def searchRange(self, nums, target):
        l = self.findLeft(nums, target)
        r = self.findRight(nums, target)
        # NOTE !!! l <= r is the existence check (no nums[] lookup needed)
        return [l, r] if l <= r else [-1, -1]

    def findLeft(self, nums, target):
        l, r = 0, len(nums) - 1
        while l <= r:
            mid = l + (r - l) // 2
            # NOTE !!! strict `<` → equality falls to else → keep pushing LEFT
            if nums[mid] < target:
                l = mid + 1
            else:
                r = mid - 1
        return l   # NOTE !!! return l

    def findRight(self, nums, target):
        l, r = 0, len(nums) - 1
        while l <= r:
            mid = l + (r - l) // 2
            # NOTE !!! `<=` → equality goes into if → keep pushing RIGHT
            if nums[mid] <= target:
                l = mid + 1
            else:
                r = mid - 1
        return r   # NOTE !!! return r
java
// java - LC 34 Find First and Last Position of Element in Sorted Array
// IDEA: two binary searches — left boundary + right boundary
// time = O(log N), space = O(1)
public int[] searchRange(int[] nums, int target) {
    int l = findLeft(nums, target);
    int r = findRight(nums, target);
    return l <= r ? new int[]{l, r} : new int[]{-1, -1};
}

private int findLeft(int[] nums, int target) {
    int l = 0, r = nums.length - 1;
    while (l <= r) {
        int mid = l + (r - l) / 2;
        if (nums[mid] < target) l = mid + 1;   // strict <
        else r = mid - 1;
    }
    return l;
}

private int findRight(int[] nums, int target) {
    int l = 0, r = nums.length - 1;
    while (l <= r) {
        int mid = l + (r - l) / 2;
        if (nums[mid] <= target) l = mid + 1;  // <= (the only difference)
        else r = mid - 1;
    }
    return r;
}
替代解法 1:一個輔助函式,呼叫兩次(target 與 target + 1)⭐⭐⭐⭐⭐

最乾淨的技巧 — 只寫 bisect_left,然後注意到 target 最後一次出現的位置 就在 target + 1 第一次出現的位置的前一格:

python
# python - LC 34, half-open boundary [lo, hi)
# time = O(log N), space = O(1)
class Solution:
    def searchRange(self, nums, target):
        def search(x):
            """first index i where nums[i] >= x  (== bisect_left)"""
            lo, hi = 0, len(nums)          # NOTE: hi = len(nums), NOT len-1
            while lo < hi:
                mid = (lo + hi) // 2
                if nums[mid] < x:
                    lo = mid + 1
                else:
                    hi = mid               # NOTE: no -1 (half-open)
            return lo

        lo = search(target)
        hi = search(target + 1) - 1        # last idx of target = first idx of target+1, minus 1
        return [lo, hi] if lo <= hi else [-1, -1]
替代解法 2:相等時記錄 bound(最明確)

保留 nums[mid] == target 這個分支,並記下目前為止看到的最佳候選:

python
# python - LC 34, explicit "record then keep searching"
# time = O(log N), space = O(1)
class Solution:
    def searchRange(self, nums, target):
        def find_bound(is_first):
            l, r = 0, len(nums) - 1
            bound = -1
            while l <= r:
                mid = l + (r - l) // 2
                if nums[mid] == target:
                    bound = mid                      # record candidate
                    if is_first:
                        r = mid - 1                  # DON'T return — keep going left
                    else:
                        l = mid + 1                  # keep going right
                elif nums[mid] < target:
                    l = mid + 1
                else:
                    r = mid - 1
            return bound

        left = find_bound(True)
        if left == -1:                               # early exit: target absent
            return [-1, -1]
        return [left, find_bound(False)]
替代解法 3:bisect 一行解(面試的保底寫法 / 驗算用)
python
# python - LC 34 via bisect
# time = O(log N), space = O(1)
import bisect
class Solution:
    def searchRange(self, nums, target):
        left = bisect.bisect_left(nums, target)
        if left >= len(nums) or nums[left] != target:
            return [-1, -1]
        return [left, bisect.bisect_right(nums, target) - 1]
視覺追蹤:nums = [5,7,7,8,8,10]、target = 8
text
findLeft (strict <, return l):
  l=0 r=5  mid=2  nums[2]=7 <  8  → l=3
  l=3 r=5  mid=4  nums[4]=8 !< 8  → r=3
  l=3 r=3  mid=3  nums[3]=8 !< 8  → r=2
  l=3 > r=2 → exit, return l=3          ✓ first index of 8

findRight (<=, return r):
  l=0 r=5  mid=2  nums[2]=7 <= 8  → l=3
  l=3 r=5  mid=4  nums[4]=8 <= 8  → l=5
  l=5 r=5  mid=5  nums[5]=10 > 8  → r=4
  l=5 > r=4 → exit, return r=4          ✓ last index of 8

l=3 <= r=4 → [3, 4] ✓

target 不存在的情況,nums = [5,7,7,8,8,10]、target = 6:

text
findLeft  → l = 1   (insertion point)
findRight → r = 0   (= insertion point - 1)
l=1 > r=0 → [-1, -1] ✓
模板比較
模板 邊界 迴圈 「太小」時的更新 回傳 適用時機
兩個輔助函式 < / <= 閉區間 [l, r] l <= r l = mid + 1 / r = mid - 1 l / r 推薦 — 對稱、沒有相等分支、l <= r 順便驗證
一個輔助函式,target 與 target+1 半開區間 [lo, hi) lo < hi lo = mid + 1 / hi = mid lo 要背的程式碼最少;只適用於整數 target
相等時追蹤 bound 閉區間 [l, r] l <= r 保留三向 if/elif/else bound 口頭講解時最好懂
bisect_left / bisect_right — — — — 允許用函式庫的 Python 面試
常見陷阱
  • ❌ nums[mid] == target 時就 return mid → 停在區塊中間,而不是邊緣
  • ❌ 從 findLeft 回傳 r(或從 findRight 回傳 l)→ 差一錯誤;左搜尋回傳 l,右搜尋回傳 r
  • ❌ 混用邊界風格:hi = len(nums) 必須搭配 while lo < hi 與 hi = mid(不減 1);r = len(nums)-1 必須搭配 while l <= r 與 r = mid - 1
  • ❌ 忘了空陣列的情況 — 兩個閉區間輔助函式都會自然處理(l=0, r=-1 → 跳過迴圈 → l=0 > r=-1 → [-1,-1])
  • ❌ 事先檢查 if target not in nums — 那是 O(N),直接毀掉 O(log N) 的要求
相似題目
LC # 題目 關鍵差異
34 Find First and Last Position of Element in Sorted Array 基準題 — 兩個邊界都要
35 Search Insert Position 只用 findLeft,回傳 l 且不做驗證
704 Binary Search 只找確切值,不需處理重複
278 First Bad Version 對布林判定式(而非 <)做 findLeft
852 / 162 Peak Index / Find Peak Element 以 nums[mid] < nums[mid+1] 為邊界條件
744 Find Smallest Letter Greater Than Target bisect_right + 取模繞回
1146 Snapshot Array 對每個索引的版本清單做 bisect
658 Find K Closest Elements 用 findLeft 定位視窗起點,再往外擴
300 Longest Increasing Subsequence (O(N log N)) 用 bisect_left 替換 tails — §1.5
981 Time Based Key-Value Store findRight(最大且 <= 查詢值的時間戳)
436 Find Right Interval 對排序後的起點做 findLeft
1898 Maximum Number of Removable Characters 對答案做邊界搜尋 + 可行性檢查

1.4) 對答案空間做二分搜尋 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

不是在陣列裡面找某個值,而是對候選答案的範圍做二分搜尋,並用一個可行性判定式決定要留下哪一半。它是 tier-5 的二分搜尋技巧中最少被練到的一項,因此獨立成一份文件:

完整內容 — canFinish / isValid 的思考框架、最小化 vs 最大化的 決策矩陣、left = max(nums) / right = sum(nums) 的邊界配方、 單調判定式的證明、值域計數,以及所有題解 (LC 875、410、1011、1283、1482、1231、2616、1631、378、287、1539): binary_search_on_answer.md。

辨識關鍵字:「minimize the maximum」、「maximize the minimum」、「找出最小的 capacity / speed / divisor」、「能不能切分 / 分配 / 派送」。

1.5) 對自己維護的陣列做二分搜尋 — tails 模式 (LC 300) 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

上面每個模板搜尋的都是輸入陣列。這一個搜尋的是掃描過程中一邊走一邊建出來的小陣列 — 而那個陣列是刻意保持有序的,正是為了能對它做 lower bound。這就是 O(n log n) LIS 背後的模式, 也是 LC 300 會被歸在二分搜尋底下的原因。

這個演算法自己的專屬 sheet — 它源自的紙牌遊戲、牌堆 / Dilworth 論證、如何還原出子序列本身, 以及可以歸約成 LIS 的題目 — 在 patience_sorting.md。本節是二分搜尋 的視角:跑的是哪一個模板,以及為什麼「每個元素只寫一次」就是完整的更新。

核心想法 — 每個可達成的長度一格,存最小的結尾值

python
# python - LC 300 Longest Increasing Subsequence, O(n log n)
# IDEA: tails[k] = the SMALLEST tail value among increasing subsequences of length k+1
#       -> tails is sorted -> lower-bound it (findLeft, §1.3) -> append or overwrite
# time = O(n log n), space = O(n)
class Solution(object):
    def lengthOfLIS(self, nums):
        # tails[k] : smallest possible ending value of an increasing run of length k + 1
        tails = []

        for num in nums:
            # NOTE !!! this IS findLeft from §1.3, run on `tails` instead of on `nums`
            l, r = 0, len(tails) - 1
            while l <= r:
                mid = l + (r - l) // 2
                if tails[mid] < num:
                    l = mid + 1      # strict < -> equality fails this test, so it
                                     # takes the else and pushes r left
                else:
                    r = mid - 1
            # l == lower_bound(tails, num) == first index with tails[l] >= num

            if l == len(tails):
                tails.append(num)    # num beats every tail -> a NEW longest length exists
            else:
                tails[l] = num       # same length, cheaper tail -> overwrite

        return len(tails)            # NOTE !!! the LENGTH is the answer, not the contents

bisect_left 一行版、Java 版,以及 LC 354 的變化題都在 binary_search_examples.md §18。這裡刻意把迴圈完整寫出來: 它和 findLeft(§1.3)逐字相同,而這正是這題該放在本 sheet 的理由。

為什麼可行 — 三個論證

1) tails 永遠有序,所以二分搜尋是合法的。 先假設它嚴格遞增,再檢查掃描可能走的兩種情況。只要格子 l-1 存在,lower bound 就保證 tails[l-1] < num;只要格子 l 存在,就保證 num <= tails[l]:

  • append(l == len(tails)):陣列尾端多出 num,且 tails[l-1] < num — 仍然嚴格遞增。
  • overwrite(l < len(tails)):num 取代 tails[l],新的鄰居關係是 tails[l-1] < num <= tails[l] < tails[l+1] — 最後一項只在格子 l+1 存在時才需要看, 而且換成更小的值在哪種情況下都不會破壞順序。

所以處理完每個元素後陣列都仍嚴格遞增,這讓判定式 tails[i] < num 在前綴為真、在後綴為假 — 也就是任何二分搜尋都需要的單調條件。

2) 每次最多只有一個格子會被改善,而那個格子就是 l。 num 只有在某個長度 k+1 的遞增序列結尾 < num 時,才能接在它後面。因為 tails 有序, 結尾 < num 的長度剛好是 k = 0 .. l-1,所以 num 能延伸的最長序列長度是 l,接出來的 新長度是 l+1:

text
tails:   [ tails[0] ... tails[l-1] | tails[l] ... ]
           <  num  (extendable)      >= num
                          ^ longest extendable run has length l
                            -> num is a candidate tail for length l + 1 -> slot l
  • 格子 l 會被改善:新的候選結尾是 num,而 num <= tails[l],所以 min(tails[l], num) = num。(若 l 已超出尾端,代表長度 l+1 之前做不到、現在做到了 — 那就是 append。)
  • 任何 j < l 的格子不會被改善:num 可以 當長度 j+1 的結尾,但 tails[j] < num, 已經存著更小的結尾。
  • 任何 j > l 的格子不會被改善:要透過 num 達到長度 j+1,需要一個長度 j 且結尾 < num 的序列,但 tails[j-1] >= tails[l] >= num,這樣的序列並不存在。

所以「每個元素只寫一次」不是最佳化 — 它就是完整的更新。

3) len(tails) 就是 LIS 長度。 >=:存在的每個格子都是某次 append 產生的,而 append 只在 num 真的延伸了某個序列時才發生 — 所以長度 len(tails) 的遞增序列確實存在。 <=:一個長度 L 的遞增子序列讓長度 L 變成可達成,依論證 2,tails 一定會有格子 L-1。 兩個方向都無法再更好。

tails 不是一個子序列。 只有它的長度有意義:

text
nums  = [3, 4, 5, 1]
tails = [1, 4, 5]     <- 1 sits at index 0 but arrives LAST in the input
len   = 3             <- correct: the LIS is [3,4,5]

overwrite 所編碼的那個貪心

tails[l] = num 就是「結尾更小絕不會更糟」這個貪心:任何未來能接在舊結尾後面的 y,也一定能 接在 num 後面,甚至更多。這個覆寫不會丟掉任何可達成的答案 — 而這正是論證 2 所證明的事。它和 patience sorting 是同一個動作:把每張牌放到「牌頂 >= num 的最左邊那一堆」,沒有這樣的牌堆就 開一堆新的,答案就是牌堆數。

模式:怎麼認出它

當以下全部成立時,就該想到 tails:

  • 答案是最長鏈的長度(或 n − 長度),不是鏈本身,也不是這種鏈有幾條;
  • 「可以接起來」是單一 key 上的全序(數值上的 <,或某個可以先排序的 key),所以中途的 進度可以用一個結尾值總結;
  • 顯然的解法是 O(n²) DP dp[i] = max(dp[j]) + 1,而追問要求 O(n log n)。

這個推廣值得記住:當一個 DP 的狀態是「每個已達成長度的最佳值」而那張表是單調的,這張表就可以 二分搜尋,整列 DP 轉移也就塌成一次 lower bound 查詢。

差一位的地方 查詢 Python
嚴格遞增 (LC 300) 第一個 >= num 的結尾 bisect_left
非遞減(允許重複) 第一個 > num 的結尾 bisect_right
非遞增 / 遞減 把數值取負,再用上面的做法 對 -num 用 bisect_*

要的是子序列本身,不只是長度? 在 tails 之外,另外記下每格所存元素在輸入中的索引, 以及一個 prev[i] 指向 i 被放進來時位在格子 l-1 的那個索引;再從最後一次 append 沿著 prev 走回去。tails 本身不能直接當答案讀出來(見上面 [3,4,5,1] 的追蹤)。

相似題目:同樣是 tails + lower bound

LC # 題目 差異在哪
300 Longest Increasing Subsequence 基準題 — bisect_left,答案是 len(tails)
354 Russian Doll Envelopes 排序 (w 遞增, h 遞減),再對 heights 做 LIS — 同寬時的 tie 規則正是用來擋掉同寬成鏈
1964 Longest Obstacle Course at Each Position 非遞減 → bisect_right,而且每個位置的答案就是插入位置 + 1,邊走邊回報
2111 Minimum Operations to Make the Array K-Increasing 依 mod k 拆成 k 個剩餘類,各自跑非遞減版;保留 len - LIS
1671 Minimum Removals to Make a Mountain Array 從左邊算「結尾在 i」的 LIS + 從右邊算「起點在 i」的 LIS,兩邊都用這個掃描
646 Maximum Length of Pair Chain 同樣是成鏈問題;按結尾值排序後單純貪心就夠了,但 tails 掃描也能解
1996 The Number of Weak Characters 來自 LC 354 的排序 + tie 規則同門技巧,只是不需要二分搜尋

陷阱 — 看起來像 LIS,但不屬於這個模式的題目:

LC # 題目 為什麼 tails 不行
673 Number of Longest Increasing Subsequence 要的是數量而不是長度 — 每個長度只存一個結尾,無法帶著重數。改用 O(n²) DP 加一個計數陣列,或對值域開 BIT
368 Largest Divisible Subset 「可以接起來」是整除關係,不是全序,所以沒有單一結尾能總結一條鏈 — 用 O(n²) DP + parent pointer
1027 Longest Arithmetic Subsequence 狀態是 (index, 差值) — 是 hash map DP,不是一張有序表

1.6) 相關演算法與資料結構

互補演算法:

  • 雙指標:用於沒有隨機存取能力的已排序序列
  • 滑動視窗:用於具備特定性質的子陣列問題
  • 遞迴:另一種實作方式

資料結構:

  • 陣列:二分搜尋的主要應用場景
  • 二元搜尋樹:樹走訪中隱含的二分搜尋
  • 雜湊表:不需要排序時的 O(1) 查詢替代方案

2) 二分搜尋模板與模式

補充資源

2.0) 迴圈結束條件比較

關鍵差異:結束條件決定迴圈何時停止,也連帶影響邊界的處理方式。

條件 邊界型態 使用時機 主要特徵
while (l <= r) 閉區間 [l, r] 標準二分搜尋 • 最常見的做法
• 搜尋空間同時包含 l 與 r
• 需要 l = mid + 1、r = mid - 1
while (l < r) 半開區間 [l, r) 找邊界 / 插入位置 • 搜尋空間不含 r
• l == r 時迴圈結束
• 使用 l = mid + 1、r = mid
while (l < r - 1) 保留間隙 特殊情況下避免無窮迴圈 • 確保 l 與 r 永不相鄰
• 迴圈結束後需要再做一次檢查
• 較少見,用於複雜條件

詳細分析:

java
// 1) while (l <= r) - CLOSED BOUNDARY [l, r]
while (l <= r) {
    int mid = l + (r - l) / 2;
    if (nums[mid] == target) return mid;
    else if (nums[mid] < target) l = mid + 1;  // MUST +1
    else r = mid - 1;                          // MUST -1
}
// Pros: Standard, easy to understand
// Cons: Can return -1 if not found
java
// 2) while (l < r) - HALF-OPEN [l, r)
while (l < r) {
    int mid = l + (r - l) / 2;
    if (nums[mid] < target) l = mid + 1;       // +1 to exclude mid
    else r = mid;                              // NO -1, keep mid in range
}
// After loop: l == r, points to answer or insertion point
// Pros: Great for finding boundaries, no -1 return
// Cons: Requires different logic for different problems
java
// 3) while (l < r - 1) - GAP-BASED
while (l < r - 1) {
    int mid = l + (r - l) / 2;
    if (condition(mid)) l = mid;
    else r = mid;
}
// Final check needed: examine both l and r
// Pros: Avoids infinite loops in complex conditions
// Cons: More complex, requires post-processing

各自的使用時機:

  • while (l <= r):經典二分搜尋,找確切值
  • while (l < r):找第一次 / 最後一次出現的位置、插入位置、找峰值
  • while (l < r - 1):mid 可能等於 l 或 r 的複雜條件

依模式分類的經典 LeetCode 題目

模式 1:while (l <= r) — 精確搜尋

  • LC 704: Binary Search(基本實作)
  • LC 33: Search in Rotated Sorted Array
  • LC 81: Search in Rotated Sorted Array II
  • LC 74: Search a 2D Matrix
  • LC 240: Search a 2D Matrix II
  • LC 69: Sqrt(x)
  • LC 367: Valid Perfect Square
  • LC 441: Arranging Coins

模式 2:while (l < r) — 找邊界 / 找峰值

  • LC 34: Find First and Last Position of Element
  • LC 35: Search Insert Position
  • LC 162: Find Peak Element
  • LC 852: Peak Index in a Mountain Array
  • LC 153: Find Minimum in Rotated Sorted Array
  • LC 154: Find Minimum in Rotated Sorted Array II
  • LC 278: First Bad Version
  • LC 658: Find K Closest Elements
  • LC 744: Find Smallest Letter Greater Than Target

模式 3:驗證函式類題目(LC 410、875、1011、1060、1482)— 這些題二分搜尋的是 答案,而不是索引;見 binary_search_on_answer.md。

2.1) 標準二分搜尋模板 — LC 704

關鍵原則:

  • 初始化:left = 0, right = nums.length - 1(閉區間)
  • 迴圈條件:while (left <= right)
  • 指標更新:left = mid + 1、right = mid - 1
  • 清晰度小訣竅:所有條件都用 else if,把邏輯攤開講明白

寫程式小訣竅:避免使用 else — 把所有條件都寫成 else if,清楚列出每種情況,減少 bug。

java
// Java Implementation
public int binarySearch(int[] nums, int target) {
    int left = 0;
    int right = nums.length - 1;
    
    // Use <= to search when left == right
    while (left <= right) {
        int mid = left + (right - left) / 2;  // Avoid overflow
        
        if (nums[mid] == target) {
            return mid;
        } else if (nums[mid] < target) {
            left = mid + 1;   // Target in right half
        } else {  // nums[mid] > target
            right = mid - 1;  // Target in left half
        }
    }
    return -1;  // Not found
}
python
# Python Implementation  
def binary_search(nums, target):
    left, right = 0, len(nums) - 1
    
    # Closed boundary [left, right] - includes both endpoints
    while left <= right:
        mid = left + (right - left) // 2  # Avoid overflow
        
        if nums[mid] == target:
            return mid
        elif nums[mid] < target:
            left = mid + 1   # Search right half
        else:
            right = mid - 1  # Search left half
            
    return -1  # Target not found

2.2) 浮點數二分搜尋

用於答案是實數的題目(開根號、最佳分配、幾何問題):

python
def sqrt(x: float, precision=1e-9) -> float:
    lo, hi = 0.0, max(1.0, x)
    while hi - lo > precision:
        mid = (lo + hi) / 2
        if mid * mid <= x:
            lo = mid
        else:
            hi = mid
    return lo

# General pattern: binary search on continuous domain
def minimize_real(lo: float, hi: float, iterations=100) -> float:
    for _ in range(iterations):   # fixed iterations avoids float precision issues
        mid = (lo + hi) / 2
        if feasible(mid):
            hi = mid
        else:
            lo = mid
    return (lo + hi) / 2

2.3) 二維矩陣搜尋 — 兩種不同的題目 (LC 74 / LC 240)

LC 74(矩陣的列與行都排序,值由左到右、由上到下遞增):

python
def searchMatrix(matrix, target):
    m, n = len(matrix), len(matrix[0])
    lo, hi = 0, m * n - 1
    while lo <= hi:
        mid = (lo + hi) // 2
        val = matrix[mid // n][mid % n]
        if val == target: return True
        elif val < target: lo = mid + 1
        else: hi = mid - 1
    return False

LC 240(每列排序、每行也排序,但整體並未排序 — 用階梯搜尋):

python
def searchMatrix(matrix, target):
    row, col = 0, len(matrix[0]) - 1   # start top-right
    while row < len(matrix) and col >= 0:
        if matrix[row][col] == target: return True
        elif matrix[row][col] > target: col -= 1   # too big → go left
        else: row += 1                             # too small → go down
    return False

關鍵洞見:LC 240 的階梯搜尋每一步消掉一整列或一整行 → O(m+n)。不要把 LC 240 當成攤平後的二分搜尋 — 這個矩陣並非整體有序。

Java — 同樣的攤平做法,完整寫出來:

java
// java
// LC 74
// V1
// IDEA : BINARY SEARCH + FLATTEN MATRIX
// https://leetcode.com/problems/search-a-2d-matrix/editorial/
public boolean searchMatrix_2(int[][] matrix, int target) {
    int m = matrix.length;
    if (m == 0)
        return false;
    int n = matrix[0].length;

    // binary search
    /** NOTE !!! FLATTEN MATRIX */
    int left = 0, right = m * n - 1;
    int pivotIdx, pivotElement;
    while (left <= right) {
        pivotIdx = (left + right) / 2;
        /** NOTE !!! TRICK HERE :
         *
         *   pivotIdx / n : y index
         *   pivotIdx % n : x index
         */
        pivotElement = matrix[pivotIdx / n][pivotIdx % n];
        if (target == pivotElement)
            return true;
        else {
            if (target < pivotElement)
                right = pivotIdx - 1;
            else
                left = pivotIdx + 1;
        }
    }
    return false;
}

2.4) 雙調 / 山脈陣列 — 遞減順序的翻轉

山脈(雙調)陣列本身沒有排序,但它確實是兩段有序序列的接合, 所以需要三次二分搜尋:先用爬坡搜尋找到峰值,接著在左半段做一般的 遞增搜尋,最後在右半段做遞減搜尋。

遞減順序的模板就是把標準模板的比較方向翻過來:

text
ascending : nums[mid] < target  -> go RIGHT (l = mid + 1)
descending: nums[mid] > target  -> go RIGHT (l = mid + 1)

有個漂亮的寫法能把兩者合成一個函式:if ((val < target) == ascending) l = mid + 1; else r = mid - 1;

題解範例 — LC 1095 Find in Mountain Array — 見 binary_search_examples.md。

2.5) 沒有右端點 — 指數(Galloping)搜尋 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

二分搜尋需要一個右端點,而有時候輸入就是不給你:LC 702 (Search in a Sorted Array of Unknown Size) 給你一個沒有長度的 reader,CtCI 10.4 給你一個 Listy,它的 elementAt() 超出尾端時只會回傳 -1,而分頁式 API 的行為也一樣。先用加倍的方式找出右端點,再在其中做二分搜尋。

text
probe 1, 2, 4, 8, 16, ...  until the value overshoots the target (or falls off the end)
                           -> the answer is inside [prev probe, this probe]

兩個階段各花 O(log p) 次探測,其中 p 是目標的索引 — 所以整個搜尋是 O(log p),與陣列(未知、可能極大)的總長度無關。

python
# python
# LC 702 - Search in a Sorted Array of Unknown Size
# IDEA: double the index until reader.get() overshoots -> that bounds the answer;
#       then run an ordinary binary search inside the window
# time = O(log p) where p is the target's index, space = O(1)
class Solution(object):
    def search(self, reader, target):
        hi = 1
        while reader.get(hi) < target:       # out of range returns 2^31-1, so this stops
            hi <<= 1                         # 1, 2, 4, 8, ... only O(log p) probes
        lo = hi >> 1                         # the previous probe was still < target

        while lo <= hi:
            mid = lo + (hi - lo) // 2
            val = reader.get(mid)
            if val == target:
                return mid
            elif val < target:
                lo = mid + 1
            else:
                hi = mid - 1                 # also the "past the end" case
        return -1
java
// java
// CtCI 10.4 - search a sorted "Listy" that has no size(); elementAt() returns -1 past the end
// IDEA: same doubling, but the sentinel here is -1, which reads as TOO SMALL —
//       so it has to be tested for explicitly instead of comparing naturally
// time = O(log p), space = O(1)
int search(Listy list, int target) {
    int hi = 1;
    while (list.elementAt(hi) != -1 && list.elementAt(hi) < target) {
        hi *= 2;
    }
    int lo = hi / 2;

    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;
        int val = list.elementAt(mid);
        if (val == target) return mid;
        if (val == -1 || val > target) hi = mid - 1;   // -1 means past the end -> go left
        else lo = mid + 1;
    }
    return -1;
}
  • 陷阱在於越界哨兵值的方向。 2^31-1 排在所有真實值之上,所以一般的比較就能處理它。 -1 則排在它們之下,所以沒被檢查的 -1 會把搜尋推向右邊、衝出尾端,永遠停不下來。
  • 為什麼用加倍而不是固定步長。 步長 k 需要 p/k 次探測;加倍只要 log2(p) 次,而且超過答案的幅度永遠不會超過兩倍。
  • 面試之外的同一招。 把一個小的有序清單和一個巨大的有序清單取交集時,會對每個元素做 galloping 搜尋而不是逐一掃描;Timsort 的合併也用它來跳過某一側的長串。

2.6) 探測可能落在空洞上 — 稀疏搜尋 優先度 3/5 — 值得會 — 多半是必備模式的變形

CtCI 10.5:一個排序好的字串陣列,隨機位置塞了 ""。mid 可能落在空字串上, 而空字串不帶任何順序資訊 — 你無法判斷目標在它的左邊還是右邊,所以標準模板會卡住。 解法:從 mid 往外走到最近的真實項目,再照常比較。

python
# python
# CtCI 10.5 - find a word in a sorted string array that is padded with "" entries
# IDEA: a probe landing on "" tells you nothing, so walk outward from mid — both
#       directions at once — to the closest real string, then continue normally
# time = O(log n) when holes are sparse, O(n) worst case; space = O(1)
def sparse_search(words, target):
    if not target:                            # "" has no defined position
        return -1
    lo, hi = 0, len(words) - 1

    while lo <= hi:
        mid = lo + (hi - lo) // 2

        if not words[mid]:                    # in a hole: find the nearest word
            left, right = mid - 1, mid + 1
            while True:
                if left < lo and right > hi:
                    return -1                 # this whole window is empty
                elif right <= hi and words[right]:
                    mid = right
                    break
                elif left >= lo and words[left]:
                    mid = left
                    break
                left -= 1
                right += 1

        if words[mid] == target:
            return mid
        elif words[mid] < target:
            lo = mid + 1
        else:
            hi = mid - 1
    return -1
  • 往兩側外擴,而不是只往右。 只往右掃可能會越過 hi,逼你丟掉仍然包含目標的那一半。
  • 保證沒了,而這正是答案。 長度為 n 的一整串 "" 會讓它變成 O(n);只有在空洞稀疏時才能維持 O(log n)。要說出這一點 — 這和 LC 81(§1.2) 是同一個教訓:nums[l] == nums[mid] == nums[r] 讓探測失去資訊, 退而求其次的 l++ / r-- 會讓搜尋退化成 O(n)。

2.7) 速查 — 其他帶有二分搜尋味道的題目

這些知名題目都是重用本文件已有的模板;列在這裡是為了讓你一眼認出來,不需要新技巧。

LC 題目 用哪個模板
275 H-Index II 對索引做邊界搜尋:第一個滿足 citations[i] >= n - i 的 i(§1.3)
1268 Search Suggestions System 排序 products,對逐步變長的前綴做 lower_bound(§1.3;binary_search_examples.md 的 LC 436);Trie 是另一種做法
349 / 350 Intersection of Two Arrays I / II 排序較大的陣列,對每個元素做二分搜尋(也可用雜湊集合 / 雙指標)
792 Number of Matching Subsequences 每個字元一份排序索引清單 + upper_bound 跳到下一次出現處(前綴和 + lower bound 家族 — binary_search_examples.md §19)
222 Count Complete Tree Nodes 對最後一層的節點索引做二分搜尋,沿著候選值的位元路徑往下走來驗證 — O(log²n)
1044 Longest Duplicate Substring 對答案長度做二分搜尋 + Rabin-Karp 滾動雜湊當判定式(binary_search_on_answer.md)
1385 Find the Distance Value Between Two Arrays 排序 arr2,對每個 arr1[i] 二分搜尋最接近的鄰居
1346 Check If N and Its Double Exist 排序 + 二分搜尋 2*x(也可用雜湊集合)

3) 總結與速查

3.1) 何時該用二分搜尋

✅ 以下情況請用二分搜尋:

  • 陣列有序(完全有序、部分有序或旋轉有序)
  • 搜尋空間具備單調性
  • 需要 O(log n) 的搜尋效能
  • 要找邊界或插入位置
  • 具備二元性質的最佳化問題

3.2) 模板選擇指南

整份文件用這一張表就夠了:看輸入的形狀,決定該拿哪個模板。

題型 / 輸入形狀 模板 題解範例
在排序陣列中做精確搜尋 標準閉區間 while l <= r — §2.1 LC 704
左邊界(第一個 >= target 的索引) Lower bound — §1.3 LC 34、LC 35、LC 278
右邊界(最後一個 <= target 的索引) Upper bound − 1 — §1.3 LC 34、LC 981
插入位置 Lower bound,回傳 l 不做驗證 — §1.3 LC 35
峰 / 谷,沒有目標值 半開區間 while l < r、r = mid LC 162、LC 852
旋轉排序陣列 找出有序的那一半 — §1.2 LC 33、LC 81、LC 153、LC 154
陣列先升後降(山脈 / 雙調) 找峰值 + 兩次有序搜尋,其中一次遞減 — §2.4 LC 1095
輸入沒有長度(reader / 串流 / 分頁式 API) 把索引加倍找出右端點,再搜尋 — §2.5 LC 702
探測可能落在不帶資訊的位置(""、重複值) 往外走到最近的可用項目 — §2.6 CtCI 10.5、LC 81
二維矩陣 整體有序就攤平,只有列 + 行有序就走階梯 — §2.3 LC 74 vs LC 240
答案是實數、要求精度 浮點數 / 固定迭代次數 — §2.2 LC 69(浮點數版)
「最小化最大值」/「最大化最小值」 對答案做二分搜尋 — binary_search_on_answer.md LC 410、875、1011、1231、2616
值域已知,但陣列沒有排序 對值域做二分搜尋 + 計數 — binary_search_on_answer.md LC 287、LC 378
可行性判斷需要走訪圖 對答案做二分搜尋 + BFS/DFS 判定式 — binary_search_on_answer.md LC 1631、LC 778
O(n log n) LIS、加權抽樣、有序歷史紀錄 對你維護的有序陣列做 lower_bound — §1.5,題解在 binary_search_examples.md LC 300、LC 354、LC 528、LC 981

3.3) 常見陷阱與訣竅

🚫 常見錯誤:

  • mid = (left + right) / 2 的整數溢位 → 改用 mid = left + (right - left) / 2
  • 邊界更新寫錯(mid 與 mid ± 1 搞混)
  • 忘了做後續的有效性驗證
  • while l < r 搭配錯誤的更新方式造成無窮迴圈

✅ 最佳實務:

  • 一律用 else if 讓邏輯清楚
  • 邊界搜尋結束後要驗證結果
  • 邊界型態要前後一致(閉區間 vs 半開區間)
  • 用邊界情況測試:空陣列、單一元素、重複值

3.4) 面試訊號 — 該用哪個模式?

訊號 模式
「找出最小 / 最大的 X 使得……」 對答案做二分搜尋
「已排序陣列,找第一次 / 最後一次出現」 左 / 右邊界二分搜尋
「最小的值 >= X」/「最大的 <= X」,每個元素各問一次 排序一次 + lower / upper bound — §1.3
「左邊 / 右邊第一個比它大的元素」(位置性) 單調堆疊,不是二分搜尋 — §1.3
「陣列沒有 size()」/「API 是分頁式的」 用指數(galloping)搜尋找右端點 — §2.5
「矩陣的列與行都排序」 階梯搜尋(不是攤平的二分搜尋)
「答案是實數,要求精度」 浮點數二分搜尋
「最長遞增 / 鏈式子序列」,要把 O(n²) DP 改成 O(n log n) tails + lower bound — §1.5
「我們做得到 X 嗎?」具備單調性 對單調判定式做二分搜尋
已有 O(n) 解法但題目要 O(log n) 想一想:那個有序的搜尋空間是什麼?