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