二分搜尋

搜尋與排序優先度 5/5 — 必備 — 幾乎每一輪面試都會出現必備 更新於 Nov 12, 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 — 把模板 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),就能用二分搜尋
  • 搜尋邊界:找某個值第一次 / 最後一次出現的位置
  • 最佳化問題:找滿足限制條件的最小 / 最大值

參考資料

辨識

限制條件線索依照 complexity_cheatsheet.md 裡的尺規。

題目敘述中的訊號 限制條件線索 該用 不適用時
「已排序陣列……回傳 target 的索引,找不到回傳 -1」 「必須在 O(log n) 內完成」 模板 1 精確比對 有重複值又問「第一個 / 最後一個」→ 模板 2
「第一個與最後一個位置」、「插入位置」、「最小的值 >= x」、「最大的值 <= x」 n ≤ 10⁵,每個元素一次 O(log n) 查詢 模板 2 lower / upper bound 「右邊第一個比它大的元素」是位置性的 → monotonic_stack.md
「value 在 arr[left..right] 中出現幾次」,查詢很多次 q ≤ 10⁵ 次查詢 模板 2,以及 LC 2080 變形 陣列在查詢之間會被更新 → BIT / SortedList
「已排序陣列在未知樞紐點被旋轉……找出最小值」 要求 O(log n) 模板 3 有重複值(LC 154)會讓最壞情況變成 O(n)
「旋轉排序陣列……搜尋 target」 要求 O(log n) 模板 4
「最長遞增 / 鏈式子序列」,要把 O(n²) DP 加速 n ≤ 10⁵ 排除 O(n²) 模板 5 tails + lower bound 問的是最長子序列的個數(LC 673)→ O(n²) DP
「m x n 矩陣,每列有序,且每列第一個數大於上一列最後一個數」 O(log(m·n)) 模板 6 只有列與行各自有序(LC 240)→ 階梯搜尋,O(m + n)
「峰值元素」、「山脈陣列」、「nums[i] != nums[i + 1]」 要求 O(log n) 模板 7
「大小未知的陣列」、沒有長度的 reader、分頁式 API 目標索引為 p,O(log p) 模板 8
「最小化最大值」、「讓……成立的最小速度 / 容量 / 除數」 答案範圍可達 10⁹ binary_search_on_answer.md
「回傳一個誤差在 10⁻⁵ 內的實數」 §2.2 浮點數

理解二分搜尋的指標行為

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

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

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

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

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

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

迴圈本身就是下方的模板 1。

迴圈結束時:指標的位置

迴圈在 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)

關鍵 — 迴圈結束時,nums[0..l-1] < target 且 nums[l..] > target,所以 l 就是插入位置,不需要任何特判。

歸約到:模板 1 — 回傳 l 而不是 -1。

java
// java
// LC 35 - Search Insert Position
// IDEA: Template 1 unchanged; on a miss the exit position l is the insert position
// time = O(log n), space = O(1)
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(模板 1)

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

旋轉陣列搜尋(模板 3、模板 4)

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

找邊界 — 左 / 右,即 lower / upper bound(模板 2)

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

二維矩陣搜尋(模板 6)

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

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

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

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

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

找峰值(模板 7)

  • 目的:順著 mid 處的斜率,找出任一個局部最大值

沒有右端點 — 指數搜尋(模板 8)

  • 目的:把索引加倍找出右端點,再在其中搜尋

模板 1:閉區間上的精確比對 — LC 704 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

辨識 — 「給定一個已排序陣列與 target,回傳它的索引,找不到回傳 -1」,而且題目堅持 要 O(log n)。n ≤ 10⁴–10⁵ 時線性掃描也會過,所以 O(log n) 那一行就是這題的重點。

關鍵 — left 左邊的每個索引都 < target,right 右邊的每個索引都 > target,所以 target 只可能在 [left, right] 之內。每一輪丟掉 mid 和其中一半;當 left > right 時 視窗已空,而 left 正是 target 該被插入的位置。

python
# python
# LC 704 - Binary Search
# IDEA: closed window [left, right]; drop mid and the half that cannot hold target
# time = O(log n), space = O(1)
def search(nums, target):
    left, right = 0, len(nums) - 1
    while left <= right:                      # <= : a one-element window is still a candidate
        mid = left + (right - left) // 2      # // : an int index, never a float
        if nums[mid] == target:
            return mid
        elif nums[mid] < target:
            left = mid + 1                    # nums[..mid] are all < target
        else:
            right = mid - 1                   # nums[mid..] are all > target
    return -1                                 # window empty; left is the insert position
