对二分查找的简单理解
二分查找的算法很好理解,但是二分查找的各种模板却比较繁杂,什么时候该用left <= right, 什么时候该用left < right, 什么时候该用left = mid + 1, 什么时候该用left = mid等等问题却常常困扰着我们,尤其是当长时间没使用二分查找的时候更是如此。因此也经常有二分的细节是魔鬼这种说法。
本文的主要目的就是对于这个问题进行一个简单的梳理,从而帮助你摆脱二分查找的条件选择困难。
二分查找条件的关键
其实,二分查找的条件并不是没有依据的,条件的选择其实取决于你对循环条件(即left和right)关系的定义,我们以二分查找常见的两个模板为例:
- 模板 1
while (left <= right) { // 定义查找区间为[left,right]
int mid = (right - left) / 2 + left; // mid = (left + right) / 2, 此处为防止left + right溢出而使用的技巧,其实都是left / 2 + right / 2
if (nums[mid] > target) {
right = mid - 1; // 根据[left,right]区间的定义, mid不可能是答案,所以有效区间变为[left, mid - 1], 因此此处right = mid - 1;
} else if (nums[mid] < target) {
left = mid + 1; // 同理, 根据[left,right]区间的定义, mid不可能是答案,所以有效区间变为[mid + 1, right], 因此此处left = mid + 1;
} else { // 此时nums[mid] == target
return mid;
}
}
注意:在此模板中,循环条件的定义为left <= right, 也就是说left == right时循环条件仍成立,代表
你选择的查找区间是[left, right]这样一个左闭右闭的区间,因此,在整个循环中,牢记查找的区间是[left, right]这个区间,所有的条件都是根据这个区间来选择的。
结合上面的注释,相信不难理解left和right是如何确定的。
- 模板2
while (left < right) { // 定义查找区间为[left, right)
int mid = (right - left) / 2 + left; // mid = (left + right) / 2, 此处为防止left + right溢出而使用的技巧,其实都是left / 2 + right / 2
if (nums[mid] > target) {
right = mid; // 根据[left,right)区间的定义, mid不可能是答案,所以有效区间变为[left, mid), 由于右边是开区间, 可以取到mid, 因此此处right = mid;
} else if (nums[mid] < target) {
left = mid + 1; // 同理, 根据[left,right)区间的定义, mid不可能是答案,所以有效区间变为[mid + 1, right), 由于左边是闭区间, 不可以取到mid, 因此此处left = mid + 1;
} else { // 此时nums[mid] == target
return mid;
}
}
注意:在此模板中,循环条件的定义为left < right, 也就是说left == right时循环条件不成立,代表
你选择的查找区间是[left, right)这样一个左闭右开的区间,因此,在整个循环中,牢记查找的区间是[left, right)这个区间,所有的条件都是根据这个区间来选择的。
如上述注释,右端点为开区间,nums[mid] > target 可以确定右端点为mid。左端点为闭区间,nums[mid] < target 可以确定左端点为mid + 1。
二分查找的变化
根据上面的描述, 你可能会猜想: 能否将区间定义为左开右闭,左开右开呢?答案是可以,只要你能确保在整个循环中牢记以你定义的区间去思考端点的变化, 并保证搜索了整个区间,就能得到正确的答案。
二分查找的模板不是一成不变的,根据具体查找条件的不同会产生不同的变化,因此,在使用二分查找的时候你需要做的不是记住模板, 而是记住你定义的有效查找区间, 只要保证你的所有条件产生的端点变化(即left, right)是根据这个有效查找区间变化的,你就能写出正确的二分。
为了更好的理解上面这段话,以leetcode 154 寻找旋转排序数组中的最小值 II这道题的核心代码为例:
while (left <= right) { // 定义查找区间为[left, right]
if (left == right) { // 特殊情况处理
return nums[left];
}
int mid = (right - left) / 2 + left;
if (nums[mid] < nums[right]) {
right = mid;
} else if (nums[mid] > nums[right]) {
left = mid + 1;
} else {
right--;
}
}
简单描述一下这道题的题目:
升序排序的数组在预先未知的某个点上进行了旋转。 例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] 这道题中数字是可能重复的,它的简化版本(没有重复数字是第153题)。
这里不去探究具体的解法,只关心代码中与二分相关的部分。
- 此处的写法基于模板1,定义查找的区间为[left, right]。
- 我们先看第二个条件:
nums[mid] < nums[right], 从这个条件,我们可以判断的是最小数肯定小于等于nums[mid], 因此此时的有效区间是[left, mid], 所以 right = mid, 注意此处与模板一的区别,此处这个条件并不能确定nums[mid]不是最小数下标,因此mid仍为有效区间。这也就是上文说的,条件不是不变的,端点的变化取决于区间,而不是模板。 - 我们再看第三个条件:
nums[mid] > nums[right], 从这个条件,我们可以判断的是最小数肯定小于nums[mid] (至少存在nums[right]小于nums[mid]), 因此此时的有效区间是[left + 1, mid], 所以 left = mid + 1, 还是那句话,端点的变化取决于区间。 - 我们再看第一个条件:为什么还会有新的条件?这是由于我们对于查询条件的定义导致的,思考一下,由于第二个条件中right = mid并不会一直缩小搜索区间,因此,极端情况下,left == right时,可能需要特殊判断才能保证能够搜索完所有区间,即跳出循环。这对应了上面说的另外一点,确保搜索完所有的区间。当然,这道题中,没有这个判断也能够得到答案。
- 最后简要说一下最后一个else判断,这种情况对应
nums[left] == nums[mid] == nums[right], 此时,nums[right]肯定不是唯一最小值,因此将right右移以改变搜索区间。这个判断与题目相关,不是二分的关键,不做具体叙述,可以参考这道题目的题解。
二分查找小结
通过上述二分查找的分析,相信你已经知道了二分查找的两个关键点:用搜索区间判断端点以及确保搜索完所有的区间。
对于第二个关键点,常出现的情况是搜索条件无法判断0, 1, 2个点,根据具体的情况,选择是否需要对特殊情况进行特殊判断。
牢记这两个关键点,再加上适当的二分题目的练习与理解,就可以自信地面对任何二分的题目了。