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

У меня есть случай использования, где набор строк будет искать определенную строку, s, Процент совпадений или положительных совпадений для этих поисков будет очень высоким. Скажем, 99% + времени, s будет в наборе.

я использую boost::unordered_set прямо сейчас, и даже с его очень быстрый алгоритм хеширования, занимает около 40мс 600 мс на хорошее оборудование ВМ для поиска набора 500 000 раз. Да, это довольно хорошо, но неприемлемо для того, над чем я работаю.

Итак, существует ли какая-либо структура данных, оптимизированная для высокого процента попаданий? Я не могу предварительно вычислить хэши для входящих строк, поэтому я думаю, что я смотрю на сложность \ $ O (средняя длина строки) \ $ для хеш-набора, например boost::unordered_set, Я посмотрел на Tries, они, вероятно, будут хорошо работать в противоположном случае, когда есть хиты, но на самом деле не лучше, чем хэш-сеты.

редактировать: некоторые другие детали с моим конкретным вариантом использования:

количество строк в наборе составляет около 5000. Самая длинная строка, вероятно, не более 200 символов. Поиск вызывается снова и снова с теми же строками, но они поступают из внешней системы, и я не могу предсказать, какой будет следующая строка. Точный коэффициент совпадения на самом деле составляет 99,975%.

edit2: Я сделал некоторые из моих собственных тестов

Я собрал 5000 строк, которые встречаются в реальной системе. Я создал два сценария.

1) Я перебираю список известных строк и выполняю поиск их в контейнере. Я делаю это для 500 000 поисков («хиты«).

2) Я перебираю набор строк, о которых известно, что их нет в контейнере, для 500 000 запросов («пропускает«).

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

Тесты проводились на виртуальной коробке CentOS 5.6, работающей на хосте macbook.

                                                                  hits (ms)   misses (ms)
boost::unordered_set with default hash and no reserved size:                591.15     441.39
tr1::unordered_set with default hash                                        191.09     143.80
boost::unordered_set with a reserve size set:                               579.31     431.54
boost::unordered_set w/custom hash (hash on the last 15 chars + str size):  357.34     812.13
boost::unordered_set w/custom hash (hash on the last 25 chars + str size):  362.60     795.33
trie:                                                                      1809.34      58.11
trie with reversed insertion/search:                                       2806.26     311.14

В моих тестах, где есть много совпадений, набор tr1 является лучшим. Там, где много промахов, «Три» побеждает.

мой цикл тестирования выглядит следующим образом, где function_set — это тестируемый контейнер, загруженный 5000 строк, а functions — вектор либо всех строк в контейнере, либо набора строк, которых нет в контейнере.

while (searched < kTotalSearches) {
for(std::vector<std::string>::const_iterator i = functions.begin(); i != functions.end(); ++i) {
function_set.count(*i);
searched++;
if (searched == kTotalSearches)
break;
}
}
std::cout << searched << " searches." << std::endl;

2

Решение

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

  • Применяются обычные предостережения о бенчмаркинге (не доверяйте моим цифрам, делайте свои собственные тесты с вашим конкретным вариантом использования и данными и т. Д.).
  • Тесты проводились с использованием MSVS C ++ 2010 (оптимизация скорости, сборка релиза).
  • Тесты проводились с использованием 10 миллионов циклов для повышения точности синхронизации.
  • Имена генерировались путем случайной конкатенации 20 различных фрагментов строк в строки длиной от 4 до 65 символов.
  • Имена включают только буквы, и некоторые тесты (trie) не учитывают регистр для простоты, хотя нет причин, по которым методы не могут быть расширены для включения других символов.
  • Тесты пытаются соответствовать 99,975% -ому коэффициенту попадания, указанному в вопросе.

