今天学习二分查找。
定义
二分查找针对的是一个有序的数据集合,查找思想有点类似分治思想。每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为0。
时间复杂度
假设数据大小是n
,每次查找后数据都会缩小为原来的一半,也就是会除以2。最坏情况下,直到查找区间被缩小为空,才停止。被查找区间的大小变化:
\[
n, \frac{n}{2}, \frac{n}{4}, \frac{n}{8},...\frac{n}{2^k}...
\] 其中, \[
当\frac{n}{2^k}=1时,
\] k
的值就是缩小的次数。而每一次缩小操作只涉及两个数据的大小比较,所以,经过了k
次区间缩小操作,时间复杂度就是\(O(k)\)。通过\(\frac{n}{2^k}=1\),得到\(k=log_2n\),所以时间复杂度就是\(O(logn)\)。
常量级时间复杂度的算法有时候可能还没有\(O(logn)\)的算法执行效率高。比如,\(O(1)\)有可能表示的是一个非常大的常量值,比如\(O(1000)\),\(O(10000)\)。而logn
即使n
非常非常大,对应的logn
也很小。比如,n
等于2的32次方,大约是42亿,也就是说,如果在42亿个数据中用二分查找一个数据,最多需要比较32次。
实现
非递归
1 | # -*- coding:utf-8 -*- |
递归
1 | # -*- coding:utf-8 -*- |
易错点
循环退出条件
注意是
low<=high
,而不是low<high
。当low=high
时,如果此时刚好a[low]=a[high]=target
,加上终止条件low<high
退出循环,导致查找失败。mid
的取值实际上,
mid=(low+high)//2
这种写法是有问题的。因为如果low
和high
比较大的话,两者之和就有可能溢出。改进的方法是将mid
的计算方式写成low+(high-low)//2
。更进一步,如果要将性能优化到极致的话,可以将除以2操作转化成位运算low+(high-low)>>1
。因为相比除法运算来说,计算机处理位运算要快得多。为什么右移一位相当于除以2,左移一位相当于乘以2?以十进制数举例。如136,左移一位后,空出的位置用0填补,左移一位相当于把小数点右移一位,就变成了1360,相当于
136*10
;同理,136右移一位后,移出的位舍去,就变成了13,相当于136/10。因此,十进制左移一位相当于乘以10,右移一位相当于除以10。对于二进制的移位操作同样适用。low
和high
的更新low=mid+1
,high=mid-1
。注意这里的+1
和-1
,如果直接写成low=mid
或者high=mid
,就可能会发生死循环。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19def binary_search(nums: List[int], target: int) -> int:
low, high = 0, len(nums) - 1
while low <= high:
mid = low + (high - low) // 2
if nums[mid] == target:
return mid
elif nums[mid] < target:
low = mid
else:
high = mid
return -1
if __name__ == '__main__':
a = [1, 2, 2, 4, 5, 6, 9, 10]
print(binary_search(nums=a, target=3)) # 出现死循环
局限性
二分查找依赖的是顺序表结构,简单点说就是数组。主要原因是二分查找算法需要按照下标随机访问元素。二分查找只能用在数据是通过顺序表来存储的数据结构上。
二分查找针对的是有序数据。二分查找只能用在插入、删除操作不频繁,一次排序多次查找的场景。针对动态变化的数据集合,二分查找将不再使用。
数据量太小不适合二分查找。
如果要处理的数据量很小,完全没必要用二分查找,顺序遍历就足够了。
如果数据之间的比较操作非常耗时,不管数据量大小,都推荐使用二分查找。比如,数组中存储的都是长度超过300的字符串,如此长的两个字符串之间比对大小,就会非常耗时。需要尽可能地减少比较次数,而比较次数的减少会大大提高性能,此时二分查找就比顺序遍历更有优势。
数据量太大也不适合二分查找。二分查找的底层需要依赖数组这种数据结构,而数组为了支持随机访问的特性,要求内存空间连续,对内存的要求比较苛刻。
解答开篇
假设我们有1000w个整数数据,每个数据占8个字节,如何设计数据结构和算法,快速判断某个整数是否出现在这1000w数据中?希望这个功能不要占用太多的内存空间,最多不超过100MB,该怎么做?
内存限制是100MB,每个数据大小是8字节,最简单的办法就是将数据存储在数组中,内存占用为1000w*8/1024/1024=76.3MB
,符合内存的限制。可以先对这1000w数据从小到大排序,然后再利用二分查找算法,就可以快速地查找想要的数据了。
虽然大部分情况下,用二分查找可以解决的问题,用散列表、二叉树都可以解决。但是,不管是散列表还是二叉树,都会需要比较多的额外的内存空间。二分查找底层依赖的是数组,除了数据本身之外,不需要额外存储其他信息,是最省内存空间的存储方式,所以刚好能在限定的内存大小下解决这个问题。
课后思考
如何编程实现“求一个数的平方根”?要求精确到小数点后6位。参考LeetCode答案:Python 开根号 保留小数点后几位
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16def binary_search_sqrt(x, k):
low, high = 0, x
while low <= high:
mid = low + (high-low) / 2
if abs(mid * mid - x) < 0.1 ** k:
# 在误差范围内 保留指定位数
return round(mid, k)
elif mid * mid > x:
high = mid
else:
low = mid
if __name__ == '__main__':
result = binary_search_sqrt(2, 6) # 1.414214
result = binary_search_sqrt(4, 6) # 2.0
print(result)如果数据使用链表存储,二分查找的时间复杂就会变成很高,那查找的时间复杂度究竟是多少呢?
来自置顶评论的答案:假设链表长度为
n
,二分查找每次都要找到中间点(计算中忽略奇偶数差异):第一次查找中间点,需要移动指针
n/2
次,第二次查找中间点,需要移动指针
n/4
次,第三次查找中间点,需要移动指针
n/8
次,...
以此类推,直到1次为止。
总共移动指针次数,根据等比数列求和公式: \[ S_n = \frac{a_1*(1-q^n)}{1-q}=\frac{a_nq-a_1}{q-1}=\frac{1*\frac{1}{2}-\frac{n}{2}}{\frac{1}{2}-1}=n-1 \] 所以,时间复杂度是\(O(n)\)。
参考链接: