leetcode系列特殊篇(跟着东哥总结二分法)

二分查找:思路很简单,细节是魔鬼。

在不了解二分细节的情况下,写二分肯定就是玄学编程,能通过全靠菩萨保佑🙏。。
本文将代入东哥的思路,从三个二分最常用的场景入手,带你深入了解二分算法的细节。

1.寻找一个数
2.寻找左侧边界
3.寻找右侧边界

请着重思考:不等号什么情况下应该带等于
		  mid是否应该加一或者减一
	      ..............
// 先贴一个二分查找的框架吧 (伪代码)
// nums : 有序数组 (允许有重复)
// target : 目标值
function binarySearch(nums, target) {
	let left = 0, // 左边界一般从下标为0开始
		right = ...; // 右边界看情况(左闭右闭或者左闭右开)
	while (...) {
		let mid = Math,floor((right - left) / 2) + left;
		let midNum = nums[mid]
	}
}

东哥语录:分析二分查找的一个技巧是,不要用else,全部用else if,把所有情况都列出来,这样可以清楚地展现出来所有细节。

// mid的三种写法,大家可以按照自己的理解选择一种

let mid = Math.floor((right - left) / 2) + left;
let mid = Math.floor( left + (right - left) / 2);
let mid = Math.floor((right - left) / 2);

// 计算 mid 时需要防止溢出,代码中 left + (right - left) / 2 就和 (left + right) / 2 的结果相同,但是有效防止了 left 和 right 太大直接相加导致溢出。
// 寻找一个数 ---------最最常见的场景
function binarySearch(nums, target) {
	let left = 0,
		right = nums.length - 1; // 思考为什么要减一
	while (left <= right) { // 思考为什么要小于等于
		let mid = Math.floor((right - left) / 2) + left;
		let midNum = nums[mid];
	
		if (midNum === target) {
			return mid
		} else if (midNum < target) {
			left = mid + 1;// 思考为什么要加一
		} else if (midNum > target) {
			right = mid - 1; // 思考为什么要减一
		}
	}
	return -1
}
问题一:为什么while循环的条件是 <=, 我明明看到有的时候是< 啊?
答:我们在初始化right的赋值是nums.length - 1,即最后一个元素的索引,而不是nums.length;这两个条件可能出现在不同功能的二分查找中,区别是:前者相当于两端都闭区间[left, right],后者相当于左闭右开的区间[left, right),因为索引大小为nums.length时是越界的。
	我们这个算法使用的就是[left, right],是两端都闭合的区间,这个区间其实就是每次进行搜索的区间。
	那什么时候要停止搜索呢?找到了目标值的时候就可以终止。
if (midNum === target) {
	return mid
}
但是如果没有找到的话,就需要while循环终止了,然后返回-1,当搜索区间为空的时候,while循环终止,
left<=right 的终止条件是left = right +1,写成区间表示就是[right + 1, right],这个时候搜索区间为空,跳出循环;
left < right 的终止条件是 left = right,写成区间的形式就是[right, right],这个时候搜索区间为空,跳出循环。

问题二:为什么 left = mid + 1,right = mid - 1?我看有的代码是 right = mid 或者 left = mid,没有这些加加减减,到底怎么回事,怎么判断?
答:联想刚刚的搜索区间概念就很容易得出,本算法的搜索区间是[left, right],那么当我们发现索引mid不是我们要找的target时,那我们就应该去找[left, mid - 1]或者[mid + 1, right]对不对,因为mid已经搜索过,应该从搜索区间去除。
问题三:此算法有什么缺陷?
答:至此,你应该已经掌握了该算法的所有细节,以及这样处理的原因。但是,这个算法存在局限性。
比如说给你有序数组 nums = [1,2,2,2,3],target 为 2,此算法返回的索引是 2,没错。但是如果我想得到 target 的左侧边界,即索引 1,或者我想得到 target 的右侧边界,即索引 3,这样的话此算法是无法处理的。
这样的需求很常见,你也许会说,找到一个 target,然后向左或向右线性搜索不行吗?可以,但是不好,因为这样难以保证二分查找对数级的复杂度了。
// 寻找左侧边界的二分搜索
function left_bound(nums, target) {
	if (nums.length === 0) retuen -1
	let left = 0,
		right = nums.length; // 思考为什么这里不减一
	
	while (left < right) { // 思考这里为什么不是小于等于
		let mid = Math.floor((right - left) / 2) + left;
		let midNum = nums[mid];
	
		if(midNum === target) {
			right = mid // 思考这里为什么不要减一
		} else if (midNum > target) {
			right = mid // 思考这里为什么不要减一
		} else if (midNum < target) {
			left = mid + 1
		}
	}
	return left 
}
问题一:为什么while要小于而不是小于等于?
答:因为right 的取值是nums.length,而不是nums.length - 1,因此我们的搜索区间是左闭右开,用区间表示就是[left,right), while(left < right )的终止条件是left === right,用区间表示就是[right, right)为空,所以可以正确终止。
问题二:为什么没有return -1的操作?若果nums不存在target值的时候,怎么办?
答:对于数组[1,2,2,2,3],target=2算法会返回1,可以表示符合条件的2的下标是1,也可以表示nums中小于2的元素有1个。
		对于数组[2,3,5,7],target=1,算法会返回0,表示,nums中比1小的元素有0个。
		对于数组[2,3,5,7],target=8,算法会返回4,表示,nums中比1小的元素有4个。
	综合可以看出,函数的返回值的取值区间是[0,nums.length),所以我们简单添加两行代码就能正确的时候 return -1;