Базовое описание испытаний с соответствующими деталями:

  • Строковая итерация — Просто перебирает имя функции для сравнения базового времени.
  • картаstd::unordered_map<std::string, int>
  • Задаватьstd::unordered_set<std::string>
  • BoostSetboost::unordered_set<std::string>, v1.47.0
  • CharMapstd::unordered_map<const char*, int>
  • CharSetstd::unordered_set<const char*>
  • FastMap — Просто std::unordered_map<> используя собственный алгоритм хеширования FNV-1a.
  • FastSet — Просто std::unordered_set<> используя собственный алгоритм хеширования FNV-1a.
  • CustomMap — Основная хэш-карта, которую я написал сам много лет назад.
  • Trie — Стандартный файл, загруженный из Код Google.
  • CustomTrie — Я написал сам.
  • BinarySearch — С помощью std::binary_search() на сортированном std::vector<std::string>,
  • SortArrayMap — Попытка использовать size_t VectorIndex[26][26][26][26][26] массив для индексации в отсортированный массив.
  • PerfectMapstd::unordered_map<> используя идеальный хеш из Gperf.
  • PerfectWordSet — С использованием Gperf is_word_set() функционировать напрямую.
  • PerfectWordSetFunc — Такой же как PerfectWordSet но вызывается в функции вместо встроенного.
  • PerfectWordSetThread — Такой же как PerfectWordSet но работа разбита на N потоков (стандартных оконных потоков). Синхронизация не используется, за исключением ожидания завершения потоков.

Результаты сортируются от самого медленного к быстрому (для большинства хитов ~ 99,975%):

  • Trie — 9100 мс
  • SortArrayMap — 6600 мс
  • PerfectWordSetFunc — 4050 мс
  • CustomTrie — 3470 мс
  • BinarySearch — 3420 мс
  • CustomMap — 2700 мс
  • CharSet — 1300 мс
  • CharMap — 1300 мс
  • BoostSet — 1200 мс
  • FastSet — 970 мс
  • FastMap — 930 мс
  • Оригинальный постер — 800 мс (по оценкам)
  • Задавать — 730 мс
  • карта — 690 мс
  • PerfectMap — 650 мс
  • PerfectWordSet — 500 мс
  • PerfectWordSetThread (1) — 500 мс
  • StringIteration — 350 мс
  • PerfectWordSetThread (2) — 260 мс
  • PerfectWordSetThread (4) — 150 мс
  • PerfectWordSetThread (32) — 125 мс
  • PerfectWordSetThread (8) — 120 мс
  • PerfectWordSetThread (16) — 110 мс

Результаты сортируются от самого медленного до самого быстрого (в случае большинства промахов ~ 0,1% хитов):

  • BinarySearch — ? (слишком долго)
  • SortArrayMap — 8050 мс
  • Trie — 3200 мс
  • CustomMap — 1700 мс
  • BoostSet — 920 мс
  • CustomTrie — 850 мс
  • FastMap — 590 мс
  • FastSet — 580 мс
  • CharSet — 550 мс
  • CharMap — 550 мс
  • StringIteration — 350 мс
  • Задавать — 330 мс
  • карта — 330 мс
  • PerfectMap — 280 мс
  • PerfectWordSet — 140 мс
  • PerfectWordSetThread (1) — 130 мс
  • PerfectWordSetThread (2) — 75 мс
  • PerfectWordSetThread (4) — 45 мс
  • PerfectWordSetThread (32) — 45 мс
  • PerfectWordSetThread (8) — 40 мс
  • PerfectWordSetThread (16) — 35 мс

Мое первое предположение состояло в том, что три было бы хорошо подходит для такого рода вещей, но на основании результатов, на самом деле, все наоборот. Думая об этом еще немного, это имеет смысл и по тем же причинам не использовать связанный список.

Я полагаю, вы можете быть знакомы с таблица задержек что каждый программист должен знать. В вашем случае у вас есть 500 тысяч просмотров, выполняющихся за 40 мс, или 80 нс / поиск. При таком масштабе вы легко проиграете, если получите доступ к тому, чего еще нет в кеше L1 / L2. Это очень плохо, потому что у вас есть косвенный и, вероятно, нелокальный доступ к памяти для каждого символа. Принимая во внимание размер дерева в этом случае, я не мог придумать, как заставить все дерево поместиться в кэш для повышения производительности (хотя это может быть возможно). Я все еще думаю, что даже если бы вы сделали так, чтобы три полностью помещалось в кэш L2, вы проиграли бы со всей необходимой косвенностью.

std::unordered_ Контейнеры на самом деле очень хорошо справляются со своими задачами. На самом деле, пытаясь ускорить их, я на самом деле сделал их медленнее (в исследованиях под FastMap и FastSet).
То же самое с попыткой переключиться с std::string в const char * (примерно в два раза медленнее).