java
// java
// LC 704 - Binary Search
// IDEA: closed window [left, right]; drop mid and the half that cannot hold target
// time = O(log n), space = O(1)
public int search(int[] nums, int target) {
    int left = 0;
    int right = nums.length - 1;
    while (left <= right) {
        int mid = left + (right - left) / 2;  // no overflow, unlike (left + right) / 2
        if (nums[mid] == target) {
            return mid;
        } else if (nums[mid] < target) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    return -1;
}

複雜度

  • 時間 O(log n):while left <= right 每一輪至少把 right - left + 1 砍半,所以 迴圈最多跑 ⌊log₂ n⌋ + 1 次。
  • 空間 O(1):只有 left、right 與 mid。遞迴版要花 O(log n) 的堆疊。

試跑

text
nums = [-1, 0, 3, 5, 9, 12], target = 9
left right mid nums[mid]  action
0    5     2   3          3 < 9  -> left = 3
3    5     4   9          found  -> return 4

target = 2 (absent)
0    5     2   3          3 > 2  -> right = 1
0    1     0   -1         -1 < 2 -> left = 1
1    1     1   0          0 < 2  -> left = 2
left = 2 > right = 1 -> return -1   (left = 2 is the insert position, LC 35)

陷阱

  • while left < right 搭配 right = mid - 1 永遠不會檢查最後一個候選:nums = [5]、 target = 5 會回傳 -1。
  • left = mid 少了 + 1,視窗剩兩個元素時就會無限迴圈: nums = [1, 3]、target = 3 會一直停在 mid = 0。
  • mid = (left + right) / 2 在 Java 中當 left + right > 2³¹ − 1 時會溢位,在 Python 3 則是浮點數(nums[2.5] 會丟出 TypeError)。要寫 left + (right - left) // 2。
  • 有重複值時它回傳的是某一個索引,不是第一個:[2, 2, 2]、target = 2 回傳 1。 第一次 / 最後一次出現要用模板 2。
  • 在迴圈裡處理「target 落在 mid 與 mid + 1 之間」是多餘的,還會讀到越界的 nums[mid + 1];相信不變式,迴圈結束後用 left 即可。

不適用時 — 題目問的是 target 該放在哪裡,或它那一串重複值從哪裡開始 (LC 34、LC 35、LC 278):沒有 == target 的出口,所以要用 模板 2。

練習 — LC 704 ⭐ · LC 35 (回傳 left 而不是 -1) · LC 374 (比較是一次 API 呼叫) · LC 69 (以 mid * mid <= n 搜尋 x,保留最後一個通過的)

模板 2:Lower 與 Upper Bound — LC 34 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

辨識 — 「找出第一個與最後一個位置」、「插入位置」、「最小的值 >= x」、 「最大的值 <= x」、「時間 t 之前最近的時間戳」。答案是分界點,不是比對到的值,而且 輸入可能有重複值。n ≤ 10⁵ 且每個元素一次查詢 → 先排序一次,之後每次查詢 O(log n)。

關鍵 — 永遠不測相等:findLeft 把 == 推向左邊(<),findRight 推向右邊 (<=)。結束時 right == left - 1,兩者跨在分界點兩側,所以 findLeft 回傳第一個 >= target 的索引,findRight 回傳最後一個 <= target 的索引;target 存在若且唯若 first <= last。

python
# python
# LC 34 - Find First and Last Position of Element in Sorted Array
# IDEA: two partition searches; the one-character difference (< vs <=) decides
#       which way equality falls, and first <= last is the existence check
# time = O(log n), space = O(1)
class Solution(object):
    def searchRange(self, nums, target):
        first = self.findLeft(nums, target)
        last = self.findRight(nums, target)
        if first <= last:                     # absent -> both land in the same gap, first = last + 1
            return [first, last]
        return [-1, -1]

    def findLeft(self, nums, target):
        """first index with nums[i] >= target  (== bisect_left)"""
        left, right = 0, len(nums) - 1
        while left <= right:
            mid = left + (right - left) // 2
            if nums[mid] < target:            # strict < : equality goes to else, pushes right leftwards
                left = mid + 1
            else:
                right = mid - 1
        return left

    def findRight(self, nums, target):
        """last index with nums[i] <= target  (== bisect_right - 1)"""
        left, right = 0, len(nums) - 1
        while left <= right:
            mid = left + (right - left) // 2
            if nums[mid] <= target:           # <= : equality goes to if, pushes left rightwards
                left = mid + 1
            else:
                right = mid - 1
        return right
java
// java
// LC 34 - Find First and Last Position of Element in Sorted Array
// IDEA: two partition searches, differing only in < vs <=
// time = O(log n), space = O(1)
public int[] searchRange(int[] nums, int target) {
    int first = findLeft(nums, target);
    int last = findRight(nums, target);
    return first <= last ? new int[]{first, last} : new int[]{-1, -1};
}

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

private int findRight(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] <= target) left = mid + 1;  // <= (the only difference)
        else right = mid - 1;
    }
    return right;
}

複雜度

  • 時間 O(log n):兩個 while left <= right 迴圈,各自把視窗砍半;兩者都不會在比對成功時 提早結束,所以各跑滿 ⌈log₂ n⌉ 輪。
  • 空間 O(1):每次搜尋兩個指標。若輸入未排序,先排序會一次性多花 O(n log n) 時間, 不是每次查詢都花。

試跑

text
nums = [5, 7, 7, 8, 8, 10], target = 8
findLeft (strict <, return left):
  l=0 r=5  mid=2  7 <  8  -> l=3
  l=3 r=5  mid=4  8 !< 8  -> r=3
  l=3 r=3  mid=3  8 !< 8  -> r=2
  exit, return l=3          first index of 8
findRight (<=, return right):
  l=0 r=5  mid=2  7 <= 8  -> l=3
  l=3 r=5  mid=4  8 <= 8  -> l=5
  l=5 r=5  mid=5  10 > 8  -> r=4
  exit, return r=4          last index of 8
3 <= 4 -> [3, 4]

target = 6 (absent): findLeft -> 1, findRight -> 0, 1 > 0 -> [-1, -1]

陷阱

  • 在 nums[mid] == target 時 return mid,會停在一串重複值的任意位置: [8, 8, 8] 兩端都回傳索引 1。
  • 從 findLeft 回傳 right(或從 findRight 回傳 left)會差一:左搜尋回傳 left,右搜尋回傳 right。
  • 混用邊界風格:hi = len(nums) 需要 while lo < hi 與 hi = mid; right = len(nums) - 1 需要 while left <= right 與 right = mid - 1。兩邊各取一半, 不是無限迴圈就是漏掉最後一個索引。
  • 事先檢查 if target not in nums 是 O(n),直接丟掉題目要的 O(log n)。[] 不需要 特判:left = 0 > right = -1 就會得到 [-1, -1]。
  • 當答案是未排序輸入的原始索引(LC 436)時,要排序 (value, index) 配對, 不能只排序值,否則索引就丟了。

不適用時 — 「右邊第一個比它大的元素」取決於陣列順序,所以是位置性的掃描 → monotonic_stack.md。檢驗方式:若打亂輸入不會改變答案,就是 bound 查詢;若會改變,就是堆疊(見下方的 核心概念:Lower Bound vs 單調堆疊)。

練習 — LC 34 ⭐ · LC 35 (只用 findLeft,不做驗證) · LC 278 (對布林 API 做 findLeft) · LC 744 (upper bound,再用 % n 繞回) · LC 981 (每個 key 一次 floor 查詢) · LC 2080 (個數 = upper − lower,見下方)

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

選模板之前,先讀題目本身的措辭。凡是寫成 至少為 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
# IDEA: sort (value, original index) pairs once, then one bisect_left per query
# time = O(n log n) build + O(log n) per query, space = O(n)
import bisect

