我正在尝试使用最小的额外内存开销实现一个与BST一样好的跳过列表,目前即使不考虑任何内存限制,我的SkipList实现的性能也远远不是一个非常天真的 balancer BST实现 - 所以说,手工制作的BTS :) -
作为参考,我正在使用William Pugh的原始论文PUG89以及我在Sedgewick -13.5-的C中算法中找到的实现 . 我的代码是一个递归实现,这里是insert和find操作的线索:
sl_node* create_node()
{
short lvl {1};
while((dist2(en)<p)&&(lvl<max_level))
++lvl;
return new sl_node(lvl);
}
void insert_impl(sl_node* cur_node,
sl_node* new_node,
short lvl)
{
if(cur_node->next_node[lvl]==nullptr || cur_node->next_node[lvl]->value > new_node->value){
if(lvl<new_node->lvl){
new_node->next_node[lvl] = cur_node->next_node[lvl];
cur_node->next_node[lvl] = new_node;
}
if(lvl==0) return;
insert_impl(cur_node,new_node,lvl-1);
return;
}
insert_impl(cur_node->next_node[lvl],new_node,lvl);
}
sl_node* insert(long p_val)
{
sl_node* new_node = create_node();
new_node->value = p_val;
insert_impl(head, new_node,max_level-1);
return new_node;
}
这是查找操作的代码:
sl_node* find_impl(sl_node* cur_node,
long p_val,
int lvl)
{
if(cur_node==nullptr) return nullptr;
if(cur_node->value==p_val) return cur_node;
if(cur_node->next_node[lvl] == nullptr || cur_node->next_node[lvl]->value>p_val){
if(lvl==0) return nullptr;
return find_impl(cur_node,p_val,lvl-1);
}
return find_impl(cur_node->next_node[lvl],p_val,lvl);
}
sl_node* find(long p_val)
{
return find_impl(head,p_val,max_level-1);
}
sl_node -skip列表节点 - 如下所示:
struct sl_node
{
long value;
short lvl;
sl_node** next_node;
sl_node(int l) : lvl(l)
{
next_node = new sl_node*[l];
for(short i{0};i<l;i++)
next_node[i]=nullptr;
}
~sl_node()
{
delete[] next_node;
}
};
正如你所看到的那样,实现没有什么特别的,也没有高级的,如果与书的实现相比,我不会分享Balaced BTS代码,因为我认为这里不需要,但是是一个非常基本的BTS,在此期间触发了重新 balancer 功能 . 当新节点高度大于16 * lg(n)时插入,其中n是节点数 .
所以说,只有当最大高度比最佳理论值高16倍时才重新 balancer 这三个,正如我所说,这个直接的自制BST比自制的跳过列表要好得多 .
但首先,让我们看看一些数据,使用p = .5和n = 262144,SkipList中节点的级别具有以下分布:
1:141439, 53.9547%
2:65153, 24.8539%
3:30119, 11.4895%
4:13703, 5.22728%
5:6363, 2.42729%
6:2895, 1.10435%
7:1374, 0.524139%
8:581, 0.221634%
9:283, 0.107956%
10:117, 0.044632%
11:64, 0.0244141%
12:31, 0.0118256%
13:11, 0.00419617%
14:5, 0.00190735%
15:1, 0.00038147%
16:5, 0.00190735%
哪个几乎完美 - 哦,这个很大! - 匹配文章中的理论,即:50%1级,25%2级等等等等 . 输入数据来自我最好的伪随机数生成器,即std :: random_device,带有std :: default_random_engine和统一的int分布 . 输入对我来说很随机:)!
在SkipList -in随机顺序中搜索_262945_ 262144元素所需的时间在我的机器上是315ms,而对于天真BTS上的相同搜索操作,所需时间是134ms,因此BTS几乎是SkipList的两倍 . . 这并不是我期待的"Skip list algoriths have the same asymptotic expected time bounds as balanced trees and are simple, faster and use less space" PUG89 .
“插入”节点所需的时间对于SkipList是387ms,对于BTS是143ms,同样天真的BST表现更好 .
事情变得更有趣如果不是使用输入数字的随机序列我使用排序序列,这里我的可怜的自制BST变慢,并且插入262144排序的int需要2866ms而SkipList只需要168ms .
但是,当到达搜索时间时,BST仍然更快!对于排序的输入,我们有234ms而不是77ms,这个BST快3倍 .
使用不同的p因子值,我得到的结果略有不同:
最后但并非最不重要的是,内存使用情况图,正如您所预期的那样,确认如果我们增加每个节点的级别数量,我们会显着影响内存指纹 . 内存使用量计算为存储所有节点的附加指针所需的空间总和 .
毕竟,你们中的任何人都可以就如何实现与BTS一样好的SkipList - 不计算额外的内存开销 - 提供评论吗?
我知道DrDobbs LINK关于SkipList的文章,我通过所有论文,搜索和插入操作的代码完全匹配PUG89的原始实现,所以应该和我的一样好,文章没有提供任何性能无论如何分析,我没有找到任何其他来源 . 你能帮助我吗?
任何评论都非常感谢!
谢谢! :)
2 回答
历史
自威廉·普格撰写他的原始论文以来,时代已经发生了一些变化我们在他的论文中没有提到CPU和操作系统的内存层次结构,它已成为当今流行的焦点(现在通常与算法复杂性同样重要) .
他的基准测试输入案例有2 ^ 16个元素,然后硬件通常最多可以使用32位扩展内存寻址 . 这使得指针的大小减小了一半或者比我们今天在64位机器上使用的尺寸小一半 . 同时,一个字符串字段,例如,可能同样大,使得存储在跳过列表中的元素与跳过节点所需的指针之间的比率可能要小得多,特别是考虑到每个跳过节点我们经常需要多个指针 .
C编译器在寄存器分配和指令选择等方面的优化并不那么激进 . 即使是普通的手写组装也可以提供显着的性能优势 . 像
register
和inline
这样的编译器提示在这些时候实际上做了很多 . 虽然这似乎没什么问题,因为 balancer 的BST和跳过列表实现在这里是平等的,甚至基本循环的优化也是一个更加手动的过程 . 当优化是一个越来越多的手动过程时更容易实现更容易优化 . 跳过列表通常被认为比 balancer 树更容易实现 .因此,所有这些因素可能都与Pugh当时的结论有关 . 时代已经改变:硬件已经改变,操作系统发生了变化,编译器发生了变化,对这些主题进行了更多的研究,等等 .
实施
除此之外,让我们有一些乐趣并实现一个基本的跳过列表 . 我最终调整了懒惰的实现here . 这是一种普通的实现方式,与当今众多易于访问的示例性跳过列表实现几乎没有什么不同 .
我们将把我们实现的性能与
std::set
进行比较,后者几乎总是被实现为红黑树* .*有些人可能想知道为什么我使用0而不是nullptr和那种东西 . 这是一种习惯 . 在我的工作场所,我们仍然需要编写针对各种编译器的开放库,包括那些仅支持C 03的编译器,因此我仍习惯以这种方式编写低级/中级实现代码,有时甚至用C编写,所以请原谅我写这段代码的旧样式 .
在GCC 5.2,-O2,我得到这个:
......这非常糟糕 . 我们全面放慢了两倍 .
优化
然而,我们可以做出明显的优化 . 如果我们查看
Node
,其当前字段如下所示:这意味着Node字段的内存及其下一个指针列表是两个独立的块,它们之间可能有很远的距离,如下所示:
这对于参考局部性来说效果不佳 . 如果我们想在这里改进一些东西,我们应该将这些内存块合并为一个连续的结构,如下所示:
我们可以通过C中常见的可变长度结构习惯来实现这一点 . 在C中实现它有点尴尬,它不直接支持它,但是我们可以仿效这样的效果:
通过这种适度的调整,我们现在有以下时间:
......让我们更接近于与
std::set
的表现相媲美 .随机数发生器
真正有效的跳过列表实现通常需要非常快的RNG . 尽管如此,我在快速分析 Session 期间发现,只花了很少的时间来产生随机水平/高度,几乎不足以将其视为热点 . 它也只会影响插入时间,除非它提供更均匀的分布,所以我决定跳过这个优化 .
内存分配器
在这一点上,我要说我们对跳过列表实现与BST的期望有一个非常合理的概述:
但是,如果我们想要进一步推进,我们可以使用固定的分配器 . 此时,由于
std::set
设计用于符合标准分配器的接口要求的任何通用分配器,因此我们会作弊 . 但是让我们看一下使用固定分配器:*我还对random_level做了一个小调整,使其在发现这看起来效果很好后,只假设概率为50% .
通过使用固定分配器,我们可以使用非常简单的恒定时间策略快速分配和释放元素,更重要的是,以一种方式分配节点,使得具有相同高度的节点(最常访问的节点)更加连续地分配相互尊重(虽然不是理想的顺序) . 这改善了时间:
......对于整个行业的专家来说,这个问题已经被大约40分钟的工作所抨击,而这个工作已经受到了挑战和激励 . 我们也有比
std::set
更快的删除(插入时间在我的机器上有点波动,我会说它们大致相同) .当然,我们欺骗了应用这最后一步 . 使用自定义分配器可以倾斜对我们有利的事物,并且还会改变容器的特性,使其在清除,销毁或压缩之前无法释放其内存 . 它可以将内存标记为未使用,并在后续插入时回收它,但它不能使其使用的内存可用于跳过列表之外的那些内存 . 我也没有理会注意所有可能类型的
T
的正确对齐,这对于真正推广它是必要的 .排序输入
让我们尝试对分类输入使用它 . 为此,只需注释掉两个
random_shuffle
语句即可 . 在我结束时,我现在得到这些时间:......现在我们的
SkipSet
在所有领域都胜过std::set
,尽管这只是一种例外情况 .结论
所以我不确切地知道关于跳过列表的内容 . 几乎没有任何时间和精力,我们非常接近于与
std::set
*相媲美 . 然而,我们没有击败它,我们不得不欺骗内存分配器才能真正接近 .*请注意,里程数可能因机器,std :: set的供应商实现等而异 .
自Pugh于1989年撰写的论文以来,时代已经发生了很大的变化 .
今天,参考局部性的好处使得事物占主导地位,甚至线性复杂度算法也可以胜过线性复杂性算法,只要前者具有更多的缓存或页面友好性 . 密切关注系统从内存层次结构的上层(例如:第二阶段)获取内存块的方式,内存较慢但内存较大,一直到小的L1缓存行和少量注册,这是一个比以往更大的交易,并且如果你问我哪些好处可以与算法改进相媲美,就不再是“微观”了 .
鉴于节点的大小相当大,并且同样重要的是节点的可变大小(这使得它们难以非常有效地分配),跳过列表可能在这里被削弱 .
我们没有看到的一件事是插入时跳过列表更新的本地化特性 . 与 balancer 树通过旋转父节点重新 balancer 所需的区域相比,这往往会影响更少的区域 . 因此,跳过列表可以提供并发集或映射的潜在更有效的实现 .
跳过列表的另一个有希望的特征是它只是最低级别的链表 . 因此,它提供了非常简单的线性时间顺序遍历,而不必以具有线性复杂性的树的分支下降,因此如果我们想要通过包含的所有元素进行大量线性时间迭代,则可能非常好 . .
但要记住:
A technique is only as good as it can be applied in the hands of the implementor.
我怀疑跳过列表是比AVL树更好的选择,即使在1989年 . 在1989年或1990年作为一名学生我实施了两者:它不是跳过列表的一个很好的实现,我必须承认,我是一个新手在这段时间 .
然而,AVL树不再难以实现 . 相比之下,我在模块2中实现的跳过列表中的可变长度前向指针遇到了困难,我在初始化时总是使用最多16个下一个指针来解决这个问题 .
插入操作较少的优点,我从未见过 . AVL树,如果我记得正确,平均不超过2-3次操作 . 因此,昂贵的重新 balancer 不经常发生 .
我认为这更像是炒作 .