一文带你攻克二分查找
二分查找是一种高效的查找算法,一般普通的循环遍历查找需要O(n)的时间复杂度,而二分查找时间复杂度则为O(logN),因为每一次都将查找范围减半。
看看百度百科以及LeetCode官方给出的二分查找算法的解释:
(百度百科)
二分查找也称折半查找(Binary Search),它是一种效率较高的查找方法。但是,折半查找要求线性表必须采用顺序存储结构,而且表中元素按关键字有序排
列。
(LeetCode)
二分查找也称折半查找(Binary Search),它是一种效率较高的查找方法,前提是数据结构必须先排好序,可以在数据规模的对数时间复杂度内完成查找。但
是,二分查找要求线性表具有有随机访问的特点(例如数组),也要求线性表能够根据中间元素的特点推测它两侧元素的性质,以达到缩减问题规模的效果。
二分查找问题也是面试中经常考到的问题,虽然它的思想很简单,但写好二分查找算法并不是一件容易的事情。
能够使用二分查找有两个前提:一是要求数据结构必须先排好序或者说是有序的(这样可以推断出一个元素左边以及右边的性质) 二是还可以随机访问(顺序存
储结构)。
为什么需要者两个前提呢?我们通过一个案例来看看
二分查找最经典的案例 就是给出一个升序的无重复元素的数组,再给出一个目标值,返回数组中与目标值相等的
元素的索引(下标)找不到则返回-1;看下方核心代码:
int left = 0,right = array.length;
while(left<right){ //left 为左边界 //right 为右边界
mid = (left+right)/2;
if(array[mid]==target)
return mid; //找到目标索引 直接返回
else if(array[mid]>target)
{
right = mid; //大于目标值 则搜索区间改为左半区间
}else if(array[mid]<target)
{
left = mid+1; 0 //小于目标值 则搜索区间改为右半区间
}
return -1;
}
可能很多小伙伴看到left<right会疑惑因为还见过left<=right的,这个我们留待后面讨论。
这题其实就是在查找索引。假定数组为【1,2,3,4,5,6,7】 target值为6 我们看看是如何查找的。
其实二分查找的核心就是一步一步的把查找(搜索)范围减半。
left = 0 ,right = array.length为初始化边界,即将查找范围锁定为[0,array.length)注意是左闭右开;
接着我们将数组分为两部分,准确的说是3部分,一是 该查找范围的中间值mid 二是mid左边部分(姑且叫左半区间) 三是mid右边部分(姑且叫右半区间)
然后我们来看看 mid 这个索引 对于的数组元素与目标值的大小比较。如果相等则代表mid 就是需要查找的索引直接返回,如果mid对应元素比目标值大,值大了
就应该缩小,所以我们将查找范围改为mid 的左边部分
即right = mid。如果mid对应元素比目标值小,值小了就应该放大,所以我们将查找范围改为mid右边部分即left = mid +1.可能有人好奇为什么left = mid+1,而right 却直接变为mid。这个我们也留在后面讨论。
一开始我们left = 0 ,right = 7.那么第一步一开始查找的值便是(0+7)/2=3。即是4 比6要小,说明目标值更大所以将查找范围减半至更大的一部分(右边部分),我们通过将左边界右移至mid+1的位置来改变
边界,此时left = 4,right是7,接着我们查找(4+7)/2 = 5 索引5 对应的是6 正好是目标值所以直接返回了5。
为什么我们通过mid与目标值关系就可以知道查找范围应该落在哪里呢?
就是因为数组是已经排好序的(满足了一个条件),那为什么我们可以知道左右区间又或者说知道了范围可以直接访问中间的元素呢?因为数组是顺序存储结构可
以随机访问(通过下标),所以必须是
顺序存储结构。如果是链表没有下标访问是无法直接判断中间元素与目标值关系的。这就是为什么想要二分查找必须满足这两个条件了。
接着我们来讨论下 什么时候循环条件是left<=right,而什么时候又是left<right。
这一个其实取决与我们定义查找范围区间时是如何定义的,由于我们初始化left = 0,right = array.length。所以我们可以确定初始状态的左边界是0而右边界是数组长度。由于是查找索引,所以一开始的查找区间是[left,right)。为左闭右开的,此时我们的循环执行条件是left<right,循环的终止条件是left = right ,此时
对应的区间是[left,left),该区间内已经无法再查询了所以该循环条件是可行的。
但当我们将right 初始化为array.length-1时,此时的查找范围区间便是[left,right],左闭右闭,此时如果我们的循环条件仍然是left<right,那么代表终止时left=right,对应的搜索区间是【left,left】,此时我们带入一个具体数字如是2,则区间则为[2,2],此时区间内还有一个数2没有查询而循环却终止了(漏查,所
以是不可取的。故循环条件的中的这个=号取决于查找区间的定义是左闭右开还是左闭右闭。
还有一个问题是当减半查找范围时,left = mid +1,为什么right = mid又或者right = mid-1。这其实也跟查找区间有关,上面讲到其实二分查找时将数组分为三
部分,mid,mid左半区间,mid右半区间。
如果我们定义的right 为array.length,那么搜索区间是左闭右开的。我们每查找一次mid 的值后,就要对范围做改变,因为mid已经查找过了所以我们的选择要么左边要么右边。如果我们去右边,则left 指针右移, 即left = mid +1。而要去左边时,需要的是right指针左移,由于区间是左闭右开的,所以右边界直接等于mid。我们也可以将三部分两个区间直接用区间表示出来[left,mid) , [mid+1,right)这就是为什么right 直接等于mid了。
当然我们也可以将right 初始化为array.length-1,那么查找区间就是左闭右闭的 ,那三部分中里的两个区间分别为[left,mid-1],[mid+1,right]。这时我们改变查
找范围时left = mid+1,而right = mid-1。
所以循环条件的等号和left,right的相应变化都取决于左右边界时的定义(取决于是左闭右闭还是左闭右开)。
另外,对于 mid = (left+right)/2。 这一式子我们可以变形优化, mid = left + (right-left)/2 防止溢出,也可以将整除2换成位运算右移一位即 mid = left = ((right-left)>>1)。
这是二分查找的一种经典题型——精确查找,找到某值就直接返回。关于精确查找的例题可以直接在leetcode里找到——704.二分查找
二分查找的应用除了精确查找还有查找左右边界
关于查找左右边界的定义个人见解为:
(查找左边界)从后往前有一部分满足条件,则前面必有一个临界点属于边界,比边界大则满足,比边界小则不符合。
(查找右边界)从前往后有一部分满足条件,则后面必有一个临界点属于边界,比边界小则满足,比边界大则不符合。
对于查找两个边界我也找了两个题目来帮助理解
先看看查找左边界(原题 leetcode278.第一个错误的版本):
刚看题目,首先暴力肯定可以,直接从头到尾扫一次,发现是错误的版本直接return出来就好。
虽然不是考精确查值,但是其实这个是考二分查找的。而且极其明显的查值左边界的特征:从后往前有一部分满足条件,则前面必有一个临界点属于边界,比边界大则满足,比边界小
则不符合。从最后往前到第一个错误的版本都是满足条件的都是错误的版本,但是第一个错误的版本就是临界点就是边界。边界前的版本都是正确的版本。
首先我们想想
我们想想如果第k个版本是错误的版本 那么很自然 它后面的所有版本 (k+1~n) 也都是错误的版本,但是第一个错误的版本却是落在k的左边的或者就是k。
那如果第k个版本并非错误的版本,那错误的版本会落在哪里呢?很明显肯定是落在k的右手边,并且第一个错误的版本也是在右边。
直接看代码更便于理解:
1 /* The isBadVersion API is defined in the parent class VersionControl. 2 boolean isBadVersion(int version); */ 3 public class Solution extends VersionControl { 4 public int firstBadVersion(int n) { 5 int left = 1; 6 int right = n; 7 //【left,right】 左闭右闭搜索区间 8 while (left <= right) { 9 int mid = left + ((right-left)>>1); 10 if (isBadVersion(mid)) { //当前版本是错误的版本 要找第一个错误的版本 搜索区间改为左半部分 11 right = mid-1; 12 } else { 13 left = mid + 1; //不是错误版本直接去右方找 14 } 15 } 16 return right+1; //why? 17 } 18 }
对于区间的减半方向我们已经了解了。那这题的返回值该是什么呢?我们这样循环结束后,第一个错误的版本号是什么呢?
我们可以根据循环内部的代码得出我们想要的答案,因为是要第一个错误的版本,那么版本首先肯定是错误的那我们直接就将目光锁定在
我们看到 ,mid 版本是肯定错误的,那我们循环最后终止时的mid 应该就是我们要找的第一个错误版本号。可是我们定义的变量只有left,right两个指针。
那我们该如何返回mid呢?我们看到满足条件执行的语句是 right = mid -1;那mid 等于什么呢?很明显 mid = right +1; 所以我们可以直接返回right+1;
很多人可能会疑惑,不是查询左边界嘛,怎么返回是指针是right 再+1?想返回左指针便于理解怎么办?其实也可以,我们知道正确的返回值是right+1,那循环
条件是什么?是left<=right,那循环终止时left 的值就是right+1.所以可以返回right+1,也可直接返回left。
看完左边界我们继续看看查找右边界(特殊原因不放链接直接上图):
首先单看题目,其实就是问你,给你n块巧克力让你切成k份并且要保证切出来的是正方形。让我们求满足条件下最大这个切出来的正方形的边长是多少。
可能会有些小伙伴看不出这是个二分查找的题目。让我们分析一下。 我们求的是满足条件下切出来的正方形最大的边长为多少。我们满足的条件是什么?要切出
k块。那其实我们想象,如果切出来的正方形的边长越小,那是不是得到的块数就越多呢?假设一下, 如果最大边长是 x 那,边长为1的正方形去切也肯定满足吧?
那就是说我们需要查的最大边长,其实就是满足条件下边长是右边界。确定了是二分查找的题目,那我们怎么确定初始的搜索区间呢?对于左边,题目提示了最小
可以为1,那我们就
为1,对于右边没有规定,那我们就直接初始化为所给出的巧克力的最大边长就好。那现在问题就转变到了,对于如何确定边长x是否满足条件我们可以通过计算最
终切出来的块数而判
断出来。那对于一块巧克力,如果按照x的边长是正方形去切,我们能得到几块巧克力呢?假设为6*5的巧克力去切边长为2的正方形。正方形是特殊的长方形,他
们的面积其实都是长*宽,那我们其实可以直接通过巧克力的长宽分别除于边长再乘起来就可以了,注意是整出。如这个例子,能切出来的巧克力块数就是(6/2)*(5/2)=6块。
所有的问题都解决了我们就直接上代码吧
1 import java.util.Scanner; 2 3 public class Main { 4 5 public static void main(String[] args) { 6 Scanner in = new Scanner(System.in); 7 int left = 1; 8 int right = -1; 9 int n = in.nextInt(); 10 int k = in.nextInt(); 11 int[] h = new int[n]; 12 int[] w = new int[n]; 13 for (int i = 0; i < n; i++) { 14 h[i] = in.nextInt(); 15 w[i] = in.nextInt(); 16 right = Math.max(Math.max(h[i], w[i]), right); 17 } 18 while (left <= right) { 19 int mid = left + ((right - left) >> 1); 20 if (check(mid, h, w, k)) { 21 left = mid + 1; 22 } else 23 right = mid - 1; 24 } 25 System.out.println(right); 26 } 27 28 public static boolean check(int mid, int[] h, int[] w, int k) { 29 int cnt = 0; 30 for (int i = 0; i < h.length; i++) { 31 cnt += ((h[i] / mid) * (w[i] / mid)); 32 if (cnt >= k) 33 return true; 34 } 35 return false; 36 } 37 }
好了本篇文章到此结束了,可能并不能真的让你攻克二分查找,但肯定会让你更好的理解二分查找。感谢浏览!