Привлекательные структуры данных
В процессе изучения разных алгоритмов и структур данных приходит понимание, что не все они применимы в прикладных задачах (в отличие от задач про Васю и Петю/Алису и Боба). Но тот факт, что алгоритм/структура данных не является полезной на практике, не означает, что идеи в них содержащиеся не привлекают пытливые умы хотя бы из чистого любопытства. Потому речь пойдёт о красивых (субъективно) и, что важно, простых с точки зрения концепции структурах данных. И помните: если что-то не компилируется, это псевдокод.
Skiplist
Скиплист на уровне идеи очень интересный субъект мироздания: тут и объекты упорядочены, и вероятности прикрутили, и всё это делает его аналогом бинарного дерева поиска.
Построим его. Изначально имеем обычный односвязный отсортированный список. Пройдёмся по этому списку и выберем каждый элемент с некоторой вероятностью (обычно 0.5) во второй список. Крайние для удобства можно брать всегда. Проведём связи между соседями во втором списке и между выбранными копиями и оригинальными объектами. Получим что-то такое:
Повторим такую операцию, пока не решим, что достаточно. Ограничением можем считать конкретное ограничение на кол-во элементов в самом верхнем списке или просто закончить на ом уровне. Теперь у нас вот такая картина:
Что мы получили? Во-первых, умеем легко обходить все элементы скиплиста в отсортированном порядке. Во-вторых, умеем искать примерно за нужный элемент: начинаем с самого верхнего уровня и спускаемся по мере необходимости (смотрим на следующий элемент; если он больше искомого, двигаемся на уровень вниз):
Раз уж мы взялись использовать списки, давайте использовать до конца: хочется быстро вставлять значение в такую структуру. Для вставки элемента найдём место, куда должен встать новый элемент, вставим его в изначальный список и с опять с какой-то вероятностью пропушим до некоторого уровня, пока рандом не решит, что пора остановиться. Аналогично можем удалять.
Теперь мы можем обходить все элементы за (и не как в bst с хранением предков и разбором случаев, а просто и понятно) + поиск элемента и его вставка/удаление за в среднем. Только что памяти больше тратится, и эти вероятности нет-нет да и могут в какой-то вырожденный случай привести, но бог с ними.
Говорят, что скиплисты — очень хорошее решение, когда нужно что-то вроде thread-safe ordered set/map. Есть вот реализации для java и плюсов в folly.
Sideways heap
Sideways heap (кособокая куча?) — неявная куча, которая задаётся от самого левого листа:
Потом добавляется родитель и брат вершины (или сестра; no sexism):
Развивая эту идею, получим что-то подобное:
Если сделать проекцию кучи на числовую прямую, получим последовательность натуральных чисел.
Заметим, что все листья — это нечётные числа. Все числа на втором уровне — произведение двойки на нечётные числа. Все числа на третьем уровне — четвёрки на нечётные. И т.д. Ещё у этой кучи нет корня и бесконечно много листьев.
Попробуем по номеру вершины узнать всё её окружение. Пусть номер вершины k
. Для начала научимся получать соседнюю вершину (для 1 (1 в бинарном виде) это 3 (11) и наоборот, для 2 (10) — 6 (110), для 4 (100) — 12 (1100), …, 2018 (11111100010) — 2022 (11111100110)). То есть нам нужно получить младший бит числа (k & -k)
, сдвинуть его влево и сделать xor c самим собой, i.e. получается как-то так:
int Sibling(int k) {
return k ^ ((k & -k) << 1);
}
Для родителя и детей можно вывести следующие формулки:
int Parent(int k) {
int j = k & -k;
return (k - j) | (j << 1);
}
pair Childrens(int k) {
int j = k & -k;
return ( k - (j >> 1), k + (j >> 1) ); // если j == 1, то получаем обратно k
}
Конечно это очень специфическая структура данных, которая тем не менее позволяет делать какие-то интересные вещи. Например такие кучи используются как основа для других (имхо не сильно более полезных) структур данных. Но как по мне гораздо интереснее алгоритм нахождения наименьшего общего предка для вершин в дереве с на препроцессинг и честным на запрос. Чуть подробнее о кучах и самом алгоритме можно посмотреть в лекции Кнута.
XOR-фильтр
XOR-фильтр предлагается как замена фильтрам Блума для случаев, когда множество ключей известно заранее, что позволяет быть более эффективным по времени и памяти.
XOR-фильтр есть множество ячеек по L бит (в отличие от фильтра Блума, в котором каждый отдельный бит независим). Например он может выглядеть так:
Теперь нам нужно выбрать три хеш-функции h1, h2 и h2 (на практике это одна и та же хеш-функция с тремя разными инициализирующими значениями), которые будут отображать наше значение x в ячейку массива. После того, как мы получаем по трём значениям h1(x), h2(x) и h3(x) L-битные значения (например нулевая, вторая и третья ячейки), посчитаем их xor:
Число 01110 называется кодом (table code) x. Тут ещё вводится четвёртая функция f, которая вычисляет f (x) — L-битный fingerprint числа x. Чтобы проверить, находится ли x в нашем множестве, мы сравниваем его код c его фингерпринтом. Если они равны, можем утверждать, что x почти наверняка в нашем множестве. Если не равны — точно не в нём.
Меняя значение L, мы можем повлиять на вероятность ошибки. Утверждается, что вероятность совпадения кода и фингерпринта примерно равна , потому если вы хотите допускать ошибку в случаях, то можно брать .
Самая интересная часть — это заполнить такую структуру. Процедура довольно крупная (в среднем занимает 100+ строк), потому опишем её в общих чертах.
Для хранения элементов создадим структуру объёмом ячейки и будем следовать следующему алгоритму:
Если во множестве значений никого не осталось, мы закончили.
Выберем такой ключ , что он хешируется в такую ячейку (ячейка ) в которую не хешируется никакой другой ключ.
Удаляем из множества необработанных ключей и рекурсивно переходим к шагу 1.
Ячейке нужно подобрать такое значение, чтобы f (x) совпадал с результатом xor всех значений для (используем факт, что ).
Есть некоторая вероятность потерпеть неудачу на шаге 2, но авторы запруфали, что при указанном выше размере xor-фильтра она не очень большая. Если же у вас всё-таки не получилось найти подходящую ячейку, меняем seed у наших хеш-функций и начинаем заново. Хороший пример с картиночками есть вот тут.
Плюсы относительно фильтра Блума: если хочется повысить точность, нужно изменить количество битов в ячейке, а не количество хеш-функций; проверка наличия в фильтрах Блума обычно дольше (вычисляем много хеш-функций, потенциальный кеш-мисс на каждую из них, когда в xor-фильтре их всегда до трёх); занимаем меньше памяти (чаще всего).
Сообщество накодило репозиторий с реализациями для разных языков.
Sparse set
Sparse set это некоторый аналог std::vector
/std::bitset
за тем исключением, что в нём можно делать очистку всего множества за O (1). Вся структура — это два массива и одна переменная:
template
struct SparseSet {
int n = 0;
int dense[R];
int sparse[R];
};
dense
это массив значений. sparse
же хранит индекс значения в dense
, т.е. если dense[i] = v
, то sparse[v] = i
(вместо индекса можно хранить указатель). Тогда добавление выглядит так:
void Add(int val) {
dense[n] = val;
sparse[val] = n;
n++;
}
и проверка на наличие:
bool Has(int val) {
return sparse[val] < n && dense[sparse[val]] == val;
}
И тут мы умеем в «зануление» всей структуры: просто n = 0
.
Выглядит довольно просто. Только что памяти она кушает гораздо больше, чем структуры, в которых можно использовать битовое сжатие. Приятным бонусом имеем, что при создании/обнулении структуры не нужно инициализировать используемые массивы: пусть там лежит мусор, который почистится при добавлении новых значений.
Из самого популярного есть вот этот репозиторий. В folly есть реализация, но она почему-то insert-only. Тут можно почитать чуть подробнее.
Пополняемый массив
Пополняемый массив не столько структура, сколько классический метод обработки данных на простом примере.
Давайте будем решать следующую задачу (да, баян; да, не стыдно): в сортированном массиве требуется отвечать на запрос «сколько значений существует в массиве между l
и r
». Примерно вот так:
class ReplArr {
vector a;
ReplArr(vector data) : a(data) {
sort(a);
}
int get(int l, r) {
return upper_bound(a, r) - lower_bound(a, l);
}
}
А теперь давайте захотим добавлять элементы время от времени. Понятно, что вставка в середину массива работает не очень быстро. Включаем хак: будем хранить второй массив неупорядоченных элементов, в который будем докидывать приходящие значения и обрабатывать отдельно:
class ReplArr {
vector a, b;
ReplArr(vector data) : a(data) { sort(a); }
int get(int l, r) {
int rr = upper_bound(a, r);
int ll = lower_bound(a, l);
int res = rr - ll;
for (auto x: b) {
if (l <= i <= r) { res++; }
}
return res;
}
void add(int x) {
b.push_back(x);
}
}
Работает! Только если у нас очень много добавлений, мы станем долго отвечать на запрос get
. Давайте тогда время от времени докидывать все элементы b
в a
и сортировать заново. Время от времени это когда размер b
примерно равен корню a
(хотим, чтобы get
и модифицированный add
работали примерно одинаково, а раз их время работы это в интересующих нас случаях и , можно взять ). Модифицированный add
:
void add(int x) {
b.push_back(x);
if (sqr(size(b)) >= size(a)) {
a += b;
sort(a);
b = vector();
}
}
В целом паттерн обрабатывать часть информации втупую и время от времени с ней разбираться довольно популярен (один из кейсов для кронтасок).
LSM
LSM — Log Structured Merge. Здесь используется примерно та же идея, что и в предыдущей структуре. Суть в том, чтобы хранить данные на нескольких уровнях фиксированного размера (и, например, размеры растут в геометрической прогрессии). Как только первый уровень будет полностью заполнен, смержить его в следующий. Т.е. структуру следующего вида в целом можно назвать LSMом:
struct lsm {
vector> unsorted; // max 256
vector> sorted_c0; // ровно 256
vector> sorted_c1; // ровно 512
…
vector> sorted_ck; // ровно 65536
on_disk_storage* storage;
};
Изначально вставляем в unsorted
и производим по нему поиск втупую. Как только unsorted
заполняется, скидываем все данные в sorted_c0
(очевидно, предварительно отсортировав). В следующий, раз при необходимости такой же операции, применяем условный mergesort
и пихаем данные в sorted_c1
. И т.д. При необходимости найти элемент, делаем линейный поиск по unsorted
(небольшой размер и локальность данных помогут не очень сильно замедлиться) и бинпоиск по всем остальным векторам. Чтобы не делать поиск на больших медленных уровнях, можно рядом держать какой-нибудь фильтр Блума.
Роль промежуточных структур может выполнять что угодно, что вам удобно (например в LSM-tree, неожиданно, используются деревья).
Реализации можно найти у facebook и google.
Вместо заключения
Пока черновик статьи хранился на задворках гуглодоков, я обнаружил доклад Андрея Аксёнова про необычные структуры данных, которые ещё и используются на практике. Где-то мы пересеклись, где-то нет. В любом случае доклад посмотрите. Он хорош.
Реклама
Можно подписаться на мой канал про C++ и программирование в тг: t.me/thisnotes.