Пропустить списки, действительно ли они работают так же хорошо, как заявка на бумагу Пью?

Я пытаюсь реализовать список пропуска, который работает так же хорошо, как BST, используя минимальные дополнительные накладные расходы памяти, на данный момент даже не рассматривая ограничение памяти, производительность моей реализации SkipList далека от даже очень наивного Сбалансированного Внедрение BST - так сказать, ручной BTS:) -

В качестве ссылки я использую оригинальную бумагу от William Pugh PUG89 и реализацию, которую я нашел в Алгоритмах в C от Sedgewick -13.5-, Мой код является рекурсивной реализацией, вот ключ операции вставки и поиска:

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 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 с функцией балансировки запускается во время вставки, когда новая высота node больше 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 и т.д. и т.д. Входные данные поступали от моего наилучшего доступного генератора псевдослучайных чисел aka std:: random_device с std:: default_random_engine и равномерного распределения int. Вход выглядит довольно случайным для меня:)!

Время, необходимое для поиска "всех" элементов 262144 в случайном порядке SkipList - в 315ms на моей машине, тогда как для той же операции поиска на наивной BTS требуемое время составляет 134 мс, поэтому BTS почти в два раза быстрее, чем SkipList. Это не совсем то, чего я ожидал от "алгоритмов пропущенных списков, которые имеют те же асимптотические ожидаемые временные рамки, что и сбалансированные деревья, и просты, быстрее и используют меньше места" PUG89.

Время, необходимое для "вставки" узлов, составляет 387 мс для SkipList и 143ms для BTS, а более эффективный BST работает лучше.

Все становится немного интереснее, если вместо использования случайной последовательности входного номера я использую отсортированную последовательность, здесь мой бедный домашний BST становится медленным, а вставка 262144 отсортированного int занимает 2866 мс, тогда как СкипЛисту требуется только 168 мс.

Но, придя в поисковое время, BST все еще быстрее! для отсортированного ввода мы имеем 234 мс против 77 мс, этот BST в 3 раза быстрее.

При разных значениях p-фактора я результаты несколько отличаются:

введите описание изображения здесь

введите описание изображения здесь

И последнее, но не менее важное: график использования памяти, который, как вы можете ожидать, подтверждает, что если мы увеличим количество уровней на node, мы значительно повлияем на отпечаток памяти. Использование памяти вычисляется как сумма пространства, необходимого для хранения дополнительных указателей для всех node.

введите описание изображения здесь

После всего этого кто-нибудь из вас может предоставить мне комментарий о том, как реализовать SkipList так же хорошо, как BTS, но не подсчитывая дополнительные служебные данные памяти?

Я знаю о статье от DrDobbs LINK о SkipList, и я прошел через всю бумагу, код для операции поиска и вставки точно соответствует исходной реализации из PUG89, так что должно быть так же хорошо, как и мое, и статья не дает никакого анализа производительности в любом случае, я не нашел любой другой источник. Можете ли вы мне помочь?

Любые комментарии очень ценятся!

Спасибо!:)

Ответы

Ответ 1

История

Времена немного изменились, поскольку Уильям Пью написал свою оригинальную статью. Мы не видим упоминания в его документе о иерархии памяти центрального процессора и операционной системы, которая сегодня стала таким распространенным фокусом (теперь часто столь же важна, как и алгоритмическая сложность).

В его входном случае для бенчмаркинга были тесные 2 ^ 16 элементов, а аппаратное обеспечение тогда обычно имело, самое большее, 32-разрядную расширенную адресацию памяти. Это сделало размер указателя на половину размера или меньше, чем мы использовали сегодня на 64-битных машинах. Между тем строковое поле, например, может быть таким же большим, что делает соотношение между элементами, хранящимися в списке пропуска, и указателями, требуемыми пропуском node, потенциально намного меньше, особенно учитывая, что нам часто требуется количество указателей за пропустите node.

C Компиляторы тогда не были настолько агрессивны при оптимизации по отношению к вещам, как распределение регистров и выбор команды. Даже средняя рукописная сборка часто может обеспечить значительное преимущество в производительности. В то время подсказки компилятора, такие как register и inline, действительно сделали большую сделку. Хотя это может показаться спорным, поскольку и сбалансированная реализация BST, и реализация списка пропусков будут на равной основе здесь, оптимизация даже базового цикла была более ручным процессом. Когда оптимизация становится все более ручным процессом, легче упростить задачу, которую проще реализовать. Пропущенные списки часто считаются намного более легкими для реализации, чем дерево балансировки.

Таким образом, все эти факторы, вероятно, были частью выводов Пью в то время. Но времена изменились: оборудование изменилось, изменились операционные системы, изменились компиляторы, больше исследований было сделано в этих темах и т.д.

Реализация

С учетом этого давайте немного повеселимся и реализуем базовый список пропусков. Я закончил тем, что адаптировал имеющуюся версию здесь из лени. Это реалистичная реализация, едва ли отличная от множества легкодоступных иллюстративных реализаций списка пропуска сегодня.

Мы будем сравнивать производительность нашей реализации с std::set, которая почти всегда реализуется как красно-черное дерево *.

* Некоторые могут задаться вопросом, почему я использую 0 вместо nullptr и вещи такого типа. Это привычка. На моем рабочем месте нам все еще приходится писать открытые библиотеки, предназначенные для широкого круга компиляторов, в том числе для тех, которые поддерживают только С++ 03, поэтому я все еще использую этот код для кода ниже/среднего уровня, а иногда и в C, поэтому, пожалуйста, простите старый стиль, в котором я написал этот код.

