0%

二分查找(上)

今天学习二分查找。

定义

二分查找针对的是一个有序的数据集合,查找思想有点类似分治思想。每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# -*- coding:utf-8 -*-
from typing import List


def binary_search(nums: List[int], target: int) -> int:

low, high = 0, len(nums) - 1

while low <= high:
# mid = low + ((high - low) >> 1)
mid = low + (high - low) // 2

if nums[mid] == target:
return mid
elif nums[mid] <= target:
low = mid + 1
else:
high = mid - 1
return -1


if __name__ == '__main__':
a = [1, 2, 3, 4, 5, 6, 9, 10]
print(binary_search(nums=a, target=2)) # 1
print(binary_search(nums=a, target=3)) # 2
print(binary_search(nums=a, target=11)) # -1

递归

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# -*- coding:utf-8 -*-
from typing import List


def binary_search_by_recursion(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:
return binary_search(nums[mid+1:], target)
else:
return binary_search(nums[:mid-1], target)
return -1

if __name__ == '__main__':
a = [1, 2, 3, 3, 4, 5, 6, 9]
print(binary_search_by_recursion(nums=a, target=2)) # 1
print(binary_search_by_recursion(nums=a, target=3)) # 3
print(binary_search_by_recursion(nums=a, target=10)) # -1

易错点

  1. 循环退出条件

    注意是low<=high,而不是low<high。当low=high时,如果此时刚好a[low]=a[high]=target,加上终止条件low<high退出循环,导致查找失败。

  2. mid的取值

    实际上,mid=(low+high)//2这种写法是有问题的。因为如果lowhigh比较大的话,两者之和就有可能溢出。改进的方法是将mid的计算方式写成low+(high-low)//2。更进一步,如果要将性能优化到极致的话,可以将除以2操作转化成位运算low+(high-low)>>1。因为相比除法运算来说,计算机处理位运算要快得多。

    为什么右移一位相当于除以2,左移一位相当于乘以2?以十进制数举例。如136,左移一位后,空出的位置用0填补,左移一位相当于把小数点右移一位,就变成了1360,相当于136*10;同理,136右移一位后,移出的位舍去,就变成了13,相当于136/10。因此,十进制左移一位相当于乘以10,右移一位相当于除以10。对于二进制的移位操作同样适用。

  3. lowhigh的更新

    low=mid+1high=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
    19
    def 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)) # 出现死循环

局限性

  1. 二分查找依赖的是顺序表结构,简单点说就是数组。主要原因是二分查找算法需要按照下标随机访问元素。二分查找只能用在数据是通过顺序表来存储的数据结构上。

  2. 二分查找针对的是有序数据。二分查找只能用在插入、删除操作不频繁,一次排序多次查找的场景。针对动态变化的数据集合,二分查找将不再使用。

  3. 数据量太小不适合二分查找。

    如果要处理的数据量很小,完全没必要用二分查找,顺序遍历就足够了。

    如果数据之间的比较操作非常耗时,不管数据量大小,都推荐使用二分查找。比如,数组中存储的都是长度超过300的字符串,如此长的两个字符串之间比对大小,就会非常耗时。需要尽可能地减少比较次数,而比较次数的减少会大大提高性能,此时二分查找就比顺序遍历更有优势。

  4. 数据量太大也不适合二分查找。二分查找的底层需要依赖数组这种数据结构,而数组为了支持随机访问的特性,要求内存空间连续,对内存的要求比较苛刻。

解答开篇

假设我们有1000w个整数数据,每个数据占8个字节,如何设计数据结构和算法,快速判断某个整数是否出现在这1000w数据中?希望这个功能不要占用太多的内存空间,最多不超过100MB,该怎么做?

内存限制是100MB,每个数据大小是8字节,最简单的办法就是将数据存储在数组中,内存占用为1000w*8/1024/1024=76.3MB,符合内存的限制。可以先对这1000w数据从小到大排序,然后再利用二分查找算法,就可以快速地查找想要的数据了。

虽然大部分情况下,用二分查找可以解决的问题,用散列表、二叉树都可以解决。但是,不管是散列表还是二叉树,都会需要比较多的额外的内存空间。二分查找底层依赖的是数组,除了数据本身之外,不需要额外存储其他信息,是最省内存空间的存储方式,所以刚好能在限定的内存大小下解决这个问题。

课后思考

  1. 如何编程实现“求一个数的平方根”?要求精确到小数点后6位。参考LeetCode答案:Python 开根号 保留小数点后几位

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    def 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)
  2. 如果数据使用链表存储,二分查找的时间复杂就会变成很高,那查找的时间复杂度究竟是多少呢?

    来自置顶评论的答案:假设链表长度为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)\)

参考链接:

  1. 汇编中移位,为什么左移一位相当于乘以2
您的支持将鼓励我继续创作!