引子
早些年笔者一次真实的美团面试经历。
面试官:请问Redis有哪些数据结构?
我:string、hash、list、set、zset、 bitmaps、hyperloglogs、geospatial、布隆过滤器。
面试官:zset主要用什么应用场景?
我:zset主要是一种有权重的set集合,因为每一个元素都可以有一个“分数”,redis提供了根据这个分数操作的api,比如取分数前几的啊,所以我们可以用作一些类似于排行榜的东西,比如点赞次数前几名的啦,,叭叭叭。
面试官:嗯,,,那它的底层的数据结构有了解过么?
我:底层在数据少的时候是压缩链表,数据量多了的话会进化成跳表。
面试官:嗯,为什么用跳表呢?
我:因为跳表使链表有近似于二分查找的时间复杂度,通过向上建立索引层,使得寻找节点变得很快。
面试官:那这种红黑树不是更好么?Redis的作者为什么选择链表呢?
我:因为跳表虽然会占据很多的内存空间,但是实现起来比较简单,而且对范围查询这个功能比红黑树要好很多,而这也是zset的主要功能,我想这也是使用Redis的主要原因。
其实Redis这个中间件我想大家的使用频率应该是相当之高,基本上我想现在不会有项目中没有用到Redis,所以面试官面试的时候基本上也是必须要问的,那么你怎么表现出你的与众不同呢?底层数据结构的了解绝对是必不可少的。
我们都知道链表这种数据结构,好处就是不用一块连续的内存空间,但是查询的时候需要从头节点依次往后遍历,时间复杂度就是O(n),而数组因为是一块连续的内存空间,在有序数组的查找方面,我们就可以使用一种高效的查找算法,二分查找。那么,如果说链表也要实现这种性能的查找呢?这就需要我们了解一种新的数据结构,跳表。
跳表的原理
下面是一个普通的排序单向链表。
如果你想寻找这个链表中有没有节点8,那么你就要从节点1开始,依次向后遍历,直到遍历到一个数值等于8的节点,返回true这个答案。
那么如何能让这个遍历的次数减少呢?
我们可以考虑这原有链表上面建立一个索引层,索引层的节点是最底层链表节点的一部分,然后索引层的节点有一个指针指向下一层节点自己的本体节点。
在查询的时候,我们可以从索引层去查询,先找到节点1,然后到节点4,然后到节点7,节点7之后没有节点了,那么就去下一层去寻找,下一层的节点7之后就是节点8,这次我们只遍历了4次就找到了答案。
那么上层的节点层的个数取多少合适呢?如果我们下层的节点个数是10W个,而上层只有三个索引节点的话,从节点7之后遍历,也要遍历9W多个,肯定是达不到我们预期的效率的。所以最好的策略就是,上层节点的个数是下层节点个数的一半,且尽量是随机均匀的,这样就可以节省一半的遍历次数,比如有10W个节点,我们就建立5W个索引节点,这样遍历次数就可以减少一半。
但是遍历5W次肯定也是不行的,我们知道O(n)和O(2n)这种在算法中是近似相等的,那么怎么办呢?一层不够我们就建立两层,两层不够就建立三层。直到最上层的节点个数是一两个,这样一层一层查找下去,我们就可以做到对数级别的时间复杂度,就可以媲美二分查找。
这样的话,肉眼可见的我们需要消耗更多的空间去存储这些多余的索引层的节点,那么具体需要多多少空间的,我们以最理想的情况下举例,第一层索引层是实体层的一半,然后第二层索引层是第一层索引层的一半,(n/2 + n/4 + n/8 + … + 1)差不多就是多出一个n的样子,所以跳表理论上要多消耗O(n)的内存空间。但是我们的索引层只做索引,可以不存具体的值,这样就可以节省不少的内存空间了。
为什么不选红黑树
至于为什么不选择红黑树来做这件事情呢?红黑树了解的也拥有二叉搜索树的特性,查找节点的时候拥有二分查找的性能,而且不用多余的内存空间。但是红黑树对范围搜索的支持就比较单调了,使用跳表来做范围查询,只需要找到头节点,然后往后遍历就好了,但是红黑树呢?找到头节点之后,你依然不知道它之后节点在哪,你只知道在左子树上,剩下你啥都不知道,就要继续遍历去找,就很烦。
Redis的作者也说明过为什么使用跳表,大概就三点
- 虽然跳表消耗较多的内存,但是我们内部优化过跳表,可以接受。
- 跳表的实现很简单,至少比红黑树简单的多。
- 多范围查询的支持,跳表完胜红黑树。
关于什么时候要向上建立索引
我们在新加一个节点的时候,可以考虑一下,现在最多有几层索引,如果现在一层都没有,你上来就建了8层,就很没有必要,索引新加一个节点最好最多再往上简历一层索引,那么具体多少层,可以这么想,在第一层建立的概率是1/2,第二层建立的概率是1/4,第三层建立的概率是1/8…代码怎么实现呢?作为暖男,我肯定写好了噻。
private int randomLevel() {
int level = 1;
while (level < maxLevel && Math.random() > 0.5) {
level++;
}
return level;
}
代码
java工具库中有现成的跳表ConcurrentSkipListMap,但是我还是建议用自己的语言,手敲一遍, 下面是我敲得java版的实现。
package com.darwin.algorithm.structure;
/**
* 跳表
*
* @author yanghang
*/
public class SkipList {
double factor = 0.5d;
int maxLevel = 16;
int currentMaxLevel = 1;
Node head = new Node(maxLevel, -1);
public Node find(int value) {
Node p = head;
// 从最大层开始查找,找到前一节点,通过--i,移动到下层再开始查找
for (int i = maxLevel - 1; i >= 0; --i) {
while (p.nextNodes[i] != null && p.nextNodes[i].value < value) {
// 找到前一节点
p = p.nextNodes[i];
}
}
if (p.nextNodes[0] != null && p.nextNodes[0].value == value) {
return p.nextNodes[0];
} else {
return null;
}
}
public boolean insert(int value) {
// level代表这个节点存在于几层链表中
int level = randomLevel();
if (level > currentMaxLevel) {
level = ++currentMaxLevel;
}
Node newNode = new Node(level, value);
Node point = head;
for (int i = currentMaxLevel - 1; i >= 0; i--) {
while (point.nextNodes[i] != null && point.nextNodes[i].value < value) {
point = point.nextNodes[i];
}
if (level > i) {
Node temp = point.nextNodes[i];
point.nextNodes[i] = newNode;
newNode.nextNodes[i] = temp;
}
}
return true;
}
private int randomLevel() {
int level = 1;
while (level < maxLevel && Math.random() > factor) {
level++;
}
return level;
}
public void printAll(int level) {
if (level > currentMaxLevel) {
throw new RuntimeException("还没有到这个层数");
}
Node point = head;
while (point.nextNodes[level - 1] != null) {
System.out.print(point.nextNodes[level - 1] + " ");
point = point.nextNodes[level - 1];
}
System.out.println();
}
static class Node {
/**
* 节点的值
*/
int value;
/**
* 这个节点在某一层的下一个节点的集合,比如nextNodes[2]就是node在第二层的下一个节点
*/
Node[] nextNodes;
public Node(int level, int value) {
nextNodes = new Node[level];
this.value = value;
}
@Override
public String toString() {
return "Node{" +
"value=" + value +
'}';
}
}
public static void main(String[] args) {
SkipList skipList = new SkipList();
for (int i = 0; i < 1000; i++) {
skipList.insert(i);
}
for (int i = skipList.currentMaxLevel; i > 0; i--) {
skipList.printAll(i);
}
}
}