跳过清单,它们真的表现得和Pugh纸张一样好吗?

时间:2015-07-23 07:31:45

标签: c++ algorithm performance data-structures skip-lists

我正在尝试使用最小的额外内存开销实现一个与BST一样好的跳过列表,目前即使不考虑任何内存限制,我的SkipList实现的性能也远远不是一个非常天真的平衡BST实施 - 也就是说,手工制作的BTS :) -

作为参考,我正在使用William Pugh PUG89的原始论文以及我在Sedgewick -13.5-的C算法中找到的实现。我的代码是一个递归实现,这是插入和查找操作的线索:

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 list node-如下所示:

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,具有重新平衡功能在新节点高度大于16 * lg(n)时插入时触发,其中n是节点数。

所以说,只有当最大高度比最佳理论值高16倍时才会重新平衡这三个,正如我所说,这个直接的自制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中以随机顺序搜索“全部”262144个元素所需的时间在我的机器上是315毫秒,而对于天真BTS上的相同搜索操作,所需时间是134毫秒,所以BTS几乎是比SkipList快两倍。这并不是我所期望的那样“跳过列表算法与平衡树具有相同的渐近预期时间界限,并且简单,快速且占用空间更少”PUG89

节点“插入”所需的时间对于SkipList为387ms,对于BTS为143ms,同样天真的BST表现更好。

如果不是使用输入数字的随机序列而是使用排序序列,那么事情变得更有趣了,这里我的可怜的自制BST变慢,并且插入262144排序的int需要2866ms而SkipList只需要168ms。

但是,当来到搜索时间时,BST仍然更快!对于排序的输入,我们有234ms而不是77ms,这个BST快3倍。

对于p因子的不同值,我的性能结果略有不同:

enter image description here

enter image description here

最后但并非最不重要的是,内存使用情况图,正如您所预期的那样,确认如果我们增加每个节点的级别数量,则会显着影响内存指纹。内存使用量计算为存储所有节点的附加指针所需的空间总和。

enter image description here

毕竟,你们中的任何人都可以就如何实现与BTS一样好的SkipList - 不计算额外的内存开销 - 提供评论吗?

我知道DrDobbs LINK关于SkipList的文章,我通过所有论文,搜索和插入操作的代码完全匹配PUG89的原始实现,所以应该一样好作为我的,并且该文章无论如何也没有提供任何性能分析,我没有找到任何其他来源。你能救我吗?

任何评论都非常感谢!

谢谢! :)

2 个答案:

答案 0 :(得分:23)

历史

自威廉·普格撰写他的原始论文以来,时代已经发生了一些变化。我们在他的论文中没有提到CPU和操作系统的内存层次结构,它已成为当今流行的焦点(现在通常与算法复杂性同样重要)。

他的基准测试输入案例有2 ^ 16个元素,然后硬件通常最多可以使用32位扩展内存寻址。这使得指针的大小只有我们今天在64位计算机上使用的尺寸的一半或更小。同时,字符串字段(例如,可能同样大)使得存储在跳过列表中的元素与跳过节点所需的指针之间的比率可能要小得多,特别是考虑到每个跳过节点通常需要多个指针

对于寄存器分配和指令选择之类的事情,C编译器在优化方面并没有那么积极。即使是普通的手写组装也可以提供显着的性能优势。像registerinline这样的编译器提示在这些时候实际上做了很多。虽然这可能看起来有点没有实际意义,因为平衡的BST和跳过列表实现在这里是平等的,甚至基本循环的优化也是更加手动的过程。当优化是一个越来越多的手动过程时,更容易实现的东西通常更容易优化。跳过列表通常被认为比平衡树更容易实现。

因此,所有这些因素可能都与Pugh当时的结论有关。但时代已经发生了变化:硬件发生了变化,操作系统发生了变化,编译器发生了变化,对这些主题进行了更多的研究等等。

实施

除此之外,让我们有一些乐趣并实现一个基本的跳过列表。我最终调整了可用的实现here而不是懒惰。它是一种普通的实现方式,与当今众多易于访问的示例性跳过列表实现几乎没有什么不同。

我们将实施的效果与std::set进行比较,0几乎总是以红黑树*的形式实现。