boost::unordered_set<> был в два раза медленнее, чем std::unordered_set<> и я не знаю, потому ли это, что я просто использовал встроенную хэш-функцию, использовал немного старую версию boost или что-то еще. Ты пытался std::unordered_set<> сам?

Используя gperf Вы можете легко создать идеальную хеш-функцию, если ваш набор строк известен во время компиляции. Вы также можете создать идеальный хеш во время выполнения, в зависимости от того, как часто новые строки добавляются на карту. Это дает вам увеличение скорости на 23% по сравнению со стандартной реализацией карты.

PerfectWordSetThread тесты просто используют идеальный хеш и разбивают работу на 1-32 потока. Эта проблема совершенно параллельна (по крайней мере, тест), поэтому вы получаете почти 5-кратное повышение производительности в случае с 16 потоками. Это дает только 6,3 мс / 500 тыс. Просмотров или 13 нс / поиск … всего 50 циклов на процессоре 4 ГГц.

StringIteration Дело действительно указывает на то, как трудно будет стать намного быстрее. Простая итерация найденной строки занимает 350 мс или 70% времени по сравнению со случаем отображения 500 мс. Даже если бы вы могли точно угадать каждую строку, вам все равно потребовалось бы это 350 мс (для 10 миллионов поисков), чтобы фактически сравнить и проверить соответствие.

Редактировать: Другая вещь, которая иллюстрирует, насколько трудны вещи, — это разница между PerfectWordSetFunc в 4050 мс и PerfectWordSet при 500 мс. Единственное различие между ними состоит в том, что один вызывается в функции, а другой — встроенный. Вызов его как функции снижает скорость в 8 раз. В базовом псевдокоде это просто:

bool IsInPerfectWordSet (string Match)
{
return in_word_set(Match);
}

//Inline benchmark: PerfectWordSet
for i = 1 to 10,000,000
{
if (in_word_set(SomeString)) ++MatchCount;
}

//Function call benchmark: PerfectWordSetFunc
for i = 1 to 10,000,000
{
if (IsInPerfectWordSet(SomeString)) ++MatchCount;
}

Это действительно подчеркивает разницу в производительности, которую может внести встроенный код / ​​функции. Вы также должны быть осторожны, чтобы убедиться, что вы измеряете в тесте. Иногда вы хотели бы включить накладные расходы вызова функции, а иногда нет.

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

Пара идей, на которые у меня нет знаний:

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

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

2

Другие решения

Я уверен, что Трис это то, что вы ищете. Вы гарантированно не уменьшите количество узлов, превышающих длину вашей строки. Как только вы достигли листа, тогда может быть некоторый линейный поиск, если есть столкновения для этого конкретного узла. Это зависит от того, как вы его построите. Поскольку вы используете набор, я бы предположил, что это не проблема.

Сложность unordered_set будет в худшем случае O (n), но в данном случае n — это количество узлов, которые у вас есть (500 КБ), а не количество символов, которые вы ищете (вероятно, менее 500 КБ).

После редактирования:
Возможно, что вам действительно нужно, это кеш результатов после успешного поиска.

3

Если набор строк фиксирован во время компиляции (например, это словарь известных человеческих слов), вы можете использовать идеальный хэш алгоритм, и использовать Gperf генератор.

В противном случае вы можете использовать массив из 26 хеш-таблиц, проиндексированных по первой букве слова для хеширования.

Кстати, возможно, использование отсортированного массива этих строк с дихотомическим доступом может быть быстрее (так как log 5000 составляет около 13), или std::map или std::set,
Наконец, вы можете определить свою собственную функцию хэширования: возможно, в вашем конкретном случае хэширования будет достаточно только первых 16 байтов!

Если набор строк фиксирован, вы можете подумать о создании дихотомического поиска по нему (например, написать скрипт для генерации функции с 5000 тестами, но только с логом 5000, который выполняется).

Кроме того, даже если набор строк является слегка переменным (например, переход от одного запуска программы к следующему, но остается постоянным в течение одного запуска), вы можете даже подумать о создании функции (путем генерирования кода C ++, а затем его компиляции) в летать и dlopenэто

Вы действительно должны сравнить и попробовать несколько решений! Вероятно, это скорее инженерная проблема, чем алгоритмическая.

2