二分法是面试当中出现频率比较高,考察的点也会比较多,像LeetCode的热题中也会考察很多,因此我们先从简单的二分法入手。
1 二分法入门
一般来说,使用二分法的前提数组是有序的,然后从排序数组中找到target值的位置,常用的方法就是递归实现,当然在面试中可能会要求使用非递归的方式也实现一遍。
1.1 递归实现
java
public int binarySearch(int[] nums, int start, int end, int target) {
//中间查找
int middle = (start + end) / 2;
if (nums[middle] == target) {
return middle;
}
if (nums[middle] < target) {
return binarySearch(nums, middle + 1, end, target);
} else {
return binarySearch(nums, start, middle - 1, target);
}
}
递归实现是比较简单的,下面我们着重介绍一下非递归的实现方案。
1.2 非递归实现
当然从排序数组中找到target的位置比较简单,但是如果数组中有重复的数字,我们需要通过二分法拿到数字出现的第一个位置,或者数字出现的最后一个位置,其实我们是有一个通用的模板的。
java
public static int binarySearch2(int[] nums, int target) {
if (nums == null || nums.length == 0) {
return -1;
}
int start = 0;
int end = nums.length - 1;
while (start + 1 < end) {
int middle = start + (end - start) / 2;
if (nums[middle] == target) {
start = middle;
} else if (nums[middle] < target) {
start = middle;
} else {
end = middle;
}
}
Log.d("TAG", "binarySearch2: start=" + start + " end=" + end);
if (nums[start] == target) {
return start;
}
if (nums[end] == target) {
return end;
}
return -1;
}
技术要点:
- 双指针,start和end分别指向数组的头部和尾部;
- 循环结束的条件,使用
start + 1 < end
目的是为了防止死循环; - 循环结束后,如果题目要求找到数组中某个元素的位置,或者在数组中第一次出现的位置,那么就使用start判断;否则就使用end判断,命中就返回,没有命中就返回-1.
1.3 查找数字第一次或者最后一次在数组中出现的位置
前面我们基本的二分法模板,通过双指针start和end,最终拿到了对应的target,此时start和end是连续的,例如 1,2 和 3,4,所以我们要判断target出现的位置,可以有下面的3种情况:
- start位置上的数字等于target但是end位置上的数字不等于target,此时start位置就是最后一次出现的位置;
- start位置上的数字不等于target但是end位置上的数字等于target,此时end位置就是第一次出现的位置;
- 如果start和end位置上的数组都等于target,那么就需要前后寻找对应第一次出现和最后一次出现的位置。
java
public int[] searchRange(int[] nums, int target) {
if(nums == null || nums.length == 0){
return new int[]{-1,-1};
}
int start = 0;
int end = nums.length-1;
while(start + 1 < end){
int mid = start + (end - start) / 2;
if(nums[mid] == target){
//此时end拿到的是第一次出现的位置
start = mid;
}else if(nums[mid] < target){
start = mid;
}else{
end = mid;
}
}
int[] res = new int[]{-1,-1};
//这里start和end是相邻的
if(nums[start] == target && nums[end] != target){
//start是最后一个
res[1] = start;
//找第一次出现的位置
while(start >=0 && nums[start] == target){
start--;
}
res[0] = start+1;
return res;
}
if(nums[end] == target && nums[start] != target){
//end是第一个
res[0] = end;
//找最后一次出现的位置
while(end < nums.length && nums[end] == target){
end++;
}
res[1] = end-1;
return res;
}
//分别寻找,例如[1,2,2,2,2] target = 2
if(nums[end] == target && nums[start] == target){
while(start >=0 && nums[start] == target){
start--;
}
res[0] = start+1;
while(end < nums.length && nums[end] == target){
end++;
}
res[1] = end-1;
return res;
}
//没进到循环里的,例如[1],[2,2],[1,2] target = 2
if(start == end){
if(nums[0] == target){
return new int[]{0,0};
}
}else{
//两位数
if(nums[0] == target && nums[1] == target){
res[0] = 0;
res[1] = 1;
}else if(nums[0] != target && nums[1] == target){
res[0] = 1;
res[1] = 1;
}else{
// res[0] = 0
}
}
return res;
}
1.4 拓展:寻找离target最近的k个数字
题目:给定一个升序的数组,非负数k,和目标数值target,要求返回离target最近的k个数字,如果两者距离一致,那么相对小的放在前面。
如果数组中有target数字,那么就很简单,只需要两个背向双指针移动即可;而如果没有target这个值,那么按照1.2中的算法,此时start指向的就是target应在位置的上一个元素的位置,例如:
java
arr: [2,4,6,9,12] target = 5
此时start = 1,就是指向了元素4的位置,那么中心线就是4和6的中心位置
看算法:
java
public static int[] binarySearch3(int[] nums, int target, int k) {
if (nums == null || nums.length == 0) {
return new int[]{};
}
int start = 0;
int end = nums.length - 1;
while (start + 1 < end) {
int middle = start + (end - start) / 2;
if (nums[middle] == target) {
start = middle;
} else if (nums[middle] < target) {
start = middle;
} else {
end = middle;
}
}
Log.d("TAG", "binarySearch2: start=" + start + " end=" + end);
if (nums[start] == target) {
start = start - 1;
end = start + 1;
} else {
//不存在target
end = start + 1;
}
int[] res = new int[k];
int pos = 0;
//两个指针转为背向双指针
while (start >= 0 && end < nums.length) {
if (pos == k - 1) {
return res;
}
//先左后右
res[pos] = nums[start];
pos++;
res[pos] = nums[end];
pos++;
start--;
end++;
}
//因为存在两边长度不一致的况
while (pos < k && start >= 0) {
//此时右侧已经结束了
res[pos] = nums[start];
pos++;
start--;
}
while (pos < k && end < nums.length) {
//此时左侧已经结束了
res[pos] = nums[end];
pos++;
end++;
}
return res;
}
前半部分,依然使用我们的二分查找模板,此时拿到的start就是target第一次出现的位置,或者应该出现的位置的上一个现有元素位置(nums中不存在target),那么接下来的分析就是获取中心线:
- 如果target存在,那么start和end分别为target两边的元素index;
- 如果target不存在,那么start不变,end为start的下一个元素,相当于target属于一个虚拟的中心线。
- 双指针依次背向而驰,因为如果指针的两边都有值,那么距离相近,需要按照相对小在前的原则,先处理start指针入组,然后end指针入组。
- 如果有一侧指针超限,那么剩下的补充即可,只需按照距离即可,不需要关心大小。
2 无序数组xxoo二分法
前面我们介绍的都是有序数组的二分,只通过寻找中间值,与target做比较,决定舍弃哪块区域。而如果数组是无序的,如何通过二分法解决这类问题。
1.1 峰值问题
峰值元素是指其值严格大于左右相邻值的元素。
给你一个整数数组 nums
,找到峰值元素并返回其索引。数组可能包含多个峰值,在这种情况下,返回 任何一个峰值 所在位置即可。
如图,数组中可能会存在多个峰值,题目要求我们只返回一个即可,所以我们可以通过二分查找,当mid的下一个元素大于mid时,说明此时正在上升,因此去往mid后面找峰值即可。
java
public int findPeakElement(int[] nums) {
if(nums == null || nums.length == 0){
return -1;
}
//双指针
int start = 0;
int end = nums.length-1;
while(start + 1 < end){
//求中点
int middle = start + (end - start) / 2;
if(nums[middle] > nums[middle + 1]){
//下山
end = middle;
}else{
//上山
start = middle;
}
}
if(nums[start] > nums[end]){
return start;
}else{
return end;
}
}
1.2 旋转数组问题
已知一个长度为 n
的数组,预先按照升序排列,经由 1
到 n
次 旋转 后,得到输入数组。例如,原数组 nums = [0,1,2,4,5,6,7]
在变化后可能得到:
- 若旋转
4
次,则可以得到[4,5,6,7,0,1,2]
- 若旋转
7
次,则可以得到[0,1,2,4,5,6,7]
注意,数组 [a[0], a[1], a[2], ..., a[n-1]]
旋转一次 的结果为数组 [a[n-1], a[0], a[1], a[2], ..., a[n-2]]
。
给你一个元素值 互不相同 的数组 nums
,它原来是一个升序排列的数组,并按上述情形进行了多次旋转。请你找出并返回数组中的 最小元素 。
你必须设计一个时间复杂度为 O(log n)
的算法解决此问题。
这里需要注意的一点是没有重复元素,很重要!因为如果存在重复元素,无法判定需要舍弃哪部分。
例如我们在做辅助线的时候,如下图所示,我们希望的是两边往中间挤,因为第一次求中点,如果是数组长度是奇数,那么mid一定落在中间。
那么这个时候,如果在两侧开刀,如果mid大于end,说明起点在右边,需要抛弃左边,start来到mid的位置,从图中也可以看出;反之亦然。
java
public int findMin(int[] nums) {
if(nums == null || nums.length == 0){
return -1;
}
int start = 0;
int end = nums.length-1;
while(start + 1 < end){
//拿mid值
int mid = start + (end - start) / 2;
if(nums[mid] > nums[end]){
//起点在右边
start = mid;
}else{
//起点在左边
end = mid;
}
}
return Math.min(nums[start],nums[end]);
}
注:这种算法可以获取旋转数组中的最大值和最小值,因为start和end一定有一个对标着最大值和最小值的索引。
那么既然可以找到最小值或者最大值的索引,我们便可以从数组中通过二分法找到target值,具体流程为:
- 先通过二分查找到最小值的位置minIndex;
- 判断target和数组最后一位nums[end]的大小,如果比target大,那么只需要在[0,mindex]上二分即可;如果比target小,那么只需要在[mindex,end]上二分即可。
二次二分,时间复杂度依然为logn。
java
public int search(int[] nums, int target) {
if(nums == null || nums.length == 0){
return -1;
}
int start = 0;
int end = nums.length-1;
while(start + 1 < end){
//找mid
int mid = start + (end - start) / 2;
if(nums[mid] > nums[end]){
start = mid;
}else{
end = mid;
}
}
//先找到最小值的位置
int minIndex = -1;
if(nums[start] < nums[end]){
minIndex = start;
}else{
minIndex = end;
}
if(nums[nums.length-1] < target){
//那么就在0-minIndex上二分
start = 0;
end = minIndex;
}else if(nums[nums.length-1] > target){
start = minIndex;
end = nums.length-1;
}else{
return nums.length-1;
}
while(start + 1 < end){
int mid = start + (end - start) / 2;
if(nums[mid] < target){
start = mid;
}else{
end = mid;
}
}
if(nums[start] == target){
return start;
}
if(nums[end] == target){
return end;
}
return -1;
}