Алгоритм: гигантское количество очень разреженных бит-массивов, кодирование которых используется

У меня есть особая потребность, и наиболее важные проблемы:

  • в памяти
  • очень низкая занимаемая площадь памяти
  • скорость

Вот моя "проблема": мне нужно хранить в памяти огромное количество очень разреженных бит-массивов. Эти биты устанавливаются только "добавляются" и должны использоваться в основном для пересечений. Огромным я имею в виду до 200 000 бит массивов.

Диапазон должен быть между [0... 16 000 000] для каждого битового набора.

Я провел предварительный тест с "только" 10 673 бит массивами, содержащими некоторые фактические данные, которые у меня есть, и получил следующие результаты:

  1% of the bit arrays (  106 bit arrays) Hamming weight: at most     1 bit  set
  5% of the bit arrays (  534 bit arrays) Hamming weight: at most     4 bits set
 10% of the bit arrays ( 1068 bit arrays) Hamming weight: at most     8 bits set
 15% of the bit arrays ( 1603 bit arrays) Hamming weight: at most    12 bits set
 20% of the bit arrays ( 2137 bit arrays) Hamming weight: at most    17 bits set
 25% of the bit arrays ( 2671 bit arrays) Hamming weight: at most    22 bits set
 30% of the bit arrays ( 3206 bit arrays) Hamming weight: at most    28 bits set
 35% of the bit arrays ( 3740 bit arrays) Hamming weight: at most    35 bits set
 40% of the bit arrays ( 4274 bit arrays) Hamming weight: at most    44 bits set
 45% of the bit arrays ( 4809 bit arrays) Hamming weight: at most    55 bits set
 50% of the bit arrays ( 5343 bit arrays) Hamming weight: at most    67 bits set
 55% of the bit arrays ( 5877 bit arrays) Hamming weight: at most    83 bits set
 60% of the bit arrays ( 6412 bit arrays) Hamming weight: at most   103 bits set
 65% of the bit arrays ( 6946 bit arrays) Hamming weight: at most   128 bits set
 70% of the bit arrays ( 7480 bit arrays) Hamming weight: at most   161 bits set
 75% of the bit arrays ( 8015 bit arrays) Hamming weight: at most   206 bits set
 80% of the bit arrays ( 8549 bit arrays) Hamming weight: at most   275 bits set
 85% of the bit arrays ( 9083 bit arrays) Hamming weight: at most   395 bits set
 90% of the bit arrays ( 9618 bit arrays) Hamming weight: at most   640 bits set
 95% of the bit arrays (10152 bit arrays) Hamming weight: at most  1453 bits set
 96% of the bit arrays (10259 bit arrays) Hamming weight: at most  1843 bits set
 97% of the bit arrays (10366 bit arrays) Hamming weight: at most  2601 bits set
 98% of the bit arrays (10473 bit arrays) Hamming weight: at most  3544 bits set
 99% of the bit arrays (10580 bit arrays) Hamming weight: at most  4992 bits set
100% of the bit arrays (10687 bit arrays) Hamming weight: at most 53153 bits set

Увидев вовлеченные числа, я, очевидно, должен использовать сжатые бит-массивы, и это не проблема: он будет легко справляться с тем, что бит-массивы "добавляются только".

Битовые биты бит, которые включены, сгруппированы, но не полностью. Таким образом, вы будете иметь несколько бит в одной области (но обычно не один за другим, что делает RLE не очень хорошим для бит, который включен).

Мой вопрос: какое сжатие нужно использовать?

Теперь я не знаю, должен ли я использовать свой первый подход здесь или в ответе на мой собственный вопрос.

В основном я представил сценарий "наихудшего случая" с использованием очень тупой кодировки:

  • 1 бит: если включено, следующие 5 бит определяют, сколько бит необходимо для вычисления "пропустить", если выключено, оптимизация: следующие 5 бит определяют, сколько бит слишком воспринимается буквально (это 'on' или "off", без пропусков) [это было бы переключено только тогда, когда определено, чтобы быть более эффективным, чем другое представление, поэтому, когда он срабатывает, он всегда должен быть оптимизацией (по размеру)]

  • 5 бит: сколько бит мы можем пропустить до следующего бит на

  • x bits: skip

Вот пример: бит-бит имеет 3-битный набор, первый бит - 3 098 137, второй - 3 098 141, третий - 3 098 143.

                               +-- now we won't skip
                               |
                               |     +-- 3 because we need 3 bits to store "6" (from 3 098 138 to 3 098 143)
                               |     |    +--- 3 098 141 is on
  22    3 098 137              |     3    | +- 3 098 143 is on
1 10110 1011110100011000011001 0 00011 000101 etc. 