*有些人可能想知道为什么我使用nullptr代替#include <iostream> #include <algorithm> #include <cstdlib> #include <ctime> #include <cmath> #include <vector> #include <cassert> #include <cstring> #include <set> using namespace std; static const int max_level = 32; static const float probability = 0.5; static double sys_time() { return static_cast<double>(clock()) / CLOCKS_PER_SEC; } static int random_level() { int lvl = 1; while ((static_cast<float>(rand()) / RAND_MAX) < probability && lvl < max_level) ++lvl; return lvl; } template <class T> class SkipSet { public: SkipSet(): head(0) { head = create_node(max_level, T()); level = 0; } ~SkipSet() { while (head) { Node* to_destroy = head; head = head->next[0]; destroy_node(to_destroy); } } bool contains(const T& value) const { const Node* node = head; for (int i=level; i >= 0; --i) { while (node->next[i] && node->next[i]->value < value) node = node->next[i]; } node = node->next[0]; return node && node->value == value; } void insert(const T& value) { Node* node = head; Node* update[max_level + 1]; memset(update, 0, sizeof(Node*)*(max_level + 1)); for (int i = level; i >= 0; i--) { while (node->next[i] && node->next[i]->value < value) node = node->next[i]; update[i] = node; } node = node->next[0]; if (!node || node->value != value) { int lvl = random_level(); assert(lvl >= 0); if (lvl > level) { for (int i = level + 1; i <= lvl; i++) { update[i] = head; } level = lvl; } node = create_node(lvl, value); for (int i = 0; i <= lvl; i++) { node->next[i] = update[i]->next[i]; update[i]->next[i] = node; } } } bool erase(const T& value) { Node* node = head; Node* update[max_level + 1]; memset(update, 0, sizeof(Node*)*(max_level + 1)); for (int i = level; i >= 0; i--) { while (node->next[i] && node->next[i]->value < value) node = node->next[i]; update[i] = node; } node = node->next[0]; if (node->value == value) { for (int i = 0; i <= level; i++) { if (update[i]->next[i] != node) break; update[i]->next[i] = node->next[i]; } destroy_node(node); while (level > 0 && !head->next[level]) --level; return true; } return false; } private: struct Node { T value; struct Node** next; }; Node* create_node(int level, const T& new_value) { void* node_mem = malloc(sizeof(Node)); Node* new_node = static_cast<Node*>(node_mem); new (&new_node->value) T(new_value); void* next_mem = calloc(level+1, sizeof(Node*)); new_node->next = static_cast<Node**>(next_mem); return new_node; } void destroy_node(Node* node) { node->value.~T(); free(node->next); free(node); } Node* head; int level; }; template <class T> bool contains(const std::set<T>& cont, const T& val) { return cont.find(val) != cont.end(); } template <class T> bool contains(const SkipSet<T>& cont, const T& val) { return cont.contains(val); } template <class Set, class T> void benchmark(int num, const T* elements, const T* search_elements) { const double start_insert = sys_time(); Set element_set; for (int j=0; j < num; ++j) element_set.insert(elements[j]); cout << "-- Inserted " << num << " elements in " << (sys_time() - start_insert) << " secs" << endl; const double start_search = sys_time(); int num_found = 0; for (int j=0; j < num; ++j) { if (contains(element_set, search_elements[j])) ++num_found; } cout << "-- Found " << num_found << " elements in " << (sys_time() - start_search) << " secs" << endl; const double start_erase = sys_time(); int num_erased = 0; for (int j=0; j < num; ++j) { if (element_set.erase(search_elements[j])) ++num_erased; } cout << "-- Erased " << num_found << " elements in " << (sys_time() - start_erase) << " secs" << endl; } int main() { const int num_elements = 200000; vector<int> elements(num_elements); for (int j=0; j < num_elements; ++j) elements[j] = j; random_shuffle(elements.begin(), elements.end()); vector<int> search_elements = elements; random_shuffle(search_elements.begin(), search_elements.end()); typedef std::set<int> Set1; typedef SkipSet<int> Set2; for (int j=0; j < 3; ++j) { cout << "std::set" << endl; benchmark<Set1>(num_elements, &elements[0], &search_elements[0]); cout << endl; cout << "SkipSet" << endl; benchmark<Set2>(num_elements, &elements[0], &search_elements[0]); cout << endl; } } 以及那种事情。这是一种习惯。在我的工作场所,我们仍然需要编写针对各种编译器的开放库,包括那些仅支持C ++ 03的编译器,所以我仍然习惯用这种方式编写中/低级实现代码,有时候即使在C中,请原谅我编写此代码的旧样式。