def first_at_least(raw, queries):
    pairs = sorted((v, i) for i, v in enumerate(raw))   # keep each value WITH its original index
    keys = [v for v, _ in pairs]                        # bisect needs a plain sorted list
    answers = []
    for x in queries:
        j = bisect.bisect_left(keys, x)                 # first value >= x
        answers.append(pairs[j][1] if j < len(keys) else -1)   # sorted position -> original index
    return answers
java
// java - the same query, hand-rolled (identical to findLeft in the template above)
// IDEA: strict < pushes equality left, so l ends on the first key >= x
// 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 § Find Right Interval。

相似題目:有序集合上的 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,然後覆寫它 — 模板 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
替代解法 1:一個輔助函式,呼叫兩次(target 與 target + 1)⭐⭐⭐⭐⭐

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

python
# python - LC 34, half-open boundary [lo, hi)
# IDEA: one bisect_left helper; last of target = first of target + 1, minus 1
# 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"
# IDEA: on a match record mid as the bound, then keep searching toward that side
# 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
# IDEA: bisect_left / bisect_right are findLeft and findRight + 1
# 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]
模板比較
模板 邊界 迴圈 「太小」時的更新 回傳 適用時機
兩個輔助函式 < / <= 閉區間 [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 面試
相似題目
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 — 模板 5
981 Time Based Key-Value Store findRight(最大且 <= 查詢值的時間戳)
436 Find Right Interval 對排序後的起點做 findLeft
1898 Maximum Number of Removable Characters 對答案做邊界搜尋 + 可行性檢查
2080 Range Frequency Queries 每個值一份索引清單;個數 = upper bound − lower bound — 見下一節

變形 — 區間內計數:Upper Bound − Lower Bound (LC 2080) 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

關鍵 — 同一個值的索引由左到右收集起來,本來就是有序的,所以 「[left, right] 裡有幾個」就是在那份清單上算 bisect_right(right) − bisect_left(left)。

歸約到:模板 2 — 兩個邊界都用,只是作用在每個值的索引清單上,而不是輸入本身。

LC 34 找的是一段相等值的區塊從哪裡開始、到哪裡結束。把兩端相減,就得到區塊裡有幾個 元素。同樣的減法可以算出一個排序清單落在任意閉區間 [lo, hi] 內的元素個數:

text
count of x in [lo, hi]  =  (# elements <= hi)  -  (# elements < lo)          # O(log N)
                        =  bisect_right(xs, hi) - bisect_left(xs, lo)
                        =  lower_bound(xs, hi + 1) - lower_bound(xs, lo)   # integers only

和 LC 34 不同的前提。 輸入陣列沒有排序,而且一排序就會毀掉查詢要問的位置。所以二分搜尋 不是跑在輸入上,而是跑在你自己建的排序清單上:每個值各一份清單,記錄它出現過的所有索引。 這份清單是免費排好序的,因為由左到右掃描時,索引本來就是遞增地被 append 進去。

text
"how often does value v appear in arr[left..right]?"
    -> the indices of v form a sorted list
    -> the question becomes "how many of those indices fall in [left, right]?"
    -> two bound queries on that list
核心概念
text
arr      = [12, 33, 4, 56, 22, 2, 34, 33, 22, 12, 34, 56]
index      0   1   2  3   4   5  6   7   8   9   10  11

positions[33] = [1, 7]          positions[22] = [4, 8]

query(0, 11, 33):  bisect_right([1,7], 11) = 2   (both indices <= 11)
                   bisect_left ([1,7],  0) = 0   (no index  <  0)
                   -> 2 - 0 = 2 ✓

query(5, 7, 22):   bisect_right([4,8], 7)  = 1   (4 <= 7, 8 > 7)
                   bisect_left ([4,8], 5)  = 1   (4 <  5)
                   -> 1 - 1 = 0 ✓   (index 4 is before the range, index 8 is after it)

這兩個邊界就是 LC 34 的 findLeft 與 findRight + 1,所以不用多背任何東西。它就是本卡的 Alternative 1(search(target) 與 search(target + 1)),只是套用在一個區間而不是單一值上。

python
# python
# LC 2080 - Range Frequency Queries
# IDEA: value -> sorted list of its indices (built in one scan),
#       then count the indices inside [left, right] with two bound queries
# time = O(n) build, O(log n) per query, space = O(n)
import bisect
from collections import defaultdict

class RangeFreqQuery(object):

    def __init__(self, arr):
        # value -> every index it appears at; appended left to right, so already sorted
        self.positions = defaultdict(list)
        for idx, val in enumerate(arr):
            self.positions[val].append(idx)

    def query(self, left, right, value):
        # `in` check first, so the defaultdict does not grow an empty list per miss
        if value not in self.positions:
            return 0
        idx_list = self.positions[value]

        start = bisect.bisect_left(idx_list, left)    # first index >= left
        end = bisect.bisect_right(idx_list, right)    # first index >  right
        # idx_list[start:end] are exactly the indices in [left, right]
        return end - start
java
// java
// LC 2080 - Range Frequency Queries
// IDEA: same lists; one hand-rolled lowerBound, called with left and right + 1
// time = O(n) build, O(log n) per query, space = O(n)
class RangeFreqQuery {
    private final Map<Integer, List<Integer>> positions = new HashMap<>();

    public RangeFreqQuery(int[] arr) {
        for (int i = 0; i < arr.length; i++) {
            positions.computeIfAbsent(arr[i], k -> new ArrayList<>()).add(i);
        }
    }

    public int query(int left, int right, int value) {
        List<Integer> idx = positions.get(value);
        if (idx == null) {
            return 0;
        }
        // right + 1 turns "<= right" into "< right + 1", so one helper serves both ends
        return lowerBound(idx, right + 1) - lowerBound(idx, left);
    }

    /** first position whose index is >= target (half-open [l, r), == bisect_left) */
    private int lowerBound(List<Integer> idx, int target) {
        int l = 0, r = idx.size();
        while (l < r) {
            int mid = l + (r - l) / 2;
            if (idx.get(mid) < target) l = mid + 1;
            else r = mid;
        }
        return l;
    }
}

為什麼不用其他做法:

做法 建構 每次查詢 結論
掃描 arr[left..right] 並計數 — O(n) 10^5 次查詢 × 10^5 個元素 = 10^10 → TLE
每個值一份前綴計數:pre[v][i] = v 在 arr[0..i) 中的個數 O(n · distinct) O(1) 10^4 個值 × 10^5 格 → 記憶體與建構時間都爆掉
每個值一份索引清單 + 兩次邊界查詢 O(n) O(log n) 每個索引只存一次,所有清單加起來共 n 筆

每次查詢是 O(log N):在單一值的索引清單上做兩次二分搜尋,清單長度最多 N,通常短得多。 建構子裡 O(N) 的掃描只跑一次,而且不是排序,因為掃描產生的清單本來就有序。

常見陷阱
  • ❌ 兩端都用 bisect_left → 會漏掉剛好位在 right 的那一次出現。因為區間是閉區間,右端要用 bisect_right(或 lower_bound(right + 1))。
  • ❌ 對不存在的值直接取 defaultdict 的 self.positions[value] → 結果 0 是對的,但每次查不到都會 塞進一個空清單。先用 in 檢查。
  • ❌ 為了「讓它可以二分」而排序 arr → 查詢問的是位置,排序會把位置丟掉。要排序的是每個值的 索引,而掃描已經順便做完了。
  • ❌ 直接上線段樹 → 可行,但每個節點都要合併一份頻率表,比這個做法更重也更慢,而這題根本沒有更新操作。
相似題目:在每個 key 的索引清單裡計數或跳躍
LC # 題目 差在哪裡
2080 Range Frequency Queries 基準題:個數 = bisect_right(right) - bisect_left(left)
792 Number of Matching Subsequences 每個字元一份索引清單;用 upper_bound 跳到目前位置之後的下一次出現
1157 Online Majority Element In Subarray 同樣的計數查詢,用來驗證隨機抽樣(或線段樹)挑出的候選值
981 Time Based Key-Value Store 每個 key 一份時間戳清單;只做一次 floor 查詢(upper bound − 1),不是計數
1146 Snapshot Array 每個索引一份版本清單;同樣的 floor 查詢
2055 Plates Between Candles 排序後的蠟燭位置;兩次邊界查詢找出最內側的蠟燭,再用前綴和數盤子

如果 arr 在查詢之間會變動(update(i, v)),這些清單就不再便宜好維護:用一般陣列時,把一個 索引從一個值的清單搬到另一個是 O(n)。改用每個值一個 SortedList,或以位置為 key 的 BIT / 線段樹。

模板 3:旋轉陣列的最小值 — LC 153 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

辨識 — 「一個由不重複值組成的排序陣列,在未知樞紐點被旋轉……找出最小值」 (或旋轉次數、或樞紐索引),要求 O(log n)。這個陣列是兩段遞增序列,左段整段都高於右段。

關鍵 — 拿 nums[mid] 和 nums[right] 比,永遠不和 target 比:若 nums[mid] < nums[right],mid..right 是同一段遞增序列,所以最小值在 mid 或它左邊 (right = mid);否則下墜點嚴格在 mid 右邊(left = mid + 1)。 最小值永遠在 [left, right] 之內,迴圈在只剩一個索引時停下。

python
# python
# LC 153 - Find Minimum in Rotated Sorted Array
# IDEA: closed squeeze [left, right] down to one candidate; nums[right] tells which run mid is in
# time = O(log n), space = O(1)
def findMin(nums):
    left, right = 0, len(nums) - 1
    while left < right:                       # stop at one candidate, not zero
        mid = left + (right - left) // 2
        if nums[mid] < nums[right]:
            right = mid                       # mid..right ascending: min is mid or left of it
        else:
            left = mid + 1                    # mid is in the high run: min is right of mid
    return nums[left]
java
// java
// LC 153 - Find Minimum in Rotated Sorted Array
// IDEA: closed squeeze [left, right] down to one candidate; nums[right] tells which run mid is in
// time = O(log n), space = O(1)
public int findMin(int[] nums) {
    int left = 0, right = nums.length - 1;
    while (left < right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] < nums[right]) {
            right = mid;       // keep mid: it may be the minimum
        } else {
            left = mid + 1;
        }
    }
    return nums[left];
}

