二分查找的典型问题(一):二分下标

「基本二分查找」问题的变形问题,「二分下标」是指在一个有序数组(该条件可以适当放宽)中查找目标元素的下标。
在这里插入图片描述
分析:这道题要求我们在一个有序数组里查找插入元素的位置,那么什么是插入元素的位置呢?我们看示例。

示例 1:目标元素 55 在有序数组 [1, 3, 5, 6] 里,下标为 2,输出 2;
示例 2:目标元素 22 不在有序数组 [1, 3, 5, 6] 里,返回 3 的下标 1 ,我们可以知道,如果数组中不存在目标元素,返回第 1 个严格大于目标元素的数值的下标;
示例 3:目标元素 77 不在有序数组 [1, 3, 5, 6] 里。特别地,7 比最后一个元素 6 还大,返回最后一个元素的下标 +1;
示例 4:目标元素 00 不在有序数组 [1, 3, 5, 6] 里。特别地,0 比第一个元素 1 还小,返回第 1 个元素的下标 0。

public class Solution {

    public int searchInsert(int[] nums, int target) {
        int len = nums.length;
        // 题目没有说输入数组的长度可能为 0,因此需要做特殊判断
        if (len == 0) {
            return 0;
        }

        if (nums[len - 1] < target) {
            return len;
        }
        int left = 0;
        int right = len - 1;

        // 在区间 [left, right] 查找插入元素的位置
        while (left < right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] < target){
                // 下一轮搜索的区间是 [mid + 1, right]
                left = mid + 1;
            } else {
                // 下一轮搜索的区间是 [left, mid]
                right = mid;
            }
        }
        // 由于程序走到这里 [left, right] 里一定存在插入元素的位置
        // 且退出循环的时候一定有 left == right 成立,因此返回 left 或者 right 均可
        return left;
    }
}

复杂度分析

  • 时间复杂度:O(logN),这里 N 是数组的长度,每一次都将问题的规模缩减为原来的一半,因此时间复杂度是对数级别的;
  • 空间复杂度:O(1),使用到常数个临时变量。
    由于下标 len 这个位置,也有可能是插入元素的位置,于是可以省去最开始的特殊判断,通过两边 逼近 的方式找到插入元素的位置。
public class Solution {

    public int searchInsert(int[] nums, int target) {
        int len = nums.length;
        // 题目没有说输入数组的长度可能为 0,因此需要做特殊判断
        if (len == 0) {
            return 0;
        }

        // 在区间 [left, right] 查找插入元素的位置
        int left = 0;
        // 注意:这里初始值设置为 len,表示 len 这个下标也有可能是插入元素的位置
        int right = len;
        while (left < right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] < target){
                // 下一轮搜索的区间是 [mid + 1, right]
                left = mid + 1;
            } else {
                // 下一轮搜索的区间是 [left, mid]
                right = mid;
            }
        }
        // 由于程序走到这里 [left, right] 里一定存在插入元素的位置
        // 且退出循环的时候一定有 left == right 成立,因此返回 left 或者 right 均可
        return left;
    }
}

在这里插入图片描述

分析:这道题要求我们在一个有序的数组中找到和目标元素相等的元素的第一个位置和最后一个位置。

暴力解法在最坏情况下需要遍历数组一遍,时间复杂度为 O(N),我们就不具体介绍这种做法了。

比较容易想到的做法是:当我们取中间数发现它等于目标元素的时候,马上线性地向左边逐个检查,找到和目标元素相等的元素的第 1 个位置,然后再线性地向右边逐个检查,找到和目标元素相等的元素的最后一个位置。这样代码的时间复杂度就又变成线性的了。一个更好的做法是,当看到元素和目标元素相等的时候,继续二分去查找两个边界的下标。

首先,我们写出代码的「框架」,然后再去实现具体的「二分查找」子函数的逻辑。

友情提示:写模块化的代码是程序员的基本素养,写代码的时候需要保证主线逻辑清晰,增强可读性。

public class Solution {

