二分核心思想
问题满足二段性的性质,即数组的一半满足给定的问题的解,另一半不满足给定问题的解。
算法思路:
假设目标值在闭区间[l, r]
中,每次将区间长度缩小一半,当l = r
时,我们就招到了目标值。
二分查找算法模板
二分模板一共有两个,分别适用于不同情况。
版本 1
当我们将区间 [l, r]
划分为 [l, mid]
和 [mid+1, r]
时,其更新操作为 r = mid
或者 l = mid+1
,计算 mid
时不需要加 1 。
Python 模板:
def bsearch_1(l, r):
while (l < r):
mid = (l + r) >> 1
if (check(mid)):
r = mid
else:
l = mid + 1
版本 2
当我们将区间 [l, r]
划分成 [l, mid-1]
和 [mid, r]
时,其更新操作是 r = mid-1
或者 l = mid
,此时为了防止死循环,计算 mid
时需要加 1 。
Python 模板:
def bsearch_1(l, r):
while (l < r):
mid = (l + r + 1) >> 1
if (check(mid)):
l = mid
else:
r = mid - 1
二分只有下面两种情况:
- 找大于等于给定数的第一个位置 (满足某个条件的第一个数)
- 找小于等于给定数的最后一个位置(满足某个条件的最后一个位置)
常见二分问题
x 的平方根
问题链接:x的平方根
问题分析
答案区间在 [0, x]
,假设我们的答案是 mid
,则要找的是满足 mid * mid <= x
的最后一个数,选择模板 2 的思路来解决。
代码详解
def mySqrt(x):
l = 0
r = x # 确定边界
while (l < r):
mid = (l + r + 1) >> 1 # 模板 2 计算 mid 需要加 1
if (mid * mid <= x):
l = mid
else:
r = mid - 1
return r
搜索插入位置
问题链接:搜索插入位置
问题分析
答案区间在 [0, len(nums)-1]
,数组单调递增,我们要找到第一个满足性质 mid >= x
的 mid
,则我们需要找的答案是后一段的第一个点,所以利用模板 1 。
代码详解
def searchInsert(nums, target):
# 边界条件:数组为空或者所有数都小于 target
if not nums or nums[-1] < target:
return len(nums)
l = 0
r = len(nums) - 1
while (l < r):
mid = (l + r) >> 1
if (nums[mid] >= target):
r = mid
else:
l = mid + 1
return r
在排序数组中查找元素的第一个和最后一个位置
问题链接:在排序数组中查找元素的第一个和最后一个位置
问题分析
该问题是对模版 1 和模版 2 的综合,即既需要找到给定满足 target
的第一个点,也需要找到最后一个点。
代码详解
def searchRange(nums, target):
if not nums:
return [-1, -1]
# 找右区域的第一个点,选择模版 1
l = 0
r = len(nums) - 1
while (l < r):
mid = (l + r) >> 1
if (nums[mid] >= target):
r = mid
else:
l = mid + 1
if (nums[r] != target):
return [-1, -1]
start = r # 找到左边界
# 找左区域的最后一个点,选择模版 2
l = 0
r = len(nums) - 1
while (l < r):
mid = (l + r + 1) >> 1
if (nums[mid] <= target):
l = mid
else:
r = mid - 1
end = r
return [start, end]
搜索二维矩阵
问题链接:搜索二维矩阵
问题分析
每行的第一个整数大于前一行的最后一个整数,所以整个数组还是单调递增的,所以搜索边界为 [0, n*m-1]
,选择性质 nums[mid] >= target
,则我们要找的是右区域的第一个数,选择模版 1 。
代码详解
def searchMatrix(matrix, target):
if not matrix or not matrix[0]:
return False
n = len(matrix)
m = len(matrix[0])
# 确定边界
l = 0
r = n * m - 1
while (l < r):
mid = (l + r) >> 1
# 找到 mid 对应的元素
if matrix[mid // m][mid % m] >= target:
r = mid
else:
l = mid + 1
if matrix[r // m][r % m] != target:
return False
else:
return True
寻找旋转数组的最小值
问题链接:寻找旋转排序数组中的最小值
问题分析
首先寻找一个性质将整个数组划分为两端,以数组 [4,5,6,7,0,1,2]
为例,我们要找的是元素 0
,可以发现该元素将整个数组切分成了两部分,前一部分的元素都大于后一部分的元素,且前后两个子序列都是递增的。所以我们可以很容易地找到一个性质来将整个数组划分为两段,即 nums[mid] <= nums[-1]
。而且我们要找的解是右区域的第一个元素,所以选择模版 1 。
代码详解
def findMin(nums):
l = 0
r = len(nums)
while (l < r):
mid = (l + r) >> 1
if (nums[mid] <= nums[-1]):
r = mid
else:
l = mid + 1
return nums[r]
搜索旋转排序数组
问题链接:搜索旋转排序数组
问题分析
虽然整个数组不是有序的,但是可以划分为两个有序的子序列,而且前一个序列的元素都大于后一个序列的元素。因此该题我们可以先找到切分点,将整个数组划分为两个都是递增的子序列,然后在判断目标值在哪一个子序列中,利用二分查找该值。
代码详解
def search(nums, target):
if not nums:
return -1
# 找到分界点,划分为两个子序列
l = 0
r = len(nums) - 1
while (l < r):
mid = (l + r) >> 1
if (nums[mid] <= nums[-1]):
r = mid
else:
l = mid + 1
# 确定二分查找的区间
if target <= nums[-1]: # [r, len(nums)-1]
r = len(nums) - 1
else: # [0, r-1]
l = 0
r = r - 1
# 二分查找
while (l < r):
mid = (l + r) >> 1
if (nums[mid] >= target):
r = mid
else:
l = mid + 1
return r if nums[r] == target else -1
第一个错误的版本
问题链接:第一个错误的版本
问题分析
查找区间是 [1, n]
,第一个错误的版本将整个数组划分为了两部分,前一部分都是正确的版本,后一部分都是错误的版本,显然利用模版 1 就可以解决。
代码详解
def firstBadVersion(n):
l = 1
r = n
while (l < r):
mid = (l + r) >> 1
if isBadVersion(mid):
r = mid
else:
l = mid + 1
寻找峰值
问题链接:寻找峰值
问题分析
由于 nums[-1] = nums[n] = -∞
,所以划分中点之后,在中点右侧或左侧一定存在一个峰值。如果 nums[mid] < nums[mid+1]
,则右侧一定存在峰值;否则,左侧一定存在峰值。我们的目标是找到 nums[mid] > nums[mid+1]
的第一个点,所以采用模版 1 。
代码详解
def findPeakElement(nums):
l = 0
r = len(nums) - 1
while (l < r):
mid = (l + r) >> 1
if (nums[mid] > nums[mid + 1]):
r = mid
else:
l = mid + 1
return r
寻找重复数
问题链接:寻找重复数
问题分析
长度为 n+1
的数组,其值都在 [0, n]
区间。根据抽屉原理,显然存在至少两个重复的整数。由于只有一个数出现重复,所以我们可以将抽屉划分为两半,则必定有一半的数大于区间的长度,因此另一半区间就不需要再进行查找了。
注意:该题我们是将值区间进行二分,而不是将数组的元素进行二分。每次二分之后都需要遍历整个数组,所以时间复杂度为 $O(nlog n)$ 。
代码详解
def findDuplicate(nums):
# 值区间二分
l = 1
r = len(nums) - 1
while (l < r):
mid = (l + r) >> 1
cnt = 0
# 统计左侧元素
for x in nums:
if (x >= l and x <= mid):
cnt += 1
if (cnt > mid - l + 1):
r = mid
else:
l = mid + 1
return r
H指数 II
问题链接:H指数 II
问题分析
数组是升序排列,则对于要找的 h
显然满足二段性。我们要找的是 h
指数中最大的那个,即左区域的最后一个元素,因此使用模版 2 。
注意:h
的取值区间是 [0, len(nums)]
代码详解
def hIndex(citations):
l = 0
r = len(citations)
while (l < r):
mid = (l + r + 1) >> 1
# 倒数第 h 个数大于等于 h
if (citations[len(citations) - mid] >= mid):
l = mid
else:
r = mid - 1
return r
乘法表中第 k 小的数
问题链接:乘法表中第k小的数
问题分析
输入: m = 3, n = 3, k = 5
输出: 3
解释:
乘法表:
1 2 3
2 4 6
3 6 9
第5小的数字是 3 (1, 2, 2, 3, 3).
观察上述样例可以发现,整个乘法表满足从左至右是递增的,从上到下也是递增的。而且由于任何一个数都是由其索引相乘得到的,因此对于给定一个索引位置,我们可以很容易的判断在该表中比该位置的数小的总共有多少个数。因此,本题可以采用二分查找来进行求解,由于是找第 k 小的数,即第一个大于等于 k 的位置,因此采用模板 1 进行求解。
例如对于上述问题,查找区间为:[1, 9]
首先:mid = (1+9)>>1 = 5
,可以知道该位置对应的数字为 4 。
遍历每一行,可以根据 mid / i
来找到每行中小于等于 mid
位置的数的个数;
对于第一行:min(n, mid / i) = min(3, 5/1) =3
对于第二行:min(3, 5/2) = 2
对于第三行:min(3, 5/3) = 1
所以总共小于等于 4 的数有 6 个。
总时间复杂度为:
代码详解
def findKthNumber(m, n, k):
l = 1
r = m*n
while (l < r):
mid = (l + r) >> 1
if (k <= check(m, n, mid)):
r = mid
else:
l = mid + 1
return r
def check(m, n, mid):
count = 0
for i in range(1, m+1):
count += min((mid // i, n))
return count
if __name__ == "__main__":
m, n, k = 45, 12, 471
r = findKthNumber(m, n, k)
print(r)
问题扩展
拼多多笔试题 :乘法表中第 k 大的数
只需要对上述代码稍作修改,找第 k 大的数,实质上就是找第 m*n-k+1
小的数,所以只需要改变下判断条件即可:
def findKthNumber(m, n, k):
l = 1
r = m*n
while (l < r):
mid = (l + r) >> 1
if ((m*n-k+1) <= check(m, n, mid)):
r = mid
else:
l = mid + 1
return r