一 背景
索引这玩意太常见了,计算机只做两件事情,一是把信息存入电脑,二是把信息从电脑中取出来,所以搜索占了计算机功能的一半,当然很重要,而要加快搜索,索引又是必须的;索引的本质是什么,我理解索引就是可以将我们搜索问题的空间缩小,从而在一个很小的范围内快速找到要的数据。
一切可以减少搜索空间的手段都可以称为索引,简单的来说,我们将数据按照时间命名文件存储,一分钟,或10分钟存储一个文件,在我们搜索的时候就可以通过文件名过滤掉大量的不在此时间范围内的数据,这种非常简单的文件名作为索引,在特定的场合还是很实用的。
二 索引设计的考虑
2.1 索引和数据分离
大多数场合索引是要和数据相互分离的,分离的好处是可以让整个索引的数据量更小,可以完全加载到内存中,方便搜索,当然也有例外,Innodb引擎用的B+树索引,数据和索引是在一起存储的,但是实际上B+树的前几层仍然可以完全可以加载到内存中,这样实际上索引和数据还是分离的,只是磁盘上存在一起而已。
2.2 常用的索引结构
2.2.1 数组
索引是为了加快搜索的,同时本身也需要进行搜索的,搜索最快的时间算法是O(1)类的,比如数组的随机访问,比如Hashmap的查找。
数组只所以可以随机访问: 1,是其每个元素的大小相同的; 2,每个元素的计算可以通过(开始地址+index*单个元素大小)。
所以最简单的索引就是数组了,比如一个班级里面有60个学生,我们对60个学生进行从0-59进行编号,用数组保存学生信息,通过学生编号可以快速定位到一个学生。
但是数组也有局限,就是如果学生的编号特别长,或者我想通过名字来查询学生,难道只能遍历嘛?所以数组的缺点:一是,不可能申请很大的数组,数组的内存必须是连续的,不然随机个屁啊,如果数组很大,需要申请很大的存储空间,那就有很大的几率导致申请失败;二是,作为索引来说,只适合用数组下标做快速查询,其他查询只能遍历。三是,数组是连续的,如果作为索引,要实时更新就会能恼火,因为数组插入要移动数组,如果数组原来申请空间不够了,要重新申请一块大的内存,然后拷贝过去,想想都累。
2.2.2 哈希表
哈希表从本质上来说就是个特殊的数组,也是利用了数组可以支持随机访问的特性。对于数组的前两个缺点都可以通过哈希表来解决。比如我们如果通过姓名来查询学生,可以用以下方式建个hash索引:
我们引入了哈希函数,可以将"姓名" 字符串通过hash函数将姓名映射成一个数字,然后再对整个数组取余就得到了存储的位置,然后我们就可以将这个学生保存在数组为1的位置。
由于哈希函数的性能一般很好,所以查询的时候,我们通过哈希函数快速定位到学生在数组中的位置就可以取出数据了。
当然哈希表也不是没有缺点的,哈希函数将无限多个名字,映射到有限的数组长度,无限对有限,所以肯定有哈希冲突,就是不同的名字,但是得到一样的哈希值,这个问题怎么解决?
虽然我们倾向于使用更均衡的哈希算法,但是,仍然存在哈希冲突的问题,一般解决办法:
开放寻址法,即冲突了就把id直接加1或加上i^2,如果不空就存在这个位置,为空的时候就继续把索引位置加上一个数字继续寻找下一个位置;这样的坏处是查询的时候,如果当前位置值不为空,但是不等于要查询的值,只能继续找下一个位置,直到找到数据或者下一个位置为空为止。
或者再次通过一个哈希函数,得到新的位置,判断是否被占用,这种方法可能更均匀,但是如果多次都探测冲突也是挺麻烦的。
拉链法 即在冲突的位置元素用一个链表保存起来,这样做的好处,可以无限插入数据;坏处是如果冲突过多,O(1)的查找性能,结果因为链表太长变成O(n)了。如下图:
拉链法,方便插入数据,无限量。
坏处是如果冲突过多,会造成拉链过长,查询性能下降。当然会有很多优化方法,比较好的思路,比如我们按照java8实现hashmap思路,链表转成了红黑树,提升搜索性能,从O(N)提升到O(log2(N))
。
哈希表作为索引有不少优点,但是也有缺点:1 . 不支持范围查询,按照上面的例子,如果是按照学生姓名查询会很快查询出来,如果查询在一定年龄阶段的学生,就算我们用学生的年龄做hash,但是每个数组之间没有顺序关系,仍然需要遍历整个哈希表这个代价很大。
为了减少哈希冲突,负载因子不能太大,会造成有不少空位,造成空间浪费。
三 一些优化
3.1 链表查询优化
我们如果用拉链法来解决哈希冲突,必然会造成查询性能低,优化的时候可以学着java8那样,将链表改成红黑树,不过红黑树自己实现的话很复杂,可以试试跳表。
如果一个有序保存数字的数组,查找的时候,我们可以通过二分法快速查找;同样有一个有序的链表,也是保存数字,但是却不能用二分查找,因为链表的查找要遍历,数组可以O(1)时间内,取到中间元素,对于链表来说取到中间元素时间复杂度为O(N)。那么我们有没有办法直接获取到链表的中间元素那,有的,那就是直接建立多层链表,第一层是普通链表,第二层可以指向下一个节点的下一个节点,即每隔一个节点取一个节点,第三层可以每隔3个节点取一个节点,如下图:我们用一个next数组来保持多层指针,在查找的时候,先从最高层查找,如果值大于
a[4]
,就下降一层,在下一层的a[4]的next[1]
指针 对下继续寻找,如果值为小于a[6]
,则继续下沉到0层的a[4]的next[0]
的找到a[5]
,比较a[5]
和查找数值,如果相同,则查找找到,如果不相等则判断下一个a[6]
和查找值不相等,返回。
3.2 位图优化
位图是一种可以看成特殊的哈希函数,在大数据中常常用来判断是否不存在,比如举个例子,如果我们的IP是ipv4版本,ipv4可以转成一个32位的正整数,如果用一个整数数组来表示保存ipv4地址段,整个地址段则需要:4* 2^32
即需要16GB内存,显然如果直接保存在数组里面,虽然很快判断出来数据是否存在,但是内存占用过多。
我们需要知道的信息是IPv4是否存在,不需要保存太多信息,则1位数据就可以保存了,那么保存整个IPv4地址段为:2^32/8
即512MB内存保存整个IPV4地址段,我们申请一个512MB的int数组,用每一位表示一个ipv4,这个就是位图。这是一种特殊的哈希函数,数字直接映射到位。比如一个 ipv4转成整数为10,然后/8,商为1即是第一个int,余2即是第三位,我们将这一位设置为1:下次查询的时候,也按照同样的方法,去取对应位置是否为1,为1说明存在,为0,说明不存在。
布隆过滤器优化位图位图在已经以对整数缩到了极致,如果这时候数据还不够存,怎么办,比如我们保存数据整数是无边界的,那是不是没有办法了那,我们刚才聊到哈希表,同样我们可以使用哈希函数将整数通过哈希函数映射到有限的数组中,同样存在着将无限的数据映射到有限的数组中,就存在着冲突的情况,为了减少冲突,我们通过多个哈希函数映射到不同的位置。下次查询的时候,将查询值同样通过多个哈希函数映射到不同的位置,查询这些位置是否都为1,是1的就查询值可能存在(当然实际上可能不存在,假阳性),如果有一个为0,则就可以明确认为这个查询值是不存在的。如下图:
Roaring Bitmap升级版本位图Roaring Bitmap是个非常牛逼的技术了,非常巧妙的方法来优化位图。我们先来看看原理,它算是一种压缩位图了,还是以刚才ipv4的为例,如果我们用4个字节保存,刚好可以把所有的ipv4地址都保存下来,但是我们实际程序中很可能是用不了这么多地址的,就造成了浪费,所以我们可以换成如下的保存方法:
我们把一个4个字节的整数分成两个部分,高16个字节,低16个字节,请忽略我上面只画了8位,然后那,前16个字节我们直接转成一个整数存放在两个字节的可扩展的有序数组中,数组有一个指针指向低16位构造的bitmap,如上图。
这样做的好处是,如果两个整数值,高16位相同,就可以共用一个数组,不存在的高16位不需要任何分配的空间,考虑下极端的情况,如果所有的整数的高16位相同,那么整个存储只需要2个字节的高位数组和2^16/8
( 即8KB的固定空间);如果我们把高16位组成的有序数组完全分配需要的空间为:2* 2^16
(2表示一个数组为两个字节,共有2^16次方个,即128KB空间)。
如果每个高16位都有,则总共需要的空间是:2^17*2^16/8 = 2^30
,看起来比原来占用的2^29
要多,一倍,但是现实中,高16位绝大多数情况下是不可能有的,而且相同的高16位也不少见,这就极大压缩了位图。
Roaring Bitmap
更进一步,我们现在低16位固定需要2^13
即8KB的空间,如果低16位的数据量也很小,我们也可以直接用2字节的数组表示,8KB/2 = 4096
即当低16位的数字的个数少于4096个的时候,可以直接用2个字节的数组来表示。
当然这种Roaring Bitmap
位图也不是没有缺点的,查询的时候我们同样将查询的32位数字的时候,将这个整数分成高16位和低16位,高16位可以通过二分查找法(高字节组成的数组有序)查找,然后再查低位。位图的查询性能从O(1)
降低到了O(log(N))
,这又是一个典型的时间换空间的例子。
这里面将不存在的高位和重复的高位进行压缩,思想值得细细体会。
还有很多其他数据结构的索引,比如B+树索引,LSM树索引,后续有空再聊吧。
四 诗词欣赏
徐州
清 邵大业
龙吟虎啸帝王州,旧是东南最上游。
青嶂四围迎面起,黄河千折挟城流。
炊烟历乱人归市,杯酒苍茫客倚楼。
多少英雄谈笑尽,树头一片夕阳浮。