程序员基本功系列3——二分查找
1、二分查找概念
1.1、核心思想
二分查找针对的是一个有序的数据集合,查找思想有点类似分治思想。每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为 0。
二分查找的时间复杂度是 O(logn),当数据量较大时,O(logn) 往往优于常量时间复杂度 O(1),例如:2的32次方,大约是42亿也只需要32次查找,但是常量级可能是 O(1000)甚至更大。所以 O(logn)是一种很高效的算法实现。
1.2、二分查找的局限性
• 针对的是线性表结构,简单说就是数组
比如链表也不能使用二分查找,主要二分查找需要按照下标随机访问元素。
• 针对的是有序数据
• 不能数据量太大的数据
因为二分查找针对有序数组,但是数组是连续的,对于内存的要求较高。
2、简单二分查找的实现
简单二分查找可以通过递归和非递归两种方式实现。简单二分查找假设数组中没有重复元素。
2.1、非递归方式
private int bsearch(int[] nums,int value){ int low = 0; int high = nums.length-1; //1、终止条件 while (low <= high){ //2、计算中间位置 int mid = low + ((high-low) >> 1); if (nums[mid] == value){ return mid; } //3、更新范围 else if (nums[mid] > value){ high = mid-1; }else { low = mid+1; } } return -1; }
这里有几点注意的:
1、终止条件
终止条件是 low <= high,而不是low < high
2、计算中间位置 mid
经常会写 mid = (low+high) / 2,这么写不太严谨,因为如果 low 和 high 足够大的话相加可能会溢出,可以写成 mid = low + (high-low) / 2。更高效点就是上面那种写法,因为计算机处理位运算更快。
3、范围更新
更新范围时要更新到 mid 的下一位或上一位,high = mid-1 或 low = mid+1。不要写成 high = mid 或 low = mid,这样可能造成死循环。
2.2、递归方式
private int recurBsearch(int[] nums,int value){ return recur(nums,0,nums.length-1,value); } private int recur(int[] nums,int low,int high,int value){ //终止条件 if (low > high)return -1; //计算中间位置 int mid = low + ((high-low) >> 1); if (nums[mid] == value){ return mid; }else if (nums[mid] > value){ return recur(nums,low,mid-1, value); }else { return recur(nums,mid+1,high,value); } }
3、二分查找的变形
二分查找的变形有很多种,我们看几种比较经典的变形案例。注意我们分析的例子都是适用于二分查找的案例,也就是前提是顺序数组。
3.1、查找第一个值等于给定值的元素
来举一个例子:在含有重复元素的有序数组,如:数组 a[10]{1,3,4,5,6,8,8,8,11,18} 中查找第一个等于8的位置。有些解题代码很简洁,但是很难理解:
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 { low = mid + 1; } } //这段代码逻辑比较难理解 if (low < n && a[low]==value) return low; else return -1; }
这段代码的逻辑就很难理解,如果死记硬背的话过几天可能就忘了。其实不必追求代码太过简洁,代码易读懂,没bug反而更重要,来看下面这段代码的解法:
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; }
来分析下这段代码,a[mid]跟要查找的 value 的大小关系有三种情况:大于、小于、等于。对于 a[mid]>value 的情况,需要更新 high= mid-1;对于 a[mid]<value 的情况,需要更新 low= mid+1;这两点都好理解,关键是 a[mid]=value的情况,因为要查找第一个等于 value 的元素,所以要分析,如果mid=0,那前面已经没有元素了,它肯定就是第一个等于 value 的元素,如果 mid!=0,那它前一个元素如果不等于 value,那它也是第一个等于 value 的元素,否则的话就继续缩减查找范围。
3.2、查找最后一个等于给定值的元素
上面是查找第一个等于给定值的元素,现在查找最后一个,根据上面第二种思路,很容易想到这个题的解法:
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; }
3.3、查找第一个大于等于给定值得元素
来看另一类变形问题,在有序数组中,查找第一个大于等于给定值得元素,例如:在[3,4,6,7,9] 中查找第一个大于等于5的元素。这个也可以借助上面的解题思路:
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; }
如果a[mid]<value,收缩左侧边界;如果a[mid]>=value,mid=0则当前就是要找的元素,否则判断前一个元素是否小于指定值,小于的话当前位置也是第一个大于等于指定值的元素。
3.4、查找最后一个小于等于给定值的元素
例如:在[3,5,6,8,9,10] 数组中查找最后一个小于等于7的元素,利用上面的解题思路,也能很容易的写出代码:
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; }
4、实际案例
我们来看一个实际的开发案例,根据ip地址查询归属地。首先要获取ip库(可以淘宝买),ip数据是根据ip地址的十进制从高到低排序,格式如下:
1.0.1.0|1.0.3.255|16777472|16778239|亚洲|中国|福建|福州||电信|350100|China|CN|119.306239|26.075302 1.0.8.0|1.0.15.255|16779264|16781311|亚洲|中国|广东|广州||电信|440100|China|CN|113.280637|23.125178 1.0.32.0|1.0.63.255|16785408|16793599|亚洲|中国|广东|广州||电信|440100|China|CN|113.280637|23.125178 1.1.0.0|1.1.0.255|16842752|16843007|亚洲|中国|福建|福州||电信|350100|China|CN|119.306239|26.075302 1.1.2.0|1.1.7.255|16843264|16844799|亚洲|中国|福建|福州||电信|350100|China|CN|119.306239|26.075302 1.1.8.0|1.1.63.255|16844800|16859135|亚洲|中国|广东|广州||电信|440100|China|CN|113.280637|23.125178 1.2.0.0|1.2.1.255|16908288|16908799|亚洲|中国|福建|福州||电信|350100|China|CN|119.306239|26.075302 1.2.2.0|1.2.2.255|16908800|16909055|亚洲|中国|北京|北京|海淀|北龙中网|110108|China|CN|116.29812|39.95931 1.2.4.0|1.2.4.7|16909312|16909319|亚洲|中国|北京|北京|海淀|中国互联网络信息中心|110108|China|CN|116.29812|39.95931 1.2.4.8|1.2.4.8|16909320|16909320|亚洲|中国|北京|北京|海淀|SDNS|110108|China|CN|116.29812|39.95931 1.2.4.9|1.2.4.255|16909321|16909567|亚洲|中国|北京|北京|海淀|中国互联网络信息中心|110108|China|CN|116.29812|39.95931
第一个字段是网段的ip起始地址,第二个字段是网段的结束ip地址,第三、四个字段分别是对应的ip地址的十进制。
换算十进制的算法:例如 17.18.20.15 = 15+20*256+18*256^2+17*256^3。
来看下具体实现:
1、ip对象封装
public class IpBean { private String startIp; private String endIp; private long startDecIp; private long endDecIp; private String province; private String city; private String optioner; }
2、将String型ip转换成long型
/** * 通过stringIp转换为长整型的ip */ public static long strIpToLongIp(String str){ if(str==null){ return 0L; } long newIp = 0; String[] split = str.split("\\."); for(int i = 0;i<=3;i++){ long lL = Long.parseLong(split[i]); newIp |= lL <<((3-i)<<3); } return newIp; }
3、使用二分查找根据ip地址获取ip对象
/** * 使用二分法通过ip找到对应的ipBean */ public static IpBean getIpBeanByLongIpNew(long longIp){ int start = 0; int end = ipBeanList.size()-1; while(start<=end){ int middel = (start+end)/2; IpBean ipBean = ipBeanList.get(middel); //如果middel对应的ipBean是不是找的值 if(longIp>=ipBean.getStartDecIp()&&longIp<=ipBean.getEndDecIp()){ return ipBean; } //小于最小值的时候 if(longIp<ipBean.getStartDecIp()){ end = middel-1; } //大于最大值的时候 if(longIp>ipBean.getEndDecIp()){ start = middel+1; } } return null; }
5、总结和思考
凡是能用二分查找解决的,绝大部分更倾向于使用散列表或二叉查找树,虽然二分查找对于内存开销更小,但是实际开发中可以忽略。
不过对上面讨论的二分查找的变体问题就用其他数据结构实现就比较复杂,变体问题有几个要注意的点:终止条件、区间上下界更新方法、返回值选择。
思考题:
最后看一个思考题:在一个循环有序数组中查找等于给定值的元素,如:数组[4,5,6,1,2,3]中,查找等于2的元素。
说两个解题思路:
1、找到分界下标,分成两个数组,判断给定值在哪个数组中,然后使用二分查找
2、循环有序数组存在一个性质:以数组中间点为分区,会将数组分成一个有序数组和一个循环有序数组。
如果首元素小于 mid,说明前半部分是有序的,后半部分是循环有序数组;
如果首元素大于 mid,说明后半部分是有序的,前半部分是循环有序的数组;
如果目标元素在有序数组范围中,使用二分查找;
如果目标元素在循环有序数组中,设定数组边界后,使用以上方法继续查找。
这种方法更像另一种类型的二分查找变种问题,它的时间复杂度也是 O(logn)。