二分查詢也叫做折半查詢,查詢的物件是已經排好序的序列(一般預設為升序)。
讓我們來看看原理:顧名思義,就是先將中間數和目標key比較,如果相等則返回其索引,否則把序列分成兩半,根據大小判斷所查詢的key在哪一半中,對這一半序列再重複上述步驟,直到找到目標key或查詢完序列。
一般的二分查詢
被查詢的序列arr中無重複的元素,在此序列中查詢目標數target。
被查詢的序列示例:int[] arr1 = { 1, 2, 4, 5, 8, 13, 19, 20, 33, 38, 40, 48, 88 }。
public static int binarySearch(int[] arr, int target) {
if (arr == null || arr.length == 0) {
return -1;
}
// 確定別查詢的區間
int left = 0;
int right = arr.length - 1;
// 開始查詢【left,right】區間
while (left <= right) {
// 找到中間數的索引
int mid = (left + right) / 2;
// 判斷 中間數 和 target 的大小
if (arr[mid] == target) { // 若相等,則返回其索引
return mid;
} else if (arr[mid] > target) { // 若大於,確定前半段區間
right = mid - 1;
} else if (arr[mid] < target) { // 若小於,確定後半段區間
left = mid + 1;
}
}
// 如果未查詢到,則返回-1
return -1;
}
第一步,初始化原序列,left指向索引為0的位置,即此區間的第一個元素,right指向索引為arr.length-1的位置,即此區間最後一個元素,也就是說這個區間是閉區間,[left,right]區間內的元素都是被查的元素。
第二步,找到中間元素的索引mid,判斷中間元數和目標數target的值是否相等:
- 若中間數等於target,說明已經找到了目標數了,查詢成功,直接返回其索引mid;
- 若中間數小於target,說明要查詢的數在後半段,即[mid+1,right],所以將mid+1值賦給left,確定下次要查詢的區間,重複第二步;
- 若中間數大於target,說明要查詢的數在前半段,即[left,mid-1],所以將mid-1值賦給right,確定下次要查詢的區間,重複第二步;
第三步,當查詢到left==right時,是最後一個區間,即[left,left],此區間只有一個元素,即下標為left的元素。如果此元素是目標數則查詢成功,返回其索引;如果不是,會重新確定left或right的值,使得left>right,查詢完畢退出迴圈,返回-1。
有重複元素的二分查詢
被查詢的序列arr中有重複的元素,在此序列中查詢目標數target。
被查詢的序列示例:int[] arr1 = { 1, 2, 5, 5, 5, 13, 19, 33, 33, 38, 40, 48, 48 }。
1.尋找左側邊界的二分查詢
顧名思義,就是當序列中被查詢的數有多個時,找到最左邊的那個數的位置,然後返回其索引。當然,如果被查詢的數只有一個,找到並返回其索引就好了,否則返回-1。
public static int binarySearch1(int[] arr, int target) {
if (arr == null || arr.length == 0) {
return -1;
}
int left = 0;
int right = arr.length; // 右側未閉合
while (left < right) { // left==right時,退出迴圈
int mid = (left + right) / 2;
if (arr[mid] == target) { // 找到時,right向右側靠攏,記住此位置
right = mid;
}else if (arr[mid] < target) { // 小於時,左側閉合,向右靠攏
left = mid + 1;
}else if (arr[mid] > target) { // 大於時,右側不閉合,向左靠攏
right = mid;
}
}
if (left == arr.length) { // 如果target大於所有元素,返回-1
return -1;
}
return arr[left] == target ? left : -1;
}
大框架並沒有發生變化,改變的地方需要說一下:
首先,while的條件變了, left <= righ 變成了 left < right ,當left == right時退出迴圈,為什麼要變呢?因為我們這次所查詢的區間是左閉右開的,[left,right),當left增長到left == right時,所查詢的區間已查詢完畢,這時我們就要退出迴圈了。為什麼要這樣設計?此中妙處,請往下看。
if (left == arr.length) { return -1; } 這段程式碼是幹啥的?這是當我們要查詢的數大於此序列的所有數時,left會一直增加到left == right,而此時的right並未改變,就是arr.length。這段程式碼是為了處理這這種情況。
當中間數不是target時,調整下次要查詢的區間的範圍,大家應該可以理解,因為這是左閉右開的區間。
當中間數等於target時, right = mid; ,怎麼回事?找到 target 時不要立即返回,而是縮小「搜尋區間」的上界 right,在區間 [left, mid) 中繼續搜尋,即不斷向左收縮,達到鎖定左側邊界的目的。當然,如果此時right鎖定的就是左邊界,left會一直向右收縮,直到left == right。
最後的 return arr[left] == target ? left : -1; 是為了應對target小於所有數時的情況和是否找到target時的情況。
2.尋找右側邊界的二分搜尋
與上面的相反,就是當序列中被查詢的數有多個時,找到最右邊的那個數的位置,然後返回其索引。當然,如果被查詢的數只有一個,找到並返回其索引就好了,否則返回-1。
public static int binarySearch2(int[] arr, int target) {
if (arr == null || arr.length == 0) {
return -1;
}
int left = 0;
int right = arr.length;
while (left < right) {
int mid = (left + right) / 2;
if (arr[mid] == target) {
left = mid+1;
}else if (arr[mid] < target) {
left = mid+1;
}else if (arr[mid] > target) {
right = mid;
}
}
if (left == 0) {
return -1;
}
return arr[left-1] == target ? (left-1) : -1;
}
當我們查詢左邊界的時候,我們是用right指標來鎖定左側邊界的;同理,當我們查詢右邊界的時候,我們用left來鎖定右邊界,但有一點小區別,因為查詢區間是左閉右開的,left指向的元素在查詢範圍內,因此我們用left的前一個元素來鎖定右邊界。
這就是為什麼當中間元素等於target時,我們要 left = mid+1; 呢,left的前一個元素最終會鎖定的右邊界,這也就對應了後面的return語句中為什麼是left - 1,因為left - 1指向的最右邊的target元素。
if (left == 0) { return -1; } 這段程式碼是為了應對當target小於所有元素時的情況,因為後面有left-1,沒有這條語句的話會造成索引越界的情況。
總結一下
二分查詢有一個實現框架:
public static int binarySearch(int[] arr, int target) {
if (arr == null || arr.length == 0) {
return -1;
}
int left = 0;
int right = --------;
while (--------) {
int mid = (left + right) / 2;
if (arr[mid] == target) {
--------;
} else if (arr[mid] > target) {
right = --------;
} else if (arr[mid] < target) {
left = --------;
}
}
return --------;
}
當我們實現時根據題目的具體要求,來調整框架中所填部分的值。
一般的二分查詢最為簡單;當有重複數字時,查詢相對複雜一些,本文中只提到了查詢左右邊界的情況,但平常我們還會遇到一些變種的二分查詢情況,比如:查詢最後一個等於或者小於key的元素、查詢最後一個小於key的元素、查詢第一個等於或者大於key的元素、查詢第一個大於key的元素等。雖然變化很多,但萬變不離其宗!讀者只要理解查詢的原理和每一步的過程,將其融會貫通,便可攻無不克。