具代表性的面試主題

演算法面試:如何用二分搜尋目標值的首尾位置?

程式題中等
OfferCC 編輯團隊發佈 更新

題幹

給定一個按非遞減順序排列的整數陣列 nums 和整數 target,回傳 target 在陣列中的第一個和最後一個索引;若不存在則回傳 [-1, -1]。要求時間複雜度為 O(log n),額外空間為 O(1)。

題目與適用情境

給定一個按非遞減順序排列的整數陣列 nums 和整數 target,回傳 target 在陣列中的第一個和最後一個 索引;若目標值不存在,回傳 [-1, -1]。陣列可以為空,可以包含重複值,函式不得修改輸入。要求時間複雜度 為 O(log n),額外空間為 O(1)。

例如,nums = [1, 2, 2, 2, 3] 且 target = 2 時回傳 [1, 3];target = 4 時回傳 [-1, -1]。線性掃描當然能得到答案,但最差時間是 O(n),不符合題目要求。

這道題適合軟體工程師和演算法職缺的程式設計輪。Amazon 目前的 SDE II 面試指引要求寫出語法正確、可擴展、 穩健且經過邊界驗證的程式碼;LeetCode 也保留了相同核心問題。真正的考點不是背下兩個模板,而是能否先寫出 搜尋邊界的精確定義,再讓迴圈條件、區間更新和回傳值都遵守同一個不變量。

面試官考察重點

首先看候選人是否辨識到「找到任意一個目標值」還不夠。普通二分搜尋在遇到相等值時立即回傳,無法保證它是 最左或最右位置;找到後再向兩邊線性擴展,在陣列全部等於目標值時仍會退化為 O(n)。

第二個考點是邊界語意。建議把問題拆成兩個插入點:

  • lowerBound:第一個大於等於 target 的位置。
  • upperBound:第一個嚴格大於 target 的位置。

Python 官方 bisect_left 和 bisect_right 使用相同分割定義。只要這兩個位置正確,目標存在時答案就是 [lowerBound, upperBound - 1]。

第三個考點是迴圈不變量。使用半開區間 [left, right) 時,空陣列自然表示為 [0, 0);迴圈結束條件是 left === right。如果程式混用閉區間和半開區間,例如把 right 初始化為 nums.length,卻又存取 nums[right],就會產生越界或無限迴圈。

最後看驗證能力。強回答會涵蓋空陣列、單一元素、全部重複、目標小於最小值、目標大於最大值、目標位於兩端和 目標不存在,還會說明為什麼 target + 1 不是通用的 upper bound 寫法:它依賴離散數值的「後繼」, 會在最大安全整數處建立約束域外的值,也無法直接推廣到字串或自訂比較器。

回答前需要釐清的問題

  • 陣列是否已經排序? 本題保證非遞減。如果未排序,必須先排序並保留原索引,整體就不再是 O(log n)。
  • 回傳原陣列索引還是排序後索引? 本題輸入已排序,兩者相同;若允許先排序,這會改變資料結構。
  • 目標不存在時回傳什麼? 本題固定回傳 [-1, -1],不能把插入點誤當成命中位置。
  • 允許重複值嗎? 允許,而且重複值正是需要邊界二分的原因。
  • 陣列可以為空嗎? 可以。半開區間實作不需單獨存取首尾元素,能自然回傳未命中。
  • 數值範圍是什麼? 使用 JavaScript 安全整數。實作不計算 target + 1,因此不會為了搜尋邊界建立約束域外的哨兵值。
  • 是否必須手寫二分搜尋? 面試題要求手寫;生產程式若標準函式庫契約完全相符,應優先重用標準函式庫。
  • 能否修改陣列? 不能,也不需要。兩個搜尋都只讀取陣列。

30 秒回答框架

「我會做兩次邊界二分,不會找到一個目標後線性擴展。lowerBound 在半開區間 [left, right) 中找第一個 大於等於 target 的位置;upperBound 找第一個嚴格大於 target 的位置。迴圈中使用 middle = left + floor((right - left) / 2)。若中點仍在目標左側,就令 left = middle + 1, 否則收縮 right = middle。先檢查 lower bound 是否越界或不等於目標;存在時回傳 [lower, upper - 1]。兩次搜尋都是 O(log n),額外空間 O(1)。」

分步深入解答

第一步:把「首尾位置」改寫為兩個分割點。

對 nums = [1, 2, 2, 2, 3] 和 target = 2:

text
lowerBound = 1  // 第一個 nums[i] >= 2
upperBound = 4  // 第一個 nums[i] > 2
answer = [1, 4 - 1] = [1, 3]

