概念
注意 这三点是二分搜索最易写错的
- 计算中点:在计算中点时,我们通常使用
(left + right) / 2。然而,如果left和right都很大,这可能会导致整数溢出。一个更安全的做法是使用left + (right - left) / 2。 - 搜索区间的更新:在每一步中,我们都需要根据
nums[mid]与目标值的比较结果来更新搜索区间。需要特别注意的是,我们应该将left更新为mid + 1或将right更新为mid - 1,以确保搜索区间在每一步中都在缩小。 - 循环条件:循环应该在
left <= right时继续,而不是left < right。这是因为当left == right时,搜索区间仍然包含一个元素,我们需要检查这个元素是否是目标值。
1.0 二分搜索概念
二分查找并不简单,Knuth 大佬(发明 KMP 算法的那位)都说二分查找:思路很简单,细节是魔鬼。很多人喜欢拿整型溢出的 bug 说事儿,但是二分查找真正的坑根本就不是那个细节问题,而是在于到底要给 mid 加一还是减一,while 里到底用 <= 还是 <。
你要是没有正确理解这些细节,写二分肯定就是玄学编程,有没有 bug 只能靠菩萨保佑(谁写谁知道)。我特意写了一首诗来歌颂该算法,概括本文的主要内容,建议保存(手动狗头):
摘自 labuladong
题目
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]之间。
解题思路🙋🏻 ♀️
在数组 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 中查找目标值 7:
使用公式 mid = left + (right - left) / 2 来计算中点,以防止可能的整数溢出。在这种情况下,每一步中 left、mid 和 right 的值将如下:
- 初始状态:我们设置
left = 0,right = nums.count - 1 = 9。计算得到mid = left + (right - left) / 2 = 4。
nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
l m r
- 第一步:我们比较
nums[mid] = 5和目标值7。因为5 < 7,我们知道7必须在数组的右半部分,所以我们将left移动到mid + 1 = 5。此时,mid = left + (right - left) / 2 = 7。
nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
l m r
- 第二步:我们再次比较
nums[mid] = 8和7。因为8 > 7,我们知道7必须在数组的左半部分,所以我们将right移动到mid - 1 = 6。此时,mid = left + (right - left) / 2 = 6。
nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
l, r,
m
- 第三步:我们再次比较
nums[mid] = 7和7。这次7 == 7,所以我们找到了目标值,返回其索引6。
nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
l, r,
m
所以,经过这三步,我们找到了目标值 7,并返回其在数组中的索引 6。
边界思考🤔
2.0 二分搜索书写的时候易错点
- 计算中点:在计算中点时,我们通常使用
(left + right) / 2。然而,如果left和right都很大,这可能会导致整数溢出。一个更安全的做法是使用left + (right - left) / 2。 - 搜索区间的更新:在每一步中,我们都需要根据
nums[mid]与目标值的比较结果来更新搜索区间。需要特别注意的是,我们应该将left更新为mid + 1或将right更新为mid - 1,以确保搜索区间在每一步中都在缩小。 - 循环条件:循环应该在
left <= right时继续,而不是left < right。这是因为当left == right时,搜索区间仍然包含一个元素,我们需要检查这个元素是否是目标值。
假设我们有一个升序数组 [1, 2, 3, 4, 5, 6, 7, 8, 9],我们想要找到目标值 7。
首先,我们初始化左右指针 left 和 right,并计算中间位置 mid:
1 2 3 4 5 6 7 8 9
L M R
- 易错点 1(初始化):左右指针的初始化应该为
left = 0和right = nums.count - 1。
接下来,我们看一下 nums[mid] 是否等于目标值。如果 nums[mid] < target,我们更新 left = mid + 1;如果 nums[mid] > target,我们更新 right = mid - 1。在这个例子中,nums[mid] = 5 < 7,所以我们更新 left = mid + 1:
1 2 3 4 5 6 7 8 9
L M R
- 易错点 2(边界处理):我们应该设置
left = mid + 1或right = mid - 1,因为我们已经检查过nums[mid],并确定它不等于目标值。
接下来,我们再次比较 nums[mid] 和目标值。现在,nums[mid] = 8 > 7,所以我们更新 right = mid - 1:
1 2 3 4 5 6 7 8 9
L R
M
然后,我们再次比较 nums[mid] 和目标值。现在,nums[mid] = 6 < 7,所以我们更新 left = mid + 1:
1 2 3 4 5 6 7 8 9
L=R
M
- 易错点 3(溢出问题):在计算
mid时,我们应该使用mid = left + (right - left) / 2或mid = right - (right - left) / 2来防止溢出。
最后,我们发现 nums[mid] 等于目标值,所以我们返回 mid。如果我们没有找到目标值,我们会继续这个过程,直到 left > right:
1 2 3 4 5 6 7 8 9
M
-
易错点 4(循环条件):循环的条件应该是
left <= right。只有当left > right时,我们才能确定搜索空间为空,也就是说我们已经检查过所有可能的元素。 -
易错点 5(返回值):如果我们没有找到目标值,应该返回
-1或其他特定的值。
代码
import Foundation
class Solution {
func search(_ nums: [Int], _ target: Int) -> Int {
// 如果数组为空, 则直接返回 -1
if nums.count <= 0 {
return -1
}
var left = 0
var right = nums.count - 1
// 当左边界小于等于右边界时, 执行循环
while left <= right {
// 计算中间元素索引 middle
let middle = left + (right - left)/2
// 如果中间元素等于目标值, 则返回其索引
if nums[middle] == target {
return middle
// 如果中间元素小于目标值, 则目标值在数组的右半部分
// 更新左边界为 middle + 1
} else if nums[middle] < target {
left = middle + 1
// 如果中间元素大于目标值, 则目标值在数组的左半部分
// 更新右边界为 middle - 1
} else if nums[middle] > target {
right = middle - 1
}
}
// 如果在数组中找不到目标值, 则返回 -1
return -1
}
}
时空复杂度分析
O (logn)