複雜度

  • 時間 O(log n):while left < right 每輪把視窗砍半;right = mid 仍然會縮小視窗, 因為只要 left < right 就有 mid < right。
  • 空間 O(1):兩個指標。

試跑

text
nums = [3, 4, 5, 1, 2]
left right mid nums[mid] nums[right]  action
0    4     2   5         2            5 >= 2 -> left = 3
3    4     3   1         2            1 < 2  -> right = 3
left == right = 3 -> return 1

陷阱

  • 改拿 nums[left] 而不是 nums[right] 來比,在沒有旋轉的陣列上會出錯: [1, 2, 3] 會把 left 推過最小值,因為「左段有序」不再代表「最小值在右邊」。
  • right = mid - 1 在最小值剛好是 mid 時會把它丟掉:[3, 1, 2] 回傳 3。
  • while left <= right 搭配 right = mid,一旦 left == right 就永遠不會結束。
  • 有重複值(LC 154)時 nums[mid] == nums[right] 不帶任何資訊;用 right -= 1 縮小, 最壞情況是 O(n)([1, 1, 1, 0, 1])。

不適用時 — 你要找的是某個給定的 target,而不是最小值 → 模板 4。

練習 — LC 153 ⭐ · LC 154 (有重複值:相等時 right -= 1) · LC 33 → 模板 4 · LC 81 (有重複值的搜尋)

旋轉陣列最小值 — 閉區間的替代寫法

同一個搜尋,改寫成 while l <= r 並維護一個 ans。只有在你想到處都用閉區間時才保留它; 它需要在視窗已經有序時提早結束。