這種定義比「繼續往左找」和「繼續往右找」更容易驗證。目標不存在時,兩個插入點仍有意義。例如 target = 4 時兩者都等於陣列長度 5,但這不代表目標存在,因此必須另外檢查 nums[lower] === target。

第二步:固定半開區間不變量。

lowerBound 在每輪開始時維持:

  1. 所有索引小於 left 的值都嚴格小於 target。
  2. 所有索引大於等於 right 的值都大於等於 target。
  3. 尚未確定的候選區間是 [left, right)。

初始時 left = 0、right = nums.length,區間外沒有元素,不變量成立。若 nums[middle] < target, 中點及其左側都不可能是答案,因此令 left = middle + 1。否則中點可能就是第一個合格位置,不能丟掉, 所以令 right = middle。

每輪都嚴格縮短區間。當 left === right 時沒有未確定元素;左側全部小於目標,右側全部大於等於目標, 因此這個位置就是第一個大於等於目標的位置。

upperBound 使用相同框架,只把分割條件改成:

  1. 所有索引小於 left 的值都小於等於 target。
  2. 所有索引大於等於 right 的值都嚴格大於 target。

因此當 nums[middle] <= target 時移動 left,否則收縮 right。

第三步:實作兩個邊界函式。

typescript
function lowerBound(nums: number[], target: number): number {
  let left = 0;
  let right = nums.length;

  while (left < right) {
    const middle = left + Math.floor((right - left) / 2);
    if (nums[middle] < target) {
      left = middle + 1;
    } else {
      right = middle;
    }
  }

  return left;
}

function upperBound(nums: number[], target: number): number {
  let left = 0;
  let right = nums.length;

  while (left < right) {
    const middle = left + Math.floor((right - left) / 2);
    if (nums[middle] <= target) {
      left = middle + 1;
    } else {
      right = middle;
    }
  }

  return left;
}

兩段程式碼只有比較條件不同。保留兩個有明確名稱的函式,比傳入一個難讀的布林開關更適合面試說明,也避免 為了單一呼叫提前抽象複雜的通用框架。

中點寫成 left + floor((right - left) / 2),不會先把兩個大索引相加。JavaScript 陣列長度受執行環境 限制,本題通常不會觸發數值溢位,但這種寫法可以直接移植到固定寬度整數語言。

第四步:組合結果並驗證目標真的存在。

typescript
function searchRange(nums: number[], target: number): [number, number] {
  const first = lowerBound(nums, target);

  if (first === nums.length || nums[first] !== target) {
    return [-1, -1];
  }

  return [first, upperBound(nums, target) - 1];
}

檢查順序很重要。先判斷 first === nums.length,再讀取 nums[first],避免把陣列尾端之外的位置當成元素。 一旦確認 first 命中,upperBound 必定至少為 first + 1,所以減一後得到最後一個目標值索引。

不要用 lowerBound(nums, target + 1) - 1 代替 upper bound。在本題的安全整數約束下,它可能得到正確 結果,但當 target 是 Number.MAX_SAFE_INTEGER 時,加一已經超出保證精確整數運算的約束域;若輸入 擴展到任意 JavaScript Number,相鄰整數還可能因精度合併。字串、BigInt 或自訂比較器也沒有統一的 「下一個值」。直接搜尋第一個嚴格大於目標的位置才是完整語意。

第五步:證明複雜度。

每次迴圈把候選區間從長度 k 縮到至多約 k / 2,所以每個邊界函式執行 O(log n) 次比較。兩次搜尋 的總時間仍是 O(log n)。演算法只保存常數個索引,額外空間是 O(1),也不會修改輸入陣列。

若先找到任意目標再向兩邊掃描,[2, 2, ..., 2] 會掃描全部 n 個元素,最差時間變為 O(n)。 若使用雜湊表預處理,單次查詢可以很快,但建表需要 O(n) 時間和空間;它只適合同一個靜態陣列的大量 查詢,而且會浪費輸入已經有序的條件。

第六步:用邊界與隨機差分驗證。

至少涵蓋:

輸入target預期
[]1[-1, -1]
[5]5[0, 0]
[5]4[-1, -1]
[1, 2, 2, 2, 3]2[1, 3]
[2, 2]2[0, 1]
[1, 2, 3]0[-1, -1]
[1, 2, 3]4[-1, -1]

再隨機產生含重複值的排序陣列,把結果和線性基準 indexOf、lastIndexOf 比較。線性方法不符合目標複雜度, 卻很適合作為測試 oracle。固定案例檢查已知邊界,隨機差分更容易暴露只在特定重複長度或端點發生的錯誤。

高品質示範回答

「陣列已經按非遞減順序排列,而且要求 O(log n),所以我不會先找到一個目標再向兩側掃描,因為全是重複值 時會退化成線性時間。我把答案定義成兩個插入點:第一個大於等於目標的位置,以及第一個嚴格大於目標的位置。

