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

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

  • в памяти
  • очень низкий объем памяти
  • скорость

Вот моя "проблема": мне нужно хранить в памяти огромное количество очень разреженных битовых массивов. Эти наборы битов предназначены только для добавления и должны использоваться в основном для пересечений. Под огромными я подразумеваю массивы до 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 бит определяют, сколько битов следует понимать буквально (то есть «включено» или «выключено»). ', без пропуска) [это будет переключено только в том случае, если будет определено, что оно более эффективно, чем другое представление, поэтому, когда оно срабатывает, это всегда будет оптимизация (по размеру)]

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

  • х бит: пропустить

Вот пример: в битовом массиве установлено 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 включены и т.д.

Увидев удивительную разреженность этих битовых массивов, это кажется довольно эффективным по размеру.

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

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


person SyntaxT3rr0r    schedule 01.11.2010    source источник
comment
Обратите внимание, что комментарии о повторном изобретении колеса могут быть отправлены в /dev/null: если только для математики/задачи, стоящей за этим, я хочу реализовать это сам. И в любом случае я был бы очень удивлен, обнаружив колесо, которое может работать с 200 000 битовых массивов только для добавления в память :) Но если оно у вас есть, меня очень интересует механика, стоящая за ним :)   -  person SyntaxT3rr0r    schedule 02.11.2010
comment
Существует теоретический предел плотности кодирования: с массивом из N элементов, n из которых установлены, минимальное количество битов для кодирования будет -n*log2(n/N)-(N-n)*log(1-n/N). Для вашего массива, в котором установлено 53153 из 16M, это будет 514 кбит, а для набора 4992 бит - 65 кбит. И чем ближе ваша память к этому пределу, тем более сложную кодировку вам придется выбирать.   -  person Vovanium    schedule 02.11.2010
comment
@Vovanium, я думаю, вы упустили какой-то необходимый контекст для своего теоретического предела (например, какое-то статистическое предположение о распределении устанавливаемых битов?)   -  person comingstorm    schedule 04.11.2010
comment
Я думал о равномерном распределении битов (т.е. каждый 1 имеет постоянную вероятность p = n/N). Точный предел для набора n битов из N равен log2[C(N,n)], который представляет собой просто количество битов в количестве комбинаций и немного меньше. Но для больших N эту формулу трудно вычислить.   -  person Vovanium    schedule 08.11.2010
comment
краткие структуры данных будут актуальным ключевым словом для всех, кто интересуется этим вопросом.   -  person jberryman    schedule 20.04.2017


Ответы (6)


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

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

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

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

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

Набор битов представлен отсортированным массивом 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. Имеет ли это смысл?

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

person Qwertie    schedule 02.11.2010
comment
+1, хороший ответ. Пока мало что знаю о Патриции Трие (помимо имени, которое уже слышала), буду читать. Да, под только добавлением я подразумеваю, что по мере роста пространства (диапазона) некоторые из битовых массивов (обычно от 4 до 8) будут иметь бит, установленный в конце битового массива. Поэтому я никогда не вставляю бит в середину битового массива. Так что это действительно особый случай, который, я думаю, значительно упрощает задачу. - person SyntaxT3rr0r; 02.11.2010
comment
Я предполагаю, что под добавлением только я подразумеваю как то, что я добавляю только ключи, так и то, что ключ также всегда больше, чем ключ, который я добавил ранее. - person SyntaxT3rr0r; 02.11.2010
comment
Хотел бы я дать больше, чем +1, ваша статья выглядит превосходно, как и ваша реализация C# C#. На самом деле язык, который мне нужен, это вероятно Java, но мне может понадобиться простой способ портировать его как на C#, так и на Objective-C... Так что я бы предпочел что-то относительно простое. Но ваша компактная Патрисия Трай выглядит потрясающе. Опять же, мой случай очень особенный: в большинстве моих битовых массивов нет даже 0,5% каждого битового набора, поэтому он действительно супер разреженный. - person SyntaxT3rr0r; 02.11.2010
comment
кстати, нельзя использовать фильтр Блума, нужно точное представление данных. - person SyntaxT3rr0r; 02.11.2010

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

Выберите какую-нибудь числовую кодировку для [0...размер ОЗУ], например код Фибоначчи, Голомба или Райса (вы можете выбрать наиболее подходящее представление после профилирования вашей программы с фактическими данными).

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

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

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

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

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

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

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

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

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 — это коды для пустых и полных массивов, числа — это длины в элементах (должны быть заменены фактическими длинами в байтах, битах или около того)

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