java
// java
// LC 153 - Find Minimum in Rotated Sorted Array (closed boundary, track ans)
// IDEA: a sorted window's minimum is its left end; otherwise record mid and move to the unsorted half
// 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) {
        if (nums[l] <= nums[r]) {             // window already sorted -> minimum is at l
            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 -> the drop is to the right
        } else {
            r = mid - 1;                      // right plateau -> mid recorded, look left
        }
    }
    return ans;
}
text
Rotation examples (length 5) and where the template moves:
[1, 2, 3, 4, 5]  -> mid < right -> right = mid ... min at index 0
[5, 1, 2, 3, 4]  -> mid < right -> right = mid
[3, 4, 5, 1, 2]  -> mid > right -> left = mid + 1
[2, 3, 4, 5, 1]  -> mid > right -> left = mid + 1
模板 迴圈條件 更新方式 回傳 適用時機
閉區間、停在單一候選 left < right right = mid / left = mid + 1 nums[left] 標準寫法:收斂到索引
閉區間 l <= r + ans l <= r l = mid + 1 / r = mid - 1 ans 你想到處都用閉區間時
LC # 題目 關鍵差異
153 Find Minimum in Rotated Sorted Array 元素不重複,找最小值
154 Find Minimum in Rotated Sorted Array II 有重複值 — nums[mid] == nums[right] 時 right -= 1
33 Search in Rotated Sorted Array 找 target(不是最小值)— 模板 4
81 Search in Rotated Sorted Array II 在有重複值的情況下找 target
189 Rotate Array 概念相關,但任務不同

模板 4:在旋轉陣列中搜尋 — LC 33 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

辨識 — 「一個由不重複值組成、可能被旋轉過的排序陣列……回傳 target 的索引, 找不到回傳 -1」,要求 O(log n)。輸入和模板 3 一樣,只是現在要找一個值。

關鍵 — nums[left..mid] 與 nums[mid..right] 至少有一段是有序的,而有序段的兩端 就能告訴你 target 在不在裡面。所以每一輪:找出有序的那一半,若 target 落在它的兩端 之間就保留它,否則保留另一半。

python
# python
# LC 33 - Search in Rotated Sorted Array
# IDEA: one half of [left, mid, right] is always sorted; keep it iff target lies between its ends
# time = O(log n), space = O(1)
def search(nums, target):
    left, right = 0, len(nums) - 1
    while left <= right:
        mid = left + (right - left) // 2
        if nums[mid] == target:
            return mid
        if nums[left] <= nums[mid]:                    # left half nums[left..mid] is sorted
            if nums[left] <= target < nums[mid]:
                right = mid - 1                        # target inside the sorted left half
            else:
                left = mid + 1
        else:                                          # right half nums[mid..right] is sorted
            if nums[mid] < target <= nums[right]:
                left = mid + 1                         # target inside the sorted right half
            else:
                right = mid - 1
    return -1
java
// java
// LC 33 - Search in Rotated Sorted Array
// IDEA: one half is always sorted; keep it iff target lies between its ends
// time = O(log n), space = O(1)
public int search(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) return mid;
        if (nums[left] <= nums[mid]) {                 // left half is sorted
            if (nums[left] <= target && target < nums[mid]) right = mid - 1;
            else left = mid + 1;
        } else {                                       // right half is sorted
            if (nums[mid] < target && target <= nums[right]) left = mid + 1;
            else right = mid - 1;
        }
    }
    return -1;
}

複雜度

  • 時間 O(log n):while left <= right 每一輪都丟掉一半,不論那一半有沒有序。
  • 空間 O(1):三個索引。

試跑

text
nums = [4, 5, 6, 7, 0, 1, 2], target = 0
left right mid nums[mid]  sorted half       target inside?  action
0    6     3   7          left [4..7]       no              left = 4
4    6     5   1          left [0..1]       0 <= 0 < 1 yes  right = 4
4    4     4   0          found -> return 4

陷阱

  • nums[left] < nums[mid](嚴格)在 left == mid 時,會把只有一個元素的左半段誤判成 無序:[3, 1]、target = 1 會走進右半段的分支、把 1 排除,最後回傳 -1。要用 <=。
  • 範圍檢查在 mid 那一側是半開的(target < nums[mid]、nums[mid] < target),因為 mid 已經比過了;把它包含進去無害,但外側端點少了 <=(nums[left] <= target)就會 漏掉剛好在 left 的 target。
  • 有重複值(LC 81)時,nums[left] == nums[mid] == nums[right] 無法確定哪一半有序: [1, 0, 1, 1, 1]。用 left += 1; right -= 1 縮小,並接受最壞 O(n)。

不適用時 — 你只需要最小值或旋轉點 → 模板 3;可以接受兩趟 → 先用模板 3 找出 樞紐點,再在正確的那一段上跑模板 1。

練習 — LC 33 ⭐ · LC 81 (有重複值:三者相等時兩端一起縮) · LC 153 → 模板 3 · LC 1095 (山脈:先找峰值,再做兩次有序搜尋 — 模板 7)

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」、「能不能切分 / 分配 / 派送」。

模板 5:在自己維護的 tails 陣列上做 Lower Bound — LC 300 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

辨識 — 「最長遞增(或鏈式)子序列的長度」,直覺解法是 O(n²) 的 DP dp[i] = max(dp[j]) + 1,而追問要求 O(n log n)。n ≤ 2,500 時 O(n²) 會過; n ≤ 10⁵(LC 1964、LC 354)就不會。

關鍵 — tails[k] 是所有長度為 k + 1 的遞增序列中最小的結尾值,所以 tails 嚴格遞增,可以做 lower bound。第一個 >= num 的格子,就是 num 唯一能改善的長度: 覆寫它,若不存在就 append。len(tails) 就是答案。

本 sheet 其他每個模板搜尋的都是輸入。這一個搜尋的是掃描過程中一邊走一邊建出來的 小陣列,刻意保持有序,好讓模板 2 的 findLeft 能在上面跑。這個演算法自己的 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, Template 2) -> append or overwrite
# time = O(n log n), space = O(n)
class Solution(object):
    def lengthOfLIS(self, nums):
        tails = []
        for num in nums:
            # findLeft from Template 2, run on `tails` instead of on `nums`
            left, right = 0, len(tails) - 1
            while left <= right:
                mid = left + (right - left) // 2
                if tails[mid] < num:
                    left = mid + 1           # strict < : an equal tail is replaced, not extended
                else:
                    right = mid - 1
            # left == first index with tails[left] >= num
            if left == len(tails):
                tails.append(num)            # num beats every tail -> a new longest length
            else:
                tails[left] = num            # same length, cheaper tail -> overwrite
        return len(tails)                    # the LENGTH is the answer, not the contents
