二分搜尋
範圍 — 對單調搜尋空間做折半 —
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)
- 前提條件:已排序的陣列,或具備單調性
- 搜尋空間:不限於完全排序的陣列,以下情況都適用:
- 完全排序的陣列
- 部分排序的陣列
- 旋轉排序陣列
- 任何具備單調性質的空間
核心演算法步驟
- 定義邊界:把
left與right指標初始化成涵蓋所有可能情況 - 定義回傳值:想清楚要回傳什麼(索引、值、-1 等)
- 定義結束條件:選對迴圈條件(
<=、<或< -1) - 更新指標:依與 target 的比較結果移動邊界
何時使用二分搜尋
- 已排序陣列:找確切值的經典場景
- 單調函數:只要
condition(k)能推得condition(k+1),就能用二分搜尋 - 搜尋邊界:找某個值第一次 / 最後一次出現的位置
- 最佳化問題:找滿足限制條件的最小 / 最大值
參考資料
- 框架:
- 題目集合:
- Python 工具:
- Python bisect module — 插入時維持排序順序
- Python Universal Binary Search Template — 一個模板通吃多題

辨識
限制條件線索依照 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左邊的所有元素都嚴格< targetr右邊的所有元素都嚴格> targettarget可能出現的位置永遠落在[l, r]之內
每次迭代砍掉一半搜尋空間,同時保持這個不變式。
迴圈本身就是下方的模板 1。
迴圈結束時:指標的位置
迴圈在 l > r 時結束,也就是 l == r + 1。
在這一瞬間,陣列被切成兩部分:
Visual Representation:
index: 0 ... r l ... n-1
value: [< target] gap [> target]
迴圈結束時的關鍵性質:
r是最後一個小於 target 的元素l是第一個大於等於 target 的元素r和l之間不存在任何索引(因為r = l - 1)
這就是為什麼 l 正是正確的插入位置!
圖解範例
我們來追蹤 nums = [1, 3, 5, 6], target = 4:
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
// 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;
}
為什麼不需要特判就能成立:
- 若 target 存在:迴圈中就直接回傳 mid
- 若 target 不存在:
- 迴圈以
l > r結束 - 依不變式:
nums[0..l-1] < target且nums[l..end] >= target - 因此
l正是 target 該被插入的位置
- 迴圈以
該避開的常見錯誤
// ❌ 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)
- 關鍵概念:判斷哪一半是有序的,再決定往哪邊搜尋
- 應用:找 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
# 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
// 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) 的堆疊。
試跑
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
# 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
// 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) 時間, 不是每次查詢都花。
試跑
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 - 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 - 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 單調堆疊
這兩者經常被搞混,因為聽起來都像是「找下一個更大的東西」。 但它們回答的是不同的問題:
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** 的那個區間,而不是輸入陣列中緊鄰在右邊的區間
— 所以它是二分搜尋,不是單調堆疊。
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。
模式:兩次獨立的邊界搜尋
有重複值的排序陣列可以看成三個區塊:
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,正好夾住分界點:
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):
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 的對應關係 — 把這個對照記起來,兩個輔助函式就再也忘不掉:
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 - 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 - 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 - 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] 內的元素個數:
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 進去。
"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
核心概念
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
# 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
// 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
# 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
// 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):兩個指標。
試跑
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
// 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;
}
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
# 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
// 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):三個索引。
試跑
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
# 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
// 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。
試跑
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:
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 不是一個子序列。 只有它的長度有意義:
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²)DPdp[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 永不相鄰 • 迴圈結束後需要再做一次檢查 • 較少見,用於複雜條件 |
詳細分析:
// 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
// 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
// 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) 浮點數二分搜尋
用於答案是實數的題目(開根號、最佳分配、幾何問題):
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
# 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
// 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
# 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
# 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
// 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):兩個指標。
試跑
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:
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 寬。
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
# 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
// 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
# 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
# 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 半開區間)
- 用邊界情況測試:空陣列、單一元素、重複值