兩個搜尋都使用半開區間 [left, right)。找左邊界時,迴圈保持 left 左側都小於目標,right 及其右側 都大於等於目標。若中點值小於目標,答案只能在右側,令 left = middle + 1;否則中點仍可能是答案, 令 right = middle。結束時兩者相等,就是 lower bound。upper bound 只把條件改成中點值小於等於目標時 向右移動。

我先求 lower bound。如果它等於陣列長度,或對應元素不等於目標,就回傳 [-1, -1]。否則右邊界是 upper bound 減一。這樣空陣列、目標不存在、全部重複以及目標位於首尾都使用同一套邏輯。

每次迴圈把區間減半,做兩次仍是 O(log n),只用常數索引,所以空間 O(1)。我會用固定邊界案例,再用 隨機排序陣列和 indexOf、lastIndexOf 做差分測試。實作中不使用 target + 1,避免建立約束域外的 哨兵值,也讓邊界定義適用於其他有序比較域。」

常見錯誤

  • 找到任意目標後向兩邊掃描 → 全部元素相同時退化為 O(n) → 分別二分 lower bound 與 upper bound。
  • 相等時立即回傳 → 回傳的是任意命中,不保證首尾位置 → 相等時繼續保留可能包含邊界的一半。
  • 把 right 初始化為陣列長度卻存取 nums[right] → 半開區間右端不可存取 → 只存取 middle,迴圈到左右相等。
  • 更新為 left = middle → 兩元素區間可能永遠不縮小 → 排除中點時使用 middle + 1。
  • 目標不存在時直接回傳插入點 → 插入位置不等於命中位置 → 檢查越界和 nums[first] !== target。
  • 用 target + 1 求右邊界 → 依賴約束域外或不存在的後繼值 → 直接實作第一個嚴格大於目標的位置。
  • 混用閉區間與半開區間模板 → 初始化、迴圈條件和更新規則互相衝突 → 先寫區間語意和不變量,再寫程式。
  • 只測試中間有多個重複值 → 空陣列、端點和未命中仍可能錯誤 → 加入邊界表和隨機差分測試。
  • 宣稱排序後再二分仍是 O(log n) → 排序本身至少主導總成本 → 使用題目已排序前提,未排序時重新計算複雜度。

追問與應對

追問一:如果只需要判斷目標是否存在,還需要兩次二分嗎?

不需要。執行一次 lower bound,再檢查位置是否越界且值等於目標即可,仍是 O(log n)。如果標準函式庫提供 契約明確的二分搜尋,也可以直接使用。兩次搜尋只為同時取得重複區間的左右邊界。

追問二:如何回傳目標值出現的次數?

若目標存在,次數是 upperBound - lowerBound;不存在時兩個插入點相等,差也是 0。因此只計算次數時甚至 不需要另外讀取陣列元素。這個公式來自兩個邊界之間恰好都是等於目標的元素。

追問三:如果陣列按降序排列怎麼辦?

不變量和比較方向都要反轉。降序 lower bound 可以定義為第一個小於等於目標的位置,另一邊界是第一個嚴格 小於目標的位置。不能只把最終陣列倒序理解而保留原比較條件;更穩妥的方式是先寫清楚分割述詞,再依述詞真假 更新區間。

追問四:如果元素是物件,要按某個欄位查找怎麼辦?

把比較值抽成已排序的鍵,例如按 createdAt 排序就比較該欄位。若鍵提取昂貴且會重複查詢,可預先保存鍵 陣列;Python 官方文件也建議在迴圈查詢中快取或預先計算鍵。前提是物件序列在搜尋期間不能被修改,否則有序 不變量失效。

追問五:如果資料不在記憶體,而在支援有序索引的資料庫中呢?

不要把應用層二分翻譯成多次遠端查詢。讓資料庫索引處理範圍定位,例如查詢等於目標的最小和最大穩定排序鍵, 或使用有序索引的起止掃描。應用層每輪一次網路往返會把 O(log n) 比較變成多次高延遲請求,也可能在並行 寫入下觀察到不同快照。

追問六:如何把這個模板推廣到「最小可行答案」問題?

先找到一個單調述詞,例如容量 x 以下不可行、從某個位置開始都可行,然後用 lower bound 搜尋第一個 述詞為真的位置。此時陣列元素可以是隱式答案空間,nums[middle] < target 被替換為 !feasible(middle)。 必須先證明述詞只有一次從假變真;若真假交替,二分搜尋沒有正確性基礎。

公開來源

同類題目

相關面試工具

用 Screenshot 處理演算法題

截圖題目後,依序看約束、解法、程式碼、邊界條件和複雜度。

查看工具