二分查詢(折半查詢)演算法(原理、實現及時間複雜度)
阿新 • • 發佈:2018-12-23
查詢也是有特殊情況的,比如數列本身是有序的。這個有序數列是怎麼產生的呢?有時它可能本身就是有序的,也有可能是我們通過之前所學的排序演算法得到的。
不管怎麼說,我們現在已經得到了有序數列了並需要查詢。這時二分查詢該出場了。
二分查詢(Binary Search)也叫作折半查詢。二分查詢有兩個要求,一個是數列有序,另一個是數列使用順序儲存結構(比如陣列)。
以升序數列為例,比較一個元素與數列中的中間位置的元素的大小,如果比中間位置的元素大,則繼續在後半部分的數列中進行二分查詢;如果比中間位置的元素小,則在數列的前半部分進行比較;如果相等,則找到了元素的位置。每次比較的數列長度都會是之前數列的一半,直到找到相等元素的位置或者最終沒有找到要找的元素。
我們先來想象一下,如果數列中有 3 個數,則先與第 2 個數進行比較,如果比第 2 個數大,則與第 2 個數右邊的數列進行二分查詢,這時這個數列就剩下一個數了,直接比較是否相等即可。所以在 3 個數的時候最多比較兩次。
同理,在有 4 個數的時候,我們與中間數進行比較,一般中間數是首加末除以 2 算出來的,這時我們算出來的中間數是 (1+4)/2 等於 2,所以我們把要查詢的數與第 2 個數比較,若比第 2 個數小,則直接與第 1 個數比較;否則與後面兩個數進行二分查詢,這時的中間數是 (3+4)/2 等於 3,也就是後半部分的第 1 個數。再接著進行比較,相等則找到相應的元素,小於則沒有這個數(因為左邊所有的數都已經判斷過了),大於則繼續向右查詢。所以在 4 個數的時候最多比較 3 次。
以此類推,在 5 個數的時候最多查詢 3 次,在 6 個數的時候也是最多查詢 3 次。
下面我們以一個實際的例子來看看二分查詢的操作過程。假設待查詢數列為 1、3、5、7、9、11、19,我們要找的元素為 18,下面進行二分查詢。首先待查數列如圖 1 所示,我們找到中間的元素 7( (1+7)/2=4,第 4 個位置上的元素)。
圖 1 在待查序列中找到中間元素
中間元素為 7,我們要找的元素比 7 大,於是在後半部分查詢,現在後半部分數列為 9、11、19,我們找到中間元素,如圖 2 所示。
圖 2 在待查序列的後半部分找到中間元素
中間元素為 11,與 11 比較,比 11 大,則繼續在後半部分查詢,後半部分只有一個元素 19 了,這時直接與 19 比較,若不相等,則說明在數列中沒有找到元素,結束查詢。
對於這 7 個元素的數列,我們只查詢並比較了 3 次,是不是比較次數很少呢?
下面我們來看看二分查詢的實現。其實我們通過二分查詢的操作步驟,可以很輕易地想出二分查詢使用遞迴實現也很方便。下面我們用遞迴來實現二分查詢。
當然,除了遞迴實現,二分查詢也可以使用非遞迴實現,程式碼如下:
怎麼樣,是不是很簡單?用測試小程式檢查一下吧。
發散一下思維,在查字典的時候,如果要查以a開頭的單詞,則你會怎麼翻字典?肯定是從最前面開始翻;如果要查以 z 開頭的單詞,則應該會從最後開始翻。顯而易見,你不會採用二分查詢的方式去查這個單詞在哪,因為這樣你會很累。
同樣,假設資料的範圍是 1~10000,讓你找 10,你會怎麼樣?簡單來說,我覺得乾脆用順序查詢好了,因為數列是升序的,沒必要用二分查詢,用順序查詢比二分查詢的比較次數少。
所以經過這樣的考慮,我們可以優化一下二分查詢,並不一定要從正中間開始分,而是儘量找到一個更接近我們要找的那個數字的地方,這樣能夠減少很多查詢次數。
之前我們都是根據長度去找到這個中間位置,現在是根據 key 所在的序列範圍區間去找到這個位置。比如數列是 1~10,待查 key 是 3,我們可能會將大概前面三分之一的地方作為這個劃分點。
不過還是有人給出了更精準的計算方式,即要查詢的位置 P=low+(key-a[low])/(a[high]-a[low])×(high-low),這是有點複雜,但是仔細看一下,這種計算方式其實就是為了找 key 所在的相對位置,讓 key 的值更接近劃分的位置,從而減少比較次數。
這種對二分查詢的優化其實有個名字,叫作插值查詢,插值查詢對於數列比較大並且比較均勻的數列來說,效能會好很多;但是如果數列極不均勻,則插值查詢未必會比二分查詢的效能好。
二分查詢有個很重要的特點,就是不會查詢數列的全部元素,而查詢的資料量其實正好符合元素的對數,正常情況下每次查詢的元素都在一半一半地減少。所以二分查詢的時間複雜度為
其實我們在獲取一個列表的很多時候,可以直接使用資料庫針對某個欄位進行排序,在程式中需要找出某個值的元素時,就很適合使用二分查找了。
二分查詢適合元素稍微多一些的數列,如果元素只有十幾或者幾十個,則其實可以直接使用順序查詢(當然,也有人在順序查詢外面用了一個或幾個大迴圈,執行這幾層大迴圈需要計算機執行百萬、千萬遍,沒有考慮到機器的效能)。
一般對於一個有序列表,如果只需要對其進行一次排序,之後不再變化或者很少變化,則每次進行二分查詢的效率就會很高;但是如果在一個有序列表中頻繁地插入、刪除資料,那麼維護這個有序列表會讓人很累,其實有更好的方案,彆著急,我們慢慢想。
不管怎麼說,我們現在已經得到了有序數列了並需要查詢。這時二分查詢該出場了。
二分查詢(Binary Search)也叫作折半查詢。二分查詢有兩個要求,一個是數列有序,另一個是數列使用順序儲存結構(比如陣列)。
二分查詢的原理及實現
二分查詢的實現原理非常簡單,首先要有一個有序的列表。但是如果沒有,則該怎麼辦?可以使用排序演算法進行排序。以升序數列為例,比較一個元素與數列中的中間位置的元素的大小,如果比中間位置的元素大,則繼續在後半部分的數列中進行二分查詢;如果比中間位置的元素小,則在數列的前半部分進行比較;如果相等,則找到了元素的位置。每次比較的數列長度都會是之前數列的一半,直到找到相等元素的位置或者最終沒有找到要找的元素。
我們先來想象一下,如果數列中有 3 個數,則先與第 2 個數進行比較,如果比第 2 個數大,則與第 2 個數右邊的數列進行二分查詢,這時這個數列就剩下一個數了,直接比較是否相等即可。所以在 3 個數的時候最多比較兩次。
同理,在有 4 個數的時候,我們與中間數進行比較,一般中間數是首加末除以 2 算出來的,這時我們算出來的中間數是 (1+4)/2 等於 2,所以我們把要查詢的數與第 2 個數比較,若比第 2 個數小,則直接與第 1 個數比較;否則與後面兩個數進行二分查詢,這時的中間數是 (3+4)/2 等於 3,也就是後半部分的第 1 個數。再接著進行比較,相等則找到相應的元素,小於則沒有這個數(因為左邊所有的數都已經判斷過了),大於則繼續向右查詢。所以在 4 個數的時候最多比較 3 次。
以此類推,在 5 個數的時候最多查詢 3 次,在 6 個數的時候也是最多查詢 3 次。
下面我們以一個實際的例子來看看二分查詢的操作過程。假設待查詢數列為 1、3、5、7、9、11、19,我們要找的元素為 18,下面進行二分查詢。首先待查數列如圖 1 所示,我們找到中間的元素 7( (1+7)/2=4,第 4 個位置上的元素)。
圖 1 在待查序列中找到中間元素
中間元素為 7,我們要找的元素比 7 大,於是在後半部分查詢,現在後半部分數列為 9、11、19,我們找到中間元素,如圖 2 所示。
圖 2 在待查序列的後半部分找到中間元素
中間元素為 11,與 11 比較,比 11 大,則繼續在後半部分查詢,後半部分只有一個元素 19 了,這時直接與 19 比較,若不相等,則說明在數列中沒有找到元素,結束查詢。
對於這 7 個元素的數列,我們只查詢並比較了 3 次,是不是比較次數很少呢?
下面我們來看看二分查詢的實現。其實我們通過二分查詢的操作步驟,可以很輕易地想出二分查詢使用遞迴實現也很方便。下面我們用遞迴來實現二分查詢。
public class BinarySearch { private int[] array; /** * 遞迴實現二分查詢 * @param target * @return */ public int searchRecursion(int target) { if (array != null) { return searchRecursion(target, 0, array.length - 1); } return -1; } private int searchRecursion(int target, int start, int end) { if (start > end) { return -1; } int mid = start + (end - start) / 2; if (array[mid] == target) { return mid; } else if (target < array[mid]) { return searchRecursion(target, start, mid - 1); } else { return searchRecursion(target, mid + 1, end); } } }
public class BinarySearch { private int[] array; /** * 初始化陣列 * @param array */ public BinarySearch(int[] array) { this.array = array; } /** * 二分查詢 * @param target * @return */ public int search(int target) { if (array == null) { return -1; } int start = 0; int end = array.length - 1; while (start <= end) { int mid = start + (end - start) / 2; if (array[mid] == target) { return mid; } else if (target < array[mid]) { end = mid - 1; } else { start = mid + 1; } } return -1; } }
public class BinarySearchTest { public static void main(String[] args) { int[] array = new int[]{1, 3, 5, 7, 9, 11, 19}; BinarySearch binarySearch = new BinarySearch(array); System.out.println(binarySearch.search(0)); System.out.println(binarySearch.search(11)); System.out.println(binarySearch.searchRecursion(0)); System.out.println(binarySearch.searchRecursion(11)); } }
二分查詢的優化
這裡我們考慮一下為什麼是二分查詢,而不是三分之一、四分之一查詢。發散一下思維,在查字典的時候,如果要查以a開頭的單詞,則你會怎麼翻字典?肯定是從最前面開始翻;如果要查以 z 開頭的單詞,則應該會從最後開始翻。顯而易見,你不會採用二分查詢的方式去查這個單詞在哪,因為這樣你會很累。
同樣,假設資料的範圍是 1~10000,讓你找 10,你會怎麼樣?簡單來說,我覺得乾脆用順序查詢好了,因為數列是升序的,沒必要用二分查詢,用順序查詢比二分查詢的比較次數少。
所以經過這樣的考慮,我們可以優化一下二分查詢,並不一定要從正中間開始分,而是儘量找到一個更接近我們要找的那個數字的地方,這樣能夠減少很多查詢次數。
之前我們都是根據長度去找到這個中間位置,現在是根據 key 所在的序列範圍區間去找到這個位置。比如數列是 1~10,待查 key 是 3,我們可能會將大概前面三分之一的地方作為這個劃分點。
不過還是有人給出了更精準的計算方式,即要查詢的位置 P=low+(key-a[low])/(a[high]-a[low])×(high-low),這是有點複雜,但是仔細看一下,這種計算方式其實就是為了找 key 所在的相對位置,讓 key 的值更接近劃分的位置,從而減少比較次數。
這種對二分查詢的優化其實有個名字,叫作插值查詢,插值查詢對於數列比較大並且比較均勻的數列來說,效能會好很多;但是如果數列極不均勻,則插值查詢未必會比二分查詢的效能好。
二分查詢的特點及效能分析
二分查詢的平均查詢長度 ASL 為 ((n+1)log2(n+1))/n-1,有的書上寫的是 log2(n+1)-1,或者是 log2n,具體計算比較麻煩,這裡就不討論了。二分查詢有個很重要的特點,就是不會查詢數列的全部元素,而查詢的資料量其實正好符合元素的對數,正常情況下每次查詢的元素都在一半一半地減少。所以二分查詢的時間複雜度為
O(log2n)
是毫無疑問的。當然,最好的情況是隻查詢一次就能找到,但是在最壞和一般情況下的確要比順序查詢好了很多。二分查詢的適用場景
二分查詢要求數列本身有序,所以在選擇的時候需要確認數列是否本身有序,如果無序,則還需要進行排序,確認這樣的代價是否符合實際需求。其實我們在獲取一個列表的很多時候,可以直接使用資料庫針對某個欄位進行排序,在程式中需要找出某個值的元素時,就很適合使用二分查找了。
二分查詢適合元素稍微多一些的數列,如果元素只有十幾或者幾十個,則其實可以直接使用順序查詢(當然,也有人在順序查詢外面用了一個或幾個大迴圈,執行這幾層大迴圈需要計算機執行百萬、千萬遍,沒有考慮到機器的效能)。
一般對於一個有序列表,如果只需要對其進行一次排序,之後不再變化或者很少變化,則每次進行二分查詢的效率就會很高;但是如果在一個有序列表中頻繁地插入、刪除資料,那麼維護這個有序列表會讓人很累,其實有更好的方案,彆著急,我們慢慢想。