前言
这篇博客,我就查找算法里面 时间复杂度是O(logn)的二分查找算法进行一个比较完善的剖析。话不多说,干货送上。
1.基本知识点
二分查找(Binary Search)算法,有的数据结构书里面也叫做折半查找算法。算法本身针对的是一个有序的数据集合,查找思想类似于分治思想。每次通过和区间中间元素的对比,将待查找的区间缩小为原来的一半,知道找到要查找的给定元素,或者区间缩小为0。以下是二分查找算法的经典代码格式:
public int BinarySearch(int[] s,int n){
int low=0;
int high=s.length-1;
while(low<=high){
int mid=low+(high-low)/2;
if(s[mid]==n) return mid;
if(s[mid]>n)
high=mid-1;
else if(s[mid]<n)
low=mid+1;
}
return -1;
}
南国在这里强调一下 为了防止内存溢出,代码中最好不好写成mid=(low+high)/2 ,而是写成mid=low+(high-low)/2
除了二分查找的时间复杂度是O(logn),时间复杂度位O(logn)的还有堆、二叉树的操作,在后续的博客更新中 我会陆续讲到,读者也可以自行百度学习。
二分查找时间复杂度低,用来查找数据效率很高,但是二分查找的应用场景局限性很大。二分查找依赖的是顺序表结构(简单点说就是数组支持下标随机访问,如果换成链表 忽略掉系数链表的时间复杂度是O(n)),而且二分查找针对的是有序数据(无序的数据在运用二分查找前需要进行排序操作),所以二分查找不适合有频繁插入删除擦操作的数据。
再者,数据量太小不适合二分查找。数据量太小,除非是面试官或者题干中要求选用O(logn)的时间爱你开销,否则 在日常应用开发中顺便遍历就可以了。因为大O表示法表示时间复杂度实则是表示的是时间开销的趋势。数据太小,不论是二分查找还是顺序遍历,查找速度都会差不多。
数据量特别大也不适合用二分查找。前面我已经提到二分查找底层依赖于数组这种数据结构,并且数据有序才可以使用二分查找。如果有几个G以上的数据,用数组存储就需要对应的连续内存空间,这显然是不切实际的。
2.leetcode 实战习题
在将leetcode上的习题之前,首先南国先讲一下对于上述传统二分查找算法的辨析:
二分查找的辨析
二分查找的辨析主要是针对数组中有重复元素的情况,它有以下几种情况:
2.1 查找数组中第一个等于给定值的元素下标
关于二分查找的辨析 网上其实已经有很多版本,这里了 我推荐爱两种我觉得理解起来比较快 好掌握的代码模板。
代码1:
/**
* 辨析1:查找数组中第一个等于给定值的元素下标
*/
public static int BSearch02(int[] s,int n){
int low=0;
int high=s.length-1;
while(low<=high){
int mid=low+(high-low)/2;
if(s[mid]<n)
low=mid+1;
else if(s[mid]>n)
high=mid-1;
else{ //这里主要是要二分查找到等于给定值的元素的下标和其前一个元素进行一次判断
if(mid==0||s[mid-1]!=n)
return mid;
else high=mid-1;
}
}
return -1;
}
代码2:
public int binarySearch0202(int[] nums, int key) {
int l = 0, h = nums.length - 1;
while (l < h) {
int m = l + (h - l) / 2;
if (nums[m] >= key) {
h = m;
} else {
l = m + 1;
}
}
return l;
}
代码2的实现因为要注意边界值的判断,和正常实现有以下不同:
循环条件为 l < h
h 的赋值表达式为 h = m
最后返回 l 而不是 -1
在 nums[m] >= key 的情况下,可以推导出最左 key 位于 [l, m] 区间中,这是一个闭区间。h 的赋值表达式为 h = m,因为 m 位置也可能是解。
在 h 的赋值表达式为 h = mid 的情况下,如果循环条件为 l <= h,那么会出现循环无法退出的情况,因此循环条件只能是 l < h。以下演示了循环条件为 l <= h 时循环无法退出的情况:
nums = {0, 1, 2}, key = 1
l m h
0 1 2 nums[m] >= key
0 0 1 nums[m] < key
1 1 1 nums[m] >= key
1 1 1 nums[m] >= key
当循环体退出时,不表示没有查找到 key,因此最后返回的结果不应该为 -1。为了验证有没有查找到,需要在调用端判断一下返回位置上的值和 key 是否相等。
代码2 也是我最近刷题 学到的新方法,他的代码冗余少一些。可作为模板参考。
2.2 查找数组中最后一个等于给定值的元素下标
/**
* 辨析2:查找数组中最后一个等于给定值的元素下标
*/
public static int BSearch03(int[] s,int n){
int low=0;
int high=s.length-1;
while(low<=high){
int mid=low+(high-low)/2;
if(s[mid]<n)
low=mid+1;
else if(s[mid]>n)
high=mid-1;
else{ //这里主要是要二分查找到等于给定值的元素的下标和其哪一个元素进行一次判断
if(mid==s.length-1||s[mid+1]!=n)
return mid;
else low=mid+1;
}
}
return -1;
}
2.3 查找第一个大于等于给定值的元素下标
/**
* 查找第一个大于等于给定值的元素下标
* @param s
* @param n
* @return
*/
public static int BSearch04(int[] s,int n){
int low=0;
int high=s.length-1;
while (low<=high){
int mid=low+(high-low)/2;
if(s[mid]<n){
low=mid+1;
}
else if(s[mid]>=n){
if(mid==0||s[mid-1]<n)
return mid;
else high=mid-1;
}
}
return -1;
}
2.4 查找最后一个小于等于给定值的元素下标
/**
* 查找最后一个小于等于给定值的元素下标
* @param s
* @param n
* @return
*/
public static int BSearch05(int[] s,int n){
int low=0;
int high=s.length-1;
while (low<=high){
int mid=low+(high-low)/2;
if(s[mid]>n){
high=mid-1;
}
else if(s[mid]<=n){
if(mid==s.length-1||s[mid+1]>n)
return mid;
else low=mid+1;
}
}
return -1;
}
关于后面三种情况 聪明的你一定知道 还有代码2的实现方式,因为代码写的时间早一些,这里我就不多加累赘了。读者可自行根据代码1的实现 把代码2实现出来。
leetcode真题
1.求开平方
69. Sqrt(x)
这道题可直接调用Java中的库来实现,不过如果这道题在面试中遇到,面试官肯定是希望你能了解底层,手动实现。
/**
* leetcode 69 Sqrt(x)
* @author xjh 2018.12.25
*/
public class test69 {
public static int mySqrt(int x) {
//1.直接调用Math库中的sqrt方法 提交需要26ms
// double n=Math.sqrt(x);
// return (int)n;
//2.利用二分查找的方法
if(x==0||x==1) return x;
int low=1,high=x,result=0;
while (low<=high){
int mid=low+(high-low)/2;
if(mid==x/mid) return mid;
else if(mid<x/mid){
low=mid+1;
result=mid; //考虑到x的平方根很大可能是double 这里输出的是int 所以输出值极大可能为m^2<=x
}
else high=mid-1;
}
return result;
//3.利用牛顿迭代法的公式
// int r=x;
// while (r*r>x)
// r=(r+x/r)/2;
// return r;
}
public static void main(String[] args) {
int s=mySqrt(8);
System.out.println(s);
}
}
2. 大于给定元素的最小元素
744. Find Smallest Letter Greater Than Target
/**
* leetcode 744 Find Smallest Letter Greater Than Target
* @author xjh 2019.03.11
* 二分查找的辨析
*/
public class t744_FindSmallestLetter {
public char nextGreatestLetter(char[] letters, char target) {
int n= letters.length-1;
int l=0,res;
while (l<=n){
int mid=l+(n-l)/2;
if (letters[mid]<=target){
l=mid+1;
}else{//letters[mid]>target
if (mid==0||letters[mid-1]<=target) return letters[mid]; //第一个
else n=mid-1; //不是第一个 就右下标减小
}
}
return letters[0];
}
public static void main(String[] args) {
t744_FindSmallestLetter xjh=new t744_FindSmallestLetter();
char[] letters={'c','f','g'};
System.out.println(xjh.nextGreatestLetter(letters,'g'));
}
}
3. 有序数组的 Single Element
540. Single Element in a Sorted Array
/**
* leetcode 540 Single Element in a Sorted Array
* @author xjh 2019.03.11
* 题干的条件是在一个有序数组中所有元素有两个 只有一个元素m是一个 求出m
* 这个题的题干要求是在O(logn)时间复杂度下 所以首先想到的是二分查找法
* 这里有一个很有意思的地方是有p个两个元素的数 所以数组中的总个数是n=2*p+m 他一定是个奇数
* 利用二分查找算法时 注意每次取得中间元素最好是下标偶数位 这样做的目的是对应两个相同元素的首下标(如果mid之前的元素无单个元素时)
*/
public class t540_SingleElem {
public int singleNonDuplicate(int[] nums) {
int n=nums.length-1;
int l=0;
while (l<n){ //这里只能写成l<n 不能写成l<=n
int mid=l+(n-l)/2;
if (mid%2==1) mid--;
if (nums[mid]==nums[mid+1]){ //表明单独元素不在前半区间里面
l=mid+2;
}else{
n=mid; //这里只能写n=mid 不能按照二分查找的一贯思路写成n=mid-1
// 因为nums[mid]!=nums[mid++] 他可能是单独元素 可能是nums[mid-1]=nums[mid]
}
}
return nums[l];
}
public static void main(String[] args) {
t540_SingleElem xjh =new t540_SingleElem();
int[] nums={1,1,2,3,3,4,4,8,8};
System.out.println(xjh.singleNonDuplicate(nums));
}
}
4. 旋转数组的最小数字
153. Find Minimum in Rotated Sorted Array
/**
* leetcode 153 Find Minimum in Rotated Sorted Array
* @author xjh 2019.03.12
*/
public class t153_FindMin {
//有序数组被旋转了一下 利用二分查找在O(logn)的时间复杂度内完成
public int findMin(int[] nums) {
if (nums.length==1) return nums[0];
//方法1 利用最简单的遍历 直接遍历到小于原头节点
// int i,j;
// for (i=0,j=1;j<nums.length;i++,j++){
// if (nums[i]>nums[j]) return nums[j];
// }
// return nums[0];
//方法2 利用二分查找来解 O(logn)
int n=nums.length-1;
int l=0;
while (l<n){ //这里只能写成l<n
int mid=l+(n-l)/2;
if (nums[mid]<=nums[n]) n=mid; //中间元素小于末尾元素 说明头元素在前半段 这时mid也可能是头元素
else l=mid+1;
}
return nums[l];
}
public static void main(String[] args) {
t153_FindMin xjh=new t153_FindMin();
int[] nums={4,5,6,7,0,1,2};
System.out.println(xjh.findMin(nums));
}
}
5. 查找区间
34. Find First and Last Position of Element in Sorted Array
/**
* leetcode 34 Find First and Last Position of Element in Sorted Array
* @author xjh 2019.03.12
* 在一个有序的数组中查找指定元素的初始下标和结束下标:
* 方法一 和方法二都是基于二分查找 实现,要下考虑方法一 尽管代码量大一些 但是简单易懂;方法二 有许多边界条件值得注意
*/
public class t34_FindFirstandLast {
//方法1 两个函数查找首尾两个下标 纯手怼
public int[] searchRange(int[] nums, int target) {
//典型的二分查找
int first=-1,last=-1;
first=searchFirst(nums,target);
last=searchLast(nums,target);
return new int[]{first,last};
}
public int searchFirst(int[] nums,int target){
int low=0;
int high=nums.length-1;
while(low<=high){
int mid=low+(high-low)/2;
if(nums[mid]<target)
low=mid+1;
else if(nums[mid]>target)
high=mid-1;
else{ //这里主要是要二分查找到等于给定值的元素的下标和其哪一个元素进行一次判断
if(mid==0||nums[mid-1]!=target)
return mid;
else high=mid-1;
}
}
return -1;
}
public int searchLast(int[] nums,int target){
int low=0;
int high=nums.length-1;
while(low<=high){
int mid=low+(high-low)/2;
if(nums[mid]<target)
low=mid+1;
else if(nums[mid]>target)
high=mid-1;
else{ //这里主要是要二分查找到等于给定值的元素的下标和其哪一个元素进行一次判断
if(mid==nums.length-1||nums[mid+1]!=target)
return mid;
else low=mid+1;
}
}
return -1;
}
//方法2 更加灵活的二分查找思想 只用一个子函数求解下标 网上答案 有点难想到
//这里我们选择求解给定值target的首下标 和target+1的首下标 当然这里要注意的边界条件也会更多
public int[] searchRange2(int[] nums, int target) {
int first=search2(nums,target); //查找等于给定值的首下标
int last=search2(nums,target+1)-1;
if (first==nums.length||nums[first]!=target) return new int[]{-1,-1};
else return new int[]{first,Math.max(first,last)}; //Math.max(first,last)是因为防止数组中只有一个元素 last值为-1的情况
}
public int search2(int[] nums,int target){
int l=0,n=nums.length; //这里n初始值不能写成nums.length-1
while (l<n){
int mid=l+(n-l)/2;
if (nums[mid]>=target) n=mid;
else l=mid+1;
}
return l;
}
public static void main(String[] args) {
t34_FindFirstandLast xjh=new t34_FindFirstandLast();
int[] nums={5,7,7,8,8,10};
int[] res=xjh.searchRange2(nums,8);
for (Integer i:res){
System.out.print(i+" ");
}
}
}
[2019.05.27更新 字节跳动春招实习二面算法题]:
题干要求:
一个有序数组经过旋转,查找给定值的下标。例如:递增数组[1,2,3,4,5],变化为[5,6,1,2,3,4], 给定4,判断是否存在,并返回其索引位置
用一次二分查找 来解决问题。
package 头条;
/**
* 思路:这是一道需要技巧的二分查找
* 1.a[mid]>a[low] mid左边的数组是有序的;接着判断 当k<a[mid] && k>=a[low] ,可以在左边数组查找,否则在右边数组中查找
* 2.a[mid] <a[high] mid右边数组是有序的; 接着判断 k > a[middle] && k <= a[high] , 可以在右边数组查找,否则在左边数组查找
*/
public class BSearch02 {
public static void main(String[] args) {
BSearch02 xjh=new BSearch02();
int[] a={7,9,12,15,0,3,5,6};
System.out.println(xjh.findK(a,9));
}
public int findK(int[] a,int k){
int begin=0;
int end=a.length-1;
while (begin<end){
int mid=begin+(end-begin)/2;
if (k==a[mid]) return mid;
if (a[mid]>a[begin]){//mid左边是有序的
if (k>=a[begin]&&k<a[mid]) end=mid-1;
else begin=mid+1;
}else {//后半部分 是递增数组
if (k>a[mid]&&k<=a[end]) begin=mid+1;
else end=mid-1;
}
}
return -1;
}
}