#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;
    }
}

В GCC 5.2, -O2 я получаю следующее:

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

... Это довольно ужасно. Мы почти вдвое медленнее по всем направлениям.

Оптимизация

Тем не менее, есть вопиющая оптимизация, которую мы можем сделать. Если мы посмотрим на Node, его текущие поля выглядят следующим образом:

struct Node
{
    T value;
    struct Node** next;
};

Это означает, что память для полей node и ее список следующих указателей - это два отдельных блока, возможно, с очень далеким шагом между ними:

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

Это плохо для местности ссылки. Если мы хотим улучшить ситуацию здесь, мы должны объединить эти блоки памяти в одну непрерывную структуру, например:

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

Мы можем достигнуть этого через стандартную идиому с переменной длиной, обычную для C. Это немного неудобно для реализации на С++, которая не поддерживает ее так напрямую, но мы можем эмулировать эффект так:

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);
}

С помощью этой скромной настройки мы теперь имеем следующие моменты:

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.

Генератор случайных чисел

Поистине эффективная реализация списка исключений, как правило, требует очень быстрого RNG. Тем не менее, я нашел во время быстрого сеанса профилирования, что затрачивается только очень небольшая часть времени, генерируя случайный уровень/высоту, что едва ли достаточно, чтобы рассматривать его как часть горячей точки. Это также повлияет только на время вставки, если оно не обеспечит более равномерное распределение, поэтому я решил пропустить эту оптимизацию.

Аллокатор памяти

На этом этапе я бы сказал, что у нас есть довольно разумный обзор того, что мы можем ожидать с реализацией списка пропуска по сравнению с BST:

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

Однако, если мы хотим солдата немного дальше, мы можем использовать фиксированный распределитель. На данный момент мы немного обманываем, поскольку 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 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, чтобы просто предположить вероятность 50% после обнаружения того, что это, похоже, работает достаточно хорошо.

Используя фиксированный распределитель, мы можем быстро распределять и освобождать элементы с помощью очень простой стратегии постоянной времени и, что более важно, выделять узлы таким образом, чтобы выделялись узлы с одинаковой высотой (наиболее часто встречающиеся вместе) более смежно относительно друг друга (хотя и не в идеальном последовательном порядке). Это улучшает время:

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

..., что неплохо в течение около 40 минут работы против std::set, который был ткнул и подталкивался и настраивался экспертами по всей отрасли. У нас также есть более быстрая абсорбция, чем std::set (время вставки немного колеблется на моей машине, я бы сказал, что они примерно совпадают).

Конечно, мы обманули, чтобы применить этот последний шаг. Использование пользовательского распределителя наклоняет вещи в нашу пользу, а также изменяет характеристики контейнера таким образом, что его память не может быть освобождена до тех пор, пока она не будет очищена, уничтожена или не уплотнена. Он может пометить память как неиспользованную и восстановить ее при последующих вставках, но она не может сделать память, которую она использует, доступна для тех, кто находится за пределами списка пропуска. Я также не стал обращать внимание на правильное выравнивание для всех возможных типов 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 и т.д.

Времена изменились совсем немного, так как статья Пью писала в 1989 году.

Сегодня преимущества локальности ссылок доминируют над вещами до такой степени, что даже алгоритм линейной сложности может превосходить линейный, при условии, что первый значительно больше кэша или удобен для страниц. Обращая пристальное внимание на то, как система захватывает куски памяти с верхних уровней иерархии памяти (например: вторичная сцена) с более медленной, но большей памятью и вплоть до небольшой линии кеша L1, а подростковый регистр - это более крупная сделка, чем когда-либо прежде, и больше не "микро", если вы спросите меня, когда выгоды могут конкурировать с алгоритмическими улучшениями.

Список пропусков потенциально искалечен здесь, учитывая значительно больший размер узлов и, что не менее важно, переменный размер узлов (что затрудняет их распределение очень эффективно).

Одна вещь, на которую мы не смотрели, - это локализованный характер, при котором список пропусков обновляется при вставке. Это имеет тенденцию воздействовать на гораздо меньшее количество областей, чем дерево балансировки требует перебалансировки путем вращения родительских узлов. В результате список пропусков может предложить потенциально более эффективную реализацию параллельного набора или карты.

Еще одна перспективная характеристика списка пропусков - это просто связанный список a самый низкий уровень. В результате он предлагает очень простой последовательный обход по линейному времени без необходимости спускаться по ветвям дерева с помощью линейной сложности, поэтому он потенциально очень хорош, если мы хотим делать много итераций с линейным временем через все содержащиеся элементы.

Но всегда помните:

Техника настолько же хороша, насколько это может быть применена в руках разработчика.

Ответ 2

Я сомневаюсь, что список пропусков был лучшим выбором, чем, например, дерево AVL даже в 1989 году. В 1989 или 1990 году в качестве студента я реализовал оба: это была не лучшая реализация списка пропусков, я должен признать, что я был новичком в то время.

Однако дерево AVL больше не было сложно реализовать. Напротив, у меня возникли трудности с указателями перетаскивания переменной длины пропусков в списке, реализующим в модуле 2, который я решил использовать, всегда используя максимум 16 следующих указателей.

Преимущество меньшего количества операций при вставке я никогда не видел. Дерево AVL, если я правильно помню, в среднем составляло не более 2-3 опер. Поэтому дорогостоящее перебалансирование происходит не часто.

Я думаю, что это была скорее реклама.