    public int[] searchRange(int[] nums, int target) {
        int len = nums.length;
        if (len == 0) {
            return new int[]{-1, -1};
        }

        int firstPosition = searchFirstPosition(nums, target);
        if (firstPosition == -1) {
            return new int[]{-1, -1};
        }

        // 能走到这里,一定是数组中存在目标元素
        int lastPosition = searchLastPosition(nums, target);
        return new int[]{firstPosition, lastPosition};
    }
}

分析:首先做一次特殊判断,当数组的长度为 0 的时候,按照题意返回 [-1, -1]。

我们先查找目标值在数组中的第 1 次出现的位置,如果目标值在数组中不存在,可以直接返回 [-1, -1],否则继续寻找目标值在数组中的最后一次出现的位置。

我们这样写的思路是:能执行到 searchLastPosition() 方法,则说明目标元素在有序数组中一定存在,请大家思考这是为什么。

接下来编写 searchFirstPosition(nums, target); 这个方法。

首先分别设置 left 和 right 的初始值,这里 left = 0 和 right = len - 1,表示在 [0, len - 1] 左闭右闭这个范围内搜索目标元素。

int left = 0;
int right = nums.length - 1;

然后写上 while (left < right),在循环体内先写上 int mid = (left + right) / 2; 。下面的分析是很关键的。我们把搜索区间 [left, right] 分为两个部分:

如果看到的 mid 位置的元素的值严格小于 target 的话,那么 mid 以及 mid 的左边的所有元素一定不存在目标元素,因此下一轮搜索的区间是 [mid + 1, right],此时我们将左边界设置为 mid + 1,即 left = mid + 1;

然后是另一个分支,此时我们不单独做等于 target 的判断,把剩下的情况归为一类:

如果看到的 mid 位置的元素大于等于 target 的话,

如果 mid 位置的元素的值等于目标元素。 请注意,此时 mid 有可能是第 1 个等于目标元素的位置,也有可能不是。如果它不是第 1 个等于目标元素的位置,真正的第 1 个等于目标元素的位置应该在它的左边,同时,mid 的右边一定不是目标元素第 1 次出现的位置,因此下一轮搜索的区间是 [left, mid],此时我们将右边界设置为 mid,即 right = mid

如果 mid 位置的元素的值不等于目标元素,即严格大于目标元素的值,那么下一轮搜索的区间是 [left, mid - 1],我们不单独做这个判断,因为这个区间包含在子区间 [left, mid] 中,这两个区间内只相差 mid 这一个元素。

事实上,你只要非常确定第 1 个分支的逻辑是正确的话,第 2 个分支就不用分析了,因为第一个分支得到的结论是下一轮在 [mid + 1, right] 里查找,那么第 2 个分支是第 1 个分支的反面,就一定在剩下的区间 [left, mid] 里查找,因此需要把 right 移动到 mid 的位置。

在退出循环的时候,一定有 left 和 right 相等,但是这个位置上的元素是否等于目标元素,还需要单独做一次判断。

private int searchFirstPosition(int[] nums, int target) {
    int left = 0;
    int right = nums.length - 1;
    while (left < right) {
        int mid = (left + right) / 2;
        if (nums[mid] < target) {
            // mid 以及 mid 的左边一定不是目标元素第 1 次出现的位置
            // 下一轮搜索的区间是 [mid + 1, right]
            left = mid + 1;
        } else {
            // 下一轮搜索的区间是 [left, mid]
            right = mid;
        }
    }

    if (nums[left] == target) {
        return left;
    }
    return -1;//可能有找不到的情况,这时候就用-1来退出后续的程序
}

private int searchLastPosition(int[] nums, int target) {
    int left = 0;
    int right = nums.length - 1;
    while (left < right) {
        int mid = (left + right) / 2;
        if (nums[mid] > target) {
            // mid 以及 mid 的右边一定不是目标元素最后一次出现的位置
            // 下一轮搜索的区间是 [left, mid - 1]
            right = mid - 1;
        } else {
            // 下一轮搜索的区间是 [mid, right]
            left = mid;
        }
    }
    return left;
}

注意:此时退出循环的时候不用判断,直接返回 left 或者 right 即可,因为根据逻辑,能走到这个方法的话,目标元素在数组中一定存在。如果不存在的话,在主函数里就直接返回 [-1, -1] 也就走不到这个方法了。