while (left < right) {
	...
}
if (left === nums.length) retuen -1
return nums[left] === target ? left : -1
问题三:为什么 left = mid + 1,right = mid ?和之前的算法不一样?
答:还是因为搜索区间,我们的搜索区间是[left,right)左闭右开,所以当nums[mid]被检测之后,下一步的搜索区间应该去掉mid分割成两个部分,即[left, mid)或者[mid + 1, right).
问题四:为什么该算法能够搜索左侧边界?
答:关键在于 if(midNum === target) { right = mid } 当我们找到target的时候不要立即返回,而是缩小搜索区间的上界,在[left.mid)中继续搜索,即不断的向左收缩,达到锁定左侧边界的目的。
问题五:为什么返回的是left而不是right?
答:我们whie的终止区间是left===rigth,所以返回left和right其实是一样的。
// 寻找右侧边界的二分查找
function right_bound(nums. target) {
	if (nums.length === 0) return -1
	let left = 0,
		right = nums.length;
	while (left < right) {
		let mid = Math.floor((right - left) / 2) + left;
		let midNum = nums[mid];
		
		if (midNum === target) {
			left = mid + 1 // 思考为什么要mid + 1
		} else if (midNum > target) {
			right = mid
		} else if (midNum < target) {
			left = mid + 1
		}
	}
	return left - 1 // 思考为什么要减一
}
问题一:为什么这个算法能够找到右侧边界?
答:if (nums[mid] == target) {  left = mid + 1 }
	当nums[mid] === target的时候,不要直接返回mid,而是增大搜索区间[mid + 1, right)使得区间不断向右收缩,达到锁定右侧边界的目的。
问题二:为什么最后返回 left - 1 而不像左侧边界的函数,返回 left?而且我觉得这里既然是搜索右侧边界,应该返回 right 才对。
答:首先,while循环的跳出条件是left === right,所以返回left和right其实是一样的。至于为什么要减一,这是搜索右侧边界的一个特殊点,关键在于这个条件的判断:
if (nums[mid] == target) { left = mid + 1; } 因为我们对left的更新必须是 left = mid + 1,就是说当while循环结束的时候,nums[left]一定不等于target,而nums[left - 1]肯定是target。
问题三:为什么没有返回 -1 的操作?如果 nums 中不存在 target 这个值,怎么办?
答:类似之前的左侧边界搜索,因为 while 的终止条件是 left == right,就是说 left 的取值范围是 [0, nums.length),所以可以添加两行代码,正确地返回 -1:
while (left < right) {
    // ...
}
if (left == 0) return -1;
return nums[left-1] == target ? (left - 1) : -1;

逻辑统一:

// 最基本的二分查找
首先我们初始化right的时候是nums.length - 1;
所以我们的搜索区间是 [left, right]
所以我们的while条件是 left <= right
同时也决定了 left = mid + 1 或者 right = mid - 1

因为我们只要找到一个target的索引就可以了,
所以当nums[mid] === target的时候就可以返回了
// 寻找左侧边界的二分查找
我们初始化right的时候是nums.length
所以我们的搜索区间是 [left,right) 左闭右开
所以我们while 的条件是 left < right 
同时也决定了 left = mid + 1 或者 right = mid 

因为我们要找符合条件的最左侧索引,所以当target === nums[mid]的时候不要立即返回结果,而是收紧右侧边界以锁定左侧边界。
// 寻找右侧边界的二分查找
我们初始化right的时候是nums.length
所以我们的搜索区间是 [left, right)
所以我们 while 的条件是 left < right
同时也决定了 left = mid + 1 或者 right = mid

因为我们要寻找右侧边界,所以当nums[mid] === target的时候不要立即返回,而要收缩左侧的边界以锁定右侧的边界。

又因为收紧左侧边界的时候必须left = mid + 1,所以最后的返回应该是 left -1
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值