二分查詢的區間到底是開還是閉?
在這兩個月的時間裡,我似乎沒有產出任何的有關知識點的文章,大多數都是題解相關的內容。以至於許多人覺得 Macw07 “失蹤”了。本文是我來到北美之後的第一篇知識點文章,請大家多多關照。
這次不講難的知識點了,講一個大家都熟悉的,但又非常令人抓毛的演算法:二分查詢和二分答案演算法。
引言 Introduction
注意:本文僅針對瞭解過二分查詢基本演算法原理的使用者群體,若您從未接觸過或瞭解過該演算法,請先學習基礎的二分查詢演算法。
二分查詢演算法是大家一個再熟悉不過的演算法了,二分查詢演算法可以在一個 有序數列 中高效地查詢某個或多個特定的目標值。一般來說,二分查詢的時間複雜度在 \(O(\log_2 N))\) 級別。二分演算法非常適合在大資料集上實現快速查詢。與此同時,除了基本的二分查詢演算法,它的許多變種也被廣泛應用於各種場景,比如求最大值、最小值,甚至在複雜的資料結構中最佳化資料的查詢效能。
許多同學肯定在學習完基本的二分查詢後一直有一個疑問:我到底該如何設定 \(L\) 和 \(R\) 的區間閉合狀態?什麼時候需要輸出 \(L\) 或 \(R\),為什麼有時候還需要 \(+1\)?\(\text{Mid}\) 到底儲存的是什麼東西?etc.
事實上,區間開閉的變數定義 確實是一個核心且容易混淆的問題,在 CSP 考試中也常考此知識點,因此本文將重點圍繞區間開閉的變數定義來展開。
二分查詢的基本原理 Basic Principles of Binary Search
在深入討論區間開閉之前,有必要回顧一下二分查詢的基本原理。二分查詢透過反覆將搜尋區間分成兩半,逐步縮小目標值所在的範圍,直到找到目標值或確定其不存在。具體步驟如下:
-
初始化:設定搜尋區間的左右邊界 \(L\) 和 \(R\)。
-
計算中點:計算中點 \(M = L + \dfrac{R - L}{2}\)。
-
比較
:將目標值與中點元素進行比較。
- 若相等,返回中點位置。
- 若目標值小於中點元素,縮小搜尋區間至左半部分。
- 若目標值大於中點元素,縮小搜尋區間至右半部分。
-
重複:重複上述步驟,直到找到目標值或搜尋區間為空。
開區間/閉區間 Open Interval/Closed Interval
在文章開始,先了解一下區間的開閉性。
開區間
定義:開區間表示區間的端點 不包含在區間內,用小括號 \(()\) 表示。
示例:\((2, 5)\) 表示所有介於 \(2\) 和 \(5\) 之間的數,但不包含數字 \(2\) 和 \(5\)。
閉區間
定義:開區間表示區間的端點 包含在區間內,用方括號 \([]\) 表示。
示例:\([2, 5]\) 表示所有介於 \(2\) 和 \(5\) 之間的數,而且包含數字 \(2\) 和 \(5\)。
半開區間/半閉區間
定義:半開區間或半閉區間表示區間的一個端點包含在內,另一個端點不包含在內。
示例:\((2, 5]\) 表示所有介於 \(2\) 和 \(5\) 之間的數,且包含數字 \(5\),但不包含數字 \(2\)。
區間型別 | 表示方式 | 是否包含左端點 \(a\) | 是否包含右端點 \(b\) |
---|---|---|---|
開區間 | \((a, b)\) | 否 | 否 |
閉區間 | \([a, b]\) | 是 | 是 |
左開右閉 | \((a, b]\) | 否 | 是 |
左閉右開 | \([a, b)\) | 是 | 否 |
區間開閉的型別 Interval Categories
在實現二分查詢的時候,區間的定義是最常見的一個問題,你可能會看到過以下不同的區間開閉性的定義:
- 左開右開 \((\text{left}, \text{right})\)
- 左閉右閉 \([\text{left}, \text{right}]\)
- 左開右閉 \((\text{left}, \text{right}]\)
- 左閉右開 \([\text{left}, \text{right})\)
通常來說,我們一般會選擇【左閉右開】或者【左閉右閉】的區間定義,所以本文也就著重圍繞這兩個部分講解。但對於不同的定義區間,如果稍有不慎,就容易使程式碼進入 死迴圈。
左閉右閉區間
定義:搜尋區間包括 left
和 right
,即 left
和 right
都可能是目標值。
退出條件:left > right
,表示搜尋區間為空。
左閉右閉區間的二分查詢的常見寫法如下:
while (left <= right) { // 注意是 <=
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1; // [mid+1, right]
} else {
right = mid - 1; // [left, mid-1]
}
}
左閉右開區間
定義:搜尋區間包括 left
但不包括 right
,即目標值可能是 left
,但不可能是 right
。
退出條件:當 left == right
時,表示搜尋區間為空。
左閉右開區間的二分查詢的常見寫法如下:
while (left < right) { // 注意是 <
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1; // [mid+1, right)
} else {
right = mid; // [left, mid)
}
}
兩種區間的迭代過程中的差異 Differences During Iterating
left
的更新:
- 左閉右閉:
left = mid + 1
,因為mid
已經被檢查過了,mid+1
開始的新區間仍是閉區間。 - 左閉右開:
left = mid + 1
,保持right
的開區間性質。
right
的更新:
- 左閉右閉:
right = mid - 1
,因為mid
已經被檢查過了,mid-1
保證了閉區間不重複。 - 左閉右開:
right = mid
,將mid
排除,保證開區間不包含right
。
退出條件:
- 左閉右閉:迴圈結束條件為
left > right
。 - 左閉右開:迴圈結束條件為
left == right
。
兩種區間的優缺點 Pros & Cons
左閉右閉的有點
- 直觀易懂:包括
left
和right
的寫法更加接近自然語言的描述,例如 “在 \([left, right]\) 區間查詢目標值”。 - 處理小區間:對於某些需要特別處理的小區間問題,左閉右閉可以更容易描述邏輯。
左開右閉的優點
避免陣列越界:使用左閉右開區間,right
永遠是無效位置,不會直接訪問陣列越界的索引。
邏輯一致性:左閉右開區間的範圍在迭代過程中可以穩定保持邏輯清晰,容易與數學符號對應。
程式碼簡潔:由於退出條件是 left == right
,很多情況下可以直接用 left
返回結果,無需做出額外檢查。
實際應用中的選擇 Choosing the Right Interval in Practice
在實際應用中,選擇使用左閉右閉還是左閉右開區間,往往取決於具體問題的需求和個人習慣。以下是一些指導原則:
- 陣列索引:在處理陣列索引時,左閉右開區間更加自然,因為陣列的索引從
0
到n-1
,左閉右開可以避免n
的無效訪問。 - 範圍劃分:當需要頻繁劃分範圍時,左閉右開區間的邏輯更清晰,減少了混淆和錯誤。
- 邊界條件:如果問題中涉及到明確的邊界條件,如查詢第一個或最後一個滿足條件的元素,選擇合適的區間型別可以簡化邏輯。
典型例題分析 Exemplars
1. 在陣列中查詢目標值,返回索引
左閉右閉實現:
int binarySearch(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
左閉右開實現:
int binarySearch(vector<int>& nums, int target) {
int left = 0, right = nums.size();
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid;
}
}
return -1;
}
2. 在有序陣列中找到目標值的插入位置
綜上所述,左閉右開更適合這一場景,因為它的區間邏輯更加貼合“邊界”問題:
int searchInsert(vector<int>& nums, int target) {
int left = 0, right = nums.size();
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid;
}
}
return left; // 返回插入位置
}
複雜度分析 Complexity Analysis
二分查詢的時間複雜度為 \(O(\log_2 N)\),空間複雜度為 \(O(1)\)。這種高效性使得二分查詢在處理大規模資料時表現出色。然而,二分查詢的前提條件是資料必須是有序的,這在某些情況下可能需要額外的排序時間。
相關題目 Practice Problems
可以在閱讀本文後自己實踐一下以下題目:
- 查詢最接近的元素 在一個升序序列中,查詢與給定值最接近的元素。
- 二分法求函式的零點 已知函式在某區間內有且只有一個根,使用二分法求出該根。
- 查詢 x 給定一個升序序列(元素均不重複),在該序列中查詢指定的值,若存在則輸出對應的下標,否則輸出 \(-1\)。
- 二分查詢 在 \(N\) 個從小到大排列且不重複的整數中,快速找到指定的數字 \(t\),若找不到則輸出 \(-1\)。