[LeetCode]-二分查找

前言

记录刷 LeetCode 时遇到的二分查找相关题目

704.二分查找

二分查找的前提条件:有序数组+无重复元素,一般满足这两个条件的查找题就用二分。二分的板子:左边界left为-1,有边界right为数组长度,循环条件为left + 1 < right,中间位置mid = (left + right) >> 1

public static int search(int[] nums, int target) {
    
    
    int length = nums.length;
    int left = -1;
    int right = length;
    while (left + 1 < right){
    
    
        int mid = (left + right) >> 1;
        if(nums[mid] == target){
    
    
            return mid;
        }else if (nums[mid] > target){
    
    
            right = mid;
        }else{
    
    
            left = mid;
        }
    }
    return -1;
}

35.搜索插入位置

与704思路一样,只不过最后如果没有查找到的话是要返回目标值按顺序插入数组的位置的下标

public static int searchInsert(int[] nums, int target) {
    
    
    int length = nums.length;
    int left = -1;
    int right = length;
    while (left + 1 < right){
    
    
        int mid = (left + right) >> 1;
        if(nums[mid] == target){
    
    
            return mid;
        }else if (nums[mid] > target){
    
    
            right = mid;
        }else{
    
    
            left = mid;
        }
    }
    //最后没有查找到的话,此时left和right应该是left + 1 == right的状态,按顺序插入,就是插在right的位置
    return left + 1;
}

34.在排序数组中查找元素的第一个和最后一个位置

还是在二分查找的思路上,只不过,如果找到了,要找到该元素出现的起始位置跟结束位置

public int[] searchRange(int[] nums, int target) {
    
    
    int length = nums.length;
    int left = -1;
    int right = nums.length;
    while (left + 1 < right){
    
    
        int mid = (left + right) >> 1;
        if(nums[mid] == target){
    
    
        	//如果找到了,从mid开始向左向右遍历该元素出现的位置
            int l = mid,r = mid;
            while(nums[l] == nums[mid] && l > 0){
    
    
                l--;
            }
            if(nums[l] != nums[mid]){
    
    
                l++;
            }
            while (nums[r] == nums[mid] && r < length - 1){
    
    
                r++;
            }
            if(nums[r] != nums[mid]){
    
    
                r--;
            }
            return new int[]{
    
    l,r};
        }else if (nums[mid] > target){
    
    
            right = mid;
        }else{
    
    
            left = mid;
        }
    }
    return new int[]{
    
    -1,-1};
}

367.有效的完全平方数

跟69题一样,int最大值的开根为46340,而我习惯的二分查找模板的左右边界是取值区间的左边界减一和右边界加一所以left是0,right是46341

public static boolean isPerfectSquare(int num) {
    
    
    int left = 0;
    int right = 46341;
    while (left + 1 < right){
    
    
        int mid = (left + right) >> 1;
        int multi = mid * mid;
        if(multi == num){
    
    
            return true;
        }else if(multi > num){
    
    
            right = mid;
        }else{
    
    
            left = mid;
        }
    }
    return false;
}

153.寻找旋转排序数组中的最小值

使用二分查找。尝试模拟几次旋转的过程,可以发现,如果中间的元素小于当前查找区间最右边的元素,说明最小值一定在中间元素的左边(包含这个中间元素,说不定就是他);如果中间元素大于当前查找区间最右边元素的值,那么最小值一定在中间元素的左边(不包含这个中间元素,毕竟以及知道有元素比他小了)。不含重复元素,所以只有这两个判断条件,然后就可以写出二分的代码了:

public int findMin(int[] nums) {
    
    
    int len = nums.length;
    int left = 0;
    int right = len - 1;
    while(left < right){
    
    
        int mid = (left + right) >> 1;
        //根据条件收缩区间
        if(nums[mid] < nums[right]){
    
    
            right = mid;
        }else{
    
    
            left = mid + 1;
        }
    }
    return nums[left];
}

154.寻找旋转排序数组中的最小值 II

我们顺着153题的思路下来,这时在判断中间元素跟区间最右边元素的大小关系的时候出现了第三种可能:两者相等,这时候就不能马上判断出最小值是在中间元素的左边还是右边了,例如下图所示(来自LeetCode官方题解)
在这里插入图片描述
所以我们只能选择最保险的区间收缩方式:由于nums[mid] == nums[right],那么我们完全可以把nums[right]舍弃掉,因为至少能有nums[mid]能来替代他(我们只需要找出区间最小值,而值等于最小值的元素可能不止一个,我们只需要一个就行,其它的可以随便舍弃),所以区间收缩方式就是让right减一

public int findMin(int[] nums) {
    
    
    int len = nums.length;
    int left = 0;
    int right = len - 1;
    while(left < right){
    
    
        int mid = (left + right) >> 1;
        if(nums[mid] < nums[right]){
    
    
            right = mid;
        }else if(nums[mid] > nums[right]){
    
    
            left = mid + 1;
        }else{
    
     //第三个判断条件
            right--;
        }
    }
    return nums[left];
}

