二分查找
二分查找法的原理非常简单,而且能把查找的时间复杂度降为 O(logn),但我之前在做二分法的时候经常因为边界问题而摸不着头脑,比如右边界我该不该取的问题、while 循环的判断条件带不带 = 的问题。当然你完全可以根据自己的定义的边界,根据自己的想法决定 while 循环里带不带等号,但需要注意最后跳出循环时左右边界情况,再做讨论。所以,二分查找题主要考察的是细节,要把细节理解清楚就需要规范且完整的思考过程,定义好 l 和 r 的含义,到底是 [l, r] 还是 [l, r)。
刷题的意义就再于形成一套自己的解题思路,有利于在面试现场很短时间内 bug free 掉一道算法题。因此,以下介绍我使用的这套二分模板应对求解大部分二分查找题,透彻地理解了模板才能应对复杂多变的情况。
二分查找模板:寻找 target
例1. 查找数组 arr 中元素等于 target 的索引,若找不到则返回 -1
以下是在 [l, r] 区间里找 target 值,我习惯于用这种形式作为我解二分法题型的模板,后面会解释这样写的好处,特点有:
- [l, r]
- while( l <= r)
- if 判断语句里面不包括等号的情况
- 在 2 的条件下有 l = r + 1
Java 代码:
// 当 arr 数组中没有 target 值时,方法返回 -1。
private static int binarySearch(int[] arr, int target) {
if (arr == null || arr.length == 0) {
return -1;
}
int l = 0, r = arr.length - 1;
while (l <= r) {
int mid = l + (r - l) / 2;
if (target < arr[mid]) {
r = mid - 1;
} else if (target > arr[mid]) {
l = mid + 1;
} else {
return mid;
}
}
return -1;
}
得出第四点的结论分析如下:
最后一个 while 循环时,l = r,mid = l,
若 target < arr[mid], 则 r = mid - 1 = l - 1,即 l = r + 1;
若 target > arr[mid], 则 l = mid + 1 = r + 1;
若 target = arr[mid], 则 target = mid = l = r;
LeetCode34 (寻找左侧边界、右侧边界)
LeetCode34. 在排序数组中查找元素的第一个和最后一个位置。给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。如果数组中不存在目标值,返回 [-1, -1]。(你的算法时间复杂度必须是 O(log n) 级别。)
示例1:
输入: nums = [5,7,7,8,8,10], target = 8
输出: [3,4]
分析:
第一种方式是通过二分法先找到第一个 8 的位置,再用循环在 8 前后查找,即可确定开始索引和结束索引。但这种方法由于循环查找的加入,使得时间复杂度不是 O(logn) 的。如何只用二分法查找?
我的思路是,先用二分法查找 < 8 的最大索引即可确定 8 的开始索引,用 left 记录,同理查找 > 8 的最小索引即可确定 8 的结束索引,用 right 记录,最后要返回的就是 [left + 1, right - 1],如果 [left + 1, right - 1] 无效(即left + 1 > right - 1),则返回 {-1, -1}。
寻找左侧边界
- < 8 的最大索引。
还是按照上面的模板写,由于是 < 8,没有取到 8 ,所以 while 循环里不会出现 return target 的情况,因此 if 判断只有两种情况。按照模板的要求写的代码如下
private int searchLeft(int[] nums, int target) {
int l = 0, r = nums.length-1;
while (l <= r) {
int mid = l + (r - l) / 2;
if (nums[mid] < target) {
} else {
}
}
return ;
}
还需要确定 1.if 判断里的替换 l;2.替换 r 的语句;3. return 返回的是什么。
先确定 else 里的语句,如果 nums[mid] >= target,而要找的索引位置是**< 8的最大所用**,此时要找的索引在[l, mid-1] 这一侧,因此 r = mid - 1;
private int searchLeft(int[] nums, int target) {
int l = 0, r = nums.length-1;
while (l <= r) {
int mid = l + (r - l) / 2;
if (nums[mid] < target) {
} else {
r = mid - 1;
}
}
return ;
}
再写 if 结果为真的执行语句,如果 l = mid;
,则在最后一次 while 循环时,l = r = mid,num[mid] < target,如果 l = mid,永远也跳不出循环,所以 l = mid + 1;
。
private int searchLeft(int[] nums, int target) {
int l = 0, r = nums.length-1;
while (l <= r) {
int mid = l + (r - l) / 2;
if (nums[mid] < target) {
l = mid + 1;
} else {
r = mid - 1;
}
}
return ;
}
最后,退出循环的隐含条件是 l = r + 1, 即 [r+1, r]。最后一次 while 循环时,l = r = mid,
如果 num[mid] < target,则 l = mid + 1, 则需要返回的值是 mid®,因为不能保证 r + 1 还满足 num[r + 1] < target;
如果 num[mid] >= target, 则 r = mid - 1,则返回的值是 mid - 1®,因为这个时候 mid - 1一定是 < target 的最大索引。
因此,return r;
。整个过程的代码如下
private int searchLeft(int[] nums, int target) {
int l = 0, r = nums.length-1;
while (l <= r) {
int mid = l + (r - l) / 2;
if (nums[mid] < target) {
l = mid + 1;
} else {
r = mid - 1;
}
}
return r; // 一定会跳出while循环,此时l = r + 1;
}
寻找右侧边界
- />8 的最小索引
和上面的分析过程一样,按照三步确定最后的代码为:
private int searchRight(int[] nums, int target) {
int l = 0, r = nums.length-1;
while (l <= r) {
int mid = l + (r - l) / 2;
if (nums[mid] > target) {
r = mid - 1; //如果是r = mid,则会陷入死循环
} else {
l = mid + 1; //如果nums[mid] < target的话,说明要找的索引在右边,因此把l往mid往右挪一个
}
}
return l; // 一定会跳出while循环,此时l = r + 1;
}
返回结果
- 得到了 < 8 的最大索引和 > 8 的最小索引,用 left 和 right 记录索引位置。如果 left + 1 > right - 1,即[left +1, right - 1] 这个范围无效,说明找不到目标值,因此返回
new int[]{-1,-1}
public int[] searchRange(int[] nums, int target) {
int left = searchLeft(nums, target); // left: < target 的最大索引
int right = searchRight(nums, target); // right: > target 的最小索引
if (left + 1 > right - 1) {
return new int[]{-1, -1};
}
return new int[]{left+1, right-1};
}
private int searchLeft(int[] nums, int target) {
int l = 0, r = nums.length-1;
while (l <= r) {
int mid = l + (r - l) / 2;
if (nums[mid] < target) {
l = mid + 1;
} else {
r = mid - 1;
}
}
return r; // 一定会跳出while循环,此时l = r + 1;
}
private int searchRight(int[] nums, int target) {
int l = 0, r = nums.length-1;
while (l <= r) {
int mid = l + (r - l) / 2;
if (nums[mid] > target) {
r = mid - 1; //如果是r = mid,则会陷入死循环
} else {
l = mid + 1; //如果nums[mid] < target的话,说明要找的索引在右边,因此把l往mid往右挪一个
}
}
return l; // 一定会跳出while循环,此时l = r + 1;
}
剑指Offer 面试题53—II,0~n-1 中缺失的数字
一个长度为n-1的递增排序数组中的所有数字都是唯一的,并且每个数字都在范围0~n-1之内。
在范围0~n-1内的n个数字中有且只有一个数字不在该数组中,请找出这个数字。
示例:
输入: [0,1,2,3,4,5,6,7,9]
输出: 8
思路:找值大于索引的最小索引
public int missingNumber(int[] nums) {
// 找值大于索引的最小索引
int l = 0, r = nums.length - 1;
while(l <= r) {
int mid = l + (r - l) / 2;
if(nums[mid] != mid) {
if(mid == 0 || nums[mid - 1] == mid - 1) {
return mid;
}
r = mid - 1;
} else {
l = mid + 1;
}
}
return l;
}
LeetCode287 寻找重复数
LeetCode 287. 寻找重复数。给定一个包含 n + 1 个整数的数组 nums,其数字都在 1 到 n 之间(包括 1 和 n),可知至少存在一个重复的整数。假设只有一个重复的整数,找出这个重复的数。
示例 1:
输入: [1,3,4,2,2]
输入: [1,3,4,2,2]
二分查找法:
时间O(nlogn), 空间O(1)
public int findDuplicate2(int[] nums) {
int l = 1, r = nums.length;
while (l <= r) {
int mid = l + (r - l) / 2;
int count = 0;
for (int num : nums) {
if (num <= mid) {
count++;
}
}
if (count <= mid) {
l = mid + 1;
} else {
r = mid - 1;
}
}
return l;
}