【二分查找】
二分查找针对的是一个有序的数据集合,查找思想有点类似分治思想。每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为 0。
二分查找是一种非常高效的查找算法,时间复杂度是 O(logn)。O(logn) 这种对数时间复杂度,是一种极其高效的时间复杂度,有的时候甚至比时间复杂度是常量级O(1) 的算法还要高效。二分查找更适合处理静态数据,也就是没有频繁的数据插入、删除操作。
使用循环和递归都可以实现二分查找。
二分查找应用场景的局限性:
-
二分查找依赖的是顺序表结构,简单点说就是数组。(链表不可以)
-
二分查找针对的是有序数据。(如果数据没有序,我们需要先排序。)
-
数据量太大不适合二分查找。
四种常见的二分查找变形问题
1.查找第一个值等于给定值的元素
2.查找最后一个值等于给定值的元素
3.查找第一个大于等于给定值的元素
4.查找最后一个小于等于给定值的元素
适用性分析
1.凡事能用二分查找解决的,绝大部分我们更倾向于用散列表或者二叉查找树,即便二分查找在内存上更节省,但是毕竟内存如此紧缺的情况并不多。
2.求“值等于给定值”的二分查找确实不怎么用到,二分查找更适合用在”近似“查找问题上。比如上面讲几种变体。
【跳表】
跳表是一种动态数据结构,可以支持快速的插入、删除、查找操作,写起来也不复杂,甚至可以替代红黑树(Red-black tree)。Redis 中的有序集合(Sorted Set)就是用跳表来实现的。
链表加多级索引的结构,就是跳表。
在一个单链表中查询某个数据的时间复杂度是 O(n)。那在一个具有多级索引的跳表中查询任意数据的时间复杂度是 O(logn)。这个查找的时间复杂度跟二分查找是一样的。换句话说,我们其实是基于单链表实现了二分查找。(这种查询效率的提升,前提是建立了很多级索引,也就是空间换时间的设计思路。)
跳表的空间复杂度是O(n)。也就是说,如果将包含 n 个结点的单链表构造成跳表,我们需要额外再用接近 n 个结点的存储空间。
在实际的软件开发中,原始链表中存储的有可能是很大的对象,而索引结点只需要存储关键值和几个指针,并不需要存储对象,所以当对象比索引结点大很多时,那索引占用的额外空间就可以忽略了。
跳表这个动态数据结构,不仅支持查找操作,还支持动态的插入、删除操作,而且插入、删除操作的时间复杂度也是 O(logn)。
作为一种动态数据结构,我们需要某种手段来维护索引与原始链表大小之间的平衡,也就是说,如果链表中结点多了,索引结点就相应地增加一些,避免复杂度退化,以及查找、插入、删除操作性能下降。
跳表是通过随机函数来维护“平衡性”,当我们往跳表中插入数据的时候,我们可以选择同时将这个数据插入到部分索引层中。
为什么 Redis 要用跳表来实现有序集合,而不是红黑树?
Redis 中的有序集合支持的核心操作主要有下面这几个:
-
插入一个数据;
-
删除一个数据;
-
查找一个数据;
-
按照区间查找数据(比如查找值在 [100, 356] 之间的数据);
-
迭代输出有序序列。
对于按照区间查找数据这个操作,跳表可以做到 O(logn) 的时间复杂度定位区间的起点,然后在原始链表中顺序往后遍历就可以了。这样做非常高效。
散列表用的是数组支持按照下标随机访问数据的特性,所以散列表其实就是数组的一种扩展,由数组演化而来。可以说,如果没有数组,就没有散列表。
散列函数,可以把它定义成hash(key),其中 key 表示元素的键值,hash(key) 的值表示经过散列函数计算得到的散列值。
散列函数设计的基本要求:
1. 散列函数计算得到的散列值是一个非负整数;
2. 如果 key1 = key2,那 hash(key1) == hash(key2);
3. 如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)。
散列冲突
再好的散列函数也无法避免散列冲突。常用的散列冲突解决方法有两类,开放寻址法(open addressing)和链表法(chaining)。
开放寻址法的核心思想是,如果出现了散列冲突,我们就重新探测一个空闲位置,将其插入。三种探测方法是:线性探测(Linear Probing)、二次探测(Quadratic probing)、双重散列(Double hashing)。