std::set
-- Inserted 200000 elements in 0.104869 secs
-- Found 200000 elements in 0.078351 secs
-- Erased 200000 elements in 0.098208 secs

SkipSet
-- Inserted 200000 elements in 0.188765 secs
-- Found 200000 elements in 0.160895 secs
-- Erased 200000 elements in 0.162444 secs

在GCC 5.2上,-O2,我明白了:

Node

......这太糟糕了。我们全面放慢了两倍。

优化

然而,我们可以做出明显的优化。如果我们查看struct Node { T value; struct Node** next; }; ,其当前字段如下所示:

    [Node fields]-------------------->[next0,next1,...,null]

这意味着Node字段的内存及其下一个指针的列表是两个独立的块,它们之间可能有很远的距离,如下所示:

    [Node fields,next0,next1,...,null]

这对于参考地点来说效果不佳。如果我们想在这里改进一些东西,我们应该将这些内存块合并为一个连续的结构,如下所示:

struct Node
{
    T value;
    struct Node* next[1];
};

Node* create_node(int level, const T& new_value)
{
    void* node_mem = malloc(sizeof(Node) + level * sizeof(Node*));
    Node* new_node = static_cast<Node*>(node_mem);
    new (&new_node->value) T(new_value);
    for (int j=0; j < level+1; ++j)
        new_node->next[j] = 0;
    return new_node;
}

void destroy_node(Node* node)
{
    node->value.~T();
    free(node);
}

我们可以通过C中常见的可变长度结构习惯来实现这一点。在C ++中实现它有点尴尬,它不直接支持它,但我们可以仿效这样的效果:

SkipSet (Before)
-- Inserted 200000 elements in 0.188765 secs
-- Found 200000 elements in 0.160895 secs
-- Erased 200000 elements in 0.162444 secs

SkipSet (After)
-- Inserted 200000 elements in 0.132322 secs
-- Found 200000 elements in 0.127989 secs
-- Erased 200000 elements in 0.130889 secs

通过这种适度的调整,我们现在有了这些时间:

std::set

...这让我们更接近于与Insertion -- std::set: 0.104869 secs -- SkipList: 0.132322 secs Search: -- std::set: 0.078351 secs -- SkipList: 0.127989 secs Removal: -- std::set: 0.098208 secs -- SkipList: 0.130889 secs 的表现相媲美。

随机数发生器

真正有效的跳过列表实现通常需要非常快的RNG。尽管如此,我发现在一个快速的分析会话中,只花了很少的时间来产生一个随机的水平/高度,几乎不足以将其视为一个热点。它也只会影响插入时间,除非它提供更均匀的分布,所以我决定跳过这个优化。

内存分配器

此时,我说我们对跳过列表实施与BST的期望非常合理:

std::set