我们当然可以再判断一次,只是这一步操作没有必要,这一点是二分查找算法思路 2 的写法可以稍微偷懒的地方。此时如果我们马上提交代码,会看到我们写的代码「超出时间限制」,而且是这么小规模的测试用例。
在这里插入图片描述
这就说明我们有可能写了一个死循环。事实上,问题出在第 2 个方法,在取中间数的时候,应该加上 1。加上 1 以后,马上就得到了一个「通过」。原因我们再复习一下:当区间只剩下 2 个元素的时候,一旦进入 left = mid; 这个分支,下一轮搜索的区间不能缩小,这样无休止的进行下去,结果是死循环。解决的方法很简单,在取中间数的时候上取整。

当我们将中间数的写法写成 int mid = (left + right + 1) / 2; ,并且搜索区间只剩下 2 个元素的时候,我们再审视一下两个分支逻辑:

  • 如果进入第 1 个分支,right = mid - 1; 把右边界向左移动一位,此时 left == right 成立,退出循环;
  • 如果进入第 2 个分支,left = mid; 把左边界向右移动到 mid,同样也有 left == right 成立,退出循环。

二分查找算法每一轮搜索都必须使得搜索区间的长度收缩,直到为 1。因此,需要特别注意的情况就是,当分支的逻辑是 left = midright = mid - 1 的时候,在取中间数的时候取靠右边的中间数,在计算上向上取整即可。

我们再来看 searchFirstPosition() 方法为什么可行,还是看区间只有 2 个元素的时候。由于 int mid = left + (right - left) / 2; 此时 mid = left ,于是:

  • 如果进入第 1 个分支,left = mid + 1; 把左边界向右移动一位,此时 left == right 成立,退出循环;
  • 如果进入第 2 个分支,right = mid 把右边界向左移动到 mid,同样也有 left == right 成立,退出循环。

因此,我们只需要记住一个结论:当看到分支是 left = mid;right = mid - 1; 的时候,在计算中间数的索引的时候要向上取整

最后我们再强调一下编写分支逻辑的小技巧。可以考虑把 mid 所在元素排除的思路来,也就是先想想,mid 在什么情况下一定不是我们想要查找的元素。原因是,排除的逻辑更简单,不太容易出错。

友情提示:如果要查找的目标元素的逻辑需要同时满足的逻辑较多,「排除法」只需要对其中一个逻辑取反,就能将目标元素的待搜索区间进行缩减。

第 1 个分支写对的前提下,第 2 个分支就不用考虑了,取反面区间就好

class Solution {
    public int[] searchRange(int[] nums, int target) {
        int len = nums.length;
        if (len == 0) return new int[]{-1, -1};
        int firstposition = searchFirstposition(nums, target);
        if (firstposition == -1) return new int[]{-1, -1};
        // 能走到这里,一定是数组中存在目标元素
        int lastPosition = searchLastPosition(nums, target);
        return new int[]{firstposition, lastPosition};
    }

    public int searchFirstposition(int[] nums, int target) {
        int left = 0;
        int right = nums.length - 1;
        int mid;
        while (left < right) {
            mid = left + (right - left) / 2;
            // mid 以及 mid 的左边一定不是目标元素第 1 次出现的位置
            // 下一轮搜索的区间是 [mid + 1, right]
            if (nums[mid] < target) left = mid + 1;
            else right = mid;// 下一轮搜索的区间是[left, mid]
        }
        if (nums[left] == target) return left;
        return -1;
    }

    private int searchLastPosition(int[] nums, int target) {
        //用的上取整,如果有多个重复值可以找到后面的?
        int left = 0;
        int right = nums.length - 1;
        while (left < right) {
            int mid = (left + right+1) / 2;
            if (nums[mid] > target) {
                // mid 以及 mid 的右边一定不是目标元素最后一次出现的位置
                // 下一轮搜索的区间是 [left, mid - 1]
                right = mid - 1;
            } else {
                // 下一轮搜索的区间是 [mid, right]
                left = mid;
            }
        }
        return left;
    }
}
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值