Hi,大家好,我是半亩花海。近期在学习算法与数据结构相关知识,纯纯小白,欢迎一起交流呀。打算从算法学起,首先学习查找算法(搜索)中的二分法,我使用的是 python 语言进行学习。本算法学习参考了很多博主的文章,比如:算法第三期——二分法(Python)_python二分法-CSDN博客、玩转二分法(python版)——leetcode二分法题总结【简单易懂】_len(nums) - 1-CSDN博客、Python实现二分法搜索_二分法python-CSDN博客 以及 LeetCode 等等。
目录
一、二分法概述
1.1 理论背景:非线性方程的求根问题
1.2 使用条件
1.3 复杂度分析
二、二分法求解方法
2.1 基本思想
2.2 代码模板
2.2.1 闭区间:[left, right]
2.2.2 左闭右开区间:[left, right)
2.2.3 开区间:(left, right)
三、二分法例题
1. LeetCode[704.二分查找]【简单】
方法一:暴力法
方法二:二分查找法
2. LeetCode[69.x的平方根]【简单】
方法:二分查找法
3. LeetCode[35.搜索插入位置]【简单】
方法:二分查找法
4. LeetCode[34.在排序数组中查找元素的第一个和最后一个位置]【中等】
方法一:暴力法
方法二:二分查找法
一、二分法概述
在一个有序序列中查找某个元素,在之前我们可以使用暴力法来遍历序列,直至找到该元素,复杂度是 ,但其实可以利用序列有序的特征进行折半查找。
- 二分法定义:把一个长度为 的有序序列上 的查找时间,优化到了 。
- 二分法本质:折半搜索。
- 二分法效率:很高,时间复杂度为 (其实不太严谨,因为需要考虑底数,那就要看分治的复杂度:二分法底数为 2,则为复杂度为 ;三分法底数为 3,则为 ... 以此类推。当然不写底数也行,但是得知道它有底数)。
- 二分法实例——猜数游戏:若 万,只需要猜 次。
1.1 理论背景:非线性方程的求根问题
- 满足方程: 的数 称为方程的根。
- 非线性方程:指 中含有三角函数、指数函数或其他超越函数。很难或者无法求得精确解。二分法是一种求解的方法。
1.2 使用条件
- 上下界 [a, b] 确定
- 函数在 [a, b] 内单调
1.3 复杂度分析
- 次二分后,区间缩小到
- 给定 和精度要求 ,可以算出二分次数 ,即满足:
- 二分法的复杂度:
- 时间复杂度: ,其中 是数组的长度。
- 空间复杂度:
- 示例:如果函数在区间 内单调变化,要求根的精度是 ,那么二分次数是 44 次。因为:
二、二分法求解方法
2.1 基本思想
首先把循环可以进行的条件写成 while left <= right,在退出循环的时候,一定有 left == right 成立,此时返回 nums[mid] 中对应的 mid 即可。
- 深层次的思想是“夹逼法”或者称为“排除法”——二分查找算法的基本思想。
- “排除法”:在每一轮循环中排除一半以上的元素,于是在对数级别的时间复杂度内,就可以把区间“夹逼”只剩下 1 个数,而这个数是不是我们要找的数,单独做一次判断就可以了。
2.2 代码模板
下面的代码包含闭区间、左闭右开区间和开区间三种写法。选择自己喜欢的一种写法即可。
- binary_search 返回最小的满足 nums[i] >= target 的 i
- 如果数组为空,或者所有数都小于 target,则返回 len(nums)
- 要求 nums 是非递减的,即 nums[i] <= nums[i + 1]
2.2.1 闭区间:[left, right]
# 闭区间写法
def binary_search1(nums, target):
left, right = 0, len(nums) - 1 # 闭区间 [left, right]
while left <= right: # 区间不为空
# 循环不变量:
# nums[left-1] < target
# nums[right+1] >= target
mid = (left + right) // 2
if nums[mid] < target:
left = mid + 1 # 范围缩小到 [mid+1, right]
else:
right = mid - 1 # 范围缩小到 [left, mid-1]
return left
2.2.2 左闭右开区间:[left, right)
# 左闭右开区间写法
def binary_search2(nums, target):
left = 0
right = len(nums) # 左闭右开区间 [left, right)
while left < right: # 区间不为空
# 循环不变量:
# nums[left-1] < target
# nums[right] >= target
mid = (left + right) // 2
if nums[mid] < target:
left = mid + 1 # 范围缩小到 [mid+1, right)
else:
right = mid # 范围缩小到 [left, mid)
return left # 返回 left 还是 right 都行,因为循环结束后 left == right
2.2.3 开区间:(left, right)
# 开区间写法
def binary_search3(nums: List[int], target: int) -> int:
left, right = -1, len(nums) # 开区间 (left, right)
while left + 1 < right: # 区间不为空
mid = (left + right) // 2
# 循环不变量:
# nums[left] < target
# nums[right] >= target
if nums[mid] < target:
left = mid # 范围缩小到 (mid, right)
else:
right = mid # 范围缩小到 (left, mid)
return right
三、二分法例题
基本模板就是这样,接下来就是实践,积累经验。目前做过的 LeetCode 题有:704、69、35、34(后面会继续写153、33、81、154、4)。推荐按照这个顺序做题(难度从易到难)。
1. 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
思路及题解:
方法一:暴力法
def binary_search(nums, target):
for i in range(len(nums)):
if nums[i] == target:
return i
return -1
# nums = [-1, 0, 3, 5, 9, 12]
# target1 = 9
# target2 = 2
'''使用input()函数输入:
nums = eval(input("nums = "))
target1 = int(input("target1 = "))
target2 = int(input("target2 = "))
'''
# print(binary_search(nums, target1)) # 输出:4
# print(binary_search(nums, target2)) # 输出:-1
方法二:二分查找法
(1)思路
在升序数组 中寻找目标值 ,对于特定下标 ,比较 和 的大小:
- 如果 ,则下标 即为要寻找的下标;
- 如果 ,则 只可能在下标 的左侧;
- 如果 ,则 只可能在下标 的右侧。
基于上述事实,可以在有序数组中使用二分查找寻找目标值,分为以下三步:
① 定义查找的范围 (这里的 left 和 right 是索引),初始查找范围是整个数组。
二分查找的条件是查找范围不为空,即 。如果 在数组中,二分查找可以保证找到 ,返回 在数组中的下标。如果 不在数组中,则当 时结束查找,返回 。由于每次查找都会将查找范围缩小一半,因此二分查找的时间复杂度是 ,其中 是数组的长度。
② 每次取查找范围的中点 ,比较 和 的大小。
中间索引 有两种写法:
由于整数运算没有溢出问题,因此通常两种写法都可以使用。第二种写法更为安全,以确保在边界条件下也能正常工作,特别是当 和 都很大的时候。
③ 如果 和 的大小相等,则 即为要寻找的下标;如果两者不相等,则根据 和 的大小关系将查找范围缩小一半:
- 时: 在 的左边,新的搜索区间是左半部分, 不变,更新 。
- 时: 在 的右边,新的搜索区间是右半部分, 不变,更新 。
代码执行完毕直至 ,则此时两者相等,即此时为 所处的位置。
(2)题解
class BinarySearch(object):
def binary_search(self, nums, target):
left, right = 0, len(nums) - 1
while left <= right:
mid = (right - left) // 2 + left
# mid = (left + right) // 2
if nums[mid] == target:
return mid
elif nums[mid] > target:
right = mid - 1
else:
left = mid + 1
return -1
# search = BinarySearch()
# nums = [-1, 0, 3, 5, 9, 12]
# target = 9
# print(search.binary_search(nums, target)) # 输出:4
2. LeetCode[69.x的平方根]【简单】
题目:
给你一个非负整数 ,计算并返回 的 算术平方根 。
由于返回类型是整数,结果只保留整数部分,小数部分将被舍去 。
注意:不允许使用任何内置指数函数和算符,例如 或者 。
示例 1:
- 输入:x = 4
- 输出:2
示例 2:
- 输入:x = 8
- 输出:2
- 解释:8 的算术平方根是 2.82842..., 由于返回类型是整数,小数部分将被舍去。
思路及题解:
方法:二分查找法
(1)思路
由于 平方根的整数部分 是满足 的最大 值,因此我们可以对 进行二分查找,从而得到答案。
二分查找的下界为 ,上界可以粗略地设定为 。在二分查找的每一步中,我们只需要比较中间元素 的平方与 的大小关系,并通过比较的结果调整上下界的范围。由于我们所有的运算都是整数运算,不会存在误差,因此在得到最终的答案 后,也就不需要再去尝试 了。
(2)题解
class Solution(object):
def mySqrt(self, x):
left, right, ans = 0, x, -1
while left <= right:
mid = (left + right) // 2
if mid * mid <= x:
ans = mid
left = mid + 1
else:
right = mid - 1
return ans
# mysqrt = Solution()
# x = 8
# print(mysqrt.mySqrt(x)) # 输出:2
3. LeetCode[35.搜索插入位置]【简单】
题目:
给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。
请必须使用时间复杂度为 的算法。
示例 1:
- 输入:nums = [1,3,5,6], target = 5
- 输出:2
示例 2:
- 输入:nums = [1,3,5,6], target = 2
- 输出:1
示例 3:
- 输入:nums = [1,3,5,6], target = 7
- 输出:4
思路及题解:
方法:二分查找法
(1)思路
考虑这个插入的位置 ,它成立的条件为:
其中 代表排序数组。由于如果存在这个目标值,我们返回的索引也是 ,因此我们可以将两个条件合并得出最后的目标:「在一个有序数组中找第一个大于等于 的下标」。
问题转化到这里,直接套用二分法即可,即不断用二分法逼近查找第一个大于等于 的下标 。下文给出的代码是笔者习惯的二分写法, 初值设置为数组长度可以省略边界条件的判断,因为存在一种情况是 大于数组中的所有数,此时需要插入到数组长度的位置。
(2)题解
class Solution(object):
def searchInsert(self, nums, target):
left, right = 0, len(nums) - 1
while left <= right:
mid = left + (right - left) // 2
if nums[mid] == target:
return mid
elif nums[mid] < target:
left = mid + 1
else:
right = mid - 1
return left
# solution = Solution()
# nums = [1, 3, 5, 6]
# target = 2
# print(solution.searchInsert(nums, target)) # 输出:1
4. LeetCode[34.在排序数组中查找元素的第一个和最后一个位置]【中等】
题目:
给你一个按照非递减顺序排列的整数数组 ,和一个目标值 。请你找出给定目标值在数组中的开始位置和结束位置。
如果数组中不存在目标值 ,返回 。你必须设计并实现时间复杂度为 的算法解决此问题。
示例 1:
- 输入:nums = [5,7,7,8,8,10], target = 8
- 输出:[3,4]
示例 2:
- 输入:nums = [5,7,7,8,8,10], target = 6
- 输出:[-1,-1]
示例 3:
- 输入:nums = [], target = 0
- 输出:[-1,-1]
思路及题解:
方法一:暴力法
(1)思路
① 定义 、 初始值为 ,利用数组有序的特点从头到尾遍历列表,先寻找 再寻找 ,最后;
② 在遍历的开始,检查遍历到的元素是否等于 ,遇到刚好等于 的时候,记录当前的位置;
③ 接着遍历,检查遍历到的元素是否不等于 ,遇到刚好不等于 的时候,记录当前位置的前一个位置即可。
(2)复杂度分析
- 时间复杂度:,其中 为数组的长度。
- 空间复杂度:,只用到常数个临时变量。
(3)题解
def searchRange(nums, target):
if not nums: # 数组为空
return [-1, -1]
left, right = -1, -1
for i in range(len(nums)):
if nums[i] == target:
if left == -1:
left = i
right = i
return [left, right]
# nums = [5, 7, 7, 8, 8, 10]
# target = 8
# print(searchRange(nums, target)) # 输出:[3, 4]
方法二:二分查找法
(1)思路
- 目标元素 在有序数组中很可能存在多个;
- 使用二分查找方法看到的处在中间位置的元素的值 恰好等于目标元素 的时候,还需要继续查找下去;
- 此时比较容易陷入的误区是线性查找,正确的做法是继续二分查找。
① 初始化 左右指针,循环条件设为“当 ”。这样设立是因为跳出循环的时候总是有 ,不用思考是 还是 ;
② 取中值。取中左还是中右,这就需要看情况了。 如果我们想要找目标值的起始下标,那么当 大于目标值 (或者找到这个目标值的时候),右边界都要缩小。即当 的时候,。
注意:不能以 这样的形式缩小,否则当找到起始下标的时候还得再减 1,就会出现问题。因为不可以左右两个都 ,必须有一个 ,否则会陷入死循环。所以在其他情况下,左边界 。
③ 取左中还是右中,要以你选择哪个是 来定。对于第一个循环,这里我们选了 的是左边 ,所以我们要取左中值。否则会陷入死循环。
(2)题解
class Solution(object):
def searchRange(self, nums, target):
l, r = 0, len(nums) - 1 # 取起始下标
while l < r:
mid = (l + r) // 2
if nums[mid] >= target:
r = mid
else:
l = mid + 1
if not nums or nums[l] != target: # 数组为空或没找到
return [-1, -1]
a, b = l, len(nums) - 1 # 取结束下标
while a < b:
mid = (a + b + 1) // 2
if nums[mid] <= target:
a = mid
else:
b = mid - 1
return [l, a]
# solution = Solution()
# nums = eval(input('nums = '))
# target = int(input('target = '))
# print(solution.search_Range(nums, target)) # 输出:[3, 4]
34小结:个人感觉这个题对于初学者的我来说还是有点难理解的,需要反复学习。