二分查找的变形应用3----legend050709

二分查找的应用总结

(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 区间内,如果在,我们就取出对应的归属地显示;如果不在,就返回未查找到。

 

总结:

凡是用二分查找能解决的,绝大部分我们更倾向于用散列表或者二叉查找树。

即便是二分查找在内存使用上更节省,但是比内存如此紧缺的情况并不多。那二分查找真的没什么用处了吗?

实际上,上一节讲的求“值等于给定值”的二分查找确实不怎么会被用到,二分查找更适合用在“近似”查找问题,在这类问题上,二分查找的优势更加明显。比如今天讲的这几种变体问题,用其他数据结构,比如散列表、二叉树,就比较难实现了。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章