二分查找算法是面试的基础算法之一,它能在有序数组中使用O(logN)的时间复杂度查找目标元素是否存在。在这种经典的二分查找中,数组有序是使用二分算法非常重要的前提条件之一,那么是不是无序数组就一定不能使用二分查找算法呢?
最经典的二分查找
我们先来看一下最经典的二分查找问题,也就是leetcode704的原题,给定长度为n一个升序数组和一个目标值,如果数组中目标值存在就返回它的下标,如果不存在就返回-1。最朴素的想法肯定是for循环遍历整个数组,这样做的时间复杂度是O(N),但题目中给到的“升序数组”这个条件显然没有用到。
二分查找算法的流程是这样的:设置两个指针left和right,初始状态left指向nums[0],right指向nums[n-1],计算出left到right这个区域的中间位置,假设下标是mid,也就有mid = (left + right) / 2。如果nums[mid] == target,那说明已经找到了目标元素,返回mid,整个算法流程就可以结束了。如果nums[mid] > target,由于数组是升序的,nums[mid+1] ... nums[right]的所有元素都是大于nums[mid],也就更大于target了,所以nums数组中mid ... right的这部分就肯定不等于target,此时可以将right指针指向mid-1位置,去数组的左侧区域继续查找;同理,如果nums[mid] < target,nums[left] ... nums[mid-1]的所有元素都是小于nums[mid]的,也就更小于target了,所以nums数组中left ... mid的这部分也就肯定不等于target了,所以可以让left指向mid + 1,去数组的右侧区域继续查找。循环的条件是left <= right,如果直到循环结束还没找到目标值,说明数组中不存在这个target,返回-1。
以题目中给的两个示例为例,示例1中nums = [-1, 0, 3, 5, 9, 12],初始状态left指针指向0位置的-1,right指针指向5位置的12,mid指向left ... right位置的中间区域,也就是2位置的3,由于3 < 9,说明nums[0]...nums[2]肯定都不等于target,所以将left指向mid + 1,也就是3位置的5,此时right依然指向5位置的12,本轮的mid指针指向4位置的9,和目标值相等,所以直接返回。
示例2中,nums = [-1, 0, 3, 5, 9, 12], target = 2。初始状态依然是left指向0位置的-1,right指向5位置的12,首轮mid指向的是2位置的3,大于目标值,所以nums[mid] ... nums[right]中的元素肯定都不等于目标值,不予考虑。将right指向mid-1,也就是1位置的0,此时计算mid = (0+1) / 2 = 0,nums[mid] < target,所以应该取的是数组的右半部分,即left = mid + 1 = 1。此时left = 1, right = 1,依然符合循环的条件,所以计算mid = (1 + 1) / 2 = 1,nums[1] = 0,小于目标值,所以执行left = mid + 1,此时left = 2,不再满足left <= right的循环条件,所以循环结束,返回-1表示没有找到目标值。
// 二分查找
public int binarySearch(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
while (left <= right) {
int mid = (left + right) / 2;
if (nums[mid] == target) {
return mid;
}
// 去左侧找
if (nums[mid] > target) {
right = mid - 1;
} else {
// nums[mid] < target, 去右侧找
left = mid + 1;
}
}
return -1;
}
根据二分查找的算法流程可以知道,由于每次循环都会将待查询区域缩小一半,所以算法的时间复杂度是O(logN)。
leetcode 34. 在排序数组中查找元素的第一个和最后一个位置
leetcode34题也是一道经典的二分查找变种题,给定一个非递减顺序排列的数组nums和target,让我们求出target在数组中的开始位置和结束位置。
我们思考一下,在二分的过程中,如果nums[mid] > target,那么依然需要去二分的左侧位置去查找;如果nums[mid] < target,需要去二分的右侧位置查找,这两种case和经典二分是一样的。如果nums[mid] == target,那我们可以将当前位置作为一个候选结果存下来,但数组非递减,也就意味着当前位置的左侧和右侧都可能还存在target值,如果我们要找的是开始位置,那么需要去左侧位置继续尝试,所以让right = mid - 1;如果要找的是结束位置,那么需要去右侧继续尝试,让left = mid + 1。所以我们就得到了下面的代码。我们可以发现,在这个代码中,findFirst和findLast两个方法比较相似,所以也可以在方法中加一个参数作为区分,然后在代码里做参数判断执行不同的分支逻辑,大家可以自行尝试。
public int[] searchRange(int[] nums, int target) {
return new int[]{findFirst(nums, target), findLast(nums, target)};
}
public int findFirst(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
int res = -1;
while (left <= right) {
int mid = (left + right) / 2;
if (nums[mid] == target) {
res = mid;
}
if (nums[mid] >= target) {
right = mid - 1;
} else {
left = mid + 1;
}
}
return res;
}
public int findLast(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
int res = -1;
while (left <= right) {
int mid = (left + right) / 2;
if (nums[mid] == target) {
res = mid;
}
if (nums[mid] > target) {
right = mid - 1;
} else {
left = mid + 1;
}
}
return res;
}
leetcode 162. 寻找峰值
思考一下在文章的开篇中我们提到的问题,是不是无序数组就一定不能使用二分查找呢?
我们看一下leetcode162题:寻找峰值。给定一个整数数组,找到峰值元素并返回索引,数组中可能包含多个峰值,我们只需要返回任意一个峰值的索引即可。这里的峰值元素是指严格大于左右相邻值的元素。重点是这里:假设nums[-1] = nums[n] = -∞,这意味着如果把nums数组中所有元素在二维坐标系中表示出来并连成曲线的话,这个曲线趋势一定是先上升后下降的,这意味着曲线在中间一定至少有一个拐点存在,也就是题目中要求的峰值。
首先考虑以下几种特殊情况:如果数组中仅有一个元素,由于这个元素左右两侧都认为是-∞,所以这个元素就是数组的峰值;如果nums[0] > nums[1],而nums[0]的左侧又是-∞,所以0位置也可以认为是数组的峰值;同理可得,如果nums[n-1] > nums[n-2],而nums[n-1]的右侧是-∞,所以n-1位置也可以认为是数组的峰值。
如果没有命中上面的特殊情况,我们让left指针指向nums[0],right指针指向nums[n-1],再找到left...right区域的中点mid,如果nums[mid]比它左右两侧的值都大,说明mid位置就是峰值元素,将索引mid返回。如果mid位置的元素比它右侧的元素小,说明此时的mid还在爬坡阶段,mid左侧可能也会有峰值元素,也可能没有,但mid右侧一定有峰值元素,所以我们去二分的右侧继续查找;
如果mid位置的元素比它右侧的元素大,说明此时的mid已经开始下降,左侧一定会有峰值元素,右侧可能有也可能没有,所以我们去二分的左侧继续查找。循环执行上面的步骤,直到找出峰值元素。
代码贴在下面,供大家参考。
public int findPeakElement(int[] nums) {
int n = nums.length;
if (n == 1 || nums[0] > nums[1]) {
return 0;
}
if (nums[n-2] < nums[n-1]) {
return n-1;
}
int left = 0;
int right = n - 1;
while (left <= right) {
int mid = (left + right) / 2;
boolean gtLeft = mid == 0 || nums[mid] > nums[mid - 1];
boolean gtRight = mid == n - 1 || nums[mid] > nums[mid + 1];
if (gtLeft && gtRight) {
return mid;
}
if (nums[mid] < nums[mid + 1]) {
left = mid + 1;
} else if (nums[mid] > nums[mid + 1]) {
right = mid - 1;
}
}
return -1;
}
总结
通过上面的几道题目的练习,相信大家对二分查找算法有了一些基础的认识。虽然在最经典的二分查找中我们要求数组必须有序,但有序并不是使用二分的必要条件。只要你的数组在二分之后具有二段性,即其中的一侧一定满足某种性质,另一侧不一定满足,就可以向二分查找的方向去思考。
如果这篇文章对你有帮助,请你帮我点一个免费的赞,这对我非常重要,谢谢!
欢迎关注公众号《程序员冻豆腐》,所有文章都会在公众号首发!