二分搜索问题分析
使用二分搜索框架分析解答leetcode/力扣中经典的算法题。
文章目录
二分搜索的两种写法
左闭右闭区间[left, right]
写法(推荐)
后续举例都优先使用这种写法
# the num array to search is "f"
# 定义区间[left, right]
left = 0
right = len(f)-1
# 先看什么时候区间[left, right]不含有任何元素,注意left==right的时候,区间内是有一个元素f[left] 也就是f[right]的,所以要区间内没有元素,必须left>right,而最小满足条件的left就是right+1
# the stop condition is left > right ==> left == right + 1 ([left, left - 1] range has no element)
# 因此将区间内没有元素的条件(left>right)取反,即left<=right
# therefore the while codition is left <= right
while left <= right:
mid = (left + right) // 2
mid_element = f[mid]
if mid_element == target:
...
elif mid_element < target:
...
elif mid_element > target:
...
# to fill "..." part
# (1) if want to search left side [left, mid-1], use:
right = mid - 1
# 搜左边半区间(不含mid),因为是闭区间,所以直接设置为right=mid-1
# (2) if want to search right side [mid+1, right], use:
left = mid + 1
# 搜右边半区间(不含mid),因为是闭区间,所以直接设置为left=mid+1
# 当问题是搜索左边界(最小满足条件的位置)在mid_element == target时,需要继续向左搜索,因为当前满足条件的可能不是最小的位置,所以仍要right=mid-1;反过来,当问题是搜索左=右边界(最大满足条件的位置)在mid_element == target时,需要继续向右搜索,因为当前满足条件的可能不是最大的位置,所以仍要left=mid+1;
# check if out of bound
# 如果左指针超过了f的最右侧边界,说明target太大,没有搜索到;或者右指针超过了f的左侧边界,说明target太小,,没有搜索到
if left > len(f) - 1 or right < 0:
return NOT_FOUND
# 很好记,定义的区间是[left, right],所以左边界(或者等值元素)的结果就是left,右边界是right
# result == left == right+1 (search left bound)
# result == right == left-1 (search right bound)
# 最后检查是不是满足搜索需求,如果刚好卡在两个元素中间,虽然不越界,也是没搜索到
if f[result] == target
return result
else:
return NOT_FOUND
左闭右开区间[left, right)
写法(传统)
# the num array to search is "f"
# 定义区间[left, right)
left = 0
right = len(f)
# 先看什么时候区间[left, right)不含有任何元素,左闭右开需要left==right
# the stop condition is left == right ([left,. left) range has no element)
# 因此while条件是left < right
# therefore the while coindition is left < right
while left < right:
mid = (left + right) // 2
mid_element = f[mid]
if mid_element == target:
...
elif mid_element < target:
...
elif mid_element > target:
...
# to fill "..." part
# (1) if want to search left side [left, mid), use:
right = mid
# (2) if want to search right side [mid+1, right), use:
left = mid + 1
# when searching left / right bound, go to left / right side when mid_element == target
# check if out of bound
if left > len(f) - 1 or right <= 0:
return NOT_FOUND
# 看定义的区间是[left, right),right是不在范围内的,范围内最大的值是right-1
# result == left == right (search left bound)
# result == right-1 == left-1 (search right bound)
# check element
if f[result] == target
return result
else:
return NOT_FOUND
几种基本问题
搜索元素、搜索左边界、搜索右边界。这些问题可能是其他问题的子过程。
LC-704 二分搜索
给定一个n个元素有序的(升序)整型数组nums和一个目标值target,写一个函数搜索nums中的target,如果目标值存在返回下标,否则返回-1。
def search(self, nums: List[int], target: int) -> int:
left = 0
right = len(nums) - 1
while left <= right:
mid = int((left + right) / 2)
if nums[mid] == target:
return mid
elif nums[mid] > target:
right = mid - 1
elif nums[mid] < target:
left = mid + 1
return -1
LC-34 在排序数组中查找元素的第一个和最后一个位置
给你一个按照非递减顺序排列的整数数组 nums,和一个目标值 target。请你找出给定目标值在数组中的开始位置和结束位置。如果数组中不存在目标值 target,返回 [-1, -1]。
你必须设计并实现时间复杂度为 O(log n) 的算法解决此问题。
分析:
logn的暗示很明显就是要用二分法;找元素的开始位置和结束位置其实是两个问题:二分搜索找左边界、二分搜索找右边界
def searchRange(self, nums: List[int], target: int) -> List[int]:
# 二分搜索寻找左边界
res_left = -1
left = 0
right = len(nums) - 1
while left <= right:
mid = int((left + right) / 2)
if nums[mid] == target: # still go left side since getting left bound
right = mid - 1
elif nums[mid] < target:
left = mid + 1
elif nums[mid] > target:
right = mid - 1
# if out of bound left > right = len(nums)-1, means it is not found
if left > len(nums) - 1:
res_left = -1
else:
if nums[left] == target:
res_left = left
else:
res_left = -1
# 二分搜索寻找右边界
res_right = -1
left = 0
right = len(nums) - 1
while left <= right:
mid = int((left + right) / 2)
if nums[mid] == target: # still go right side since getting right bound
left = mid + 1
elif nums[mid] < target:
left = mid + 1
elif nums[mid] > target:
right = mid - 1
# if out of bound right < left = 0, means it is not found
if right < 0:
res_right = -1
else:
if nums[right] == target:
res_right = right
else:
res_right = -1
return [res_left, res_right]
查找插入位置
给定一个排序的整数数组nums和一个整数目标值target,请在数组中找到target,并返回其下标。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。
请必须使用时间复杂度为 O(log n) 的算法。
def searchInsert(self, nums, target: int) -> int: # Of-068
left = 0
right = len(nums) - 1
while left <= right:
mid = int((left + right)/2)
if nums[mid] == target:
return mid
elif nums[mid] > target:
right = mid - 1
elif nums[mid] < target:
left = mid + 1
return left
二分搜索的变形问题
LC-74 搜索二维矩阵
编写一个高效的算法来判断m x n矩阵中,是否存在一个目标值。该矩阵具有如下特性:
每行中的整数从左到右按升序排列,每行的第一个整数大于前一行的最后一个整数。
分析思路1:
先二分搜索行,然后二分搜索列
def searchMatrix(self, matrix, target: int) -> bool: # LC-74
# 先搜索row 再搜索column
mat_row_count = len(matrix)
mat_col_count = len(matrix[0])
# 二分搜索行
row_res = -1
row_left = 0
row_right = mat_row_count - 1
# 元素在某一行的条件是该行的最左边大于等于元素且该行的右边小于等于元素
while row_left <= row_right:
mid = int( (row_left + row_right) / 2)
if matrix[mid][0] > target:
row_right = mid - 1
elif matrix[mid][-1] < target:
row_left = mid + 1
else:
row_res = mid
break
if row_res == -1:
return False
# 二分搜索列,等同于常规的一维二分搜索
col_left = 0
col_right = mat_col_count - 1
while col_left <= col_right:
mid = int( (col_left + col_right) /2)
if matrix[row_res][mid] == target:
return True
elif matrix[row_res][mid] > target:
col_right = mid - 1
elif matrix[row_res][mid] < target:
col_left = mid + 1
return False
分析思路2:
将矩阵展平为数组,仍然满足单调性,直接使用row,col=divmod(ind, n)即可完成展平索引和行列索引的转换。
def searchMatrix(self, matrix, target: int) -> bool: # flatten
m = len(matrix)
n = len(matrix[0])
search_range_lower = 0
search_range_upper = m * n - 1
left = search_range_lower
right = search_range_upper
while left <= right:
mid = (left + right) // 2
mid_row, mid_col = divmod(mid, n)
mid_val = matrix[mid_row][mid_col]
if mid_val == target:
return True
elif mid_val > target:
right = mid - 1
elif mid_val < target:
left = mid + 1
return False
任何单调非增/单调非减函数都可以使用二分搜索
给定k找f(x)=k的x值的解方程问题,如果f(x)是单调非增/单调非减函数,那么可以使用二分搜索。过程一般是:
- 写出f(x)的表达式
- 确定搜索的上下边界
- 使用二分搜索的三个基本问题作为子过程解决问题
0~n-1中缺失的数字
一个长度为n-1的递增排序数组中的所有数字都是唯一的,并且每个数字都在范围0~n-1之内。在范围0~n-1内的n个数字中有且只有一个数字不在该数组中,请找出这个数字。
分析:如果第i位上的数字是i,说明0~i之间都没有缺少数字,缺少的可能从i+1~结尾;如果第i位上的数字不是i,那必然是i-1,因为0~i之间缺少了一个数字,应该继续找0~i。
注意:这个数组就代表一个单调非减函数,其中只有缺失的位置是不增,其他位置都是严格增的,因此可以二分搜索。
def missingNumber(self, nums: List[int]) -> int:
left = 0
right = len(nums) - 1
while left <= right:
mid = int((left + right) / 2)
if nums[mid] == mid:
left = mid + 1
elif nums[mid] > mid:
right = mid - 1
return left
LC-793 阶乘函数后K个0
f(x)是x!末尾是0的数量。回想一下 x! = 1 * 2 * 3 * … * x,且 0! = 1 。
例如, f(3) = 0 ,因为 3! = 6 的末尾没有 0 ;而 f(11) = 2 ,因为 11! =39916800末端有 2 个 0 。给定 k,找出返回能满足 f(x) = k 的非负整数 x 的数量。
分析:
以一个数为自变量,求该数的阶乘后面的0的个数的函数必然是一个单调非减函数;求满足条件的x的数量也就是找满足条件的最小x的索引和最大的x的索引(二分求左右边界);另外的问题就是确定搜索的上下边界,题目给定0 <= k <= 109,因此估计x小于1010。
def preimageSizeFZF(self, k: int) -> int:
def get_zero_count(x): # f(x) = get_zero_count(x)是单调非减函数
res = 0
inc = int(x/5)
while inc > 0:
inc = int(x/5)
res += inc
x = inc
return res
long_max = 2 ** 64
# 找左边界:
l_b = -1
left = 0
right = long_max
while left <= right:
mid = int((left + right) /2)
c = get_zero_count(mid)
if c == k:
right = mid - 1
elif c > k:
right = mid -1
elif c< k :
left = mid + 1
if left > long_max:
return 0
else:
if get_zero_count(left) == k:
l_b = left
else:
return 0
# 找右边界:
r_b = -1
left = 0
right = long_max
while left <= right:
mid = int((left + right) /2)
c = get_zero_count(mid)
if c == k:
left = mid + 1
elif c > k:
right = mid -1
elif c< k :
left = mid + 1
if right < 0:
return 0
else:
if get_zero_count(right) == k:
r_b = right
else:
return 0
return r_b - l_b + 1
LC-1011 在D天内送达包裹的能力
传送带上的包裹必须在 days 天内从一个港口运送到另一个港口。
传送带上的第 i 个包裹的重量为 weights[i]。每一天,我们都会按给出重量(weights)的顺序往传送带上装载包裹。我们装载的重量不会超过船的最大运载重量。
返回能在 days 天内将传送带上的所有包裹送达的船的最低运载能力。
分析:
船的最大运载量c为自变量,求在该运载量情况下运送天数的函数是一个单调非增函数(当然,c必须要大于等于weights中的最大值);这题给定天数求最小的c就是二分搜索求左边界的问题;注意,非增函数和之前的非减函数在二分的时候有一些不一样;另外,搜索的上下界限是 数组最大值~数组值之和。
def shipWithinDays(self, weights, days: int) -> int: # 1011
def capacity_to_days(c): # capacity_to_days(x) 是一个非增函数(当x>=weights.max时)
day_count = 0
day_remaining_capacity = c
for w in weights:
# if c < w:
# return -1 # max capacity < any weight
if w <= day_remaining_capacity:
day_remaining_capacity -= w
else:
day_count += 1
day_remaining_capacity = c - w
if day_remaining_capacity < c:
day_count += 1
return day_count
search_start = 0
search_end = 0
for w in weights:
search_start = max(search_start, w)
search_end += w
left = search_start
right = search_end
while left <= right: # find left bound
mid = int( (left+right)/2)
mid_val = capacity_to_days(mid)
if mid_val == days:
right = mid - 1
elif mid_val < days:
right = mid - 1
elif mid_val > days:
left = mid + 1
# if right < search_start or left > search_end:
# return
return left
LC-875 爱吃香蕉的珂珂
珂珂喜欢吃香蕉。这里有n堆香蕉,第i堆中有piles[i]根香蕉。警卫已经离开了,h小时后回来。
珂珂可以决定她吃香蕉的速度k (单位:根/小时)。每个小时,她将会选择一堆香蕉,从中吃掉k根。如果这堆香蕉少于k根,她将吃掉这堆的所有香蕉,然后这一小时内不会再吃更多的香蕉。
珂珂喜欢慢慢吃,但仍然想在警卫回来前吃掉所有的香蕉。
返回她可以在h小时内吃掉所有香蕉的最小速度k(k 为整数)。
分析:
吃的速度s为自变量,吃完所有香蕉的时间是一个非增函数;这题给定时间求最小的是就是二分搜索求左边界的问题;注意,非增函数和之前的非减函数在二分的时候有一些不一样;另外,搜索的上下界限是 数组最大值~数组值之和。
def minEatingSpeed(self, piles, h: int) -> int: # 875
def hours_to_eat_by_speed(s): # hours_to_eat_by_speed(x) 是一个非增函数
return sum([math.ceil(p/s) for p in piles])
left = 1
right = sum(piles)
while left <= right:
mid = int((left+right)/2)
mid_val = hours_to_eat_by_speed(mid)
if mid_val == h:
right = mid -1
elif mid_val < h:
right = mid - 1
elif mid_val > h:
left = mid + 1
return left
LC-410 分隔数组的最大值
给定一个非负整数数组 nums 和一个整数 m ,你需要将这个数组分成 m 个非空的连续子数组。
设计一个算法使得这 m 个子数组各自和的最大值最小。
分析:
实际上,转换一下,这个题和LC-1011货船问题是一样的,只不过是抽象了;子数组=货船,子数组的和=货物量,子数组的和的最大值=货船容量,子数组个数=运输天数。
def splitArray(self, nums, m: int) -> int:
def split_count_by_max_sum(ms):
current_array_capacity = ms
split_count = 0
for num in nums:
if num <= current_array_capacity:
current_array_capacity -= num
else:
split_count += 1
current_array_capacity = ms - num
if current_array_capacity< ms:
split_count += 1
return split_count
max_element = 0
nums_total = 0
for n in nums:
max_element = max(max_element, n)
nums_total += n
left = max_element
right = nums_total
while left <= right:
mid = int( (left+right)/2)
mid_val = split_count_by_max_sum(mid)
if mid_val == m:
right = mid - 1
elif mid_val < m:
right = mid - 1
elif mid_val > m:
left = mid + 1
return left
更广义的二分搜索
实际上,只要是能够通过某些信息,将搜索答案的范围缩小或者减半,都可以用二分搜索;上面提到的单调函数只是其中的一个特例。
LC-852 山脉数组的峰顶索引
符合下列属性的数组 arr 称为 山脉数组 :
arr.length >= 3
存在 i(0 < i < arr.length - 1)使得:
arr[0] < arr[1] < ... arr[i-1] < arr[i]
arr[i] > arr[i+1] > ... > arr[arr.length - 1]
给你由整数组成的山脉数组 arr ,返回任何满足 arr[0] < arr[1] < ... arr[i - 1] < arr[i] > arr[i + 1] > ... > arr[arr.length - 1]
的下标 i 。
分析:
在索引i上的值如果大于i+1上的值,说明山脉数组在山顶或者下坡部分,也就是山峰在[0,i]中;在索引i上的值如果小于i+1上的值,说明山脉数组在上坡部分,也就是说山峰在[i+1,end]中。
def peakIndexInMountainArray(self, arr) -> int:
left = 0
right = len(arr) - 1
while left < right:
mid = int((left + right)/ 2)
if arr[mid] - arr[mid+1] < 0:
left = mid + 1
else:
right = mid
return left
LC-33 搜索旋转排序数组
整数数组nums按升序排列,数组中的值互不相同。
在传递给函数之前,nums 在预先未知的某个下标 k(0 <= k < nums.length)上进行了旋转 ,使数组变为 [nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]]
(下标 从 0 开始 计数)。例如, [0,1,2,4,4,4,5,6,6,7]
在下标 5 处经旋转后可能变为 [4,5,6,6,7,0,1,2,4,4]
。
给你 旋转后 的数组 nums 和一个整数 target ,请你编写一个函数来判断给定的目标值是否存在于数组中。如果 nums 中存在这个目标值 target ,则返回 true ,否则返回 false 。
分析:
虽然旋转后数组不再是单调的,但是仍然可以根据任何一个区间的中间的值与开头和结尾值比较判断需要查找的数字在左还是右半区间内;
- 如果区间右边值R大于等于中间值M,说明旋转点在左半区间,也就是说右半区间是单调的,且右半区间最小值为M最大值为R,如果要找的数在[M,R]范围内,就找右半区,否则找左半区;
- 如果L小于等于M,说明旋转点在右半区间,也就是说左半区间是单调的,且左半区间最小值为L最大值为M,如果要找的数在[L,M]范围内,就找左半区,否则找右半区;
def search(self, nums, target: int) -> int:
left = 0
right = len(nums) - 1 # [left, right)
while left <= right:
mid = int((left+right)/2)
mid_elem = nums[mid]
if mid_elem == target:
return mid
elif mid_elem >= nums[0]: # left is mono
if nums[0] <= target < mid_elem:
right = mid - 1
else:
left = mid + 1
elif mid_elem <= nums[-1]: # right is mono
if mid_elem < target <= nums[-1]:
left = mid + 1
else:
right = mid - 1
return -1