二分查找的应用总结
(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)二分查找源码
(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;
说明:
上面的方法只是用二分查找找到平方根的范围,然后递增步长进行尝试,虽然能够快速找到根的范围,但是递增步长将是一个漫长的等待。
(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;
但是需要考虑特殊情况:
比如左边没有元素了,或者左边的元素不再等于该值;
实现:
(5.2)查找最后一个值等于给定值的元素;
分析:
查找最后等于指定值的元素;当a[mid]==指定值时,需要不断的往右边挤,即low=mid+1;
但是需要考虑特殊情况:
比如右边没有元素了,或者右边的元素不再等于该值;
实现:
(5.3)查找第一个大于等于给定值的元素;
分析:
查找第一个大于等于指定值的元素;当a[mid]>=指定值时,需要不断的往左边挤,即high=mid-1;
但是需要考虑特殊情况:
比如左边没有元素了,或者左边的元素不再大于等于目标值;
实现:
扩展:
(5.4)查找最后一个小于等于给定值的元素;
分析:
查找最后一个小于等于指定值的元素;当a[mid]<=指定值时,需要不断的往右边挤,即low=mid+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 区间内,如果在,我们就取出对应的归属地显示;如果不在,就返回未查找到。
总结:
凡是用二分查找能解决的,绝大部分我们更倾向于用散列表或者二叉查找树。
即便是二分查找在内存使用上更节省,但是比内存如此紧缺的情况并不多。那二分查找真的没什么用处了吗?
实际上,上一节讲的求“值等于给定值”的二分查找确实不怎么会被用到,二分查找更适合用在“近似”查找问题,在这类问题上,二分查找的优势更加明显。比如今天讲的这几种变体问题,用其他数据结构,比如散列表、二叉树,就比较难实现了。