java
// java
// LC 300 - Longest Increasing Subsequence, O(n log n)
// IDEA: tails[0..size) is strictly increasing; lower-bound each num into it, overwrite or append
// time = O(n log n), space = O(n)
public int lengthOfLIS(int[] nums) {
    int[] tails = new int[nums.length];
    int size = 0;
    for (int num : nums) {
        int left = 0, right = size - 1;
        while (left <= right) {
            int mid = left + (right - left) / 2;
            if (tails[mid] < num) left = mid + 1;
            else right = mid - 1;
        }
        tails[left] = num;                // left == size appends, otherwise overwrites
        if (left == size) size++;
    }
    return size;
}

複雜度

  • 時間 O(n log n):外層 for num in nums 跑 n 次,內層 while left <= right 是在 最多 n 長的 tails 上做二分搜尋。
  • 空間 O(n):tails 只在 append 時長一格,最多到 LIS 長度 ≤ n。

試跑

text
nums = [10, 9, 2, 5, 3, 7, 101, 18]
num   lower bound   action        tails
10    0 (empty)     append        [10]
9     0             overwrite     [9]
2     0             overwrite     [2]
5     1             append        [2, 5]
3     1             overwrite     [2, 3]
7     2             append        [2, 3, 7]
101   3             append        [2, 3, 7, 101]
18    3             overwrite     [2, 3, 7, 18]     -> 4

陷阱

  • tails 不是一條子序列:[3, 4, 5, 1] 最後得到 tails = [1, 4, 5],而 1 是最後才 出現的。只有它的長度有意義;要還原那條鏈需要 prev 指標。
  • 非遞減(允許重複,LC 1964)要找第一個 > num 的 tail(bisect_right):用 bisect_left 的話,[2, 2, 2] 會得到 1 而不是 3。
  • LC 354 在做 LIS 前若只按寬度排序,兩個寬度相同的信封會被套在一起:要按 (w 遞增, h 遞減) 排序,讓每個寬度最多貢獻一個高度。

不適用時 — 題目要的是最長子序列的個數(LC 673),或「可串接」不是全序 (LC 368 的整除關係),或狀態還帶有第二個鍵(LC 1027 的公差):每個長度只存一個 tail 裝不下這些,所以是 O(n²) DP。

練習 — LC 300 ⭐ · LC 1964 (非遞減,逐一回報每個索引的位置) · LC 354 (帶平手規則的排序,再做 LIS) · LC 2111 (k 個餘數類別,len − LIS)

bisect.bisect_left(tails, num) 是內層迴圈的函式庫寫法。LC 354 的題解在 sort.md § Template 9, 歸約到 LIS 的題目在 patience_sorting.md。近親題是對按結束時間排序的 DP 陣列做二分搜尋、找最後一個相容的工作:LC 1235、LC 1751。

