Java 算法 - 二分法查找


Java 算法 - 二分法查找

數據結構與算法之美目錄(https://www.cnblogs.com/binarylei/p/10115867.html)

二分法查找是一種非常高效的查找方式,時間復雜度為 O(logn)。

唐納德·克努特(Donald E.Knuth)在《計算機程序設計藝術》的第 3 卷《排序和查找》中說到:"盡管第一個二分查找算法於 1946 年出現,然而第一個完全正確的二分查找算法實現直到 1962 年才出現。"

二分查找原理非常簡單,但想要寫出沒有 Bug 的二分查找並不容易,"十個二分九個錯"。本文先介紹最簡單的一種二分查找的代碼實現,再深入分析幾種二分查找的變形問題。

1. 工作原理

這一部分,我們說的簡單二分查找法,也是精確查找。如 JDK 的 Collections#binarySearch。

public int bsearch(int[] arr, int value) {
    int low = 0;
    int high = arr.length - 1;
    while (low <= high) {
        int mid = (low + high) / 2;
        if (value < arr[mid]) {
            high = mid - 1;
        } else if (value == arr[mid]) {
            return mid;
        } else {
            low = mid + 1;
        }
    }
    return -1;
}

說明: 簡單的二分查找非常簡單,但還是有幾個細節需要特別注意一下:

  1. 循環退出條件。注意是 low <= high,而不是 low < high,否則可能會查找不到數組,反回 -1。
  2. mid 的取值。因為如果 low 和 high 比較大的話,兩者之和就有可能會溢出。寫成 low + (high - low) / 2,或改寫成位運算 low + ((high - low) >> 1),或 (low + high) >>> 1。
  3. low 和 high 的更新。如果直接寫成 low = mid 或者 high = mid,就可能會發生死循環。比如,當 high = 3,low = 3 時,如果 a[3] 不等於 value,就會導致一直循環不退出。

改進后的二分查找法如下,以 Collections#binarySearch 為例:

private static <T> int indexedBinarySearch(List<? extends Comparable<? super T>> list, T key) {
    int low = 0;
    int high = list.size() - 1;

    while (low <= high) {
        int mid = (low + high) >>> 1;
        Comparable<? super T> midVal = list.get(mid);
        int cmp = midVal.compareTo(key);

        if (cmp < 0)
            low = mid + 1;
        else if (cmp > 0)
            high = mid - 1;
        else
            return mid; // key found
    }
    return -(low + 1);  // key not found
}

2. 使用場景

二分查找法雖然查找效率高效,但使用條件非常苛刻,場景使用有限:

  1. 二分查找的底層數據結構必須是數組。因為需要根據下標隨機訪問數組。

  2. 二分查找針對的是有序數組。二分查找只能用在插入、刪除操作不頻繁,一次排序多次查找的場景中。針對動態變化的數據集合,二分查找將不再適用。

    對於頻繁變化的動態數據,二分法不適合。因為每次查找前都需要重新排序,雖然查找的時間復雜度是 O(logn),但排序的時間復雜度是 O(nlogn),因而總的時間復雜度就變成 O(nlogn)。

  3. 數據量太小不適合二分查找。數據量太小則不能體現二分查找法的優勢,還不如直接順序遍歷。

    比如我們在一個大小為 10 的數組中查找一個元素,不管用二分查找還是順序遍歷,查找速度都差不多。只有數據量比較大的時候,二分查找的優勢才會比較明顯。當然,如果數據比較操作非常耗時,不管數據量大小,都推薦使用二分查找。如長度超過 300 的字符串比較。

  4. 數據量太大也不適合二分查找。數據量太大內存不夠,因為數組必須使用連續的內存進行存儲。二分查找的底層需要依賴數組這種數據結構,而數組為了支持隨機訪問的特性,要求內存空間連續,對內存的要求比較苛刻。比如,我們有 1GB 大小的數據,如果希望用數組來存儲,那就需要 1GB 的連續內存空間。

總結來說:二分法查找底層必須使用有序的靜態數組,對於動態數據,或數據量太小,或太大都不適合用二分法。

思考1:二分查找法的底層數據結構為什么不能是鏈表?

二分查找法每次都獲取鏈表的中間結點,采用快慢結點算法獲取鏈表的中間節點時,快慢指針都要移動鏈表長度的一半次,也就是 n / 2 次,總共需要移動 n 次指針才行。

  • 第一次,鏈表長度為 n,需要移動指針 n 次;
  • 第二次,鏈表長度為 n / 2,需要移動指針 n / 2 次;
  • 第三次,鏈表長度為 n / 4,需要移動指針 n / 4 次;
  • ......
  • 以此類推,一直到 1 次為值
  • 指針移動的總次數 n + n / 2 + n / 4 + n / 8 + ... + 1 = n(1 - 0.52) / (1 - 0.5) = 2n

也就是說,如果采用鏈表的數據結構,僅獲取中間結點的時間復雜度是 O(2n),不僅遠遠大於數組二分查找 O(logn),也要大於順序查找的時間復雜度 O(n)。

思考2:動態數據如何快速查找呢?

我們知道動態數據每次查找前都先進行排序后查找,查找的時間復雜度就變成 O(nlogn)。有沒有好的快速查找方法呢?這時跳表就登場了。跳表使用空間換時間的設計思路,通過構建多級索引來提高查詢的效率,實現了基於鏈表的“二分查找”。跳表是一種動態數據結構,支持快速的插入、刪除、查找操作,時間復雜度都是 O(logn)。

3. 模糊匹配 - 二分法查找法變形

事實上,二分法在精確匹配上使用的並不多,我們可以用 HashMap 等數據結構替換(雖然 HashMap 比數組更耗內存),二分法往往用在模糊查找上。

  • 查找第一個值等於給定值的元素
  • 查找最后一個值等於給定值的元素
  • 查找第一個大於等於給定值的元素
  • 查找最后一個小於等於給定值的元素

3.1 查找第一個值等於給定值的元素

public int bsearch(int[] arr, int value) {
    int n = arr.length;
    int low = 0;
    int high = n - 1;
    while (low <= high) {
        int mid = low + ((high - low) >> 1);
        if (arr[mid] > value) {
            high = mid - 1;
        } else if (arr[mid] < value) {
            low = mid + 1;
        } else {
            // 和簡單二分法查找不同,如果前一個元素值相等還需要繼續遞歸
            if ((mid == 0) || (arr[mid - 1] != value)) return mid;
            else high = mid - 1;
        }
    }
    return -1;
}

說明: 只需要在二分查找的基礎上做一點改動即可,如果查找到相等的元素,需要進一步判斷前一個元素是否等於要查找的值,如果等於要查找的值,則需要繼續遞歸。

上述的二分法查找代碼可讀性最好,當然還有一種更高效的寫法,可讀性就稍微差一點了:

public int bsearch(int[] arr, int value) {
    int n = arr.length;
    int low = 0;
    int high = n - 1;
    while (low <= high) {
        int mid = low + ((high - low) >> 1);
        if (arr[mid] >= value) {
            high = mid - 1;
        } else {
            low = mid + 1;
        }
    }

    if (low < n && arr[low] == value) return low;
    else return -1;
}

3.2 查找最后一個值等於給定值的元素

public int bsearch(int[] arr, int value) {
    int n = arr.length;
    int low = 0;
    int high = n - 1;
    while (low <= high) {
        int mid = low + ((high - low) >> 1);
        if (arr[mid] > value) {
            high = mid - 1;
        } else if (arr[mid] < value) {
            low = mid + 1;
        } else {
            if ((mid == n - 1) || (arr[mid + 1] != value)) return mid;
            else low = mid + 1;
        }
    }
    return -1;
}

3.3 查找第一個大於等於給定值的元素

public int bsearch(int[] arr, int value) {
    int n = arr.length;
    int low = 0;
    int high = n - 1;
    while (low <= high) {
        int mid = low + ((high - low) >> 1);
        if (arr[mid] >= value) {
            if ((mid == 0) || (arr[mid - 1] < value)) return mid;
            else high = mid - 1;
        } else {
            low = mid + 1;
        }
    }
    return -1;
}

3.4 查找最后一個小於等於給定值的元素

public int bsearch(int[] arr, int value) {
    int n = arr.length;
    int low = 0;
    int high = n - 1;
    while (low <= high) {
        int mid = low + ((high - low) >> 1);
        if (arr[mid] > value) {
            high = mid - 1;
        } else {
            if ((mid == n - 1) || (arr[mid + 1] > value)) return mid;
            else low = mid + 1;
        }
    }
    return -1;
}

3.5 模糊匹配應用場景

比如,我們需要根據 IP 查找對應的地址,如果數據庫中有 100 萬個 IP 段對應的地址庫,如何高效的進行 IP 匹配呢?比如:

171.43.252.0 ~ 171.43.252.254 武漢
171.43.253.0 ~ 171.43.253.254 廣州
171.43.254.0 ~ 171.43.254.254 上海
...

我們的解決方案是這樣,首先我們知道 IPv4 可以轉換成一個 int 類型數據。我們以每個地址段的起始 IP 進行排序,這樣問題就轉換成了查找最后一個小於等於給定 IP 問題的解,如果查找的 IP 在查找的 IP 段內,就返回這個地址,否則返回空。


每天用心記錄一點點。內容也許不重要,但習慣很重要!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM