二分查找
核心思想
二分查找针对的是一个有序的数据集合,查找思想有点类似分治思想。每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为 0。
O(logn)惊人的查找速度
O(logn)这是一种极其高效的时间复杂度,有的时候甚至比时间复杂度是常量级 O(1) 的算法还要高效。为什么这么说呢?
因为 logn 是一个非常“恐怖”的数量级,即便 n 非常非常大,对应的 logn 也很小。比如 n 等于 2 的 32 次方,这个数很大了吧?大约是 42 亿。也就是说,如果我们在 42 亿个数据中用二分查找一个数据,最多需要比较 32 次。
用大 O 标记法表示时间复杂度的时候,会省略掉常数、系数和低阶。对于常量级时间复杂度的算法来说,O(1) 有可能表示的是一个非常大的常量值,比如 O(1000)、O(10000)。所以,常量级时间复杂度的算法有时候可能还没有 O(logn) 的算法执行效率高。
简单实现
最简单的情况就是有序数组中不存在重复元素,我们在其中用二分查找值等于给定值的数据。
def binary_search(list, item):
low = 0
high = len(list) - 1
while low <= high:
mid = int((low + high) / 2)
guess = list[mid]
if guess == item:
return mid
if guess > item:
high = mid - 1
else:
low = mid + 1
return None
应用场景的局限性
- 二分查找依赖的是顺序表结构,简单点说就是数组。二分查找算法需要按照下标随机访问元素。
- 二分查找针对的是有序数据。
- 二分查找只能用在插入、删除操作不频繁,一次排序多次查找的场景中。更适合处理静态数据。
- 数据量太小不适合二分查找(没必要,顺序遍历就足够了)
- 数据量太大也不适合二分查找(需要太大的连续内存)
二分查找变形问题
-
查找第一个值等于给定值的元素
public int bsearch(int[] a, int n, int value) { int low = 0; int high = n - 1; while (low <= high) { int mid= low + ((high - low) >> 1); if (a[mid] > value) { high = mid - 1; } else if (a[mid] < value) { low = mid + 1; } else { if ((mid == 0) || (a[mid - 1] != value)) return mid; else high = mid - 1; } } return -1; }如果 mid 等于 0,那这个元素已经是数组的第一个元素,那它肯定是我们要找的;如果 mid 不等于 0,但 a[mid] 的前一个元素 a[mid-1] 不等于 value,那也说明 a[mid] 就是我们要找的第一个值等于给定值的元素。
如果经过检查之后发现 a[mid] 前面的一个元素 a[mid-1] 也等于 value,那说明此时的 a[mid] 肯定不是我们要查找的第一个值等于给定值的元素。那我们就更新 high=mid-1,因为要找的元素肯定出现在 [low, mid-1] 之间。
-
查找最后一个值等于给定值的元素
-
查找第一个大于等于给定值的元素
-
查找最后一个小于等于给定值的元素
跳表
-
跳表实质就是一种可以进行二分查找的有序链表。
-
跳表在原有的有序链表上面增加了多级索引,通过索引来实现快速查找。
-
跳表不仅能提高搜索性能,同时也可以提高插入和删除操作的性能。
-
跳表的时间复杂度为O(logn),空间复杂度为O(n)。
我们其实并不需要担心跳表会浪费内存,因为在实际的软件开发中,原始链表中存储的有可能是很大的对象,而索引结点只需要存储关键值和几个指针,并不需要存储对象,所以当对象比索引结点大很多时,那索引占用的额外空间就可以忽略了。
-
跳表支持高效的动态插入和删除,时间复杂度也是O(logn)
-
跳表索引动态更新:随机插入
当我们不停地往跳表中插入数据时,如果我们不更新索引,就有可能出现某 2 个索引结点之间数据非常多的情况。极端情况下,跳表还会退化成单链表。