彻底搞懂二分(包括寻找二分的左边界与右边界)Java&Go实现代码

291 阅读2分钟

文章主要分为基础的二分讲解以及寻找二分的左边界与右边界两个部分来讲解,Go代码放最后了

一. 二分讲解

力扣题目链接

image.png

基础二分的思路很简单,首先要注意二分的条件要是排好序的数组,然后主要注意一下边界条件的处理,很多小伙伴可能听到边界条件都会被吓一跳,其实也并不困难,下面一起来看看为什么要注意边界:

一般基础二分都有两种写法:

写法一:

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

写法二:

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

有什么不同呢,其实主要就是

1.right初始化为一个是nums.length-1, 一个是nums.length

2.第二个是while一个是小于等于,还有一个是小于

3.right的变化

这就是要注意的边界问题,一个是左闭右开区间,一个是左闭右闭区间, 一个右边界不可以取,一个则可以取,二分能带给我们的收获就是搜索区间

首先看第一种写法,因为我们让right初始化为nums.length,当索引从0开始,数组对应的下标为nums.length是不可取的,所以也就是说我们选择了左闭右开的区间。因为我们选择了左闭右开的区间,所以导致了while循环终止的条件是left == right (循环条件是left < right,相等循环终止), 此时搜索区间是不是就变成了[left, left)了,说明数组已经被搜索完了,没毛病吧老铁。关于right的不同,因为是左闭右开区间,所以right的更新是right = mid, 这个应该很好理解,判断完mid和target不同,那我们就去它的左边继续搜索,开区间说明这个点已经判断完了,而左闭右闭的话,就得-1,不然就多判断了一个元素。

再看第二种写法,同理我们选择了搜索区间为左闭右闭,所以第一个不同点产生的分歧就在这里。左闭右闭搜索区间的终止条件是left == right + 1 。 此时搜索区间是不是就变成了[left,left-1],搜索区间也没有元素可以搜索了。有的小伙伴也选择说我这里能不能和上面一样选择left < right呢,这样终止条件就也变成了left == right了,我是觉得可以但没必要,此时搜索区间变成了[left,left],也就是此时搜索区间还剩下个元素没有判断符不符合条件,怎么办,打补丁呗,什么叫打补丁?哈哈哈哈,就是写个if判断一下。怎么做呢,最后面找不到不是return -1吗,我们再前面加个if判断一下,或者直接上三元运算符:return nums[left] == target ? left : -1; 但是Go就没有三元运算符的语法糖了。所以你明确搜索区间这个重要的东西了吗。这个东西是好东西!下面到第二部分二分左边界与右边界的分享:

二. 二分左右边界

力扣链接

思路:为什么说上面是基础二分呢,你有没有发现那个数组的元素是不重复的,也就是target在目标元素中有且只出现一次。当出现了多个,要取第一个和最后一个你会怎么取,这也就是所谓的左边界与右边界,下面一起来看看。还是一样的,保证搜索区间就没问题,第一次接触的小伙伴第二天一定要再去重新敲代码实现。 先给出代码实现。

class Solution {
    public int[] searchRange(int[] nums, int target) {
        int left = left_bound(nums, target);
        int right = right_bound(nums, target);
        return new int[]{left, right};   

    }

    int left_bound(int[] nums, int target) {
        int left = 0;
        int right = nums.length; // 注意
        
        while (left < right) { // 注意
            int mid = left + (right - left) / 2;
            if (nums[mid] == target) {
                right = mid;
            } else if (nums[mid] < target) {
                left = mid + 1;
            } else if (nums[mid] > target) {
                right = mid; // 注意
            }
        }
        // 此时 target 比所有数都大,返回 -1
        if (left == nums.length) return -1;
        // 判断一下 nums[left] 是不是 target
        return nums[left] == target ? left : -1;
    }

    int right_bound(int[] nums, int target) {
        int left = 0, right = nums.length;
        
        while (left < right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] == target) {
                left = mid + 1; // 注意
            } else if (nums[mid] < target) {
                left = mid + 1
            } else if (nums[mid] > target) {
                right = mid;
            }
        }
        // 最后改成返回 left - 1
        if (left - 1 < 0) return -1;
        return nums[left - 1] == target ? (left - 1) : -1;
    }
  
}

