前言 & 跳表概要
本文为个人使用跳表对抗 std::multimap
的优化总结,记录下优化过程中所学到的新知识,分享下好文。
跳表概要
跳表采用随机法决定链表中哪些节点应该增加向前指针以及在该节点中应增加多少个指针。
跳表结构的头节点需要有足够的指针域,以满足可能构造最大级数的需求,而尾节点不需要指针域。
跳表在原有的有序链表上增加了多级索引,通过索引来实现快速查找。
1、首先在最高级索引上查找最后一个小于当前查找元素的位置
2、调到次高级索引继续查找,直到调到最底层为止,如果查找元素存在的话,此刻已经十分接近要查找的元素的位置了。
关于我之前为什么说跳表是用到了倍增思想,感兴趣的朋友可以参考: OI-wiki 树上求LCA(最近公共祖先)使用倍增算法的经典解法
总之,都属于是一种空间换时间的思路,个人认为,最主要的区别在于倍增是严格采用2的幂次做跳跃步长,典型的二分另一种表现形式;跳表则是采用随机化的方式,通过调整概率避免层数添加过快过多,也正因为这个,跳表实际运用中节点的索引是比较“乱”的,理想状态下不会完全向下层逐层二分。
性能优化
1、提前缓存 层数随机数
在执行插入的时候需要通过随机法产生新节点的层数,百万数量级的插入,每次都会通过概率调用若干次随机数生成器,结合设定的最大层数来确定新节点的层数,那总共会调用多少次随机数生成器呢?
Redis的设计思路是:使用第n级索引是使用第n-1级索引概率的1/4,即概率因子是0.25。
假设平均每隔两个结点会抽出一个结点作为上一级索引的结点,那第一级索引的结点个数大约就是n/2,第二级索引的结点个数大约就是n/4,第三级索引的结点个数大约就是n/8,依次类推,也就是说,第k级索引的结点个数是第k-1级索引的结点个数的1/2,那第k级索引结点的个数就是n/(2^ k)。
总之,通过概率来控制随机的场景下,我们只能以期望的方式进行大概的计算,但期望终究是理想情况。
所以,还是得亲自实践,通过手动二分调整随机数缓存池的大小,在个人机上测试得出了一个较为稳定的结果:100万次的插入,随机数缓存池大小为40万,最大层数设置为16(Redis的Zset跳表设置的是32)
2、更高效的 随机数生成器
LCG(Linear Congruential Generator)
和 PCG(Permuted Congruential Generator)
两大伪随机方程算法下:混乱中的秩序——计算机中的伪随机数序列
c++中的随机数生成器大概有三种,
// 1、最简单的方法,先设置随机数种子,再使用rand()生成随机数
srand(time(0));
int x = rand();
// 2、创建带种子的随机数引擎,使用 离散均匀分布类 可生成指定区间内的随机数
std::default_random_engine e(std::time(0));
std::uniform_int_distribution<int> u(1, 100);
int x = u(e);
// 3、梅森旋转算法,理论速度最快,周期长达 2^19937−1 = 10^6001,分布十分均匀
std::mt19937 e(std::time(0));
std::uniform_int_distribution<int> u(1, 100);
int x = u(e);
经过测试,使用第三种理论最快的随机数生成器,确实在个人机上有更好的表现。
3、自增/自减 的写法
针对临时变量加一,我们显然有1、 i = i + 1
,2、 i += 1
,3、 i++
,4、 ++i
,这么多种写法。
结果肯定是一样的,但是从计算机编译原理的角度来讲,它们存在着差别,编译器也会根据情况不同进行指令优化。
- i++是先用临时对象保存原来的对象,然后对原对象自增,再返回临时对象,不能作为左值;++i是直接对于原对象进行自增,然后返回原对象的引用,可以作为左值。
- 由于要生成临时对象,i++需要调用两次拷贝构造函数与析构函数(将原对象赋给临时对象一次,临时对象以值传递方式返回一次);
- ++i由于不用生成临时变量,且以引用方式返回,故没有构造与析构的开销,效率更高。
4、成员变量的 修饰
c++11 新特性总结
c++11 静态成员在类中的初始化总结
在 C++中,如何最大程度规避宏定义?
关键字之 static,const,constexpr,volatile
重点是 constexpr
这个关键字,有这几条需要了解:
- constexpr修饰的函数,生效于编译时而不是运行时, 重点应用于修饰函数使其在编译期大幅度被解释
- constexpr修饰的函数,无论是普通函数,还是类成员函数,必须是编译器可计算得到结果,即字面常量,不可是运行时才能获取的内容
- constexpr与引用(指针):指针和引用都能定义为constexpr,但是他们的初始值受到严格控制,比如一个constexpr指针的初始值必须是nullptr或者0,或者是存储在某个固定地址中的对象。一般来说,静态变量和全局变量的对象地址是固定不变的,能够来初始化constexpr指针。
- constexpr与const区别:constexpr 仅对指针本身有效,与指针所指的对象无关:例如:const int *p=nullptr;//p是个指向整型常量的指针(point to const); 和 constexpr int *q=nullptr;//q是个指向整数的指针常量(const point);它们的区别在于constexpr把它所定义的对象变为顶层const。同时constexpr将后面定义的常量在程序编译阶段就计算出来了。
总之就是 constexpr
修饰静态常量,能在编译期进行优化。
5、不必要的 重复调用
由于跳表本质是链表,无论是插入还是删除操作,都需要先找到当前操作元素的前一位,并对链表索引进行更新。那么这是最开始编写在类中的私有成员: _search()
查找函数:
private:
vector<Node*> _search(double x) {
Node* cur = &head;
vector<Node *> prevs(MAX_LEVEL); // 创建vector
for (int i = maxlevel - 1; i >= 0; --i) {
while (cur->level[i] && cur->level[i]->x < x)
cur = cur->level[i];
prevs[i] = cur;
}
return prevs;
}
显然,对于vector<Node*> prevs的重复使用,每次函数调用时重新创建,非常浪费性能。这是更改后的版本:
private:
vector<Node *> prevs;
void _search(double x) {
prevs.clear();
Node* cur = &head;
for (int i = maxlevel - 1; i >= 0; --i) {
while (cur->level[i] && cur->level[i]->x < x)
cur = cur->level[i];
prevs[i] = cur;
}
}
6、其他
再然后,对于 vector<>
这一容器,不仅是在 _search()
这个私有函数中有使用,随机数缓存中也有使用,我尝试对其进行深入优化:
目前的随机数缓存池的定义是: vector<int> randNumbers;
,在构造函数中使用 reserve
方法来分配内存,通过下标的方式将元素赋值,如下所示。
public:
Skiplist() : head(numeric_limits<double>::lowest(), nullptr, MAX_LEVEL) {
prevs.reserve(MAX_LEVEL);
randNumbers.reserve(CACHE_SIZE);
std::mt19937 e(std::time(0));
std::uniform_int_distribution<int> u(1, 100);
for (int i = 0; i < CACHE_SIZE; ++i) randNumbers[i] = u(e);
}
我们都知道,对 vector<>
这一容器初始化,除了最基本的下标赋值,还有经典的 push_back()
和 C++11新支持的 emplace_back()
两种API,它们的性能差距又如何呢?
至于 push_back()
,要调用构造函数和复制构造函数,这也就代表着要先构造一个临时对象,然后把临时的copy构造函数拷贝或者移动到容器最后面;而 emplace_back()
在实现时,则是直接在容器的尾部创建这个元素,省去了拷贝或移动元素的过程。
vec.emplace_back(x)和vec[x]相比那个更快?
上篇文章则从更底层的角度剖析了 emplace_back()
和 operator []
的区别,测试了后,也是发现下标赋值的方式已经是最优了,无法再提高。