二分查詢的區間到底是開還是閉?

Macw發表於2024-11-27

二分查詢的區間到底是開還是閉?

在這兩個月的時間裡,我似乎沒有產出任何的有關知識點的文章,大多數都是題解相關的內容。以至於許多人覺得 Macw07 “失蹤”了。本文是我來到北美之後的第一篇知識點文章,請大家多多關照。

這次不講難的知識點了,講一個大家都熟悉的,但又非常令人抓毛的演算法:二分查詢和二分答案演算法

引言 Introduction

注意:本文僅針對瞭解過二分查詢基本演算法原理的使用者群體,若您從未接觸過或瞭解過該演算法,請先學習基礎的二分查詢演算法。

二分查詢演算法是大家一個再熟悉不過的演算法了,二分查詢演算法可以在一個 有序數列 中高效地查詢某個或多個特定的目標值。一般來說,二分查詢的時間複雜度在 \(O(\log_2 N))\) 級別。二分演算法非常適合在大資料集上實現快速查詢。與此同時,除了基本的二分查詢演算法,它的許多變種也被廣泛應用於各種場景,比如求最大值、最小值,甚至在複雜的資料結構中最佳化資料的查詢效能。

許多同學肯定在學習完基本的二分查詢後一直有一個疑問:我到底該如何設定 \(L\)\(R\) 的區間閉合狀態?什麼時候需要輸出 \(L\)\(R\),為什麼有時候還需要 \(+1\)\(\text{Mid}\) 到底儲存的是什麼東西?etc.

事實上,區間開閉的變數定義 確實是一個核心且容易混淆的問題,在 CSP 考試中也常考此知識點,因此本文將重點圍繞區間開閉的變數定義來展開。

在深入討論區間開閉之前,有必要回顧一下二分查詢的基本原理。二分查詢透過反覆將搜尋區間分成兩半,逐步縮小目標值所在的範圍,直到找到目標值或確定其不存在。具體步驟如下:

  1. 初始化:設定搜尋區間的左右邊界 \(L\)\(R\)

  2. 計算中點:計算中點 \(M = L + \dfrac{R - L}{2}\)

  3. 比較

    :將目標值與中點元素進行比較。

    • 若相等,返回中點位置。
    • 若目標值小於中點元素,縮小搜尋區間至左半部分。
    • 若目標值大於中點元素,縮小搜尋區間至右半部分。
  4. 重複:重複上述步驟,直到找到目標值或搜尋區間為空。

開區間/閉區間 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

在實現二分查詢的時候,區間的定義是最常見的一個問題,你可能會看到過以下不同的區間開閉性的定義:

  1. 左開右開 \((\text{left}, \text{right})\)
  2. 左閉右閉 \([\text{left}, \text{right}]\)
  3. 左開右閉 \((\text{left}, \text{right}]\)
  4. 左閉右開 \([\text{left}, \text{right})\)

通常來說,我們一般會選擇【左閉右開】或者【左閉右閉】的區間定義,所以本文也就著重圍繞這兩個部分講解。但對於不同的定義區間,如果稍有不慎,就容易使程式碼進入 死迴圈

左閉右閉區間

定義:搜尋區間包括 leftright,即 leftright 都可能是目標值。

退出條件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

左閉右閉的有點

  1. 直觀易懂:包括 leftright 的寫法更加接近自然語言的描述,例如 “在 \([left, right]\) 區間查詢目標值”。
  2. 處理小區間:對於某些需要特別處理的小區間問題,左閉右閉可以更容易描述邏輯。

左開右閉的優點

避免陣列越界:使用左閉右開區間,right 永遠是無效位置,不會直接訪問陣列越界的索引。

邏輯一致性:左閉右開區間的範圍在迭代過程中可以穩定保持邏輯清晰,容易與數學符號對應。

程式碼簡潔:由於退出條件是 left == right,很多情況下可以直接用 left 返回結果,無需做出額外檢查。

實際應用中的選擇 Choosing the Right Interval in Practice

在實際應用中,選擇使用左閉右閉還是左閉右開區間,往往取決於具體問題的需求和個人習慣。以下是一些指導原則:

  1. 陣列索引:在處理陣列索引時,左閉右開區間更加自然,因為陣列的索引從 0n-1,左閉右開可以避免 n 的無效訪問。
  2. 範圍劃分:當需要頻繁劃分範圍時,左閉右開區間的邏輯更清晰,減少了混淆和錯誤。
  3. 邊界條件:如果問題中涉及到明確的邊界條件,如查詢第一個或最後一個滿足條件的元素,選擇合適的區間型別可以簡化邏輯。

典型例題分析 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

可以在閱讀本文後自己實踐一下以下題目:

  1. 查詢最接近的元素 在一個升序序列中,查詢與給定值最接近的元素。
  2. 二分法求函式的零點 已知函式在某區間內有且只有一個根,使用二分法求出該根。
  3. 查詢 x 給定一個升序序列(元素均不重複),在該序列中查詢指定的值,若存在則輸出對應的下標,否則輸出 \(-1\)
  4. 二分查詢\(N\) 個從小到大排列且不重複的整數中,快速找到指定的數字 \(t\),若找不到則輸出 \(-1\)

相關文章