代码分几块看,一个是寻找左边界的left_bound函数,一个是寻找右边界的right_bound函数,看完了基础二分再看这个是不是感觉差不多,关注不同的点。首先这是一个左闭右开的搜索区间,和基础二分关键的不同就在于当我们找到了target要怎么去进行处理呢?

对于寻找左边界来说

right当找到目标元素对应的下标的时候,让right为mid向左边继续收缩寻找,也就是在[left, mid)区间收缩寻找,最终锁定左边界。

对于寻找右边界来说

为什么寻找左边界是right = mid收缩,而left收缩右边界是left = mid + 1呢,首先,不要去死记,一定要明确搜索区间的概念,无论是基础的二分还是这个左右边界的二分,对于搜索区间左闭右开还是左闭右闭,左边区间永远是闭的,所以每次都是mid + 1, 这也就导致了最后为什么会是判断left-1,而右边界是left。也就是我们需要去注意的最后一个地方。如下描述:

我们对于寻找左边界是去判断left符不符合条件,而对于寻找右边界是去判断left-1符不符合条件呢。 为什么呢,这里大家可以去画个图思考一下,对于1224来说把,第一次mid=2,然后right收缩为2,在区间[0,2)去进行区间收缩寻找,第二次mid=1,right继续收缩为1,此时达到循环终止条件了。此时区间为[1,1),返回left即可。

对于右边界是去判断left-1,为什么呢,还是以1224来说,第一次mid=2,left收缩为3,此时搜索区间为[3,4),第二次mid=3,right变为3,此时达到循环终止条件,此时区间为[3,3),得返回left-1才是我们想要找的右界元素。 所以跳出例子来说,为什么是要去判断left-1,就是因为左边区间是闭的,它更新的时候得时候会是mid+1,当循环终止的时候,left也就是mid+1对应的数组元素一定会大于目标元素的。至于说我想用right去表现右边界可不可以呢,当然可以,while循环的终止条件是left==right,返回right-1也可以的。

还有一个小细节,也是最后一个小细节了,就是要去注意边界,就是当假如数组为[1],target为0,在寻找右边界的时候此时终止条件为left=right=0, 此时返回的lefr-1 < 0, 所以要在return之前加个判断,防止越界了,同理,左边界当left=right的时候终止,假如left一直往右边移动,当移动到数组的长度的时候,也会出现越界。

最后:这个搜索边界是左闭右开的写法,对于左闭右闭又怎么写呢,你能否回答出以下几个问题?欢迎在评论区分享你的答案

  1. 第一个就是while的循环是<= 还是 < (要是还不能回答下来可以说白看了)
  2. 对于左侧边界来说,当寻找到目标元素的时候要怎么进行收缩,right怎么变动
  3. 同2,对于右侧边界来说呢

补充看到一种写法是在基础二分的基础上进行向左或者向右线性搜索,这样当然也可以,就是时间复杂度就不是对数级别的了。

Go代码:

func searchLeft(nums []int, target int) int {
	left, right := 0, len(nums)-1
	for left <= right {
		var mid = left + (right-left)/2
		if nums[mid] == target {
			right = mid - 1
		} else if nums[mid] > target {
			right = mid - 1
		} else {
			left = mid + 1
		}
	}
    if (left == len(nums)) {
        return -1
    }
	if nums[left] == target {
		return left
	}else{
		return -1
	}
}

func searchRight(nums []int, target int) int {
	left, right := 0, len(nums)-1
	for left <= right {
		var mid = left + (right-left)/2
		if nums[mid] == target {
			left = mid + 1
		} else if nums[mid] > target {
			right = mid - 1
		} else {
			left = mid + 1
		}
	}
    if left - 1 < 0 {
        return -1
    } 
	if nums[left - 1] == target {
		return left - 1
	}else{
		return -1
	}
}

func searchRange(nums []int, target int) []int {
    if len(nums) == 0 {
        return []int{-1,-1}
    }
	left := searchLeft(nums, target)
	right := searchRight(nums, target)
	return []int{left, right}
}