二分查找(搜索)中的一个难点是,在查找(搜索)的过程中,如何控制边界。
因为二分查找的条件是序列有序,所以在每轮查找中,只需在其上一轮查找范围的一半中进行;二分查找过程中,每轮查找范围的更新,即是对查找边界的更新。
二分查找代码的一个难点在于如何准确地对查找边界进行更新,准确控制边界的更新需要理解循环不变量这一概念,并且在更新过程中一直保持循环不变量的定义,不会因边界的更新而发生改变。
什么是循环不变量?
循环不变量是指,在每轮查找中,查找的元素所落在的查找区间。
下面以 leetcode 704. 二分查找 为例,进行总结;这是一道经典的二分查找问题。
题目描述:
给定一个 n
个元素有序的(升序)整型数组 nums
和一个目标值 target
,写一个函数搜索 nums
中的 target
,如果目标值存在返回下标,否则返回 -1
。
示例 1:
输入: nums = [-1,0,3,5,9,12], target = 9
输出: 4
解释: 9 出现在 nums 中并且下标为 4
示例 2:
输入: nums = [-1,0,3,5,9,12], target = 2
输出: -1
解释: 2 不存在 nums 中因此返回 -1
提示:
- 你可以假设
nums
中的所有元素是不重复的。 n
将在[1, 10000]
之间。nums
的每个元素都将在[-9999, 9999]
之间。
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/binary-search
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
循环不变量的定义通常有两种方式,[left, right]
,其中 right = n - 1
,和 [left, right)
,其中 right = n
,先讨论 [left, right]
的定义方式,再讨论 [left, right)
的定义方式。
将循环不变量定义为 [left, right]
因为是在升序数组 nums
中查找指定元素 target
,所以最开始 target 的查找区间为数组中所有元素,令 left = 0
,right = n - 1
,初始的查找区间为 [left, right]
,左闭右闭表示左边界的元素和右边界的元素都访问到;因此把循环不变量定为为 [left, right]
。
令 mid = (right - left) / 2
;若 nums[mid] == target
,找到目标元素,直接返回结果;若 nums[mid] != target
,那就需要根据 nums[mid]
与 target
的大小关系确定下一轮的查找区间。
-
若
nums[mid] > target
,则target
的下一轮查找区间应该落在mid
的左边,因此更新查找区间[left, right]
,即我们强调的循环不变量。因为
target
的下一轮查找区间落在mid
的左边,而left
一直保持在mid
的左边,所以left
不用更新,只需更新right
即可;又因为我们定义的循环不变量的形式为[left, right]
,而当前nums[mid]
已经不满足条件了,即nums[mid]
元素不应该出现在下一轮的查找区间内,所以将更新right = mid - 1
; -
若
nums[mid] < target
,则target
的下一轮查找区间应该落在mid
的右边。因为
target
的下一轮查找区间落在mid
的右边,而right
一直保持在mid
的右边,所以right
不用更新,只需更新left
即可;又因为我们定义的循环不变量为[left, right]
,而当前nums[mid]
已经不满足条件了,即nums[mid]
元素不应该出现在下一轮的查找区间内,所以将更新left = mid + 1
;
实现代码:
class Solution {
public:
int search(vector<int>& nums, int target) {
// 确定循环不变量为 [left, right]
int left = 0, right = nums.size() - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
return mid;
}
else if (nums[mid] > target) {
right = mid - 1;
}
else {
left = mid + 1;
}
}
return -1;
}
};
将循环不变量定义为 [left, right)
[left, right)
与 [left, right]
的不同为,[left, right)
表示的区间为左闭右开,即 left 位置的元素可以访问到,right
位置的元素不可以访问。
当循环不变量定义为 [left, right)
时,因为 right
位置的元素取不到,所以应该令 right
的初始值为 right = n
,这样才能保证初始时,target
的查找区间才是准确的。
- 当
nums[mid] > target
,target
的下一轮查找区间落在mid
的左边,而当前nums[mid]
已经不满足条件了,即nums[mid]
元素不应该出现在下一轮的查找区间内,即下一轮查找区间为[left, mid-1]
(注意我们这里使用了左闭右闭来表示区间范围),又因为循环不变量为[left, right)
,所以应该更新right = mid
,这样才是与循环不变量为[left, right]
时,更新right = mid - 1
保持一致。 - 当
nums[mid] < target
,更新left = mid + 1
;
实现代码:
class Solution {
public:
int search(vector<int>& nums, int target) {
// 确定循环不变量为 [left, right)
int left = 0, right = nums.size();
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
return mid;
}
else if (nums[mid] > target) {
right = mid;
}
else {
left = mid + 1;
}
}
return -1;
}
};
小结:
- 循环不变量的核心思想是:对于查找的目标元素,其查找区间不应该缺少任何一个可能的元素,也不应该多任意一个已不满足查找条件的元素,具体体现在对循环不变量边界的更新上。
- 二分查找问题的编码过程中,要始终维护循环不变量的定义,不会因更新循环不变量而发生改变;只要是涉及比较更新的步骤,例如(
>=
),加了=
是否会破坏循环不变量,不加是否会破坏;