"细节魔鬼" - 二分查找
❝二分查找,是一个高效,实用,且易理解的一个查找算法, 通常时间复杂度为O(lgn)。局限性在于,待查找对象必须为有序的数组;数据量过小,优势不明显,数据量过大,数组大小受限于内存。
除此之外,二分查找是面试中比较高频考察的算法。
高频题目清单:
二分查找应用(简单)
-
374 猜数字大小 -
375 猜数字大小II -
35 搜索插入位置 -
278 第一个错误的版本 -
367 有效的完全平方数 -
69 x的平方根 -
441 排列硬币
二分查找应用(中等)
-
50 Pow(x,n) -
29 两数相除 -
34 在排序数组中查找元素的第一个和最后一个位置 -
540 有序数组中的单一元素 -
275 H指数II -
436 寻找右区间 -
300 最长上升子序列 -
718 最长重复子数组 -
354 俄罗斯套娃信封问题 -
658 找到K个最接近的元素 -
162 寻找峰值 -
4 寻找两个正序数组的中位数 -
209 长度最小的子数组 -
222 完全二叉树的节点个数 -
287 寻找重复数
二分查找与旋转数组
-
153 寻找旋转排序数组中的最小值 -
154 寻找旋转排序数组中的最小值II -
33 搜索旋转排序数组 -
81 搜素旋转排序数组II -
74 搜索二维矩阵
二分答案法
-
378 有序矩阵中第K小的元素 -
668 乘法表中第K小第数 -
410 分割数组的最大值 -
483 最小好进制
关于二分查找,有个说法 “思路简单,细节是魔鬼”,相关的刷题技巧和注意事项,网上大神们总结了很多,自己加工总结后的笔记:
1. 题目出现排序数组,优先思考是否可以用二分查找。
2. 找mid值,记住无脑写 int mid = (left + right) >>> 1
当然 int mid = left + (right - left) / 2
或者 int mid = left + ((right - left) >> 1)
也是对的.
但是万万不能写int mid = (left + right) / 2
,原因是,如果left + right 有可能造成整型溢出,而left + (right - left)
,因为right和left一般是索引值,所以基本不会溢出。
至于推荐写int mid = (left + right) >>> 1
,除了装逼外,还是jdk官方的标准写法,也是因为位运算更快的原因。
3. int mid = (left + right) >>> 1
,对于偶数数组长度来说,找的是左中点,int mid = (left + right + 1) >>> 1
,找的是右中点。
找几个case测试理解后,牢牢背住就行,不加一是左中点,加一是右中点。
4. 循环中的终止条件,写while(left < right)
还是while (left <= right)
,大有乾坤。
left < right
和left <= right
区别在于,如果左右边界一样时,前者会漏掉一个数的查找。不过这也取决于最开始,right的值是否为nums.length -1
。使用left < right的好处在,退出循环时,left一定等于right,不用额外考虑返回值。不过需要额外处理最后一个未查找的值。
这两部篇文章分析了两种条件的影响和处理调整:
https://leetcode-cn.com/problems/binary-search/solution/er-fen-cha-zhao-xiang-jie-by-labuladong/
https://leetcode-cn.com/problems/search-insert-position/solution/te-bie-hao-yong-de-er-fen-cha-fa-fa-mo-ban-python-/
这篇文章极力安利了第一种写法:https://zhuanlan.zhihu.com/p/123863325
所有的文章,包括你我自己写的,都只是个人的见解,你认为哪个有道理就学习它。
我认为必须了解两种的写法具体意义和影响,不同场景不同题目下才能,具体情况具体对待。
704.二分查找
题目描述:
给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1。
示例:
输入: nums = [-1,0,3,5,9,12], target = 9
输出: 4
解释: 9 出现在 nums 中并且下标为 4
解题思路:
最最基础的二分查找题目,考虑以上技巧,分别使用left < right
和left <= right
两种循环条件写出代码,感受边界处理。
-
left <= right
左闭右闭写法
public int search(int[] nums, int target) {
if (nums == null || nums.length <= 0) {
return -1;
}
int left = 0, right = nums.length - 1, mid = 0;
// 左闭右闭区间,搜索不会漏掉值
while (left <= right) {
mid = (right + left) >>> 1;
if (nums[mid] == target) {
return mid;
} else if (nums[mid] > target) {
// 因为循环条件中是右闭区间,
//所以要跳过mid值,mid得-1
right = mid - 1;
}else {
left = mid + 1;
}
}
return -1;
}
2.left < right
左闭右开写法
public int search2(int[] nums, int target) {
if (nums == null || nums.length <= 0) {
return -1;
}
int left = 0, right = nums.length - 1, mid = 0;
while (left < right) {
mid = (right + left) >>> 1;
if (nums[mid] == target) {
return mid;
}else if (nums[mid] > target) {
//因为循环条件为右开区间
//所以跳过mid值,右区间应为mid
right = mid;
}else {
left = left + 1;
}
}
// 处理最后没查找的元素,
//最后一次left==right一定成立,
//所以这块left或者right都可以
return nums[left] == target ? left : -1;
}
最后,附上,jdk中的写法:
34.在排序数组中查找元素的第一个和最后一个位置
题目描述:
给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。
你的算法时间复杂度必须是 O(log n) 级别。
如果数组中不存在目标值,返回 [-1, -1]。
示例:
输入: nums = [5,7,7,8,8,10], target = 8
输出: [3,4]
解题思路:
先简单二分查找到target值后,然后向前向后遍历,找到第一个不重复的和最后一个不重复的值,这样做的话,如果数组元素全是同一个target值,那么是否复杂度就是O(n)了,不符合题目要求了。
所以先解决 如何在O(log n)时间内找到元素第一次出现的位置 :
和基础二分查找不同的是,在nums[mid] == target
后,不能直接返回mid,而是应该让缩小右边界,继续查找。在退出循环后,对整个数组没有target值的情况做处理。
public int searchFirst(int[] nums, int target) {
if (nums == null || nums.length <= 0) {
return -1;
}
int left = 0, right = nums.length - 1, mid = 0;
while (left <= right) {
mid = left + (right - left) / 2;
// 当中点值和target相等时,更新右边界
// 不和下面分支判断合一块,是为了逻辑清晰
if (nums[mid] == target) {
right = mid - 1;
}else if (nums[mid] > target) {
right = mid - 1;
}else {
left = mid + 1;
}
}
// 不用nums[right] 和target比较,
//是因为在中点相等时,跳过了中点,
//且right=mid-1。
// 而left总和mid + 1。
// 所以用nums[left]和target比较。
// 当最后一次查找,left = nums.length - 1时,
// left有可能+1,等于nums.length
// nums[left]就有可能下标越界。
return left >= nums.length || nums[left] != target ? -1 : left;
}
查找目标元素的最后一次出现的位置:
同样的,在中点值等于target时,不要返回mid,而是更新左边界。记得最后在退出循环后,对整个数组没有target值的情况做处理。
public int searchLast(int[] nums, int target) {
if (nums == null || nums.length <= 0) {
return -1;
}
int left = 0, right = nums.length - 1, mid = 0;
while (left <= right) {
mid = left + (right - left) / 2;
if (nums[mid] == target) {
left = mid + 1;
}else if (nums[mid] > target) {
right = mid - 1;
}else {
left = mid + 1;
}
}
// 和找第一个元素位置一样
// 选nums[right]和target比较是因为:
// 当中点和target相等时,跳过mid,left = mid + 1了。
// 当数组只有一个元素时,right有可能-1,变成-1,造成数组下标越界。
// 当然更好一点,直接 return left - 1;
// 因为left初始值是0,如果数组不存在target元素,即返回-1.
return right < 0 || nums[right] != target ? -1 : right;
}
最后,解决这道题,只需:
public int[] searchRange(int[] nums, int target) {
// 这块处理边界条件后,剩下两个函数可去掉。
if (nums == null || nums.length <= 0) {
return new int[]{-1,-1};
}
return new int[] {searchFirst(nums, target), searchLast(nums, target)};
}
367.有效的完全平方数
题目描述:
给定一个正整数 num,编写一个函数,如果 num 是一个完全平方数,则返回 True,否则返回 False。
示例一:
输入:16
输出:true
示例二:
输入:14
输出: false
解题思路:
这是一道简单题,我们每次取中点值,然后判断中点值自乘是否等于num,如果等于,证明该num是完全平方数,否则更新左右指针。
注意学习点:
一个数的平方根最大不会超过它本身,再细点,基本不会超过它的一半。
, 解不等式,得到
或者
。所以,当a =0,1,2,3时,它们的平方根是超过它们自身一半的,对应的平方根解分别是,0,1,1,1。所以,为了处理这4个特殊情况,初始值left = 0, right = x/2 + 1
。
public boolean isPerfectSquare(int num) {
int left = 0, right = num / 2 + 1, mid = 0;
while (left <= right) {
mid = (left + mid) >>> 1;
if (mid * mid == num) {
return true;
}else if (mid * mid > num) {
right = mid - 1;
}else {
left = mid + 1;
}
}
return false;
}
69.x的平方根-sqrt(x)
题目描述:
实现 int sqrt(int x) 函数。
计算并返回 x 的平方根,其中 x 是非负整数。
由于返回类型是整数,结果只保留整数的部分,小数部分将被舍去。
示例:输入: 8
输出: 2
说明: 8 的平方根是 2.82842..., 由于返回类型是整数,小数部分将被舍去。
解题思路:
该题最快的解法并不是二分查找,有O(1)的袖珍计算器算法和同是O(lgn)却更快的牛顿迭代法。
这两种数学算法,具体思路参考官方题解:
https://leetcode-cn.com/problems/sqrtx/solution/x-de-ping-fang-gen-by-leetcode-solution/
二分查找的具体做法,和上题差不多,初始右边界,选择right = x/2 + 1
。注意在退出循环后,如果使用while(left <= right)
,得考虑返回值如何选择。
如case 8 的平方根是 2.82842...,应该返回2。left在非return正常退出循环时,可能会mid + 1,不合适,right退出循环时,是mid-1,符合题目取整数的要求。
public int mySqrt(int x) {
// 用long 防止int*int后溢出
long left = 0, right = x / 2 + 1, mid = 0;
while (left <= right) {
mid = (left + right) >>> 1;
long square = mid * mid;
if (square == x) {
return (int)mid;
}else if (square > x) {
right = mid - 1;
}else {
left = mid + 1;
}
}
// 按要求,比如8,平方根2.82842,所求解为2.
// 对于left <= right,在正常退出时,left = right + 1
// right每次更新都是mid - 1,所以直接返回right即可,或者left - 1。
return (int)right;
}
50 Pow(x,n)
题目说明:
实现 pow(x, n) ,即计算 x 的 n 次幂函数。
示例1:
输入: 2.00000, 10
输出: 1024.00000
示例2:
输入: 2.00000, -2
输出: 0.25000
解释: 2-2 = 1/22 = 1/4 = 0.25
说明:-100.0 < x < 100.0
n 是 32 位有符号整数,其数值范围是 [−231, 231 − 1]
解题思路:
最坏的解法,就是遍历一遍,不断相乘。二分法的思想在这题上,比如要算2的10次方,只要求得2的5次方即可;2的5次方只要求2的2次方,然后结果自乘即可. 自然想到也要使用递归。
可以看到,需要注意的是,当n为奇数时,中间结果还需要再乘以当前数。除此之外,还应该考虑x为负数的情况,x为负数,所求结果要被1除,即倒数。
public double myPow(double x, int n) {
long N = n;
return N >= 0 ? quickMul(x, N) : 1.0 / quickMul(x, -N);
}
public double quickMul(double x, long n) {
if (n == 0) {
return x;
}
double y = quickMul(x, n / 2);
return n % 2 == 0 ? y * y : y * y * x;
}
153.寻找旋转排序数组中的最小值
题目描述:
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。
请找出其中最小的元素。你可以假设数组中不存在重复元素。
示例:
输入: [4,5,6,7,0,1,2]
输出: 0
解题思路
主要思路就是通过判断中点值与边值的大小来缩小查找的左右区间。
本题有3个关键点:
-
while条件使用 left < right -
中点选左中点 -
边值比较使用右边值
1好理解,使用left<right后,在非return正常退出循环后,left是等于right的,随便返回一个即可,除此之外,因为是求最小值,所以当mid值等于边值时,更新左右指针时,要考虑mid也可能是最小值,如果处理不当,使用left <= right,可能会死循环。
2.3的考虑,二分查找题目分析一定要考虑数组只有两个元素的case,就能确定到底使用左中点合适还是右中点合适。
此题,如果case为[1,3],如果选左中点,用左边界比较,就会很麻烦:如果nums[mid] = num[left]
,left肯定不能mid+1跳过,但是如果left=mid,就死循环了。
如果选右中点,用左边界比较,也会有问题,正常情况nums[mid] > num[left]
,肯定右半边是较小的区间,left跳过mid,但是case[1,3]来说,跳过1就有问题。
所以,求旋转数组最小值,因为旋转点的右区间是小区间,所以用左中值和右边值比较,当左中值和右值相等时,右边值由可能是最小值,注意右边值不能跳过mid。
public int findMin(int[] nums) {
int left = 0, right = nums.length - 1, mid = 0;
while (left < right) {
mid = (left + right) >>> 1;
if (nums[mid] > nums[right]) {
left = mid + 1;
}else {
right = mid;
}
}
return nums[mid];
}
154.寻找旋转排序数组中的最小值II
题目描述:
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。
请找出其中最小的元素。
注意数组中可能存在重复的元素。
示例 1:
输入: [3,3,1,3]
输出: 1
示例 2:
输入: [2,2,2,0,1]
输出: 0
解题思路:
基于上题,如果没有重复元素,如果左中点值和右值相等时nums[mid] = nums[right]
,右值有可能是小值,不能跳过,就更新右边值right = mid
。
如果有重复元素后,处理也简单,当左中值和右值相等,让右指针往左滑动,right = right - 1
, 跳过迷惑值。
public int findMin(int[] nums) {
int left = 0, right = nums.length - 1, mid = 0;
while (left < right) {
mid = (left + right) >>> 1;
// 相等时,跳过右边这个迷惑值
if (nums[mid] == nums[right]) {
right = right - 1;
}
if (nums[mid] > nums[right]) {
left = mid + 1;
}
if (nums[mid] < nums[right]) {
right = mid;
}
}
return nums[left];
}
33 搜索旋转排序数组
题目描述:
给你一个升序排列的整数数组 nums ,和一个整数 target 。
假设按照升序排序的数组在预先未知的某个点上进行了旋转。(例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。
请你在数组中搜索 target ,如果数组中存在这个目标值,则返回它的索引,否则返回 -1 。
示例 1:
输入:nums=[4,5,6,7,0,1,2], target = 0
输出:4
解题思路:
和上面找最小值一样,通过中值和边值的比较,先确定有序的数组,然后通过判断target是否在区间内,来更新左右边值。
使用左中点的话,
-
如果 nums[mid] >= nums[left]
, 可以证明[left,mid]
左半段是有序的。当target在[left,mid)区间内,更新右边值right = mid - 1
,否则更新左边值left = mid + 1
。
使用>=
是因为是用的是左中点,得考虑只有两个元素的case。 -
nums[mid] < nums[left]
, 右半段[mid,right]
是有序的
当target在(mid,right],更新左边值left = mid + 1
, 否则更新右边值right = mid - 1
public int search(int[] nums, int target) {
int left = 0, right = nums.length - 1, mid = 0;
while (left <= right) {
mid = (left + right) >>> 1;
if (nums[mid] == target) {
return mid;
}
// 如果中点值大于左值,说明左半边是有序的
if (nums[mid] >= nums[left]) {
// 如果目标值在左区间,更新右边界
if (target >= nums[left] && target < nums[mid]) {
right = mid - 1;
}else {
left = mid + 1;
}
}else {
// 右半边是有序的,如果目标值在右半边区间,更新左边界
if (target > nums[mid] && target <= nums[right]) {
left = mid + 1;
}else {
right = mid - 1;
}
}
}
return -1;
}
81.搜索旋转排序数组II
题目描述:
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
编写一个函数来判断给定的目标值是否存在于数组中。若存在返回 true,否则返回 false。
进阶:
这是 搜索旋转排序数组 的延伸题目,本题中的 nums 可能包含重复元素。这会影响到程序的时间复杂度吗?会有怎样的影响,为什么?
示例:
输入:nums=[2,5,6,0,0,1,2], target = 0 输出: true
解题思路:
和154.寻找旋转排序数组中的最小值II
题一样,当nums[mid] == nums[left]
时,需要滑动left边值指针,跳过迷惑项。
如果这么处理,当待排序数组全是一样的重复元素,时间复杂度会退化为O(n)。
public boolean search(int[] nums, int target) {
int left = 0, right = nums.length - 1, mid = 0;
while (left <= right) {
mid = (left + right) >>> 1;
if (nums[mid] == target) {
return true;
}
// 跳过迷惑值
if (nums[mid] == nums[left]) {
left ++;
}else if (nums[mid] > nums[left]) {
if (target >= nums[left] && target < nums[mid]) {
right = mid - 1;
}else {
left = mid + 1;
}
}else {
if (target > nums[mid] && target <= nums[right]) {
left = mid + 1;
}else {
right = mid - 1;
}
}
}
return false;
}
74.搜索二维矩阵
题目描述:
编写一个高效的算法来判断 m x n 矩阵中,是否存在一个目标值。该矩阵具有如下特性:
每行中的整数从左到右按升序排列。
每行的第一个整数大于前一行的最后一个整数。
示例:
输入:matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,50]], target = 3
输出:true
解题思路:
从示例上就能看到,讲该二维矩阵按行依次展开,就是一个升序的一维数组。
然后就能按标准的二分查找来做题了。
此题关键技巧,如果展开后的一维数组的下班为i。
和m(row length) * n(col length)二维数组存在以下关系:
-
row = i / n -
col = i % n
public boolean searchMatrix(int[][] matrix, int target) {
if (matrix == null || matrix.length <= 0) {
return false;
}
int m = matrix.length;
int n = matrix[0].length;
if (n <= 0) {
return false;
}
int left = 0, right = m * n - 1, mid = 0;
while (left <= right) {
mid = (left + right) >>> 1;
int val = matrix[mid / n][mid % n];
if (val == target) {
return true;
}else if (val > target) {
right = mid - 1;
}else {
left = mid + 1;
}
}
return false;
}
-
链表题目注意在使用不同while条件时,不同的细节处理,包括左右指针的更新,退出循环后的取值考虑。
-
记得不要忘记考虑只有两个元素的case。
-
旋转数组类的查找,首先要通过中值与边值的比较,来确定哪半边是有序的。升序数组旋转后右半边是小区间,求旋转数组最小值时,使用左中值和右边值比较。
-
查找数组中,如果有重复元素,需要滑动指针,跳过疑惑值,但是有可能时间复杂度退化为O(n)。