В рамках семестрового проекта была реализована структура данных Range Min–Max Tree (RmMTree) над битовым вектором для операций над правильными скобочными последовательностями (balanced parentheses, BP). Реализация основана на идеях из статьи (ScienceDirect): https://www.sciencedirect.com/science/article/pii/S0304397516300706
Цель проекта — получить
-
сжатую (
$o(n)$ доп. памяти) - и быструю (все операции выполняются за
$O(\log n)$ времени и используют различные оптимизации, существенно уменьшающие константы) - структуру, поддерживающую типовой набор запросов:
-
rank/selectпо битам, - операции с excess (префиксная сумма с шагами +1/−1),
-
fwdsearch/bwdsearch, - диапазонные запросы минимума/максимума по префиксным суммам,
- навигацию по скобочной последовательности:
close,open,enclose, - дополнительные операции
rank10/select10для шаблона"10".
-
В проекте присутствуют:
- наивная эталонная реализация (для проверки на корректность);
- основная, оптимизированная, реализация;
- набор тестов на корректность (GoogleTest) с сравнением "наивная vs оптимизированная",
- бенчмарки (Google Benchmark) и скрипт построения графиков для визуализации результатов.
- Битовая строка
'0'/'1'или массив 64-битных словuint64_tс укладкой LSB-first (младшие биты — более ранние позиции). - Число валидных бит
num_bits.
rank1(i),rank0(i)— количество1или0на префиксе[0, i).select1(k),select0(k)— позиция k-й единицы/нуля (k 1-based).
rank10(i)— число позицийpв[0, i)таких чтоbit[p]==1иbit[p+1]==0иp+1<i.select10(k)— позиция начала k-го шаблона"10"(k 1-based).
excess(i) = rank1(i) - rank0(i) = 2*rank1(i) - i— префиксная сумма шагов:+1для1и-1для0.fwdsearch(i, d)— минимальныйp ≥ i, гдеexcess(p) = excess(i) + d.bwdsearch(i, d)— максимальныйp ≤ i, гдеexcess(p) = excess(i) + d.
range_min_query_pos(i, j)— позиция первого минимума на[i..j].range_min_query_val(i, j)— значение соответствующего минимума.- Аналогично:
range_max_query_pos/val.
mincount(i, j)— сколько раз минимум достигается на[i..j].minselect(i, j, q)— позиция q-го минимума (q 1-based).
Интерпретация: 1 — это '(', 0 — это ')'.
close(open_pos)— найти соответствующую закрывающую скобку.open(close_pos)— найти соответствующую открывающую скобку.enclose(pos)— это открывающая скобка ближайшей внешней пары, которая содержит позициюposвнутри себя, а не совпадает с ней (если рассматривать BP как дерево, тоenclose(pos)— это открывающая скобка родительского узла для позицииpos, т.е. ближайшая открывающая скобка "на уровень выше").
Дерево реализовано в двух вариантах:
Назначение:
- служит проверочным эталоном для тестов;
- реализует те же операции простыми циклами по массиву бит.
Все операции — асимптотически линейные по длине рассматриваемого диапазона:
rank:O(n),select:O(n),range_min/maxиmincount/minselect:O(j-i)и т.п.
Плюсы:
- простота,
- легко проверить корректность логики.
Минусы:
- очевидны.
std::vector<std::uint64_t> bits;- укладка: LSB-first (бит
iнаходится в словеbits[i>>6]на позиции(i & 63)).
Это решение удобно, потому что:
rankв блоке можно ускорять черезpopcount,selectв блоке делается через перебор установленных битов (внутри 64 бит),- удобно читать байты/16-битные фрагменты для LUT и SIMD.
Битовый вектор делится на листья фиксированного размера block_bits (по умолчанию минимум 64, далее может расти по степеням двойки).
Количество листьев:
leaf_count = ceil(num_bits / block_bits)
Далее строится сбалансированное двоичное дерево в heap-представлении, где:
- листья соответствуют блокам,
- внутренние вершины агрегируют статистики по своим поддеревьям.
Индексация:
1— корень,first_leaf_index— индекс первого листа (степень двойки ≥leaf_count),- лист
kхранится вfirst_leaf_index + k.
Для каждого узла дерева хранится набор агрегатов (но не в виде структуры, а в отдельных массивах, где индекс массива совпадает с номером узла):
segment_size_bits[node]— длина сегмента (в битах), покрываемого узлом.node_total_excess[node]— суммарный excess на сегменте (итоговая сумма шагов +1/−1).node_min_prefix_excess[node]— минимум префиксной суммы внутри сегмента (относительно начала сегмента).node_max_prefix_excess[node]— максимум префиксной суммы внутри сегмента.node_min_count[node]— число позиций, где минимум достигается.node_pattern10_count[node]— число шаблонов"10"внутри сегмента.node_first_bit[node],node_last_bit[node]— крайние биты сегмента (нужны для учёта"10"на границах между детьми/узлами).
Для обработки хвостов и небольших диапазонов сделана lookup table на все 256 возможных значений байта.
Структура ByteAgg хранит для байта:
- суммарный excess,
- min/max префикса,
- количество минимумов,
- число
"10"внутри байта, - первый/последний бит,
- позиции первого min/max.
Также построены таблицы:
fwd_pos[byte][delta]— первая позиция в байте, где префикс равенdelta(delta ∈ [-8..8]),bwd_pos[byte][delta]— последняя позиция.
Это позволяет:
- быстро находить минимум/максимум и их позиции на диапазонах, выровненных по байтам,
- быстро искать нужный префикс для маленьких дельт,
- ускорить
mincount/minselect, - ускорить локальные сканирования в
fwdsearch/bwdsearch.
В реализации предусмотрена опциональная поддержка AVX2 (PIXIE_AVX2_SUPPORT):
- берутся 16 бит (
get_u16), - разворачиваются в 16 "шагов" (+1/−1) по lane’ам,
- вычисляется префиксная сумма внутри SIMD-регистра,
- сравнивается с целевым значением, и по mask находится первая/последняя позиция.
Дополнительно использована эвристика:
- для "маленьких хвостов" и "маленьких дельт" (пример: delta ∈ [-8..8], tail ≤ 256) LUT8 может быть быстрее, чем AVX2 из-за стоимости подготовки SIMD.
В RmMTree предусмотрен механизм выбора block_bits:
- можно задать явно через конструктор/параметр
leaf_block_bits, - можно включить ограничение на относительные накладные расходы по памяти (объём вспомогательных массивов по отношению к размеру битвектора, далее — overhead) через
max_overhead$^*$ , - иначе выбирается значение, близкое к
ceil_pow2(log2(num_bits)), но не меньше базового порога.
Это даёт возможность балансировать:
- скорость (меньше блок → больше дерево → больше узлов, но меньше сканирования листа),
- память (больше блок → меньше узлов → меньше overhead).
max_overhead, вообще говоря, задаёт целевую верхнюю границу на overhead. При построении структура увеличивает размер листового блока block_bits, чтобы уложиться в этот лимит. Однако гарантировать выполнение ограничения не всегда возможно:
block_bitsограничен сверху значениемmin(n, 2^14)(введена как защита от неадекватно большого и на практике почти никогда не нужного размера блока), поэтому при слишком строгом лимите может не существовать допустимого размера блока;- при очень малом
nнакладные расходы неизбежно велики, потому что дерево и массивы имеют "постоянную" составляющую, аblock_bitsнельзя уменьшать ниже 64; - дополнительно overhead растёт из-за выравнивания числа листьев до степени двойки (
first_leaf_index = bit_ceil(leaf_count)), из-за чего появляются пустые слоты узлов в heap-представлении (они занимают место в массивах, хотя фактически не покрывают биты).
Тестирование построено по принципу "оптимизированная реализация должна совпадать с наивной" на большом количестве случайных сценариев.
В проекте реализованы два бенчмарка:
src/bench_rmm.cpp— бенчмарк реализацииRmMTree,src/bench_rmm_sdsl.cpp— бенчмарк реализации Range Min–Max Tree из библиотеки sdsl-lite.
- Используется генератор случайных ПСП;
- После чего для каждого
Nзаранее подготавливаются пулы запросов:- случайные индексы,
- случайные отрезки
[i, j], - случайные
kдляselect-операций, - случайные
deltaдляfwdsearch/bwdsearch, - корректные позиции
'('и')'дляclose/open/enclose, - для
minselect— заранее генерируетсяqв допустимом диапазоне (черезmincount).
--min_exp,--max_exp— диапазон размеровN = 2^e,--per_octave— сколько дополнительных точек между степенями двойки,--explicit_sizes— явный список размеров,--Q— число запросов (сэмплов),--p1— параметр генератора,--seed— seed,--block_bits— размер листа (0 = auto).
Реализован скрипт, читающий JSON-выгрузку Google Benchmark и строящий графики:
- по оси X —
N(размер последовательности), - по оси Y —
cpu_time(нс на операцию), - отдельно по каждой операции,
- опционально сравнивает
RmMTreeиsdsl-liteна одном графике, - может включить
--logxи медианное сглаживание--smooth.
- Наличие
NaiveRmMпозволило сделать строгую проверку "на каждом шаге" для большого количества случайных случаев. - Полный перебор коротких строк (1..8) полезен для ловли ошибок на границах:
- пустые диапазоны,
i=0,i=num_bits,- различия 0-based / 1-based в
open/enclose/bwdsearch, - пересечения
"10"на границах слов и блоков.
Заложено несколько уровней ускорения:
- агрегаты снижают работу на больших диапазонах до
$O(\log n)$ , - LUT8 ускоряет "мелкие" сканы и листовые операции без тяжёлых подготовок,
- AVX2 ускоряет поиски по префиксным суммам на длинных хвостах.
Самая красивая особенность этой реализации заключается в том, что в таких структурах часто именно "листья" становятся bottleneck-ом, но LUT8 и AVX2 позволяют получить немалый практический выигрыш в скорости работы на этих "листьях", а параметр block_bits позволяет подстроить баланс "дерево vs локальное сканирование".
- BP-обёртка:
parent,isancestor,depth,lcaи т.п. поверхclose/open/enclose.
- AoS:
- подумать над слиянием некоторых массивов в Array-of-Structs для улучшения кэш-локальности.
- Cуперблоки:
- объединить несколько соседних листьев в "суперблок" и строить дерево только по суперблокам, а внутри каждого суперблока хранить компактный локальный индекс (например, массив агрегатов по листьям или микро-структуру). Это позволило бы "срезать" несколько нижних уровней дерева, уменьшив число узлов, объём метаданных и количество обращений к памяти при запросах (особенно в
fwdsearch/bwdsearchиrange min/max).
- объединить несколько соседних листьев в "суперблок" и строить дерево только по суперблокам, а внутри каждого суперблока хранить компактный локальный индекс (например, массив агрегатов по листьям или микро-структуру). Это позволило бы "срезать" несколько нижних уровней дерева, уменьшив число узлов, объём метаданных и количество обращений к памяти при запросах (особенно в
В итоге получилась завершённая реализация RmMTree, пригодная для дальнейшего развития в сторону полнофункциональной поддержки succinct-структур над BP.