但是,如果我们想要进一步训练,我们可以使用固定的分配器。此时,由于#include <iostream> #include <iomanip> #include <algorithm> #include <cstdlib> #include <ctime> #include <cmath> #include <vector> #include <cassert> #include <set> using namespace std; static const int max_level = 32; class FixedAlloc { public: FixedAlloc(): root_block(0), free_element(0), type_size(0), block_size(0), block_num(0) { } FixedAlloc(int itype_size, int iblock_size): root_block(0), free_element(0), type_size(0), block_size(0), block_num(0) { init(itype_size, iblock_size); } ~FixedAlloc() { purge(); } void init(int new_type_size, int new_block_size) { purge(); block_size = max(new_block_size, type_size); type_size = max(new_type_size, static_cast<int>(sizeof(FreeElement))); block_num = block_size / type_size; } void purge() { while (root_block) { Block* block = root_block; root_block = root_block->next; free(block); } free_element = 0; } void* allocate() { assert(type_size > 0); if (free_element) { void* mem = free_element; free_element = free_element->next_element; return mem; } // Create new block. void* new_block_mem = malloc(sizeof(Block) - 1 + type_size * block_num); Block* new_block = static_cast<Block*>(new_block_mem); new_block->next = root_block; root_block = new_block; // Push all but one of the new block's elements to the free pool. char* mem = new_block->mem; for (int j=1; j < block_num; ++j) { FreeElement* element = reinterpret_cast<FreeElement*>(mem + j * type_size); element->next_element = free_element; free_element = element; } return mem; } void deallocate(void* mem) { FreeElement* element = static_cast<FreeElement*>(mem); element->next_element = free_element; free_element = element; } void swap(FixedAlloc& other) { std::swap(free_element, other.free_element); std::swap(root_block, other.root_block); std::swap(type_size, other.type_size); std::swap(block_size, other.block_size); std::swap(block_num, other.block_num); } private: struct Block { Block* next; char mem[1]; }; struct FreeElement { struct FreeElement* next_element; }; // Disable copying. FixedAlloc(const FixedAlloc&); FixedAlloc& operator=(const FixedAlloc&); struct Block* root_block; struct FreeElement* free_element; int type_size; int block_size; int block_num; }; static double sys_time() { return static_cast<double>(clock()) / CLOCKS_PER_SEC; } static int random_level() { int lvl = 1; while (rand()%2 == 0 && lvl < max_level) ++lvl; return lvl; } template <class T> class SkipSet { public: SkipSet(): head(0) { for (int j=0; j < max_level; ++j) allocs[j].init(sizeof(Node) + (j+1)*sizeof(Node*), 4096); head = create_node(max_level, T()); level = 0; } ~SkipSet() { while (head) { Node* to_destroy = head; head = head->next[0]; destroy_node(to_destroy); } } bool contains(const T& value) const { const Node* node = head; for (int i=level; i >= 0; --i) { while (node->next[i] && node->next[i]->value < value) node = node->next[i]; } node = node->next[0]; return node && node->value == value; } void insert(const T& value) { Node* node = head; Node* update[max_level + 1] = {0}; for (int i=level; i >= 0; --i) { while (node->next[i] && node->next[i]->value < value) node = node->next[i]; update[i] = node; } node = node->next[0]; if (!node || node->value != value) { const int lvl = random_level(); assert(lvl >= 0); if (lvl > level) { for (int i = level + 1; i <= lvl; ++i) update[i] = head; level = lvl; } node = create_node(lvl, value); for (int i = 0; i <= lvl; ++i) { node->next[i] = update[i]->next[i]; update[i]->next[i] = node; } } } bool erase(const T& value) { Node* node = head; Node* update[max_level + 1] = {0}; for (int i=level; i >= 0; --i) { while (node->next[i] && node->next[i]->value < value) node = node->next[i]; update[i] = node; } node = node->next[0]; if (node->value == value) { for (int i=0; i <= level; ++i) { if (update[i]->next[i] != node) break; update[i]->next[i] = node->next[i]; } destroy_node(node); while (level > 0 && !head->next[level]) --level; return true; } return false; } void swap(SkipSet<T>& other) { for (int j=0; j < max_level; ++j) allocs[j].swap(other.allocs[j]); std::swap(head, other.head); std::swap(level, other.level); } private: struct Node { T value; int num; struct Node* next[1]; }; Node* create_node(int level, const T& new_value) { void* node_mem = allocs[level-1].allocate(); Node* new_node = static_cast<Node*>(node_mem); new (&new_node->value) T(new_value); new_node->num = level; for (int j=0; j < level+1; ++j) new_node->next[j] = 0; return new_node; } void destroy_node(Node* node) { node->value.~T(); allocs[node->num-1].deallocate(node); } FixedAlloc allocs[max_level]; Node* head; int level; }; template <class T> bool contains(const std::set<T>& cont, const T& val) { return cont.find(val) != cont.end(); } template <class T> bool contains(const SkipSet<T>& cont, const T& val) { return cont.contains(val); } template <class Set, class T> void benchmark(int num, const T* elements, const T* search_elements) { const double start_insert = sys_time(); Set element_set; for (int j=0; j < num; ++j) element_set.insert(elements[j]); cout << "-- Inserted " << num << " elements in " << (sys_time() - start_insert) << " secs" << endl; const double start_search = sys_time(); int num_found = 0; for (int j=0; j < num; ++j) { if (contains(element_set, search_elements[j])) ++num_found; } cout << "-- Found " << num_found << " elements in " << (sys_time() - start_search) << " secs" << endl; const double start_erase = sys_time(); int num_erased = 0; for (int j=0; j < num; ++j) { if (element_set.erase(search_elements[j])) ++num_erased; } cout << "-- Erased " << num_found << " elements in " << (sys_time() - start_erase) << " secs" << endl; } int main() { const int num_elements = 200000; vector<int> elements(num_elements); for (int j=0; j < num_elements; ++j) elements[j] = j; random_shuffle(elements.begin(), elements.end()); vector<int> search_elements = elements; random_shuffle(search_elements.begin(), search_elements.end()); typedef std::set<int> Set1; typedef SkipSet<int> Set2; cout << fixed << setprecision(3); for (int j=0; j < 2; ++j) { cout << "std::set" << endl; benchmark<Set1>(num_elements, &elements[0], &search_elements[0]); cout << endl; cout << "SkipSet" << endl; benchmark<Set2>(num_elements, &elements[0], &search_elements[0]); cout << endl; } } 被设计为与符合标准分配器的接口要求的任何通用分配器一起使用,我们会稍微作弊。但是,让我们看一下使用固定分配器:

random_level

*我还对Insertion -- std::set: 0.104869 secs -- SkipList: 0.103632 secs Search: -- std::set: 0.078351 secs -- SkipList: 0.089910 secs Removal: -- std::set: 0.098208 secs -- SkipList: 0.089224 secs 进行了一次小调整,使其在发现这看起来效果不错后,只假设概率为50%。

通过使用固定分配器,我们可以使用非常简单的恒定时间策略快速分配和释放元素,更重要的是,以一种方式分配节点,以便分配具有相同高度的节点(最常访问的一起)相对于彼此更连续(尽管不是以理想的顺序顺序)。这改善了以下时间:

std::set

......对于std::set的大约40分钟的工作并不是很糟糕,而T已被业内专家调查和推动并进行了调整。我们的删除速度也比random_shuffle快(插入时间在我的机器上有点波动,我说它们大致相同)。

当然,我们欺骗了最后一步。使用自定义分配器可以倾斜对我们有利的东西,并且还会改变容器的特性,使其在清除,销毁或压缩之前无法释放其内存。它可以将内存标记为未使用,并在后续插入时回收它,但它不能使其使用的内存可用于跳过列表之外的那些内存。我也没有注意为所有可能类型的std::set -- Inserted 200000 elements in 0.044 secs -- Found 200000 elements in 0.023 secs -- Erased 200000 elements in 0.019 secs SkipSet -- Inserted 200000 elements in 0.027 secs -- Found 200000 elements in 0.023 secs -- Erased 200000 elements in 0.016 secs 正确对齐,这对于真正推广它是必要的。

排序输入

让我们尝试对分类输入使用它。为此,只需注释掉两个SkipSet语句即可。在我结束时,我现在得到这些时间:

std::set

...现在我们的std::set在所有领域都优于std::set,但只是针对这种特殊情况。

结论

因此,我并不确切地知道跳过列表的内容。几乎没有任何时间和精力,我们非常接近于与{{1}} *相媲美。然而,我们没有击败它,我们不得不欺骗内存分配器才能真正接近。

*请注意,里程数可能因机器,{{1}}的供应商实施等而异。

自从Pugh在1989年撰写的论文以来,时代发生了很大的变化。

今天,参考局部性的好处使得事物占据主导地位,甚至线性复杂度算法也可以胜过线性复杂算法,只要前者具有更高的缓存或页面友好性。密切关注系统从内存层次结构的较高级别(例如:第二级)获取内存块的方式,内存较慢但较大,一直到较小的L1缓存行和少量寄存器比以往任何时候都要大,并且不再是&#34;微&#34;如果你问我哪些好处可以与算法改进相媲美。

由于节点的大小相当大,并且节点的可变大小(这使得它们难以非常有效地分配),跳过列表可能会在这里瘫痪。

我们没有看到的一件事是插入时跳过列表更新的本地化特性。与平衡树通过旋转父节点重新平衡所需的区域相比,这往往会影响更少的区域。因此,跳过列表可以提供并发集或映射的潜在更有效的实现。

跳过列表的另一个有希望的特征是它只是最低级别的链表。因此,它提供了非常简单的线性时间顺序遍历,而不必以线性复杂度下降树的分支,因此如果我们想要通过包含的所有元素进行大量线性时间迭代,则可能非常好

但永远记住:

一种技术只能在实施者手中使用。

答案 1 :(得分:3)

我怀疑跳过列表是比AVL树更好的选择,即使在1989年。在1989年或1990年作为一名学生,我实施了两者:它不是跳过列表的一个很好的实现,我必须承认,我那个时候是新手。

然而,AVL树不再难以实现。 相比之下,我在模块2中实现的跳过列表中的可变长度前向指针遇到了困难,我在初始化时总是使用最多16个下一个指针来解决这个问题。

插入操作较少的优点,我从未见过。 AVL树,如果我记得正确,平均不超过2-3次操作。因此,昂贵的重新平衡不会经常发生。

我认为这更像是一种炒作。