為什麼可行 — 三個論證

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.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
# `feasible` is your monotone predicate: False on [lo, x*), True on [x*, hi]
def minimize_real(lo: float, hi: float, feasible, 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

模板 6:把二維矩陣當成一個排序陣列 — LC 74

辨識 — 「一個 m x n 矩陣;每一列有序,且每列第一個整數大於上一列最後一個整數…… target 在不在裡面?」逐列讀下來,這個矩陣就是一份含 m·n 個值的排序清單,預期 O(log(m·n))。

關鍵 — 攤平後清單的索引 i 就是格子 (i // n, i % n),而這個對應保持順序,所以 模板 1 原封不動地在 [0, m·n − 1] 上跑,每次探測只多一次除法。

python
# python
# LC 74 - Search a 2D Matrix
# IDEA: Template 1 on the virtual flat array; flat index i -> matrix[i // n][i % n]
# time = O(log(m * n)), space = O(1)
def searchMatrix(matrix, target):
    m, n = len(matrix), len(matrix[0])
    left, right = 0, m * n - 1
    while left <= right:
        mid = left + (right - left) // 2
        value = matrix[mid // n][mid % n]     # divide by the column count, not the row count
        if value == target:
            return True
        elif value < target:
            left = mid + 1
        else:
            right = mid - 1
    return False
java
// java
// LC 74 - Search a 2D Matrix
// IDEA: Template 1 on the virtual flat array; flat index i -> matrix[i / n][i % n]
// time = O(log(m * n)), space = O(1)
public boolean searchMatrix(int[][] matrix, int target) {
    int m = matrix.length, n = matrix[0].length;
    int left = 0, right = m * n - 1;
    while (left <= right) {
        int mid = left + (right - left) / 2;
        int value = matrix[mid / n][mid % n];
        if (value == target) return true;
        else if (value < target) left = mid + 1;
        else right = mid - 1;
    }
    return false;
}

複雜度

  • 時間 O(log(m·n)):while 在 m·n 個攤平索引的視窗上折半;每次探測的 // n 與 % n 都是 O(1)。
  • 空間 O(1):不建立攤平後的副本 — 複製一份要花 O(m·n),就失去意義了。

陷阱

  • 除以 m 而不是 n:在 3 x 4 矩陣上,攤平索引 5 是 (1, 1),但 5 // 3, 5 % 3 讀到的是 (1, 2)。兩個方向的轉換都用欄數。
  • m * n 在 Java 中要超過約 46,000² 才會溢位 int,遠超出 LC 74 的限制;講出來即可, 不必為它寫程式。
  • 兩次二分搜尋(先挑列、再在列中搜尋)也是 O(log m + log n) = O(log(m·n)),但挑列是 「首元素 <= target 的最大那一列」這種 bound,很容易差一。

不適用時 — 列有序、行也有序,但某列第一個值可能小於上一列最後一個值(LC 240): 這個矩陣不是一份排序清單。改用從右上角出發的階梯搜尋,每一步丟掉一列或一行:

python
# python
# LC 240 - Search a 2D Matrix II
# IDEA: from the top-right, a larger value rules out its column and a smaller one its row
# time = O(m + n), space = O(1)
def searchMatrixII(matrix, target):
    row, col = 0, len(matrix[0]) - 1
    while row < len(matrix) and col >= 0:
        if matrix[row][col] == target:
            return True
        elif matrix[row][col] > target:
            col -= 1                          # everything below in this column is bigger too
        else:
            row += 1                          # everything left in this row is smaller too
    return False

練習 — LC 74 ⭐ · LC 240 (階梯搜尋,不是二分搜尋) · LC 378 (對值域做二分搜尋, binary_search_on_answer.md)

模板 7:順著斜率找峰值 — LC 162 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

辨識 — 「找出一個峰值元素」、「山脈陣列」、「nums[i] != nums[i + 1]」、 「nums[-1] = nums[n] = -∞」,要求 O(log n)。陣列和它的斜率都沒有排序 ([1, 2, 1, 3, 5, 6, 4] 先升、再降、又升),但比較 nums[mid] 與 nums[mid + 1] 就能指出一定含有峰值的那一半。

關鍵 — 若 nums[mid] < nums[mid + 1],斜率往上,從 mid + 1 一路往上爬, 在碰到 -∞ 邊界前一定會遇到峰值,所以 [mid + 1, right] 裡有峰值; 否則 [left, mid] 裡有一個。視窗內永遠有峰值,迴圈在只剩一個索引時停下。

python
# python
# LC 162 - Find Peak Element
# IDEA: closed squeeze down to one candidate; move toward the higher neighbour of mid, which always holds a peak
# time = O(log n), space = O(1)
def findPeakElement(nums):
    left, right = 0, len(nums) - 1
    while left < right:                       # left < right, so mid + 1 is always in range
        mid = left + (right - left) // 2
        if nums[mid] < nums[mid + 1]:
            left = mid + 1                    # rising slope: a peak is to the right
        else:
            right = mid                       # falling slope: mid itself may be the peak
    return left
java
// java
// LC 162 - Find Peak Element
// IDEA: closed squeeze down to one candidate; move toward the higher neighbour of mid
// time = O(log n), space = O(1)
public int findPeakElement(int[] nums) {
    int left = 0, right = nums.length - 1;
    while (left < right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] < nums[mid + 1]) left = mid + 1;
        else right = mid;
    }
    return left;
}

複雜度

  • 時間 O(log n):在閉區間 [left, right] 上跑 while left < right,把視窗砍半,而 right = mid 仍會縮小視窗,因為 mid < right。
  • 空間 O(1):兩個指標。

試跑

text
nums = [1, 2, 1, 3, 5, 6, 4]
left right mid  nums[mid] vs nums[mid+1]   action
0    6     3    3 < 5                      left = 4
4    6     5    6 > 4                      right = 5
4    5     4    5 < 6                      left = 5
left == right = 5 -> return 5   (6 is a peak; index 1 would also be accepted)

陷阱

  • while left <= right 在 left == right == n − 1 時會讀到越界的 nums[mid + 1]: [1, 2] 會丟出索引錯誤。
  • 下坡時寫 right = mid - 1,在峰值就是 mid 時會把它丟掉:[1, 3, 2] 最後停在 索引 0。
  • 相鄰元素相等會破壞斜率論證;LC 162 禁止這種情況,若有平台([1, 2, 2, 1]) 就需要退回線性做法。

不適用時 — 你要在山脈陣列裡找一個目標值(LC 1095):這裡先找峰值,接著做兩次 有序搜尋 — 在遞增的左段跑模板 1,在遞減的右段跑比較方向翻轉過的模板 1:

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

一個函式就能兩者通用:if ((val < target) == ascending) left = mid + 1; else right = mid - 1;。 LC 1095 的題解在 binary_search_examples.md § Find in Mountain Array。

練習 — LC 162 ⭐ · LC 852 (山脈保證只有一個峰值) · LC 1901 (二維:對列做二分 搜尋,取每列的最大值) · LC 1095 (先找峰值,再做一次遞增、一次遞減搜尋)

模板 8:指數(Galloping)搜尋 — LC 702 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

辨識 — 「大小未知的排序陣列」、沒有 length 的 reader / ArrayReader、 CtCI 10.4 沒有 size() 的 Listy、分頁式 API。二分搜尋需要右端點,而輸入就是不給你。 成本應該取決於目標的索引 p,而不是一個你拿不到的長度。

關鍵 — 加倍迴圈只會讓 hi 越過一個值已知 < target 的索引,所以它停下時,答案就在 [hi // 2, hi] 之內。兩個階段都花 O(log p) 次探測:加倍在 ⌈log₂ p⌉ 步內越過 p, 而交出去的視窗最多 p 寬。

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]
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
// LC 702 - Search in a Sorted Array of Unknown Size
// IDEA: double hi until reader.get(hi) >= target, then Template 1 inside [hi / 2, hi]
// time = O(log p), space = O(1)
public int search(ArrayReader reader, int target) {
    int hi = 1;
    while (reader.get(hi) < target) {        // the 2^31-1 sentinel past the end stops this
        hi *= 2;
    }
    int lo = hi / 2;
    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;
        int val = reader.get(mid);
        if (val == target) return mid;
        else if (val < target) lo = mid + 1;
        else hi = mid - 1;                   // a sentinel reads as "too big": go left
    }
    return -1;
}

複雜度

  • 時間 O(log p):while reader.get(hi) < target 迴圈把 hi 加倍直到越過 p, 共 ⌈log₂ p⌉ + 1 次探測;第二個迴圈在最多 p 個索引的視窗中搜尋。
  • 空間 O(1):lo、hi、mid。

陷阱

  • 越界哨兵值的方向。 LC 702 的 2³¹ − 1 排在所有真實值之上,所以一般的比較就能處理。 CtCI 的 Listy 回傳 -1,它排在真實值之下:沒被檢查的 -1 會把搜尋推向右邊、 衝出尾端,永遠停不下來。要明確檢查 — while (list.elementAt(hi) != -1 && list.elementAt(hi) < target) 以及 if (val == -1 || val > target) hi = mid - 1;。
  • hi 必須從 1 開始,不能是 0:0 << 1 還是 0,目標在索引 0 之後時會無限迴圈。
  • 加倍之後用 lo = 0 依然正確,但丟掉了學到的資訊:視窗寬了一倍,多浪費一次折半。 要用 lo = hi // 2。

不適用時 — 題目有給 len(nums):加倍毫無用處,用模板 1。

練習 — LC 702 ⭐ · CtCI 10.4 (Listy,-1 哨兵值) · LC 1095 (同樣的「探測預算」 思維,API 上限 100 次呼叫)

  • 為什麼用加倍而不是固定步長。 步長 k 需要 p/k 次探測;加倍只要 log2(p) 次,而且超過答案的幅度永遠不會超過兩倍。
  • 面試之外的同一招。 把一個小的有序清單和一個巨大的有序清單取交集時,會對每個元素做 galloping 搜尋而不是逐一掃描;Timsort 的合併也用它來跳過某一側的長串。

