二分查找的應用總結
(1)n的平方根保留m位小數
(2)從小到大的有序數組循環右移n(n>=0)位,查找最小值;
(3)從小到大的有序數組中,查找絕對值最小的元素;
(4)從小到大的有序數組循環右移n(n>=0)位,查找某個特定值
(5) 二分查找變形之:
變體一:查找第一個值等於給定值的元素;
變體二:查找最後一個值等於給定值的元素;
變體三:查找第一個大於等於給定值的元素;
變體四:查找最後一個小於等於給定值的元素;
(6) 有序的無重疊區間數組,如何查找某個元素屬於哪個區間?
**************************
(0)概述:
1)二分查找的本質;
二分查找的本質在於判斷目標在哪個區間,然後擠壓法不斷縮小該區間即可;
2)數組的雙指針問題其實經常會用到:
1)二分查找;
2)將數組中負數放入到正數之前;
3)快速排序;
4)數組逆序;
------
(1)n的平方根保留m位小數
(1.1)二分查找源碼
public static int binarySearch(int[] arr, int target){
int low = 0;
int high = arr.length-1;
while (low <= high){
int mid = (low + high) / 2;
if (target > arr[mid]){
low = mid + 1;
}else if (target < arr[mid]){
high = mid - 1;
}else {
return mid;
}
}
return -1;
}
(1.2)分析
求n的平方根x,則首先想到的是可不可以從1開始,分別求1、2、3等的平方,看是否等於n。這裏就有個問題我爲什麼首先從1開始,而且爲什麼各個數的步長爲1?也就是說我們應該從哪裏開始去試,每次去試時,各個數的步長應該怎麼選?
從上面的分析中得出求n的平方根其實就是從小於n的數中找到x,使x*x等於n;
這就變成了一個查找的問題,而且是在有序數據集中查找,則最容易想到的就是二分查找;
則從哪裏開始去試,每次去試時,各個數的步長問題就都解決了。
對於一個數的平方根能夠找到該跟的範圍,即xx < n < yy,則n的平方根在x和y之間,且x+1=y。
找到平方根所在的區域之後,把所要保留的精度的平方根做爲步長對x進行遞增,直到|n-x*x|<0.0001(精度)爲止。
(1.3)實現
例如求12的平方根,精度爲小數點後2位。代碼示例如下:
(1.3.1)實現一
//查找x的平方小於等於n的最後一個元素x;
public static double sqrtByInc(int n, double precision){
int low = 0;
int high = n;
int mid = 0;
// 二分查找找到平方根的區域
while (low <= high){
mid = (low + high) / 2;
if (mid * mid > n){
high = mid - 1;
}else {
if ((mid == n-1) || (mid * mid > n)) {
break;
}
low = mid + 1;
}
}
// 按照精度的平方根做爲步長
double r = mid; //r爲double類型;
while (Math.abs(n-r*r) > 0.0001){
if (r * r < n){
r += 0.01;
r = BigDecimal.valueOf(r).setScale(2, RoundingMode.HALF_UP).doubleValue();
}else {
break;
}
}
return r;
}
說明:
上面的方法只是用二分查找找到平方根的範圍,然後遞增步長進行嘗試,雖然能夠快速找到根的範圍,但是遞增步長將是一個漫長的等待。
(1.3.2)實現二
double sqrt(double x)
{
double low = 0;
double up = x;
double mid = (low + up) / 2;
while(fabs(low - up) >= 1e-2) //循環結束條件爲 low 和 up之間的差距小於 0.01
{
if(fabs(mid * mid - x) < 1e-4)
return mid;
if(mid * mid > x)
up = mid; // 此中不設置 up= mid-1;
else if(mid * mid < x)
low = mid; //此中不設置 low = mid +1; 因爲求平方根,存在小數;
mid = (up + low) / 2;
}
DecimalFormat df = new DecimalFormat("0.00");
return df.format(mid); // mid取2位小數;
}
ps: 感覺上面如果精度爲2位小數,其實是可以 up=mid-0.01; low=mid+0.01的;
注:mid的2位小數如何獲取的問題?
1》借住printf的精度打印;nprintf保持到字符串問題;
2》long a = mid, 則a保持的是正數報文;
double target = (double)a + (double)( mid - a)/0.01/100
//mid- a 保持的是小數部分;整體就得到了保留2位小數的情況;
------------------
(2)有序循環數組查找指定值;
(2.1)分析
比如[0,1,2,4,5,6,7]
右移4位變爲[4,5,6,7,0,1,2]
,在[4,5,6,7,0,1,2]
中查找某個元素,事先不知道移動了多少位;
對於有序序列,首先可以想到的是利用二分法查找,但是序列別移動後的起點不再是最左邊的位置。比如說上面的例子,起點0在下標爲4的位置,所以不一定滿足nums[0] < nums[n - 1],不能直接使用二分法。
但是因爲是整體移動,局部仍然是有序的,所以如果確定了某個區間是有序的,那麼還是可以使用二分法的;
下面的*代表起點
1)情形1:
o * o o o o o
大 小 中
left middle right
如果起點在middle的左側,那麼nums[left],nums[middle],nums[right]的大小關係如上
可通過nums[middle] < nums[right]判斷起點在middle左邊;
2)情形2:
o o o o o * o
中 大 小
left middle right
如果起點在middle的右側,那麼nums[left],nums[middle],nums[right]的大小關係如上
可通過nums[middle] > nums[right]判斷起點在middle右邊;
3)說明:
以第一種情況爲例(起點在middle左側)即nums[middle] < nums[right] (nums[middle] < nums[right] 和 起點在middle左側是等價的)
如果目標元素在[middle, right]區間,那麼一定有nums[target] >= nums[middle] && nums[target] <= nums[right]
否則,目標元素在[left, middle]區間內
因爲[middle, right]一定是遞增的,所以可以判斷目標元素是否在這個區間內,而[left, middle]區間不是遞增的,故不能判斷。
第二種情況同理 ;
(2.2)注意點
該題和有序數組循環右移n位,然後查找最小值類似;
但是注意特殊情況:比如n=0的情況;比如,數組中存在重複元素的情況,如最小值重複,其他值重複等;
(2.3)實現
class Solution {
public:
int search(vector<int>& nums, int target) {
int left = 0;
int right = nums.size() - 1;
while(left < right)
{
int middle = (left + right) / 2;
if(nums[middle] == target)
return middle;
/* 第一種情況,起點在middle左邊 */
if(nums[middle] < nums[right])
{
/* 目標在[middle, right]區間,因爲nums[middle]已經比較過了,所以middle不需要等號 */
if(target > nums[middle] && target <= nums[right])
left = middle + 1; // 右邊不動,從左邊往右擠;
else
right = middle - 1;
//目標值在mid的左邊 && mid左邊可能不是有序的;
//不過沒有關係;繼續劃分左邊;
}
/* 第二種情況,起點在middle右邊 */
else
{
/* 目標在[left, middle]區間,middle不需要等號,原因同上 */
if(target < nums[middle] && target >= nums[left])
right = middle - 1;
else
left = middle + 1; // target在 mid右邊,但是右邊可能不是有序的;
}
}
return left < nums.size() && nums[left] == target ? left : -1;
// 最終看是否查找到;
}
};
注:感覺這個是不是還要考慮一下特殊情況,
比如整體本來就是有序的;
-------------------------
(3)有序循環數組中查找最小值
(3.1)分析
給出[4,4,5,6,7,0,1,2] 返回 0;
其實整體和上面類似;
此中給出另外一種類似的思路:
1. 如果arr[L]<arr[R],說明該數組是有序的,自然最小值在最左邊
2. 如果arr[L]>=arr[R],說明L到R範圍內包含循環部分,比如2 2 3 1 2,此時我們考察第一個數和中間的那個數的大小
(1) 如果arr[L]>arr[M],此時說明最小的那個數只能在L到M範圍內,比如 7 8 9 1 2 3 4 5 6。因爲只有當arr[M]是循環過的部分時,纔有arr[L]>arr[M]出現。
(2) 如果arr[M] >arr[R],此時說明最小的那個數只能在M到R範圍內,因爲只有當arr[M]不是循環部分的時候,纔會有arr[M] >arr[R],比如4 5 6 7 8 9 1 2 3
(3) 上述兩種情況不滿足時,說明arr[L]<=arr[M]並且arr[M] <=arr[R],此時又有條件arr[L]>=arr[R],說明arr[L]=arr[M] =arr[R],其實這種情況,無法再繼續用二分查找,比如數組2 2 …2 1 2… …2 2(只有一個1其實都是2),無論1出現在哪個位置都滿足有序循環數組的條件,此時找到1只能用遍歷的方式。當然,我們可以將左指針右移一位,略過一個相同數字,這對結果不會產生影響,因爲我們只是去掉了一個相同的,然後對剩餘的部分繼續用二分查找法,在最壞的情況下,比如數組所有元素都相同,時間複雜度會升到O(n),也就是遍歷。
(3.2)代碼實現
public class CircularArrayMinimumNum {
//遍歷,複雜度爲O(n)
public static int getMinNum_1(int[] num) {
int min = num[0];
for (int i = 1; i < num.length; i++) {
if (num[i] < min)
min = num[i];
}
return min;
}
//二分搜索,複雜度O(logN),最壞情況O(n)
public static int getMinNum(int[] num) {
if (num == null || num.length == 0)
return -1;
int left = 0;
int right = num.length - 1;
if (num[left] >= num[right]) {
while (left<right-1) {
int mid = left + (right - left) / 2;
if (num[left] > num[mid])
right = mid;
// 最小值在[left,mid]區間;不能用mid-1,因爲中間的數也有可能是最小的;從右邊往左擠;
else if(num[mid] > num[right])
left = mid;
//最小值在[mid,right]區間;該條件可替換爲 num[left] < num[mid];從左邊往右邊擠;
else // num[left] <= num[mid] <=num[right],此時就是有序的了。只能是
left++; //這種情況,把左指針右移,略過相同數字
}
return Math.min(num[left],num[right]);
}
return num[0];
}
}
上面是數組中出現重複數字的情況,那麼當數組沒有重複數字時,那麼就更簡單了,把上面的代碼刪掉相等的情況就可以,
如下:
public class CircularArrayMinimumNum_2 {
public static int getMinNum(int[] nums) {
if (nums == null || nums.length == 0)
return -1;
int left = 0;
int right = nums.length - 1;
if (nums[left] > nums[right]) {
while (left< right -1 ) {
int mid = left + (right - left) / 2;
if (nums[left] > nums[mid])
right = mid; // 最小值在left-mid區間;不能用mid-1,因爲中間的數也有可能是最小的
else //if(num[mid] > num[right]) //該條件可替換爲 num[left] < num[mid]
left = mid;
// else //num[left]==num[right]==num[mid]
// left++; //這種情況,把左指針右移,略過相同數字
}
return Math.min(nums[left],nums[right]);
}
return nums[0];
}
}
--------------------------
(4)從小到大的有序數組中,查找絕對值最小的元素
(4.1)分析
數組是從小到大排序,數值可能爲負數、0、正數。
問題的本質是找到正數的最小值,以及負數的最大值:
分析以下集中情況
數組爲a[], 數組大小爲n.
1)n=1,沒有商量的餘地,直接返回
2)a[0] * a[n-1] >= 0,說明這些元素同爲非正或同爲非負。要是a[0]>=0,返回a[0];否則返回a[n-1]
3)a[0] * a[n-1] < 0,說明這些元素中既有正數,也有負數,分爲下面幾種情況:
此時需要計算中間位置爲 mid = (low + high)/2;
如果a[low] * a[mid] >=0 說明a[mid]也爲非正,縮小範圍low=mid;
如果a[mid]*a[high] >=0,說明a[mid]非負,縮小範圍high=mid。
在期間如果還有兩個元素,那麼就比較以下他倆,直接返回了;
(4.2)注意點
比如:
全是負數的情況;
全是正數的情況;
有負數,有正數,有0的情況;
有正數,有負數,存在正數的絕對值和負數的絕對值都是最小值;
(4.3)實現
#include <iostream>
#include <cmath>
using namespace std;
int absMin(int *a, int size)
{
if(size == 1)
return a[0];
if(a[0] * a[size-1] >= 0)
return (a[0] >= 0) ? a[0] : a[size-1];
else
{ // 有正有負;
int low = 0, high = size-1, mid;
while(low < high)
{
if(low + 1 == high)
return abs(a[low]) < abs(a[high]) ? a[low] : a[high];
mid = low + (high - low) / 2;
if(a[low] * a[mid] >= 0)
low = mid;
if(a[high] * a[mid] >= 0)
high = mid;
}
}
}
int main()
{
int arr1[] = {-8, -3, -1, 2, 5, 7, 10};
size_t size1 = sizeof(arr1) / sizeof(int);
int minabs1 = absMin(arr1, size1);
cout << "Result:" << minabs1 << endl;
int arr2[] = {-8, -3, 2, 5, 7, 10};
size_t size2 = sizeof(arr2) / sizeof(int);
int minabs2 = absMin(arr2, size2);
cout << "Result:" << minabs2 << endl;
}
---------------------
(5) 二分查找變形:
(5.1)查找第一個值等於給定值的元素;
分析:
查找第一個等於指定值的元素;當a[mid]==指定值時,需要不斷的往左邊擠,即high=mid-1;
但是需要考慮特殊情況:
比如左邊沒有元素了,或者左邊的元素不再等於該值;
實現:
// 二分查找:查找第一個值等於給定值的元素
public int bSearch(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while(low <= high) {
int mid = low + ((high - low) >> 1); // 如果不明白這種寫法可以看看前面的一篇文章
if(a[mid] > value) {
high = mid - 1;
} else if(a[mid] < value){
low = mid + 1;
} else {
if((mid == 0) || (a[mid - 1] != value)) {
return mid;
} else {
high = mid - 1;
}
}
}
return -1;
}
(5.2)查找最後一個值等於給定值的元素;
分析:
查找最後等於指定值的元素;當a[mid]==指定值時,需要不斷的往右邊擠,即low=mid+1;
但是需要考慮特殊情況:
比如右邊沒有元素了,或者右邊的元素不再等於該值;
實現:
// 二分查找:查找最後一個值等於給定值的元素
public int bSearch(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while(low <= high) {
int mid = low +((high - low) >> 1);
if(a[mid] > value) {
high = mid - 1;
} else if(a[mid] < value) {
low = mid + 1;
} else {
if((mid == n - 1) || (a[mid + 1] != value)) {
return mid;
} else {
low = mid + 1;
}
}
}
return -1;
}
(5.3)查找第一個大於等於給定值的元素;
分析:
查找第一個大於等於指定值的元素;當a[mid]>=指定值時,需要不斷的往左邊擠,即high=mid-1;
但是需要考慮特殊情況:
比如左邊沒有元素了,或者左邊的元素不再大於等於目標值;
實現:
// 二分查找:查找第一個大於等於給定值的元素
public int bSearch(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while(low <= high) {
int mid = low + ((high - low) >> 1);
if(a[mid] >= value) {
if((mid == 0) || (a[mid - 1] < value)) {
return mid;
} else {
high = mid - 1;
}
} else {
low = mid + 1;
}
}
return -1;
}
擴展:
(5.4)查找最後一個小於等於給定值的元素;
分析:
查找最後一個小於等於指定值的元素;當a[mid]<=指定值時,需要不斷的往右邊擠,即low=mid+1;
但是需要考慮特殊情況:
比如右邊沒有元素了,或者右邊的元素不再小於等於目標值;
實現:
//二分查找:查找最後一個小於等於給定值的元素
public int bSearch(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while(low <= high) {
int mid = low + ((high - low) >> 1);
if(a[mid] > value) {
high = mid - 1;
} else {
if((mid == n - 1) || (a[mid + 1] > value)) {
return mid;
} else {
low = mid + 1;
}
}
}
return -1;
}
擴展:
如果是二叉查找樹,其實也是可以實現的。
某個節點的值小於等於目標值時,得到該節點的左子樹的最右邊,以及該節點的右子樹的最左邊,和當前的節點進行比較;
------------
(6) 有序的區間數組,如何查找某個元素屬於哪個區間?(6.1)範例:
通過 IP 地址來查找 IP 歸屬地的功能;假設在內存中有 12 萬條這樣的 IP 區間與歸屬地的對應關係,如何快速定位出一個 IP 地址的歸屬地呢?
通過維護一個很大的 IP 地址庫來實現的。地址庫中包括 IP 地址範圍和歸屬地的對應關係。比如,當我們想要查詢 202.102.133.13 這個 IP 地址的歸屬地時,我們就在地址庫中搜索,發現這個 IP 地址落在 [202.102.133.0, 202.102.133.255] 這個地址範圍內,那我們就可以將這個 IP 地址範圍對應的歸屬地“山東東營市”顯示給用戶了。
(6.1)分析:
1)方法一:
首先ip地址範圍都是沒有交叉重疊的。所以IP地址範圍的排序可以認爲是地址範圍的起始ip的排序;
這個問題就可以轉化爲我剛講的第四種變形問題“在有序數組中,查找最後一個小於等於某個給定值的元素”了。當我們要查詢某個 IP 歸屬地時,我們可以先通過二分查找,找到最後一個起始 IP 小於等於這個 IP 的 IP 區間,然後,檢查這個 IP 是否在這個 IP 區間內,如果在,我們就取出對應的歸屬地顯示;如果不在,就返回未查找到。
總結:
凡是用二分查找能解決的,絕大部分我們更傾向於用散列表或者二叉查找樹。
即便是二分查找在內存使用上更節省,但是比內存如此緊缺的情況並不多。那二分查找真的沒什麼用處了嗎?
實際上,上一節講的求“值等於給定值”的二分查找確實不怎麼會被用到,二分查找更適合用在“近似”查找問題,在這類問題上,二分查找的優勢更加明顯。比如今天講的這幾種變體問題,用其他數據結構,比如散列表、二叉樹,就比較難實現了。