Первый бит говорит о том, что мы будем пропускать биты. 5 следующих бит (всегда 5) сообщает, сколько бит нам нужно рассказать, сколько бит мы пропустим 22 бита, чтобы пропустить до 3 098 137 один бит, говорящий, что мы не пропускаем биты 5 следующих бит (всегда 5) сообщает, сколько бит мы будем читать "как есть", 6 бит: выключено, выключено, выключено, включено, выключено, по значению 3 098 141 и 3 098 143 включены и др.

Видимо, удивительная редкость этих бит-массивов, кажется, довольно размерна.

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

Я сделал это просто, чтобы иметь очень грубое приближение того, что может быть "худшим из худших".

Я был очень приятно удивлен:

Worst case scenario: 

108 913 290 bits needed for the 10 687 very sparse bit arrays
12.9 MB (13 295 KB)

Данные, являющиеся фактическими данными и всеми подобными данными, я знаю, что, если хуже будет хуже, я могу хранить 200 000 бит массивов примерно в 240 МБ, что хорошо.

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

Любые подсказки/идеи относительно того, как сделать это более эффективным по размеру (помня, что это суперразребимые битовые массивы, что их должно быть сотни тысяч, что они должны быть в памяти и что они будут "добавлены" только ")?

О моем случае с добавлением только

В принципе, у меня есть одно растущее "пространство" (диапазон, но "простор" - это реальный термин, как я его понимаю) и множество бит-массивов, которые имеют несколько бит-множеств. Когда диапазон изменяется, скажем, от 0 до 1 000 000, все битовые массивы идут от 0 до 1 000 000. Когда диапазон растет до 1 000 001, тогда все бит-массивы растут тоже, на один бит. Но у большинства из этих бит-массивов будет добавлено "0", в то время как от 4 до 8 бит-массивов будет добавлено "1" в конце. Однако я не могу заранее предсказать, какая из бит-массивов будет содержать 0 или 1.

Итак, у меня есть множество бит-массивов, которые имеют одинаковый размер, все очень редкие (и 0,5% от их бит), и все они "растут" по мере роста диапазона (таким образом, все они всегда растут с одинаковой скоростью).


массивы Judyотлично. Но я читал о них несколько лет назад, и этот материал был "выше моей головы". Массивы Judy представляют собой C-only 20KLOC lib, и я определенно не переустанавливаю их. Но они потрясающие.

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

Ответ 1

Вы не сказали, какой язык программирования вы хотите использовать. Похоже, вы не хотите Джуди, потому что это "C-only"... если вы используете С#, тогда вы можете использовать Compact Patricia Trie вместо. Является почти 4500 LOC (прокомментировал) и использует похожие идеи для Judy, но размер и скорость каждого trie не идеальны из-за ограничений .NET. Он также не оптимизирован для вычисления пересечений, но такой алгоритм может быть добавлен. В статье о CP Tries не подчеркивается этот момент, но он может хранить множества (разреженные битовые массивы) гораздо компактнее, чем словари (графики в статье показывают размер и скорость словарей, а не множество).

Лучшим случаем является плотный кластер бит. При 50% заполнении (каждый бит установлен) требуется менее 8 бит на ключ (менее 4 бит на целое число). (исправление: менее 8 бит, не более.)

Если вам нужно только приблизительное представление данных, используйте Bloom filter.

Кстати, что вы подразумеваете под "append only"? Означает ли это, что вы добавляете только ключи или что каждый добавленный вами ключ больше, чем ключи, которые вы добавили до этого?

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

Битовый набор представлен сортированным массивом из 32-разрядных слотов. Поскольку он сортируется, вы можете использовать двоичный поиск для поиска ключей. Каждый слот состоит из 24-битного "префикса" и 8 бит "флаги". Каждый слот представляет собой область из 8 клавиш. "Флаги" сообщают вам, какой из 8 ключей в регионе присутствует в битете, а "префикс" сообщает вам, в какой области мы говорим, указав биты с 3 по 26 ключа. Например, если следующие биты: "1" в битете:

1, 3, 4, 1094, 8001, 8002, 8007, 8009

... тогда битсет представлен массивом из 4 слотов (16 байт):

Prefix:     0,  136, 1000, 1001
 Flags:  0x15, 0x40, 0x86, 0x02

Первый слот представляет 1, 3, 4 (обратите внимание, что биты 1, 3 и 4 установлены в числе 0x15); второй интервал представляет 1094 (136 * 8 + 6); третий слот представляет 8001, 8002 и 8007; четвертый слот представляет 8009. Имеет ли это смысл?

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

Ответ 2

Вы можете использовать двоичное дерево для битового массива. Скажем, у вас есть массив с диапазоном [M..N]. Храните его таким образом:

Выберите кодировку номера для [0... ram size], например, код Fibonacci, Golomb или Rice (вы можете выбрать наиболее подходящее представление после профилирования вашей программы с фактическими данными).

  • Если массив пуст (не имеет битов), сохраните его как число 0.
  • Если массив заполнен (все биты установлены), сохраните его как номер 1.
  • Else разделил его на две части: A в [M.. (M + N)/2-1] и B в [(M + N)/2..N]
  • Создайте представления P0 и P1, используя этот алгоритм рекурсивно.
  • Получить длину P0 (в битах или других единицах длина может быть целым числом) и сохранить ее как число (вам может потребоваться добавить 1, если длина может быть 1, например, вы сохраняете 0 как один бит 0).
  • Сохранить P0, затем P1.

В этом случае, если пределы являются общими, операции пересечения объединения являются тривиальными рекурсиями:

Пересечения:

  • Если массив A пуст, сохраните 0.
  • Если массив A заполнен, сохраните копию B
  • Остальные разбитые массивы, сделайте пересечения обеих половин, сохраните длину первой половины, затем обе половины.

Этот алгоритм может иметь дело с битами (если вам нужно, чтобы они были самыми компактными) и байтами/словами (если битовые операции настолько медленны).

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

Недостатком является то, что без некоторых хаков, добавляющих/удаляющих элемент в/из массива, сложная операция (такая сложная, как операции пересечения/объединения).

Например, массив с одиночным набором бит 0xAB должен храниться в массиве 0..0xFF as (псевдокод для):

0  1      2   3  4      5  6  7      8  9  10     11 12     13    14     15     16
1, EMPTY, 13, 1, EMPTY, 9, 1, EMPTY, 5, 1, EMPTY, 1, EMPTY, FULL, EMPTY, EMPTY, EMPTY
                                                   |  AA  | AB  |
                                         |A8..A9| AA    ..   AB |
                                      | A8         ..        AB |AC..AF|
                            |A0..A7| A8             ..              AF |
                         | A0                 ..                    AF |B0..BF|
               |80..9F| A0                        ..                       BF |
            | 80                                 ..                        BF |C0..FF|
 | 0..7F| 80                                          ..                          FF |       

EMPTY и FULL - это коды для пустых и полных массивов, числа - это длины в элементах (должны быть заменены фактическими длинами в байтах, бит или около того)

Если вам не нужна быстрая проверка одного бита, вы можете использовать самый простой подход: Просто храните расстояния между битами набора с использованием кодов: фибоначчи, риса, голомба, левенштейна, elias и т.д. Или изобретайте еще один. Обратите внимание, что для того, чтобы получить минимальную длину кода, вы должны использовать код с длиной кода как можно ближе к -log p/log 2, где p - вероятность этого кода. Вы можете использовать для этого код huffman.

Например, используйте гамма-код elias, поэтому массив вроде этого:

0 1 0000 1 1 000 1 0 1 000000000000000000 1 000000000000000000 
2     5   1   4    2         19                   18           (distance)

Должен быть закодирован как:

010 00101 1 00100 010 000010011 000010010
 2    5   1   4    2     19        18     (distance code explained)

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

Ответ 3

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

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

Во всяком случае, всегда полезно украсть из лучших!

Ответ 4

Вы можете посмотреть сжатые растровые изображения. Общей стратегией является использование кодировки с длиной строки.

Реализация С++:

https://github.com/lemire/EWAHBoolArray

Реализация Java:

https://github.com/lemire/javaewah

Ссылка:

Daniel Lemire, Owen Kaser, Kamel Aouiche, Сортировка улучшает индексы bitmap с выравниванием по словам. Data and Knowledge Engineering 69 (1), страницы 3-28, 2010. http://arxiv.org/abs/0901.3751

Ответ 5

Учитывая, что вы все равно будете делать кучу тестов пересечения, возможно, вам стоит попробовать сохранить все битвекторы параллельно. Один редкий, 16M список записей. Каждая запись в этом списке содержит список, из которого из входных битвекторов 200k имеет "1" в этом месте. Похоже, вы ожидаете иметь только около 5 бит, установленных на входной вектор, или 1M всего записей? Принимая реализацию связанного списка соломенного лидера для верхнего уровня и ведер, а в худшем случае без пересечений вообще (таким образом, 1M ковши с 1 элементом каждый), вы можете сохранить все это в 32 МБ.

Ответ 6

Вам могут быть интересны диаграммы двоичных решений (BDD), а точнее, Zero-подавленная двоичная схема принятия решений (ZBDD).

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