搜索插入位置[简单]

搜索插入位置[简单]_第1张图片

一、题目

给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。

请必须使用时间复杂度为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(log⁡n)的时间内找到是否存在目标值。但这题还多了个额外的条件,即如果不存在数组中的时候需要返回按顺序插入的位置,那我们还能用二分法么?答案是可以的,我们只需要稍作修改即可。

考虑这个插入的位置pos,它成立的条件为:nums[pos−1]其中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(log⁡n),其中n为数组的长度。二分查找所需的时间复杂度为O(log⁡n)
空间复杂度: 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)表示当leftright重合的时候,我们找到了「目标值」的下标;
2、根据题意和示例,当代码中的「特殊判断」不成立时,区间里一定存在大于等于「目标值」的元素;
3、while循环里只把区间分成两个部分,退出循环的时候一定有left == right成立,所以leftright重合的这个位置就是问题的答案,因此返回left或者right都可以。

因为题目的最后说:nums中没有重复元素,所以可以在循环体里面加一个判断:

if (nums[mid] == target) {
    return mid;
}

时间复杂度: O(log⁡N),这里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位置有可能是问题的答案,所以需要用ansmid保存起来(根据「二、思路分析」「情况 2」);
3、最后要返回ans

由于有了ans变量,并且在ifelse语句中left一定是mid + 1right一定是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、elsenums[mid] > target的时候,此时本该设置right = mid,但是作者固定死了设置右边界的时候-1,所以这里写right = mid - 1;
6、因为固定死了设置右边界的时候-1,所以退出循环的时候,leftright不重合,rightleft1,所以返回left正确,返回right不正确,应该返回right + 1,大家可以自己验证。

为什么while (left <= right)正确,还是因为固定死了设置右边界的时候-1。当leftright重合的时候,虽然区间[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-

「二分查找」不会因为因为我们把区间表示成「左闭右闭」或者「左闭右开」而变得简单。大家会看到,表示成「左闭右开」反而更别扭。

很多朋友不假思索地在算法学习中应用别人写好的「代码模板」,如果只是为了把题目做对,有些时候是可以侥幸通过的。

在这个网站上,有很多种办法能让自己的代码通过测评,比起做对这些问题,我认为更重要的是解决问题的思想。希望大家在做题的时候,能够真的清楚每一行代码的意思。

讲解「二分查找」的人误导了很多人,希望大家能够仔细辨别。

如果你认为我在误导别人,首先恭喜你,质疑我本来就是你的权利和应该有的态度,我讲错的地方很多,被人纠正过很多次。其次,你可以有理有据说出我讲错的地方。我看到了,都会承认的。如果是恶意留言,我一条都不会回复。

你可能感兴趣的:(算法题,算法,数据结构,后端,java,性能优化,面试,开发语言)