LC 702 變體 — 視窗從哪裡開始,以及約束捷徑

關鍵 — 每個變體都是同樣的兩個階段;差別只在於加倍交出去的左端點,以及 視窗內用哪個模板搜尋。

歸約到:模板 8 — 第二階段改用模板 2 的 lower bound。

它們全都正確,因為加倍迴圈只會越過一個值已知 < target 的索引:

變體 第一階段(擴大 hi) 第二階段的視窗 第二階段模板
上面的模板 hi <<= 1 [hi // 2, hi] — 最後一個 < target 的探測點 閉區間 lo <= hi,精確比對 模板 1
加倍時同步追蹤 lo lo = hi + 1; hi *= 2 [前一個 hi + 1, hi] — 少一格 閉區間 lo <= hi,精確比對
Lower bound hi <<= 1 [hi // 2, hi] 半開 lo < hi → 第一個 get >= target 的索引,再檢查是否相等 模板 2
約束捷徑 無 — hi = 2 * 10^4 [0, 20000] lower bound

lower-bound 寫法每次折半只呼叫一次 reader.get,而不是精確比對寫法的三向分支, 而且它就是你在 LC 34 / 35 已經在用的模板:

python
# python
# LC 702 - Search in a Sorted Array of Unknown Size (lower-bound variant)
# IDEA: double hi until get(hi) >= target, then find the FIRST index in [hi // 2, hi]
#       whose value is >= target; the target exists iff that index holds it
# 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:
            hi *= 2

        lo = hi // 2                          # get(lo) < target, or lo == 0
        while lo < hi:
            mid = (lo + hi) // 2
            if reader.get(mid) >= target:     # sentinel 2^31-1 also lands here -> go left
                hi = mid
            else:
                lo = mid + 1

        return lo if reader.get(lo) == target else -1

約束捷徑直接跳過第一階段:LC 702 限制陣列最多 10^4 個元素,所以 [0, 20000] 保證包含答案,而哨兵值讓述詞 get(i) >= target 在尾端之後仍保持單調。只要約 15 次探測, judge 也會接受 — 但要明白說出來:它把一個約束寫死了,而這題的前提正是你不知道長度。 先講加倍法,再把捷徑當作取捨提出來。

  • hi 必須從 1 開始,不能是 0。 0 << 1 還是 0,所以目標在索引 0 之後時會無限迴圈。
  • 迴圈結束後用 lo = hi // 2 而不是 lo = 0: 0 依然正確、依然是 O(log p), 但它丟掉了加倍階段學到的資訊 — 視窗寬了一倍,多浪費一次折半。

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(模板 4) 是同一個教訓:nums[l] == nums[mid] == nums[r] 讓探測失去資訊, 退而求其次的 l++ / r-- 會讓搜尋退化成 O(n)。

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

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

LC 題目 用哪個模板
275 H-Index II 對索引做邊界搜尋:第一個滿足 citations[i] >= n - i 的 i(模板 2)
1268 Search Suggestions System 排序 products,對逐步變長的前綴做 lower_bound(模板 2;binary_search_examples.md 的 LC 436);Trie 是另一種做法
349 / 350 Intersection of Two Arrays I / II 排序較大的陣列,對每個元素做二分搜尋(也可用雜湊集合 / 雙指標)
792 Number of Matching Subsequences 每個字元一份排序索引清單 + upper_bound 跳到下一次出現處(LC 981 / LC 1146 那種按 key 排序清單的查詢)
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(也可用雜湊集合)

練習路徑

階段 題目(由易到難) 新增了什麼
核心 ⭐ LC 704 未評定 · LC 34 ok · LC 153 ok ⭐ · LC 33 ok ⭐ · LC 300 again ⭐ 精確比對、兩個邊界、旋轉的那一對、tails — 模板 1–5 各一次
練習 LC 35 again · LC 278 ok · LC 74 ok · LC 162 未評定 · LC 702 again · LC 981 ok · LC 2080 again 插入位置、布林 API、二維攤平索引、斜率搜尋、沒有右端點、每個 key 的 floor、用兩個邊界計數
延伸 LC 81 未評定 · LC 154 未評定 · LC 1095 未評定 · LC 354 未評定 · LC 1964 未評定 退化成 O(n) 的重複值、一次遞增一次遞減搜尋的山脈、帶平手規則排序後的 LIS、非遞減的 tails

對答案做二分搜尋(LC 875 ok ⭐、LC 410 未評定、LC 1011 ok)有自己的練習路徑,在 binary_search_on_answer.md。

3) 總結與速查

3.1) 何時該用二分搜尋

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

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

3.2) 模板選擇指南

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

題型 / 輸入形狀 模板 題解範例
在排序陣列中做精確搜尋 標準閉區間 while l <= r — 模板 1 LC 704
左邊界(第一個 >= target 的索引) Lower bound — 模板 2 LC 34、LC 35、LC 278
右邊界(最後一個 <= target 的索引) Upper bound − 1 — 模板 2 LC 34、LC 981
插入位置 Lower bound,回傳 l 不做驗證 — 模板 2 LC 35
值 v 在 arr[l..r] 中出現幾次,陣列未排序 每個值一份索引清單,bisect_right(r) - bisect_left(l) — 模板 2 LC 2080
峰 / 谷,沒有目標值 閉區間、停在單一候選:while l < r、r = mid — 模板 7 LC 162、LC 852
旋轉排序陣列 找出有序的那一半 — 模板 3、模板 4 LC 33、LC 81、LC 153、LC 154
陣列先升後降(山脈 / 雙調) 找峰值 + 兩次有序搜尋,其中一次遞減 — 模板 7 LC 1095
輸入沒有長度(reader / 串流 / 分頁式 API) 把索引加倍找出右端點,再搜尋 — 模板 8 LC 702
探測可能落在不帶資訊的位置(""、重複值) 往外走到最近的可用項目 — §2.6 CtCI 10.5、LC 81
二維矩陣 整體有序就攤平,只有列 + 行有序就走階梯 — 模板 6 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 — 模板 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 半開區間)
  • 用邊界情況測試:空陣列、單一元素、重複值