一、题目
给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。
请必须使用时间复杂度为O(log n)
的算法。
示例 1:
输入: nums = [1,3,5,6]
, target = 5
输出: 2
示例 2:
输入: nums = [1,3,5,6]
, target = 2
输出: 1
示例 3:
输入: nums = [1,3,5,6]
, target = 7
输出: 4
1 <= nums.length <= 104
-104 <= nums[i] <= 104
nums
为 无重复元素 的 升序 排列数组
-104 <= target <= 104
二、代码
【1】二分查找: 假设题意是叫你在排序数组中寻找是否存在一个目标值,那么训练有素的读者肯定立马就能想到利用二分法在O(logn)
的时间内找到是否存在目标值。但这题还多了个额外的条件,即如果不存在数组中的时候需要返回按顺序插入的位置,那我们还能用二分法么?答案是可以的,我们只需要稍作修改即可。
考虑这个插入的位置pos
,它成立的条件为:nums[pos−1]<target≤nums[pos]
其中nums
代表排序数组。由于如果存在这个目标值,我们返回的索引也是pos
,因此我们可以将两个条件合并得出最后的目标:「在一个有序数组中找第一个大于等于target
的下标」。
问题转化到这里,直接套用二分法即可,即不断用二分法逼近查找第一个大于等于target
的下标 。下文给出的代码是笔者习惯的二分写法,ans
初值设置为数组长度可以省略边界条件的判断,因为存在一种情况是target
大于数组中的所有数,此时需要插入到数组长度的位置。
class Solution {public int searchInsert(int[] nums, int target) {int n = nums.length;int left = 0, right = n - 1, ans = n;while (left <= right) {int mid = ((right - left) >> 1) + left;if (target <= nums[mid]) {ans = mid;right = mid - 1;} else {left = mid + 1;}}return ans;}}
时间复杂度: O(logn)
,其中n
为数组的长度。二分查找所需的时间复杂度为O(logn)
。
空间复杂度: O(1)
。我们只需要常数空间存放若干变量。
【2】思路分析: 在有序数组中查找符合条件的某个数(或者它的下标),可以使用二分查找。我们根据搜索区间[left..right]
中间位置mid
的值,判断下一轮搜索区间在哪里。根据「一、题意分析」中对示例的描述:(这里多说一句:下面的「情况 1」和「情况 2」的分析完全是分析本题题意得到的,如果有不太清楚的地方,把题目中的 3 个「示例」多看几遍。)
情况 1:如果当前mid
看到的数值严格小于target
,那么mid
以及mid
左边的所有元素就一定不是「插入元素的位置」,因此下一轮搜索区间是[mid + 1..right]
,下一轮把left
移动到mid + 1
位置,因此设置left = mid + 1
;
情况 2:否则。如果mid
看到的数值大于等于target
,那么mid
可能是「插入元素的位置」,mid
的右边一定不是「插入元素的位置」。如果mid
的左边不存在「插入元素的位置」,我们才可以说mid
是「插入元素的位置」。因此下一轮搜索区间是[left..mid]
,下一轮把right
移动到mid
位置,因此设置right = mid
。
说明:上面的两点中,「情况 2」其实不用分析得那么细致, 因为只要「情况 1」的区间分析是正确的,「情况 2」一定是「情况 1」得到的区间的反面区间。
看到一个数大于等于目标元素,此时不能说它一定是第一个大于等于目标元素的元素。
1、如果它的左边没有大于等于目标元素的元素,它才是第一个大于等于目标元素的元素;
2、如果它的左边有大于等于目标元素的元素,它不是第一个大于等于目标元素的元素。
就是这样的特点决定了:这个问题的答案有些时候需要再退出循环以后才能得到。
如果你非常清楚「二分查找」,或者看过我讲的「二分查找」的视频或者题解,下面的代码应该不难理解。我在「五、其它代码讲解」会说明:本题解里所有的代码其实是一样的,就只有一种解法,特殊情况和边界的分析也完全一样。
public class Solution {public int searchInsert(int[] nums, int target) {// 不用判断数组为空,因为题目最后给出的数据范围说数组不为空int len = nums.length;// 特殊判断if (nums[len - 1] < target) {return len;}// 程序走到这里一定有 nums[len - 1] >= target,插入位置在区间 [0..len - 1]int left = 0;int right = len - 1;// 在区间 nums[left..right] 里查找第 1 个大于等于 target 的元素的下标while (left < right) {int mid = (left + right) / 2;if (nums[mid] < target){// 下一轮搜索的区间是 [mid + 1..right]left = mid + 1;} else {// 下一轮搜索的区间是 [left..mid]right = mid;}}return left;}}
说明:
1、while (left < right)
表示当left
与right
重合的时候,我们找到了「目标值」的下标;
2、根据题意和示例,当代码中的「特殊判断」不成立时,区间里一定存在大于等于「目标值」的元素;
3、while
循环里只把区间分成两个部分,退出循环的时候一定有left == right
成立,所以left
与right
重合的这个位置就是问题的答案,因此返回left
或者right
都可以。
因为题目的最后说:nums
中没有重复元素,所以可以在循环体里面加一个判断:
if (nums[mid] == target) {return mid;}
时间复杂度: O(logN)
,这里N
是输入数组的长度;
空间复杂度: O(1)
。
既然len
也有可能是答案,可以在初始化的时候,把right
设置成len
,在一开始的时候就不需要特殊判断了。
public class Solution {public int searchInsert(int[] nums, int target) {int len = nums.length;int left = 0;int right = len;// 在区间 nums[left..right] 里查找第 1 个大于等于 target 的元素的下标while (left < right) {int mid = left + (right - left) / 2;if (nums[mid] < target){// 下一轮搜索的区间是 [mid + 1..right]left = mid + 1;} else {// 下一轮搜索的区间是 [left..mid]right = mid;}}return left;}}
复杂度分析: (同参考代码 1)。
说明:
1、「参考代码 1」和「参考代码 2」其实是一版代码。「参考代码 1」其实就是做了先特殊的判断,特殊的判断不满足的时候,在一个更小的区间里做「二分查找」。
2、有的朋友把「参考代码 2」解释成:while (left < right)
表示搜索区间是[left..right)
,所以初始化的时候right = len
,这样的解释是大错特错的。错误的地方在于:while (left < right)
表示搜索区间是[left..right)
,这句就不成立。while (left < right)
只表示它本来的意思:循环可以继续的条件是left < right
。
初始化的时候设置right = len
,是因为这道题搜索的右边界本来就是[0..len]
。
class Solution {public int searchInsert(int[] nums, int target) {int n = nums.length;int left = 0, right = n - 1, ans = n;while (left <= right) {int mid = ((right - left) >> 1) + left;if (target <= nums[mid]) {ans = mid;right = mid - 1;} else {left = mid + 1;}}return ans;}}
解释:
1、这里的ans
的含义是,把有可能是题目答案的结果用ans
保存起来,所以ans
初始化的时候应该设置为n
(根据 「示例」3);
2、在if
语句里nums[mid] >= target
的时候,mid
位置有可能是问题的答案,所以需要用ans
把mid
保存起来(根据「二、思路分析」「情况 2」);
3、最后要返回ans
。
由于有了ans
变量,并且在if
和else
语句中left
一定是mid + 1
,right
一定是mid - 1
,这种情况不会出现死循环,所以while
里面可以写left <= right
。
其实这里的「代码 1」和上面给出的「参考代码 1」「参考代码 2」是一样的。其中:
1、ans
的含义是:搜索区间的右边界;
2、right
的含义是: 搜索区间的右边界-1
。
class Solution {public int searchInsert(int[] nums, int target) {int left = 0, right = nums.length - 1;while(left <= right) {int mid = (left + right) / 2;if(nums[mid] == target) {return mid;} else if(nums[mid] < target) {left = mid + 1;} else {right = mid - 1;}}return left;}}
解释:
1、这一版代码里,所有关于右边界的设置,都要比真正的右边界少 111。也就是说,如果作者分析出搜索区间是[10, 20]
,他在代码里设置left = 10
, right = 19
。除此之外,和「四、参考代码」里的「参考代码 2」是一样的;
2、因为数组的长度有可能是问题的答案,作者固定死了设置右边界的时候-1
,所以初始化的时候right = nums.length - 1
;
3、循环体里面 if (nums[mid] == target) 我前面分析过,直接返回 mid 就好了,这里没有设置右边界;
4、else if (nums[mid] < target)
我前面也分析过,此时设置左边界;
5、else
是nums[mid] > target
的时候,此时本该设置right = mid
,但是作者固定死了设置右边界的时候-1
,所以这里写right = mid - 1
;
6、因为固定死了设置右边界的时候-1
,所以退出循环的时候,left
和right
不重合,right
比left
少1
,所以返回left
正确,返回right
不正确,应该返回right + 1
,大家可以自己验证。
为什么while (left <= right)
正确,还是因为固定死了设置右边界的时候-1
。当left
与right
重合的时候,虽然区间[left..right]
里只有一个数,但是真正的右边界是right + 1
,所以此时还应该继续搜索下去。
总结: 「二分查找」的写法很多、细节也很多。希望大家一定要有耐心,遇到问题的时候自己调试,把变量的值打印出来看一眼。
我相信,真正掌握「二分查找」的朋友,不是因为他(她)背下了「二分查找」的模板,而是他(她)对题目的意思有准确的理解。
就本题而言,一定要分析出:
1、数组的长度有可能是问题的答案,也就是nums.length
有可能是问题的答案,如果不讨论,答案肯定错;
2、当nums[mid] >= target
的时候,mid
有可能是问题的答案,如果直接去掉,也肯定错,这一点在「三、本题的特点 」里专门强调过。
任何模板都不会覆盖上面的信息,上面也和大家解释了其它版本的代码正确也离不开对上面两点的分析,本质上本题解里出现的代码都是一样的,所以审题很重要。
我看到非常多的朋友说while (left < right)
这种写法叫「左闭右开」,我在这里要很严肃地说一句:这完全没有根据。很多朋友不加思考地接受了这样的结论,所以导致它们在理解一些「二分查找」代码的时候逻辑上的混乱和矛盾。
即:while (left < right)」
与「搜索区间为左闭右开」没有因果关系。
1、while (left < right)
只表示它本来的意思,即:在left < right
的时候循环可以继续,不能因为少了「等于」号,就说搜索区间为左闭右开[left..right)
。
2、什么叫区间左闭右开[left..right)
左闭右开[left..right)
等于左闭右闭区间[left..right - 1]
。
一个具体的问题,在条件确定的情况下,搜索的范围(区间)也是确定的。这个区间,你可以表示成「左闭右闭」区间(我的所有「二分查找」的题解里的所有代码)。也可以表示成「左闭右开」区间,比如本题解「五、其它代码讲解」的「代码 2」,把right
设置成为真正的目标值存在区间的右边界 −1-
。
「二分查找」不会因为因为我们把区间表示成「左闭右闭」或者「左闭右开」而变得简单。大家会看到,表示成「左闭右开」反而更别扭。
很多朋友不假思索地在算法学习中应用别人写好的「代码模板」,如果只是为了把题目做对,有些时候是可以侥幸通过的。
在这个网站上,有很多种办法能让自己的代码通过测评,比起做对这些问题,我认为更重要的是解决问题的思想。希望大家在做题的时候,能够真的清楚每一行代码的意思。
讲解「二分查找」的人误导了很多人,希望大家能够仔细辨别。
如果你认为我在误导别人,首先恭喜你,质疑我本来就是你的权利和应该有的态度,我讲错的地方很多,被人纠正过很多次。其次,你可以有理有据说出我讲错的地方。我看到了,都会承认的。如果是恶意留言,我一条都不会回复。