二分查找
知识点参考:Hello 算法https://www.hello-algo.com/
前言
力扣刷题学习笔记和思路记录
1.基础知识
在一个已经排好序的数组中(可能需要自己来排序),我们需要用尽可能快速的方法来查找一个元素,通常想到的方法是线性查找,既通过遍历的方法来进行暴力搜索
1.1 线性查找
时间复杂度计算:
最坏情况:O(n),平均情况:O(n),空间复杂度:O(1)
1 | def linear_search(arr, x): |
图解:

这种解法在小范围数据里面是比较方便的,但是数据一多就显得比较笨重
1.2 基础版
二分查找算法也称折半查找,在数组的首个元素和最后一个元素分别设置一个节点,通过不断更新两个节点的值来找最终的元素
时间复杂度计算:
二分查找的时间复杂度是O(log2(n),其中n是待查找的数组的长度。因此,在查找范围缩小到一定程度后,二分查找的效率将会非常高。
空间复杂度计算:
二分查找算法的空间复杂度是O(1),不需要为每一次查找创建单独空间,因为它只需要常数的额外空间。
1 | def binarySearchBasic(nums,target): |
图解:

其中m是i和j向下取整的结果,用来确定两个节点的中间值,通过左右节点与中间值作比较来更新节点
1.3 改动版 (判断谁来替代m节点)
如果现在我们的i和j最开始不指向节点,而是指向数组之外,既i,j = -1,len(nums),更新节点时让节点停留在m的位置,而不像靠近m的方向加1,这意味着左右节点永远指向target,即i和j代表**target的左右边界,实际是由闭区间改到开区间,**这种想法似乎也可以
1 | def binarySearchBasic(nums,target): |
图解:

有开区间和闭区间就会有左开右闭和左闭右开区间,不在赘述
1.4 平衡版(平衡了迭代过程中的比较次数)
当target在数组的位置靠左边时,这里极端一点让他在第一个,似乎按照上面的算法在每一次循环中只需要判断
1 | if target < nums[m]: |
但是如果target在数组的最后一个元素 ,在第一个if语句判断都为false后,再去进行下一个elif的判断似乎用了更多的成本,下面这个方法平衡了查找的成本
思路:
既然老方法在最坏的情况下每次查找都要进行两次比较,那何不把elif去掉,只留下双分之结构。
现在不让i和j一定要指向target了,而是不断缩小j-i的范围到只剩下一个元素,判断这个元素是不是等于target。
其实通俗易懂的讲,if target < a[m],那就有else target >= a[m],等循环结束再来看target到底是大于还是等于a[m]
1 | def binary_search3(a, target): |
图解:

优势:
减少循环内平均比较次数(数据量大时明显)
不存在最好情况,是在循环外进行target比较,时间复杂度不改变
1.4 Leftmost
现在有另外一个问题,如果数组中存在有一些相同的元素,经过排序后他们就挨在一起了,那么我在进行二分查找的时候似乎取出的值并不可控,那有没有一种方法能够取出指定的值呢?
接下来介绍取出最左侧target元素的方法。
上面说到当target == nums[m]时我们停止循环返回索引,但是这并不能保证时最左侧的元素,那我们如果在相等后将当前num[m]作为候选值,然后继续缩小j的值来让查找范围向做移动来找下一个候选值,似乎可行
代码:
1 | def binary_Leftmost(nums,target): |
图解:

1.5 Rightmost
有Leftmost肯定就会有Rightmost啦,但是这里不单单是上面代码简单修改,而是考虑在小于等于目标target的最靠右的索引
代码:
1 | def Rightmost(nums,target): |
图解:

2.Leetcode实战
2.1 X的平方根
给你一个非负整数 x
,计算并返回 x
的 算术平方根 。
由于返回类型是整数,结果只保留 整数部分 ,小数部分将被 舍去 。
**注意:**不允许使用任何内置指数函数和算符,例如 pow(x, 0.5)
或者 x ** 0.5
。
示例 1:
1 | 输入:x = 4 |
示例 2:
1 | 输入:x = 8 |
这个题是常规的二分查找,但是需要留意两个问题来提升算法效率
按照常规解法,输入的是一个列表和一个target,但是这样就需要我们自己生成列表,当x的值很大时,这样的方法就会导致测试用例不通过,因此我们直接去掉这个列表参数,通过比较target和mid的值,不断更新target,也就是说,现在mid不是下标了,而是实际的比较值,这道题给我的启示是当列表数字连续时,常规二分法传入的列表参数似乎就没有那么有必要了。
另外,我发现当x > 2时,算术平方所得的数字似乎都要比x的一半小,所以我们再循环外设置一个额外的条件,再循环内部将right的初始值设置成target//2,以此来优化程序。
代码:
1 | class Solution(object): |
2.2 寻找比目标字母大的最小字母
给你一个字符数组 letters
,该数组按非递减顺序排序,以及一个字符 target
。letters
里至少有两个不同的字符。
返回 letters
中大于 target
的最小的字符。如果不存在这样的字符,则返回 letters
的第一个字符。
示例 1:
1 | 输入: letters = ["c", "f", "j"],target = "a" |
示例 2:
1 | 输入: letters = ["c","f","j"], target = "c" |
示例 3:
1 | 输入: letters = ["x","x","y","y"], target = "z" |
代码:
1 | class Solution(object): |
2.3 有序数组中的单一元素
给你一个仅由整数组成的有序数组,其中每个元素都会出现两次,唯有一个数只会出现一次。
请你找出并返回只出现一次的那个数。
你设计的解决方案必须满足 O(log n)
时间复杂度和 O(1)
空间复杂度。
示例 1:
1 | 输入: nums = [1,1,2,3,3,4,4,8,8] |
示例 2:
1 | 输入: nums = [3,3,7,7,10,11,11] |
这个题说难不难,说简单也不简单,主要是从原有的判断大小的基础上更改为判断nums[mid]和前一个还是后一个元素相等 。
先来说说这道题下标和元素值的关系,如果没有目标值存在的情况下,数组中都是两个相同的数字来排列,那么对于这两个相同的数字,我们给他取名叫做数对,下标呈现的状态肯定是先偶后奇,也就是说,**如果在某一个数对之前插入一个单一的目标值,那么他后面的数对所对应的下标奇偶肯定会调转,而数对之前的数没有影响,**因此我们通过设置mid指针,确保mid指针始终指向下标为偶数的地方,通过目标值对数组的影响来更新mid,这也满足二分法的思想。
下面是更加具象化的图解:
第一种情况,target在mid之前,看偶数下标上的mid是和mid-1相等,因此数对下标呈现先奇数,后偶数的情况,target在左边部分,更新right的值:right = mid - 2

第二种情况, 更新left的值:left = mid + 2

目前能够将数组排列的通解考虑进来,但是存在一个特殊情况
当目标值在数组第一个或者最后一个位置时,left和right最后会指向同一个节点,(首节点或尾节点),这样再来判断mid+1是行不通的,但是这个节点刚好是目标值,所以直接就返回了

代码:
1 | class Solution(object): |
这道题属于是魔改最小二乘法
2.4 寻找旋转排序数组中的最小值
已知一个长度为 n
的数组,预先按照升序排列,经由 1
到 n
次 旋转 后,得到输入数组。例如,原数组 nums = [0,1,2,4,5,6,7]
在变化后可能得到:
- 若旋转
4
次,则可以得到[4,5,6,7,0,1,2]
- 若旋转
7
次,则可以得到[0,1,2,4,5,6,7]
注意,数组 [a[0], a[1], a[2], ..., a[n-1]]
旋转一次 的结果为数组 [a[n-1], a[0], a[1], a[2], ..., a[n-2]]
。
给你一个元素值 互不相同 的数组 nums
,它原来是一个升序排列的数组,并按上述情形进行了多次旋转。请你找出并返回数组中的 最小元素 。
你必须设计一个时间复杂度为 O(log n)
的算法解决此问题。
示例 1:
1 | 输入:nums = [3,4,5,1,2] |
示例 2:
1 | 输入:nums = [4,5,6,7,0,1,2] |
示例 3:
1 | 输入:nums = [11,13,15,17] |