Например, используйте гамма-код Элиаса, чтобы массив выглядел следующим образом:

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)

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

person Vovanium    schedule 02.11.2010
comment
+1, тоже отличный ответ. Я пока не знаю, каким путем пойду, но это точно дает пищу для размышлений :) - person SyntaxT3rr0r; 02.11.2010
comment
Спасибо. Также могу порекомендовать посмотреть, как сделаны различные алгоритмы сжатия звука (MP2, AAC и т.д.). Они имеют дело с разреженными массивами (например, 0, 0, 0, 1, 0, -1, 1, 0, 0, 0, 0, 0, 0, 2, 0, 1, 0) при сжатии высокочастотных спектров. - person Vovanium; 02.11.2010

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

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

https://github.com/lemire/EWAHBoolArray

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

https://github.com/lemire/javaewah

Ссылка:

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

person Daniel Lemire    schedule 04.11.2010

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

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

В любом случае, воровать у лучших — всегда хорошая идея!

person comingstorm    schedule 01.11.2010
comment
да, массивы Judy великолепны, но, честно говоря, математика, стоящая за ними, слишком сложна для меня :) И AFAICT доступен только в виде библиотеки 20KLOC, написанной на C: -/ Я определенно заново изобретаю это рулевое колесо :) - person SyntaxT3rr0r; 02.11.2010
comment
Черт, я имел в виду, что я определенно не заново изобретаю это колесо :) Очевидно :) - person SyntaxT3rr0r; 02.11.2010
comment
Не нужно изобретать их колесо, но основной принцип кажется именно тем, что вы ищете: очень разреженным и легко адаптируемым для написания быстрой функции пересечения. - person comingstorm; 02.11.2010
comment
Я знаю, я знаю, но... Но реализация Джуди представляет собой кодовую базу из 20 000 строк. Это действительно одна из самых сложных для реализации структур данных, когда-либо написанных :) - person SyntaxT3rr0r; 02.11.2010

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

person Ben Jackson    schedule 02.11.2010
comment
нет-нет, список, который я разместил, показывает это, например: 50% битвекторов будут иметь [от 55 до] 67 бит. Всего будет гораздо больше, чем 1 миллион записей. Я бы сказал, что с 200 000 битовых векторов будет, очень грубо, всего установлено 100 миллионов битов. - person SyntaxT3rr0r; 02.11.2010
comment
Я не смотрел на это с этой точки зрения, но теперь, когда вы упомянули, что делаете это по-другому, гарантируется, что каждое из пространств (диапазон 16 миллионов) будет использоваться несколько раз. Как вы это сформулировали, каждая запись в списке 16M будет иметь от 4 до 8 битов. - person SyntaxT3rr0r; 02.11.2010
comment
Ага, я думал, что это сумма, поэтому 55k/10k = 5, моя ошибка. Таким образом, нет причин делать 16-мегабайтный массив разреженным, для каждой записи требуется место примерно для 8 18-битных (2^18 > 200k массивов) идентификаторов, то есть 288 МБ. Аналогично вашей оценке. - person Ben Jackson; 02.11.2010
comment
другая проблема заключается в том, что мне нужен простой способ найти, например, все биты, которые включены для битового массива номер 190 834. Я не знаю, как я мог бы сделать это быстро, если бы мне пришлось анализировать список записей 16M. - person SyntaxT3rr0r; 02.11.2010
comment
Что-то похожее на худший случай, который у меня был. Но я почти уверен, что после того, как я его реализую, он будет значительно ниже :) Потому что я думаю, что переключение между RLE (пропустить 'x' битов) и читать-x-битов как -is будет отлично работать с моим набором данных (чтобы увидеть, но эй). Кроме того, я почти уверен, что мне не часто потребуется 24 бита для хранения «пропуска» (и, очевидно, по мере того, как я буду углубляться в данные, для «пропуска» будет требоваться все меньше и меньше битов, поэтому я действительно выбрал худший вариант). случай-почти-невозможный сценарий :) - person SyntaxT3rr0r; 02.11.2010

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

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

person Noe    schedule 02.11.2010
comment
Я немного отредактировал свой вопрос, чтобы уточнить только добавление. По сути, битовые массивы постоянно растут (максимум до 16 000 000 бит), и я всегда изменяю только их конец, поэтому довольно легко работать непосредственно со сжатой формой. - person SyntaxT3rr0r; 02.11.2010