二分查找的定义如下(引自Wiki):
在计算机科学中,二分查找算法(英语:binary search algorithm),也称折半搜索算法(英语:half-interval search algorithm)、对数搜索算法(英语:logarithmic search algorithm),是一种在有序数组中查找某一特定元素的搜索算法。搜索过程从数组的中间元素开始,如果中间元素正好是要查找的元素,则搜索过程结束;如果某一特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且跟开始一样从中间元素开始比较。如果在某一步骤数组为空,则代表找不到。这种搜索算法每一次比较都使搜索范围缩小一半。
二分查找算法在最坏情况下是对数时间复杂度的,需要进行 O(logn) 次比较操作(n在此处是数组的元素数量,O是大O记号,log 是对数)。二分查找算法使用常数空间,对于任何大小的输入数据,算法使用的空间都是一样的。除非输入数据数量很少,否则二分查找算法比线性搜索更快,但数组必须事先被排序。尽管一些特定的、为了快速搜索而设计的数据结构更有效(比如哈希表),二分查找算法应用面更广。
总结一句,由于二分必须在有序数组中进行,看到题目条件有有序数组的话就应该想到二分查找。
Leetbook上有关于二分查找的内容,但还是局限在多个模板套用上,且题目与知识点对应不上。更推荐的是这篇文章,真正做到了理解核心而不是套用模板。
二分查找中使用的术语:
目标 Target —— 你要查找的值
索引 Index —— 你要查找的当前位置
左、右指示符 Left,Right —— 我们用来维持查找空间的指标
中间指示符 Mid —— 我们用来应用条件来确定我们应该向左查找还是向右查找的索引
下面我们结合题目来解析二分查找的思路:
704. 二分查找
分法一:
class Solution:def search(self, nums: List[int], target: int) -> int:left = 0right = len(nums) - 1while left < right:mid = left + (right - left) // 2if nums[mid] < target:left = mid + 1else:right = midif nums[left] == target:return leftreturn -1
分法二:
class Solution:def search(self, nums: List[int], target: int) -> int:left = 0right = len(nums) - 1while left < right:mid = left + (right - left + 1) // 2if nums[mid] > target:right = mid - 1else:left = midif nums[right] == target:return rightreturn -1
首先是左右指示符的设置,left = 0
与 right = len(nums) - 1
基本上每题开头都是把整个区间作为我们想进行二分查找的区间,当然也有例外,后面会看到。
然后就是求中间指示符 mid 的环节,mid = (left + right) // 2
,这里更推荐 mid = left + (right - left) // 2
的写法因为要防止 left + right 整形溢出。同时我们要注意到,// 2 相当于是向下取整的,如果是希望向上取整则写成mid = (left + right + 1) // 2
或者 mid = left + (right - left + 1) // 2
。向下取整时 mid 就会被分到左边,向上取整时 mid 就会被分到右边。
紧接着就是二分的核心部分,区间的划分了。很多模板会把区间划分为等于 target、大于 target 和小于 target 三个区间,实际上是有点绕了。此处我们统一每次只划分两个区间,可能存在目标元素的区间和一定不存在目标元素的区间,那么可能存在目标元素的区间要么在左边要么在右边,两种可能。
又根据 mid 是被划分在左边的区间还是右边的区间,得到两种分法。因此共有4种情况,如下图所示:
分法一(默认) mid = (left + right) // 2
第一种情况:mid 在左边区间,目标元素在右边区间,nums[mid] < target
, 则 left = mid + 1
;
第二种情况:mid 在左边区间,目标元素在左边区间,nums[mid] >= target
, 则 right = mid
;
分法二 mid = (left + right + 1) // 2
第三种情况:mid 在右边区间,目标元素在右边区间,nums[mid] <= target
,则 left = mid
;
第四种情况:mid 在右边区间,目标元素在左边区间,nums[mid] > target
,则 right= mid - 1
。
其中第一种和第二种情况一定同时出现(分法一),第三种和第四种情况一定同时出现(分法二)。然后,我们来考虑下如果只剩下两个元素的情形,如下图所示:
如果是分法一,即left = mid + 1
与right = mid
,此时 mid 必须等于 left,即向下取整,下一步才会有 left = mid + 1 = right
或者 right = mid = left
,得到 left == right 从而退出循环。
如果是分法二,即left = mid
与right = mid - 1
,此时 mid 必须等于 right,即向上取整,下一步才会有 right = mid - 1 = left
或者 left = mid = right
,得到 left == right 从而退出循环。
他们的共同点是,最后退出循环时 left 一定等于右边的那个元素(mid + 1)。
最后,可知退出循环后一定有 left == right,如果 left (或者 right,一样的)满足条件(例如 nums[left] == target
),则返回 left(或者right)。
35. 搜索插入位置
class Solution:def searchInsert(self, nums: List[int], target: int) -> int:# 特殊情况if nums[-1] < target:return len(nums)left = 0right = len(nums)- 1while left < right:mid = left + (right - left) // 2if nums[mid] < target:left = mid + 1else:right = midreturn left
本题与704题基本一样,区别只是不要求找到一样的元素,而是要找到第一个大于等于 target 的元素索引。此处使用的还是分法一,向下取整 mid = left + (right - left) // 2
,mid 一定在左边的区间,if nums[mid] < target
即左边的区间小于 target,那么第一个大于等于 target 的元素一定在右边的区间,因此到右边的区间去寻找元素, left = mid + 1
。循环结束之后,一定有 left == right,由于它们的区间 [left, right] 一定有第一个大于等于 target 的元素,所以最后区间只有一个元素,它的索引 left 即为所求。
162. 寻找峰值
class Solution:def findPeakElement(self, nums: List[int]) -> int:length = len(nums)left, right = 0, length - 1while left < right:mid = left + (right - left) // 2if nums[mid] < nums[mid + 1]:left = mid + 1else:right = midreturn left
找到大于左右相邻元素的值,若 nums[mid] < nums[mid + 1]
,则目标区间在右边,剩下两个元素时,mid向下取整等于left,可以取到更大值 left = mid + 1 = right
。
34. 在排序数组中查找元素的第一个和最后一个位置
class Solution:def searchRange(self, nums: List[int], target: int) -> List[int]:length = len(nums)# 特殊情况if (not nums) or nums[-1] < target or nums[0] > target or length == 0:return [-1, -1]# 找左边界left1, right1 = 0, length - 1while left1 < right1:mid1 = left1 + (right1 - left1) // 2 # 向下取整if nums[mid1] < target:left1 = mid1 + 1else:right1 = mid1# 数组中不存在targetif nums[left1] != target:return [-1, -1]# 找右边界left2, right2 = left1, length - 1 # 此处优化了,找右边界的过程从left1到length - 1的区间中找while left2 < right2:mid2 = left2 + (right2 - left2 + 1) // 2 # 向上取整if nums[mid2] > target:right2 = mid2 - 1else:left2 = mid2return [left1, right2]
本题可以看作是704题的高阶版,数组中的元素是可能重复的,然后要找 target 在数组出现的第一个位置和最后一个位置。
在找第一个位置的时候,可以借助35题的思路,什么样的位置是第一次出现的位置呢?那就是第一个大于等于 target 的元素位置。还是用的分法一,判断条件是 if nums[mid1] < target
,如果 mid1 小于 target 即左边的区间小于 target,所以右边的区间大于等于 target,到右边区间继续找left1 = mid1 + 1
。循环结束后由于题目是要求 target 出现,所以判断 nums[left1] 与 target 是否相等,相等才继续。
然后找最后一个位置,显然,这相当于找第一个小于等于 target 的元素位置,用分法一,判断条件为 if nums[mid2] > target
,如果 mid2 大于 target 即左边的区间大于 target,所以右边的区间小于等于 target,等等,顺序不对???左边的区间大于 target,左边的区间又小于右边的区间,怎么可能右边的区间小于等于 target 呢?因此,我们要改用分法二,向上取整,把 mid2 归到右边的区间,判断条件还是 if nums[mid2] > target
,如果 mid2 大于 target 即右边的区间大于 target,所以左边的区间小于等于 target,到左边区间继续找right2 = mid2 - 1
。能进行到这里说明 target 肯定会出现了,所以不用判断 nums[right2 ] 与 target 是否相等,直接返回答案。
33. 搜索旋转排序数组
class Solution:def search(self, nums: List[int], target: int) -> int:length = len(nums)if not nums:return -1left, right = 0, length - 1while left < right:mid = left + (right- left) // 2 # 分法一,mid在左边区间,向下取整if nums[mid] < nums[right]: # mid所在位置元素小于最右边元素,说明右边区间有序if nums[mid] < target <= nums[right]: # 如果target在右边区间left = mid + 1else: # 否则在左边区间right = midelse: # mid所在位置元素大于(不会等于)最右边元素,说明左边区间有序if nums[left] <= target <= nums[mid]: # 如果target在左边区间(mid也在左边区间,可能等于target)right = midelse: # 否则在右边区间left = mid + 1if nums[left] == target: # 等于目标值return leftelse: # 不存在目标值return -1
这题的数组是循环有序,对于 mid 来说,要么是 mid 所在的左边区间(分法一)有序,要么是右边区间有序,所以首先要判断哪个区间有序,再到有序区间进行 target 的寻找(因为 mid 与 target 的比较一定是在有序区间进行的)。
右边区间有序,判断条件是 if nums[mid] < target <= nums[right]
,第一个取小于号是因为 mid 在左边区间,一定小于在右边区间的 target,而第二个取小于等于号是因为 target 可能是最右边的元素。
左边区间有序,判断条件是 if nums[left] <= target <= nums[mid]
,同理,target 和 mid 都在左边区间,都可能等于最左边的元素。
81. 搜索旋转排序数组 II
分法一:
class Solution:def search(self, nums: List[int], target: int) -> bool:length = len(nums)left, right = 0, length - 1while left < right:mid = left + (right - left) // 2if nums[mid] < nums[right]: # 右边区间一定有序if nums[mid] < target <= nums[right]:left = mid + 1else:right = midelif nums[mid] > nums[right]: # 左边区间一定有序(旋转点在右边区间)if nums[left] <= target <= nums[mid]:right = midelse:left = mid + 1else: # 无法判断是否有序,例如[3, 1, 2, 3, 3, 3, 3]if nums[right] == target:return Trueelse:right -= 1return nums[left] == target
分法二:
class Solution:def search(self, nums: List[int], target: int) -> bool:length = len(nums)left, right = 0, length - 1while left < right:mid = left + (right - left + 1) // 2if nums[mid] < nums[right]: # 右边区间一定有序if nums[mid] <= target <= nums[right]:left = midelse:right = mid - 1elif nums[mid] > nums[right]: # 左边区间一定有序(旋转点在右边区间)if nums[left] <= target < nums[mid]:right = mid - 1else:left = midelse: # 无法判断是否有序,例如[3, 1, 2, 3, 3, 3, 3]if nums[right] == target:return Trueelse:right -= 1return nums[left] == target
作为33题的进阶版,这道题难在数组中的元素是可能相同的,如果出现 nums[mid] == nums[right] 的情况,无法判断左边区间还是右边区间是有序的。解决方法就是对于这种情况,每次缩减 right - 1 即右边界左移一位,直到可以判断左右区间哪个有序为止。
题解既有分法一也有分法二,他们的核心区别是分法一把 mid 归到左边区间,分法二把 mid 归到右边区间。由此导致了向下与向上取整的不同、寻找目标区间的左右边界更新位置不同、以及 mid 与左右区间元素的大小关系不同。
153. 寻找旋转排序数组中的最小值
class Solution:def findMin(self, nums: List[int]) -> int:length = len(nums)left, right = 0, length - 1while left < right:mid = left + (right - left) // 2if nums[mid] < nums[right]: # 右边区间有序,拐点一定在左边区间right = midelse: # 右边区间无序,拐点一定在右边区间left = mid + 1return nums[left]
本题由于没有 target,甚至比33题还要简单,只需要不断地找无序的区间(同时也是拐点所在的区间)即可,由剩余两个元素时的情况可以知道,退出循环时必然 left 等于右边的元素,即拐点的右边(最小值)。
154. 寻找旋转排序数组中的最小值 II
class Solution:def findMin(self, nums: List[int]) -> int:length = len(nums)left, right = 0, length - 1while left < right:mid = left + (right - left) // 2if nums[mid] < nums[right]: # 右边区间有序,拐点一定在左边区间right = midelif nums[mid] > nums[right]: # 右边区间无序,拐点一定在右边区间left = mid + 1else: # mid与右边界相等,无法判断,只能缩小范围right -= 1return nums[left]
本题是153题的进阶版,与81题类似,就是多了元素可能重复这个条件。由于存在无法判断是否有序的情况,所以要单独讨论,出现这种情况时就缩小范围 right -= 1
,其余情况还是正常找拐点所在区间。
658. 找到 K 个最接近的元素
class Solution:def findClosestElements(self, arr: List[int], k: int, x: int) -> List[int]:n = len(arr)# 最小的起点为0,最大的起点为n-k,这样才能保证选取长度为k的连续子数组low, high = 0, n - k # 框长度为k,所以起点范围[0, n-k]while low < high:mid = (low + high) // 2if x - arr[mid] <= arr[mid + k] - x: # x更靠近左边的元素,我们的框应该往左边找high = midelse: # x更靠近右边的元素,我们的框应该往右边找low = mid + 1return arr[low: low + k]
这题虽然代码很基本,但是思路不容易。找到 k 个与 x 最接近的数,可以把这 k 个数看作是一个长度为 k 的框,则框的左起点的范围是 [0, n-k]。然后二分查找这个左起点,若 x 与目前左起点 arr[mid] 的距离小于等于 x 与右起点 arr[mid + k] 的距离,if x - arr[mid] <= arr[mid + k] - x:
,则框应该向左移,即左起点的取值范围从右边缩小, high = mid
,反之从左边缩小,最后得到最接近 x 的 k 个数(框)。