今天來學習一下二分搜索算法。二分搜索算法針對有序數組,如果數組亂序,則無法使用二分搜索法。
先來看一下二分搜索算法的運行原理:
- 判斷區間是否有效,無效區間則退出循環。
- 取待查找區間的中間位置元素與目標值對比:
- 如果目標值小於中間位置元素,則更新待查找區間索引,到左邊子區間繼續查找。
- 如果目標值大於中間位置元素,則更新待查找區間索引,到右邊子區間繼續查找。
- 相等,則找到元素,返回索引。
- 循環退出,區間左邊索引取反後返回(負數表示未查找到目標值)。
我們舉個例子直觀感受一下:
有一個數組[1, 3, 4, 5, 9, 10, 11, 15, 16]
,我們要找到其中的一個值,比如說3。查找過程如圖所示:
這裏引入兩個索引,lo索引,hi索引,用來確定查找區間[lo, hi),爲左閉右開。
- 首先檢查一下索引
lo=0
和hi=9
是否構成有效區間,很明顯現在是有效區間。 - 然後我們去數組的中間位置查找,索引中間位置索引爲
(lo + hi) / 2 = (0 + 8) / 2 = 4
,把索引爲4的元素9與目標值3比較,發現3 < 9
,目標值比較小,所以我們要去左邊子區間查找。左邊子區間lo索引不用變,hi索引變爲4。 - 區間[0, 4)是有效區間,我們到新區間[0, 4)查找,此時中間索引爲
(0 + 4) / 2 = 2
,把索引爲2的元素4與目標值3比較,發現3 < 4
,目標值比較小,所以我們還要去左邊子區間查找。左邊子區間lo索引不用變,hi索引變爲2。 - 這時新區間變爲了[0, 2),依舊是有效區間,中間索引爲
(0 + 2) / 2 = 1
,把索引爲1的元素3與目標值3比較,發現3=3
,找到目標值,所以返回索引1。
我們也可以通過類似的方法查找目標值5,如圖所示:
不過也有可能找不到目標元素,如下圖,我們要查找目標值6,就會發現無法找到6:
來看下整個查找過程:
- 區間[0, 9)是有效區間,我們去數組的中間位置索引爲
(0 + 9) / 2 = 4
的地方查找,把索引爲4的元素9與目標值6比較,發現6 < 9
,目標值比較小,所以我們要去左邊子區間查找。左邊子區間lo索引不用變,hi索引變爲4。 - 區間[0, 4)是有效區間,我們去數組的中間位置索引爲
(0 + 4) / 2 = 2
的地方查找,把索引爲2的元素4與目標值6比較,發現4 < 6
,目標值比較大,所以我們要去右邊子區間查找。右邊子區間li索引不用變,lo索引變爲3。 - 區間[3, 4)是有效區間,我們去數組的中間位置索引爲
(3 + 4) / 2 = 3
的地方查找,把索引爲3的元素5與目標值6比較,發現5 < 6
,目標值比較大,所以我們要去右邊子區間查找。右邊子區間li索引不用變,lo索引變爲4。 - 此時[4, 4)不是有效區間,目標值查找失敗,返回索引4的取反值-3。
我們會發現目標值元素找不到的時候剛好最後的lo索引就是目標元素插入位置的索引,這個是巧合還是必然呢?可以來分析一下:
當我們查找元素到最後,都會只剩下1個元素,區間爲[lo, hi),如圖:
此時無非兩種情況:
- 當前元素小於目標值,目標元素位於
lo
右邊,如果要插入,則插入到當前的lo + 1
位置,而我們更新區間後的lo
位置剛好是lo + 1
,所以最後返回的lo位置即是當前的lo+1
位置。 - 當前元素大於目標值,目標元素位於
lo
左邊,如果要插入,則插入到當前的lo
位置,而我們更新區間後的lo
位置保持不變,所以最後返回的lo
位置即是當前的lo
位置。
綜上兩種情況,我們可以確認最後的lo
位置就是目標元素插入後的索引位置。
最後算法代碼實現如下:
/**
* 有序數組的二分搜索算法
*
* @param nums 有序數組(順序爲從小到大)
* @param target 查找目標值
* @param lo 區間的lo索引(包含)
* @param hi 區間的hi索引(不包含)
*
* @return 目標值所在位置的索引,如果沒有找到目標值,則返回如果將目標值插入指定區間後在數組中所在位置的索引的取反值;
* 如果數組爲null,返回-1;
* 返回負數則表示查找不到
*/
public static int binarySearch(int[] nums, int target, int lo, int hi) {
// 數組爲null則返回-1
if (nums == null) {
return -1;
}
// 索引檢查
if (lo < 0 || nums.length <= lo) {
throw new IllegalArgumentException("lo索引必須大於0並且小於數組長度,數組長度:" + nums.length);
}
if (hi < 0 || nums.length < hi) {
throw new IllegalArgumentException("hi索引必須大於0並且小於等於數組長度,數組長度:" + nums.length);
}
if (hi <= lo) {
// lo索引必須小於hi索引(等於也不行,因爲區間是左閉右開,如果等於,區間內元素數量就爲0了)
throw new IllegalArgumentException("lo索引必須小於hi索引");
}
while (lo < hi) {
// 當lo索引小於hi索引,區間有效,否則區間無效,退出循環,查找結束
// int mid = (lo + hi) / 2; 的優化版,避免lo + hi超過int最大值
int mid = (lo / 2) + (hi / 2) + ((lo % 2) & (hi % 2));
int current = nums[mid];
if (current < target) {
// 目標值比當前值大,說明目標值位於區間右邊
// 需要將lo索引變大
// 因爲索引lo要包含在查找區間,mid已經被排除了,所以索引lo變成mid + 1
lo = mid + 1;
} else if (target < current) {
// 目標值比當前值小,說明目標值位於區間左邊
// 需要將hi索引變小
// 因爲索引hi不包含在查找區間,mid被排除後,索引hi變成mid
hi = mid;
} else {
// 查找到目標值
// 返回索引
return mid;
}
}
// 查找不到目標值,返回lo索引的取反值,此時lo索引剛好是將目標值插入指定區間後在數組中所在位置的索引
return ~lo;
}
測試代碼如下:
int[] nums = {1, 3, 4, 5, 9, 10, 11, 16};
System.out.println("查找元素3:" + binarySearch(nums, 3, 0, nums.length));
System.out.println("查找元素5:" + binarySearch(nums, 5, 0, nums.length));
System.out.println("查找元素11:" + binarySearch(nums, 11, 0, nums.length));
System.out.println("查找元素6:" + binarySearch(nums, 6, 0, nums.length));
System.out.println("在區間[0, 5)查找元素11:" + binarySearch(nums, 11, 0, 5));
輸出如下:
查找元素3,返回:1
查找元素5,返回:3
查找元素11,返回:6
查找元素6,返回:-5
在區間[0, 5)查找元素11,返回:-6
查找元素3,5,11,分別返回1,3,6,符合預期。
其中查找元素6返回-5,小於0,表示查找不到,我們可以對-5取反,得到4,表示如果要將元素6有序地插入數組中,會出現在的索引位置。
而在區間[0, 5)裏面查找元素11,也返回-6,對於整個數組來說,11是存在的,但是因爲限定了查找區間爲[0, 5),所以最後結果也返回查找不到,並且插入位置也是相對於查找區間,所以返回的索引爲-6,取反後得到5,表示如果要將元素11有序地插入到該數組的[0, 5)區間,插入後的索引位置在5上,符合我們的預期。