34. 在排序数组中查找元素的第一个和最后一个位置

本来想直接看题解,发现题解为了复用代码将查找元素第一个位置以及查找元素最后一个位置的两个操作都放在了一个方法里,我觉得拆出来更清晰,所以还是自己写答案了:

class Solution {
    
    
    public int[] searchRange(int[] nums, int target) {
    
    
        return new int[]{
    
    findFirstTarget(nums,target),findLastTarget(nums,target)};
    }
    int findFirstTarget(int[] nums,int target){
    
    
        int l = 0,r = nums.length - 1,mid;
        //当l跟r重叠的时候,重叠的位置可能就是满足条件的位置,所以用 <=
        while(l <= r){
    
    
            mid = (l + r) >> 1;
		    //查找元素出现的第一个位置,就是查找这么一个位置,满足这个位置上的数等于 target,
		    //而前一个数小于 target。还要注意这个位置可能就在下标为 0 处
            if( (nums[mid] == target && mid == 0) || (nums[mid] == target && nums[mid - 1] < target)){
    
    
                return mid;
            }else if(nums[mid] >= target){
    
    
            	//初始l跟r分别是0跟nums.length-1,说明l跟r的位置也可能是符合条件的位置,也即后续可能要查找的位置,那么这里mid已经查找过了,后面也不用再查找了,所以更新l跟r时不更新为mid
                r = mid - 1;
            }else{
    
    
                l = mid + 1;
            }
        }
        return -1;
    }

    int findLastTarget(int[] nums,int target){
    
    
        int l = 0,r = nums.length - 1,mid;
        while(l <= r){
    
    
            mid = (l + r) >> 1;
            //查找元素出现的最后一个位置,就是查找这么一个位置,满足这个位置上的数等于 target,
		    //而后一个数大于 target。还要注意这个位置可能就在下标为 nums.length - 1 处
            if( (nums[mid] == target && mid == nums.length - 1) || (nums[mid] == target && nums[mid + 1] > target) ){
    
    
                return mid;
            }else if(nums[mid] <= target){
    
    
                l = mid + 1;
            }else{
    
    
                r = mid - 1;
            }
        }
        return -1;
    }
}

528. 按权重随机选择 (前缀和+二分查找)

设 w 数组和为 total。由于每个 w[i] 的权重为 w[i] / total,所以可以将 [1,total] 区间拆成几个部分,每个部分的长度就是 w[i],然后在 [1,total] 区间上随机选一个数,看这个数属于哪个部分,再返回这个部分对应的 w[i] 的下标即可

例如对于 w = [3,1,2,4],total = 10,可以让 [1,3] 对应 3,[4,4] 对应 1,[5,6] 对应 2,[7,10] 对应 4,如果生成的随机数是 8,说明对应的是 4,要返回的下标也就是 3

那么如何将随机数映射到所属的部分上,答案就是使用前缀和
对 w 数组生成前缀和可以得到 prefixSum = [3,4,6,10],可以发现,如果找到 i 满足 i > 0 且 prefixSum[i] >= x && prefixSum[i - 1] < x,或者i == 0 且 prefixSum[i] >= x,那么这个 i 就是要返回的下标

然后前缀和数组是单增的,所以可以使用二分查找进行查找

class Solution {
    
    
    int[] prefixSum;
    int total;
    public Solution(int[] w) {
    
    
        prefixSum = new int[w.length];
        prefixSum[0] = total = w[0];
        for (int i = 1; i < w.length; ++i) {
    
     //计算w数组总和以及前缀和
            prefixSum[i] = prefixSum[i - 1] + w[i];
            total += w[i];
        }
    }
    public int pickIndex() {
    
    
        int x = (int) (Math.random() * total) + 1; //获取一个[1,total]中的数
        return binarySearch(x);
    }
    private int binarySearch(int x) {
    
     //二分查找x所属的部分对应的是哪个数
        int low = 0, high = prefixSum.length - 1;
        while (low < high) {
    
    
        	/*
        	为了便于理解可以加入这段代码
            int mid = (high + low) >> 1;
            if( (prefixSum[mid] >= x && mid == 0) || (prefixSum[mid] >= x && mid > 0 && prefixSum[mid - 1] < x) ){
                return mid;
            }
        	*/
            int mid = (high + low) >> 1;
            if (prefixSum[mid] < x) {
    
    
                low = mid + 1;
            } else {
    
    
                high = mid;
            }
        }
        return high; //return low 也是一样的,最终low跟high一定会相等
    }
}

33. 搜索旋转排序数组

public int search(int[] nums, int target) {
    
    
    int len = nums.length;
    if (len == 1) {
    
    
        return nums[0] == target ? 0 : -1;
    }
    int l = 0, r = len - 1;
    while (l <= r) {
    
    
        int mid = (l + r) >> 1;
        if (nums[mid] == target) return mid;
        if (nums[mid] > nums[0]) {
    
     //此时左边肯定是升序区间
            if (nums[0] <= target && target < nums[mid]) {
    
    
                r = mid - 1;
            } else {
    
    
                l = mid + 1;
            }
        } else {
    
      //此时右边肯定是升序区间
            if (nums[mid] < target && target <= nums[n - 1]) {
    
    
                l = mid + 1;
            } else {
    
    
                r = mid - 1;
            }
        }
    }
    return -1;
}

81. 搜索旋转排序数组 II

与 33. 搜索旋转排序数组 做对比,33 题的数组是严格升序的,而本题的数组是非降序的,所以会出现重复的元素;另一个差别是,33 题要返回具体元素的下标,而本题只需要返回表示是否存在 target 的布尔值即可,这意味着对于数组中的重复元素,我们是可以进行删除的

那么由于有重复元素的存在,在二分时我们可能会遇到 nums[mid] == nums[l] ,此时我们无法判断 mid 左右两个区间的单调性,也就无法判断要往哪个区间去二分,此时就可以 “删除” nums[l],即 l++

只要 nums[mid] 不等于 nums[l],如果 nums[l] < nums[mid],就能判断出 mid 左边为非递减区间;如果 nums[l] > nums[mid],就能判断出 mid 右边为非递减区间,能判断出区间的单调性也就能进一步去二分了

public boolean search(int[] nums, int target) {
    
    
    if(nums == null || nums.length == 0) {
    
    
        return false;
    }
    int l = 0,r = nums.length - 1,mid = 0;
    while(l <= r) {
    
    
        mid = (l + r) >> 1;
        if(nums[mid] == target) {
    
    
            return true;
        }
        if(nums[l] == nums[mid]) {
    
     //此时影响区间单调性的判断,删除掉nums[l]
            l++;
            continue;
        }
        if(nums[l] < nums[mid]) {
    
     //此时能判断出区间单调性:mid左边为非递减区间
            if (nums[mid] > target && nums[l] <= target) {
    
     //那么就可以判断target是否在mid左边
                r = mid - 1;
            }else {
    
    
                l = mid + 1;
            }
        }else{
    
     //此时mid右边为非递减区间
            if(nums[mid] < target && nums[r] >= target) {
    
     //判断target是否在mid右边
                l = mid + 1;
            }else {
    
     
                r = mid - 1;
            }
        }
    }
    return false;
}

162. 寻找峰值

官方题解 的评论区看到的代码。借用题解中的话说就是,“人往高处走,水往低处流”,我们从某一位置开始,只要每一步都往更高的地方走,那么最终一定能到达峰值处

假设当前位置为 i,且有 nums[i] < nums[i + 1],那么下一步就会走到 i + 1 的位置;而到了 i + 1 后,由于 nums[i] < nums[i + 1],所以后续不会再回到 i 的位置,更不可能去到 i 左边的位置,可以直接排除掉 i 左边的所有位置了。如果 i 恰好是原数组的中点位置,不就相当于直接排除掉数组中一半的位置了吗,跟二分很像了。所以基于这种思路,就有了下面这种二分的做法:

public int findPeakElement(int[] nums) {
    
    
    int l = 0, r = nums.length - 1;
    while (l < r) {
    
    
        int mid = (l + r) >> 1;
        if (nums[mid] < nums[mid + 1]) {
    
    
            l = mid + 1;             //1
        } else {
    
    
            r = mid;                 //2
        }
    }
    return l;                        //3
}

二分理解起来并不难,但是有几个细节会让人迷糊,比如每次更新边界时,左右边界分别要怎么更新,最后的返回值是左指针 l 还是右指针 r

对于左右边界怎么更新:l 跟 r 初始指向的是 0 跟 nums.length - 1,所以指向的是有可能成为答案的值。在判断出 nums[mid] < nums[mid + 1] 后,我们要往 mid 右边去找,那么 l 是更新为 mid 还是 mid + 1 呢。由于我们要选的是更大值,所以 nums[mid] 后续肯定是不会被选的,而 l 指向的又是有可能成为答案的值,所以 l 要指向 mid + 1;
而如果是 else 分支,我们就要更新 r,那么同理,由于 nums[mid] >= nums[mid + 1],所以 mid + 1 后续肯定是不会被选的,所以 r 更新为 mid

对于最后的返回值,由于 while 循环的条件是 l < r,那我们不妨大但假设最后 l 跟 r 更新到了 l + 1 == r 的情况,此时依旧会进入 while 循环,如果 nums[mid] < nums[mid + 1],l 就会更新到 r 的位置上,此时两者指向的都是最终答案;否则的话,就是 r 更新到 l 的位置上,此时两者指向的还是最终要返回的答案,综上,最后其实返回 l 或者 r 都可以,因为跳出循环时两者肯定是指向同一个值,这个值也就是最终答案

猜你喜欢

转载自blog.csdn.net/Pacifica_/article/details/125247365