В большинстве реализаций STL существуют два подхода для организации отображений: map и hash_map. Ни тот, ни другой не являются эффективными для не-скалярных типов, таких как массивы, строки и т.п.
map использует red-black-tree со сравнением целых строк в каждом узле.
hash_map считает для целой строки хеш-код, затем сравнивает строки целиком, причём кол-во таких сравнений равно кол-ву строк с совпадающим хеш-кодом. Дополнительный недостаток — значения при добавлении не сортируются по ключам.
Но есть и третий подход — использование деревьев цифрового поиска (trie). Такая структура данных позволяет находить нужный узел на основании внутреннего представления строки, сравнивая строки лишь один раз. Наиболее эффективной из таких структур является PATRICIA trie, изобретенная в 1968 Дональдом Р. Моррисоном специально для быстрого поиска в больших объемах текстовой информации. Возможности такого дерева весьма высоки, краткое описание алгоритма добавления/поиска в нем можно увидеть, например, в 3-ем томе "Искусства программирования" Кнута.
Исходя из всего этого, у меня возникла идея создать шаблон контейнера pat_map, который бы в обобщённом виде реализовал структуру PATRICIA trie, и был бы однообразным и совместимым с другими контейнерами и алгоритмами STL. Заголовочный файл с шаблоном и пример использования (concordance). (компилируется MSVC 6, Intel C++ 6, MSVC 7.1)
Для тестирования производительности собрал два эквивалентных релиза (MSVC 7.1 with max optimization): этот
(с использованием std::map) и свой (using pat_map).
Времена подсчёта словоупотреблений для файла 72М (очищенные русские тексты, слова в нижнем регистре и пробел) на P4-2.8 1G RAM:
std::map — 39.1 сек.
pat_map — 29.0 сек.
Результаты вычислений совпадают. Максимум использования RAM для обеих программ был одинаков и составил 20М.
Как видно, производительность в случае использования правильной структуры данных повысилась на 25%.
Вообще говоря, это ещё не окончательная версия (в которой планируются pat_set + удаление узлов + некоторые оптимизации + полная возможная совместимость с STL), но даже в таком виде шаблон pat_map будет полезен.
PS Пожелания и предложения принимаются с благодарностью
Здравствуйте, _nn_, Вы писали:
__>Здравствуйте, R.K., Вы писали:
RK>>Насчёт дальнейшего развития: это ещё не всё, но имеются некоторые алгоритмические трудности. Будут идеи, свободное время — будут новые версии.
__>Ждем
все интерфейсные функции и классы помещены в namespace pat, особенности реализации — в pat::impl;
добавлен функтор-Decision HammingDistance, выполняющий функции отбора ключей, дистанция Хемминга которых в сравнении с образцом не более заданной величины (см. здесь
), пример — в patcont.cpp;
в классы partimator'ов добавлены методы для реализации обхода дерева в ширину (см. функтор FixedLength в partial.h, в принципе можно комбинировать и остальные функторы):
— bool leaf() — true, если партиматор указывает на лист;
— [const_][reverse_]iterator|partimator [r]begin|end([const Decis2&]) — для получения вложенного итератора|партиматора.
Пример работы см. build_re.cpp (аналогично этому
Немного о планах (в приблизительном порядке невозрастания реализуемости;):
хотелось бы доделать erase, однако это не так уж тривиально;
есть мысли реализовать контейнеры, аналогичные суффиксным деревьям (время добавления очередного суффикса константно) на уже имеющейся основе, но это уже довольно значительный уход от парадигмы стандартных контейнеров STL и неслабая возня с шаблонами;
[почти] нереальные фантазии о не-бинарных деревьях цифрового поиска PATRICIA.
__>Было бы неплохо сделать более полный тест скорости работы класса.
Привет всем, кого ещё интересует судьба моих контейнеров
trie_map & trie_set, v0.11 (MSVC 7.1)
Первая версия, в которой всё появившееся ранее работает и при этом не вызывает отвращения.
Подробнее:
проект переведён с лицензии GPL на Boost Software License, как более приемлемой для библиотечного шаблонного кода;
код полностью пересмотрен и кое-где исправлен;
в наиболее интересных местах добавлены комментарии;
bit_comparator'ы теперь реализованы в виде функторов (для большей гибкости и схожести с предикатами STL);
реализованы все конструкторы, полагающиеся ассоциативным контейнерам STL, шаблон метода insert range и методы bit_comp (аналог key_comp) и get_allocator;
метод optimize, реинициализирующий внутренний вектор shortcut, для более эффективного поиска/вставки. Также, после каждой вставки, optimize вызывается автоматически по эмпирическому условию;
полностью реализованы partimator'ы: все возможные шаблоны итераторов частичного совпадения и шаблоны методов [r]begin, [r]end для них. Пример работы — см. patcont.cpp;
появилась возможность указывать, какого размера будет shortcut, при специализации контейнера, что позволяет гибче управлять соотношением производительность/память:
trie_map<std::string,unsigned,bit_comparator<std::string,16> > map16;
// sizeof(map16.shortcut) == (1<<16)*sizeof(unsigned)
trie_map<std::string,unsigned> map0;
// sizeof(map0.shortcut) == (1<<0)*sizeof(unsigned) // по умолч. длина префикса =0
Вроде ничего не забыл.
Кратко о предыдущих версиях:
v0.07
lower_bound, upper_bound, equal_range и count получили второй параметр со значением по умолчанию (длина префикса в битах);
insert с параметром hint (подсказка для вставки за O(1)) выкинут за ненадобностью;
введёны новые типы итераторов — partial match iterators (partimators ) — const_partimator, partimator. Позволяют обходить дерево не полностью, заходя только в нужные поддеревья и проверяя совпадение с маской (как здесь
);
бит-компараторы для следующих типов ключей: standard finite std::string (тестировался только этот), simple infinite (char*) string, finite (char*) string with ASCIIZ semantics;
внутренний вектор shortcut — для константного доступа к узлу при поиске/вставке для префикса заранее заданной длины;
в качестве примера эмуляции хеш-таблицы (см. здесь
) реализован специальный класс DelusionString с соответствующим bit_comparator (см. bit_comp.h);
v0.05
trie_set и trie_map
вынос интерфейса bit_comparator из ключа
поддержка стандартных allocator'ов
реализация всех методов ассоциативных контейнеров (кроме некоторых конструкторов и erase)
слегка увеличена скорость
trie_map & trie_set, v0.13 (MSVC 7.1, GCC 3.2, Comeau 4.3)
Ничего особо нового, только теперь компилируется с вышеперечисленными компиляторами (указаны минимальные номера версий, доступных для проверки). Comeau C++ тестировался онлайн. Кстати, только он в режиме strict выдал варнинг на отсутствие return *this; в _Myt &operator=(const _Myt&) в trie_generic. Остальные компилеры предательски молчали, MSVC в /W4, а GCC, соответственно, в -pedantic -Wextra.
Удивительно ещё то, что GCC собрал релиз, выполняющийся быстрее на треть (32%), в сравнении с MSVC с max speed optimizations.
Насчёт дальнейшего развития: это ещё не всё, но имеются некоторые алгоритмические трудности. Будут идеи, свободное время — будут новые версии.
Здравствуйте, _nn_, Вы писали:
__>Здравствуйте, R.K., Вы писали:
RK>>
RK>>MSVC 7.1 (w/o PAT_SHORTCUTS) File: 93 М (russian words)
RK>>*** pat::trie_set
RK>>Timer: 37.3867 sec.
RK>>Size: 404463
RK>>Timer erase: 0.297726 sec.
RK>>*** std::set
RK>>Timer: 54.5871 sec.
RK>>Size: 404463
RK>>Timer erase: 0.302945 sec.
RK>>*** std::hash_set
RK>>Timer: 28.5497 sec.
RK>>Size: 404463
RK>>*** std::hash_set: to std::vector + std::sort
RK>>Timer: 1.42755 sec.
RK>>Size: 404463
RK>>Full hash timer: 29.9773 sec.
RK>>GCC 3.4.4 (w/o PAT_SHORTCUTS) File: 93 М (russian words)
RK>>*** pat::trie_set
RK>>Timer: 27.9667 sec.
RK>>Size: 404463
RK>>Timer erase: 0.476102 sec.
RK>>*** std::set
RK>>Timer: 45.1105 sec.
RK>>Size: 404463
RK>>Timer erase: 0.482932 sec.
RK>>*** std::hash_set
RK>>Timer: 16.078 sec.
RK>>Size: 404463
RK>>*** std::hash_set: to std::vector + std::sort
RK>>Timer: 1.94486 sec.
RK>>Size: 404463
RK>>Full hash timer: 18.0229 sec.
RK>>
__>
__>Что-то я недопонял тест, hash_set получается выходит намного быстрее чем pat::trie_set ?
Да. Но hash_set предполагается сравнивать с trie_set<DelusionString>:
MSVC 7.1 (PAT_SHORTCUTS) File: 93 М (russian words)
*** pat::trie_set
Timer: 34.4867 sec.
Size: 404463
*** pat::trie_set<pat::DelusionString>
Timer: 29.6441 sec.
Size: 404463
GCC 3.4.4 (PAT_SHORTCUTS) File: 93 М (russian words)
*** pat::trie_set
Timer: 25.2501 sec.
Size: 404463
*** pat::trie_set<pat::DelusionString>
Timer: 23.0039 sec.
Size: 404463
Ибо именно DelusionString с соответствующим бит-компаратором (и с включённым PAT_SHORTCUTS) эмулирует работу hash_table. Естественно, в результате получается множество, сортированное по хеш-значениям ключей, т.е. хорошо перемешанное, зато временные показатели добавления будут близки к хешированию, что мы и наблюдаем в тестах Для GCC это менее заметно, видимо реализация hash_set там лучше, чем в VC.
Вот только возможности trie-контейнеров по различным способам перебора элементов намного превышают стандартные, а уж тем более hash-контейнеры, это можно увидеть в предыдущих моих сообщениях.
Кстати, забыл одну вещь: в версии 0.19 появилась возможность хранить элементы в обратном порядке ключей назначением контейнеру pat::reverse_bit_comparator (что-то вроде std::greater вместо std::less), например:
Здравствуйте, VladD2, Вы писали:
VD>Здравствуйте, cranky, Вы писали:
VD>Теперь возми хорошую реализацию хэш-таблицы и хэшфункции и убедись, что потратил время зря.
Достаточно хороши?
Получил на том же тексте 24.1 сек. и хорошо перемешанный список слов с их частотой. Или может есть хеш-функция, позволяющая хранить записи, сортированные по ключу?
На всякий случай напомню: это ещё только зачаточная версия контейнера. В нём планируются такие возможности, которые хешированию так же недоступны, как и хранение в отсортированном виде. В частности, PATRICIA trie позволяет использовать в качестве ключей бесконечные строки. Это может быть полезно для нахождения всех позиций в большом файле, где начинается искомая строка.
Здравствуйте, cranky, Вы писали:
C>Достаточно хороши? C>Получил на том же тексте 24.1 сек.
Я бы назвал эту реализацию посредственной. Но как видишь она уже показала себя лучше дерева.
C> и хорошо перемешанный список слов с их частотой. Или может есть хеш-функция, позволяющая хранить записи, сортированные по ключу?
Сортировку на больших объемах лучше выполнять отдельно. Я к тому, что алгоритмически для таких задач хэширование — оптимальный выбор. Лиш бы хэш-функция не подвела.
C>На всякий случай напомню: это ещё только зачаточная версия контейнера. В нём планируются такие возможности, которые хешированию так же недоступны, как и хранение в отсортированном виде. В частности, PATRICIA trie позволяет использовать в качестве ключей бесконечные строки. Это может быть полезно для нахождения всех позиций в большом файле, где начинается искомая строка.
Я не спорю, что твой контейнер имеет право на существование и хорош в некоторых случаях. Я не согласен с твоим утверждением:
В большинстве реализаций STL существуют два подхода для организации отображений: map и hash_map. Ни тот, ни другой не являются эффективными для не-скалярных типов, таких как массивы, строки и т.п.
Сам по себе hash_map — это классическая таблица. То что он не важно реализован в некоторых версиях STL еще ни о чем не говорит. К тому же hash_map не входит в стандарт и является частным расширением. Ну, а его опрадванность ты мог наблюдать сам.
Нумаю ты понимашь, что чем сложнее тип используемый в качестве ключа, тем эффективнее может быть хэш-таблица? Ведь хэш-значение можно вычислить один раз и закэшировать в кэземпляре. Далее поиск слота осуществляется практически в костантное время и при минимуме вычислений. А сравнение ведется на очень малом объеме списка. В итоге чем больше объем данных — тем фээективнее хэш-таблица. Для защиты от коллизий можно вместо списка коллизий хранить деревья или отсортированные массивы значений. В общем, алгоритмически у чистых деревьев просто нет шансов. Смысл в них есть только если нужна постоянная отсортированность. Что является довольно большой редкостью.
... << RSDN@Home 1.2.0 alpha rev. 606>>
Есть логика намерений и логика обстоятельств, последняя всегда сильнее.
Здравствуйте, VladD2, Вы писали:
VD>Я не спорю, что твой контейнер имеет право на существование и хорош в некоторых случаях. Я не согласен с твоим утверждением: VD>
В большинстве реализаций STL существуют два подхода для организации отображений: map и hash_map. Ни тот, ни другой не являются эффективными для не-скалярных типов, таких как массивы, строки и т.п.
VD>Сам по себе hash_map — это классическая таблица. То что он не важно реализован в некоторых версиях STL еще ни о чем не говорит. К тому же hash_map не входит в стандарт и является частным расширением. Ну, а его опрадванность ты мог наблюдать сам.
Оки, я согласен, что hash_map я зацепил по инерции, но тогда он просто не из той оперы, ибо добивается несколько иных целей и делает это, естественно, по-другому. Хотя... Дело в том, что положение узла в trie определяется исходя из значений битов, указывающих, в какую сторону поворачивать. Но никто не ограничивает, в каком порядке эти биты располагаются в ключе, и есть ли они там вообще. Следовательно, возможно придумать такой порядок вычисления битов, который будет эквивалентен значению хеш-функции от этого ключа. Получается, что на деревьях можно эмулировать работу сколь угодно сложной хеш-таблицы, естественно также без гарантии сортированности по ключу. Тем более в моей реализации выдача значений битов ключа вынесена в отдельный класс.
VD>Нумаю ты понимашь, что чем сложнее тип используемый в качестве ключа, тем эффективнее может быть хэш-таблица? Ведь хэш-значение можно вычислить один раз и закэшировать в кэземпляре. Далее поиск слота осуществляется практически в костантное время и при минимуме вычислений.
Аналогично и с порядком выборки битов: если его сложно вычислять — он кешируется.
VD>А сравнение ведется на очень малом объеме списка. В итоге чем больше объем данных — тем фээективнее хэш-таблица. Для защиты от коллизий можно вместо списка коллизий хранить деревья или отсортированные массивы значений. В общем, алгоритмически у чистых деревьев просто нет шансов.
Если смущает поиск по дереву — можно применить специальный вектор, содержащий указатели на вершины, соответствущие префиксу ключа заранее определённой длины. Тогда останется пройтись по дереву меньшего объёма — аналогия с деревом коллизий. Эта возможность тоже в планах.
VD>Смысл в них есть только если нужна постоянная отсортированность. Что является довольно большой редкостью.
Отсортированность — необязательное условие для деревьев цифрового поиска. Ею при необходимости можно пожертвовать.
Здравствуйте, cranky, Вы писали:
C>Оки, я согласен, что hash_map я зацепил по инерции, но тогда он просто не из той оперы, ибо добивается несколько иных целей и делает это, естественно, по-другому.
Именно. Причем в обычно задач для которых достаточно функциональности хэштаблицы намного больше. В дотнете любой объект обязательно реализует функцию хэширования. Это как раз сделано для того чтобы максимально упростить использование хэш-таблиц.
C> Хотя... Дело в том, что положение узла в trie определяется исходя из значений битов, указывающих, в какую сторону поворачивать. Но никто не ограничивает, в каком порядке эти биты располагаются в ключе, и есть ли они там вообще. Следовательно, возможно придумать такой порядок вычисления битов, который будет эквивалентен значению хеш-функции от этого ключа. Получается, что на деревьях можно эмулировать работу сколь угодно сложной хеш-таблицы, естественно также без гарантии сортированности по ключу. Тем более в моей реализации выдача значений битов ключа вынесена в отдельный класс.
Что то я тебя не пойму. Причем тут биты? Дериво и так польностью эмулирвет поведение хэш-таблицы. Только делает оно это на баже поддержания сортированности. А это, с одной стороны требует того чтобы элементы коллекции можно было сравнивать на болше/меньше, что не всегда удобно/допустимо (например, что больше Иванов или Сидоров?). А сдругой стороны — это банально более медненно. Ведь для поиска элементов в бинарном дереве поиска используется бинарный поиск (полвинным делением), а в хэш-таблице просто вычисляется хэш-функция и на ее основании вычисляется слот хэш-таблицы. Если коллезий нет, то скорость просто константная.
C>Аналогично и с порядком выборки битов: если его сложно вычислять — он кешируется.
О каких битах ты все время ведешь речь? Чтобы найти элемент в дереве ты обязан сравнить ключ с корневой веткой, если ключ больше начать поиск в левом поддереве, если меньше, в правом. В нем ты снова обязан полностью соравинть ключь с ключем из ветки и так далее пока не найдется нужны ключ или не обнаружится, что такового нет. Заранее сравнение вычислить нельзя. В случае же с хэш-значением его можно формировать при создании объекта, а потом пользоваться гтовым значением.
C>Если смущает поиск по дереву — можно применить специальный вектор, содержащий указатели на вершины, соответствущие префиксу ключа заранее определённой длины. Тогда останется пройтись по дереву меньшего объёма — аналогия с деревом коллизий. Эта возможность тоже в планах.
Ничего нельзя тут поделать. К тому же мы говорим о данных в памяти, а они всегда доступны напрямую. Еще раз повторюсь, эффективнее хэшировния является только простой идексный доступ в массиве и то он является практически частным случаем когда хэш-функция равна значению индекса.
C>Отсортированность — необязательное условие для деревьев цифрового поиска. Ею при необходимости можно пожертвовать.
Здравствуйте, VladD2, Вы писали:
VD>Что то я тебя не пойму. Причем тут биты? VD>О каких битах ты все время ведешь речь? VD>А то у тебя какие-то странные представления. Или изложи свои идеи. Как же можно искать в неупорядоченных деревьях.
Почитай теорию по Patricia деревьям, а...
А то похоже ты принцип их работы совершенно не понимаешь. Patricia дерево выглядит примерно так
0
0
1
0
-A-
0 1
0 0
1 0
B C 0 1 0 1
0 E 1 0
1 1 G
0 F
D
A — G — ноды (цифры над ними по дереву до самого верха — соответствующие им ключи)
т.е. для ноды F ключ = 0010100011, для ноды D = 00100010010
Но при этом можно создать такое дерево
Тут уже A = 001010, B = 0010001101, C = 00100010010
Если без оптимизаций, то поиск идет побитовым сравнением ключа поиска и ключа ноды. На различающемся бите просто происходит смена ноды и поиск идет дальше. Во втором дереве правый чайлд просто указывает на эту же ноду.
В результате поиск элемента в таком дереве линейна и зависит от длинны ключа.
Пример: ключ поиска = 0010001101
Нода А — совпало 0010. Бит разницы — 0, перехожу на левого чайлда и сравниваю дальше
Нода В — совпало 001. Бит разницы — 1, перехожу на правого чайлда и сравниваю дальше
Нода В — совпало 101. Больше чайлдов нет — нашел нужную ноду
Пример: ключ поиска = 00101101
Нода А — совпало 0010. Бит разницы — 1, перехожу на правого чайлда и сравниваю дальше
Нода А — совпало 1. До бита разницы не дошло — искомой ноды нет
[...]
CC>Если без оптимизаций, то поиск идет побитовым сравнением ключа поиска и ключа ноды. На различающемся бите просто происходит смена ноды и поиск идет дальше. Во втором дереве правый чайлд просто указывает на эту же ноду.
Стоит добавить только, что обычно "оптимизация" заключается в сохранении номера бита различия в ноде, что позволяет иметь всего один узел на ключ.
CC>В результате поиск элемента в таком дереве линейна и зависит от длинны ключа.
Скорее сложность поиска это сумма [O(ln N) + O(K)], где N — общее кол-во узлов в дереве, K — длина ключа.
Здравствуйте, VladD2, Вы писали:
VD>Здравствуйте, cranky, Вы писали:
C>>Скорее сложность поиска это сумма [O(ln N) + O(K)], где N — общее кол-во узлов в дереве, K — длина ключа.
VD>Здорово. Скоростная характеристика хэш-тиблицы в отсуствии коллизий O(1) и не завист от размера ключа.
А сложность вычисления хорошего хеш-значения ключа тоже стала константной и не зависит от длины ключа?
Здравствуйте, cranky, Вы писали:
C>А сложность вычисления хорошего хеш-значения ключа тоже стала константной и не зависит от длины ключа?
Зависит от алгоритма. Может и не зависить. Но если бы ты читал внимательнее, то заметил бы, что хэш легко кешируется. Так что вычислять его каждый раз не нужно.
... << RSDN@Home 1.2.0 alpha rev. 606>>
Есть логика намерений и логика обстоятельств, последняя всегда сильнее.
Интересно, спасибо. Надо будет и себе такое сделать, думаю получится. Даже мнится, что перебор в partial_match будут делать итераторы, может даже стандартные
CS>На больших наборах TST лучше плюс всякие вкусности типа "найти похожий".
Хм, а если применить правильный аллокатор для узлов, возможно они и сравняются в производительности... Алгоритмы-то родственные, насколько я не напутал. Сложность одинакова. Вкусности и у моих шаблонов планируются, но потом
Здравствуйте, VladD2, Вы писали:
VD>Здравствуйте, cranky, Вы писали:
C>>Достаточно хороши? C>>Получил на том же тексте 24.1 сек.
VD>Я бы назвал эту реализацию посредственной. Но как видишь она уже показала себя лучше дерева.
Такой вопрос: какая хеш-функция будет оптимальной для реализации хеш-таблицы в STL VC 7.1?
Интересуюсь, потому что в "Компиляторах" приведена такая:
size_t operator()(const string &key) const
{
size_t h=0,g;
for (string::const_iterator it=key.begin();it!=key.end();++it)
{
if (g=(h=(h<<4)+*it)&0xF0000000)
h^=g>>24^g;
}
return h;
}
Называется hashpjw и в тестировании даёт "неплохие результаты для всех размеров таблиц". "Неплохие" в этой цитате — это наиболее быстрые? Но проведённые мною эксперименты с замером производительности дают странный результат — hash_map с hashpjw всегда немного отстаёт от моего мапа, в то время как "посредственная реализация" всегда несколько опережает его. Почему так получается и какая функция будет самой эффективной для моих данных (большое кол-во (300-500К) повторяющихся с разной частотой строк длиной 1..25 символов)?
Здравствуйте, kluxer, Вы писали:
K>Такой вопрос: какая хеш-функция будет оптимальной для реализации хеш-таблицы в STL VC 7.1?
Вообще-то это зависит от того что из себя представляют строки. Ведь это могут быть фантастические рассказы, а могут идентификаторы из программы.
K>Интересуюсь, потому что в "Компиляторах" приведена такая: K>
K>Называется hashpjw и в тестировании даёт "неплохие результаты для всех размеров таблиц".
Это как я понимаю переделанный под С++ вариант из "Красного дракона". Я не проводил личных эксперементов, но и так понятно, что данная хэш-функция рассчитана на короткие идентификаторы обычно встречаемые в компиляторах.
Насколько мне извесно в дотнете используется очень неплохая хэш-ункция для строк. Ее можно выковырять из Ротора (SSCLI). В дотнетом форуме даже как-то был флэйм. Один орел не мог поверить, что хэш-функции не могут давать уникальные значения, а его тесты давали уникальные результаты.
K> "Неплохие" в этой цитате — это наиболее быстрые? Но проведённые мною эксперименты с замером производительности дают странный результат — hash_map с hashpjw всегда немного отстаёт от моего мапа, в то время как "посредственная реализация" всегда несколько опережает его. Почему так получается и какая функция будет самой эффективной для моих данных (большое кол-во (300-500К) повторяющихся с разной частотой строк длиной 1..25 символов)?
Возможно вычисление хэш-функции оказывается на тестируемых объемах более затратным чем сам поиск. А возможно что функция на твоих данных дает много коллизий. Это легко определить опытным путем. Если проблема в скорости хэш-функции, то можно просто кэшировать результат. Если в качестве, то нужно ее выбрасывать.
... << RSDN@Home 1.2.0 alpha rev. 606>>
Есть логика намерений и логика обстоятельств, последняя всегда сильнее.
C>Исходя из всего этого, у меня возникла идея создать шаблон контейнера pat_map, который бы в обобщённом виде реализовал структуру PATRICIA trie, и был бы однообразным и совместимым с другими контейнерами и алгоритмами STL. C>Заголовочный файл с шаблоном и пример использования (concordance). (компилируется MSVC 6, Intel C++ 6, MSVC 7.1)
trie_set и trie_map
вынос интерфейса bit_comparator из ключа
поддержка стандартных allocator'ов
реализация всех методов ассоциативных контейнеров (кроме некоторых конструкторов и erase)
слегка увеличена скорость
Отличия от set и map: некоторые различия в работе методов lower_bound и подобных. Обусловлены отсутствием предиката и присутствием бит-компаратора, а также возможностью бесконечного ключа. Но об этом в будущей документации.
Здравствуйте, VladD2, Вы писали:
VD>Здравствуйте, cranky, Вы писали:
C>>А сложность вычисления хорошего хеш-значения ключа тоже стала константной и не зависит от длины ключа?
VD>Зависит от алгоритма. Может и не зависить. Но если бы ты читал внимательнее, то заметил бы, что хэш легко кешируется. Так что вычислять его каждый раз не нужно.
Я этот алго использую в таких случаях, когда на вход приходят сырые строки полученные откуда-то. Т.е. хэш для них надо считать в любом случае.
Для меня еще является плюсом что мне не надо парится вопросом насколько подходит выбранный алгоритм хеширования для этого типа ключей.
Давайте этот спор решим просто: набросаем тестовую программку с выполнением пары-тройки приближенных к реальным задачам алгоритмов с применением hash_map и patricia trie (+можно еще и ternary tree до кучи)
И можно на практике посмотреть на эффективность.
Потому как к сырой теории у меня полного доверия нет. Сколько раз напарывался что в теории все гладко а на практике полное г.
... дополнение к моей мессаге
набросал сравнивалку. Но только без hash_map — я щас в отпуске а дома только VC6 со стандартным STL
В общем случае ternary в 3-5 раз быстрее patricia. Тестил на русских текстах и на телефонном справочнике (список ФИО).
Но при этом в ternary пробежаться по списку строк никак нельзя — надо их самому хранить отдельно. Что увеличивает требования к памяти.
947494 уникальных слов
вставка слов из файла
Patricia: 14.368569 sec. 8788 ticks per call 110.5Mb
Ternary: 4.221286 sec. 2597 ticks per call 68.4Mb (чистое дерево, сами строки не хранятся)
повторное чтение файла и поиск слов в map
Patricia: 17.433085 sec. 10662 ticks per call
Ternary: 2.179576 sec. 1341 ticks per call
lower_bound, upper_bound, equal_range и count получили второй параметр со значением по умолчанию (длина префикса в битах). Теперь можно получить итераторы границ интервала, содержащего все ключи с заданным префиксом. Пример см. в patcont.cpp;
insert с параметром hint (подсказка для вставки за O(1)) выкинут за ненадобностью;
введёны новые типы итераторов — partial match iterators (partimap ) — const_partimap, partimap. Позволяют обходить дерево не полностью, заходя только в нужные поддеревья. Шаблоны классов этих итераторов параметризуются типом Decision — функтором, определяющим надо ли заходить в указанное поддерево. Пример реализации класса Decision имеется в partial.h, PartialMatch, и реализует прохождение дерева для поиска всех частичных совпадений по ключу (как здесь
). В дальнейшем планируются const_reverse_partimap, reverse_partimap, а также другие реализации интерфейса Decision для разных задач поиска совпадений;
бит-компараторы для следующих типов ключей: standard finite std::basic_string<char> (тестировался только этот), simple infinite (char*) string, finite (char*) string with ASCIIZ semantics;
внутренний вектор shortcut — для константного доступа к узлу при поиске/вставке для префикса заранее заданной длины (в моей реализации бит-компараторов — 16 бит (см. bit_comp.h));
в качестве примера эмуляции хеш-таблицы (см. здесь
) реализован специальный класс DelusionString с соответствующим bit_comparator (bit_comp.h), кеширующий 16 младших бит хеш-значения для строки. Для сборки в режиме эмуляции хеш-таблицы следует раскомментировать #define PAT_DELUSION в patcont.cpp. После запуска получается неотсортированный по ключу результат, но зато даже немного быстрее, чем используя hash_map с той же хеш-функцией. В таком варианте контейнеры trie_map и trie_set теряют все свои возможности, связанные с последовательной выборкой битов из ключа;
возможно что-то ещё забыл
v0.05
Изменения к лучшему:
trie_set и trie_map
вынос интерфейса bit_comparator из ключа
поддержка стандартных allocator'ов
реализация всех методов ассоциативных контейнеров (кроме некоторых конструкторов и erase)
слегка увеличена скорость
Отличия от set и map: некоторые различия в работе методов lower_bound и подобных. Обусловлены отсутствием предиката и присутствием бит-компаратора, а также возможностью бесконечного ключа. Но об этом в будущей документации.
Здравствуйте, R.K., Вы писали:
RK>Привет всем!
RK>trie_map & trie_set, v0.13 (MSVC 7.1, GCC 3.2, Comeau 4.3) RK>Ничего особо нового, только теперь компилируется с вышеперечисленными компиляторами (указаны минимальные номера версий, доступных для проверки). Comeau C++ тестировался онлайн. Кстати, только он в режиме strict выдал варнинг на отсутствие return *this; в _Myt &operator=(const _Myt&) в trie_generic. Остальные компилеры предательски молчали, MSVC в /W4, а GCC, соответственно, в -pedantic -Wextra. RK>Удивительно ещё то, что GCC собрал релиз, выполняющийся быстрее на треть (32%), в сравнении с MSVC с max speed optimizations. RK>Насчёт дальнейшего развития: это ещё не всё, но имеются некоторые алгоритмические трудности. Будут идеи, свободное время — будут новые версии.
Ждем
Было бы неплохо сделать более полный тест скорости работы класса.
Добил-таки erase(iterator), впрочем, и он меня тоже, но об этом потом. Ирейз получился красивый, константный и жутко быстрый. Зацените код:
void erase(iterator delIt)
{
// retrieve Algorithm structure from iterator
Algorithm pal(delIt);
// q back-point to p, p & q may point to same node
Node *q=pal.getQ(),*p=pal.getP();
// pBrother - sibling of pconst unsigned
pBrotherId=1ul^pal.getQid(),
pBrotherTag=q->getXtag(pBrotherId);
Node *pBrother=q->getXlink(pBrotherId);
// take parent of qconst unsigned qParentId=q->getParentId();
Node *qParent=q->getParent();
// if parent of q existif (qParent)
{
// replace q with p-brother
qParent->setXlinktag(qParentId,pBrother,pBrotherTag);
if (!pBrotherTag)
pBrother->setParentid(qParent,qParentId);
}
// if p & q isn't equalif (q!=p)
{
// make q identical to p
q->setAllButValue(*p);
Node *pParent=p->getParent();
// correct parent of p if existif (pParent)
pParent->setXlinktag(p->getParentId(),q,0);
// correct childs of pfor (unsigned i=0;i!=2;++i)
{
if (!p->getXtag(i))
p->getXlink(i)->setParentid(q,i);
}
}
// special case: del rootif (p==root_)
root_=q==p?0:q;
// sic transit gloria mundi
delNode(p);
// decrease size
--size_;
#ifdef PAT_SHORTCUTS
// fix shortcuts
optimize();
#endif
}
Как вы уже, наверное, заметили, метод возвращает void, хотя стандартный должен вернуть iterator на следующий после удаляемого элемент. Но увы, эффективно найти этот итератор не позволяет сама природа PATRICIA trie: каждый узел является одновременно и веткой и листом. Таким образом, на каждое хранимое значение существуют два указателя: как на поддерево (обычный указатель на детей) и как на лист (обратная ссылка, в Кнуте обозначается штриховой стрелкой). Благодаря этому, кстати, и становятся возможными partimator'ы с заходом только в нужные поддеревья.
Подумав, как можно было бы исправить ситуацию, я понял, что trie не сможет обеспечить валидность итераторов после удаления узла. Ещё через пару минут сообразил, что итераторы инвалидируются также после вставки узла. Получается, что условия по сохранности итераторов, которые обеспечивают стандартные ассоциативные контейнеры (итератор на элемент контейнера живёт пока жив элемент под итератором), нельзя выполнить для контейнеров, основанных на PATRICIA trie. Единственное исключение — итераторы, возвращаемые end() и rbegin() — их правильность распространяется на всё время жизни контейнера. Также после вставки/удаления не сохраняются партиматоры, но для них нет никаких гарантий даже для end() и rbegin(). Вот так, немного грустно, но суббота была непоправимо испорчена с полдня.
Неизбежные следствия: невозможно эффективно реализовать erase(iterator first,iterator last), нельзя просто пробежаться по контейнеру и удалить ненужные элементы, придётся начинать или с begin() или с end() после каждого удаления. Впрочем, я ещё попробую подумать над вычислением в erase() следующего итератора: тут не всё так запущено, я думаю...
Ещё, чуть не забыл: в связи с тем, что при удалении обновляются несколько разных узлов, необходимо пересчитывать вектор shortcut (методом optimize()), что требует немало времени (для 16-битных префиксов нужно обновить 2^16 элементов). По тестам выходит, что скорость удаления в этом случае понижается в ~20 раз. Поэтому в будущем лучше отказаться от этой небольшой (8-10%) оптимизации, тем более, что в одной из следующих версий, надеюсь, образуются не-бинарные деревья — и шоткаты станут совсем ненужны.
В качестве бонуса к erase() набросал небольшой адаптер unique_priority_queue для trie_set и set. Функциональность у него ровно такая же, как и у std::priority_queue (основанного на хипе), но сохраняются только уникальные значения. Естественно, для не-скалярных типов у trie_set наблюдается преимущество в производительности push/pop, по сравнению с std::set (см. результаты теста дальше).
Новые бит-компараторы: для unsigned и int. Вдруг кому пригодятся?
Здравствуйте, _nn_, Вы писали:
__>Здравствуйте, R.K., Вы писали:
__>>>Было бы неплохо сделать более полный тест скорости работы класса.
RK>>Это да... Вот только с чем сравнивать будем?
__>Между компиляторами и с std::map/std::set
ОК. Ловите результаты для P4-3.0, 1G RAM (исходники см. speed_test.cpp и speed_test_pq.cpp):
MSVC 7.1 (PAT_SHORTCUTS) File: 93 М (russian words)
*** pat::trie_set
Timer: 34.4867 sec.
Size: 404463
*** pat::trie_set<pat::DelusionString>
Timer: 29.6441 sec.
Size: 404463
MSVC 7.1 (w/o PAT_SHORTCUTS) File: 93 М (russian words)
*** pat::trie_set
Timer: 37.3867 sec.
Size: 404463
Timer erase: 0.297726 sec.
*** std::set
Timer: 54.5871 sec.
Size: 404463
Timer erase: 0.302945 sec.
*** std::hash_set
Timer: 28.5497 sec.
Size: 404463
*** std::hash_set: to std::vector + std::sort
Timer: 1.42755 sec.
Size: 404463
Full hash timer: 29.9773 sec.
GCC 3.4.4 (PAT_SHORTCUTS) File: 93 М (russian words)
*** pat::trie_set
Timer: 25.2501 sec.
Size: 404463
*** pat::trie_set<pat::DelusionString>
Timer: 23.0039 sec.
Size: 404463
GCC 3.4.4 (w/o PAT_SHORTCUTS) File: 93 М (russian words)
*** pat::trie_set
Timer: 27.9667 sec.
Size: 404463
Timer erase: 0.476102 sec.
*** std::set
Timer: 45.1105 sec.
Size: 404463
Timer erase: 0.482932 sec.
*** std::hash_set
Timer: 16.078 sec.
Size: 404463
*** std::hash_set: to std::vector + std::sort
Timer: 1.94486 sec.
Size: 404463
Full hash timer: 18.0229 sec.
---------------- [unique_]priority_queue
MSVC 7.1 File: 93 М (russian words)
*** std::priority_queue
Timer: 66.5425 sec.
*** pat::unique_priority_queue
Timer: 31.2714 sec.
*** pat::unique_priority_queue<std::set>
Timer: 43.163 sec.
GCC 3.4.4 File: 93 М (russian words)
*** std::priority_queue
Timer: 88.7093 sec.
*** pat::unique_priority_queue
Timer: 31.46 sec.
*** pat::unique_priority_queue<std::set>
Timer: 42.5287 sec.
__>>Что-то я недопонял тест, hash_set получается выходит намного быстрее чем pat::trie_set ?
RK>Да. Но hash_set предполагается сравнивать с trie_set<DelusionString>:
Все встало на места
Что-то я не нахожу файлы тестов, куда вы их выложили ?
Я хочу сравнить trie_map/set с hash_/map/set от STLPort.
Здравствуйте, _nn_, Вы писали:
__>Здравствуйте, R.K., Вы писали:
__>>>Что-то я недопонял тест, hash_set получается выходит намного быстрее чем pat::trie_set ?
RK>>Да. Но hash_set предполагается сравнивать с trie_set<DelusionString>:
__>Все встало на места
__>Что-то я не нахожу файлы тестов, куда вы их выложили ? __>Я хочу сравнить trie_map/set с hash_/map/set от STLPort.
Вдогонку было бы неплохо использовать один стиль именования, а то у вас их несколько.
Лучше все в стиле STL
Кстати лучше не начинать имена с подчеркивания — _Myt, _Pairib, а еще лучше заменить на что-то более информативное, например this_type, pair_type.
Здравствуйте, _nn_, Вы писали:
__>Здравствуйте, _nn_, Вы писали:
__>>Здравствуйте, R.K., Вы писали:
__>>>>Что-то я недопонял тест, hash_set получается выходит намного быстрее чем pat::trie_set ?
RK>>>Да. Но hash_set предполагается сравнивать с trie_set<DelusionString>:
__>>Все встало на места
__>>Что-то я не нахожу файлы тестов, куда вы их выложили ?
В архиве с 0.19 версией:
speed_test.cpp
speed_test_pq.cpp
__>>Я хочу сравнить trie_map/set с hash_/map/set от STLPort.
__>Вдогонку было бы неплохо использовать один стиль именования, а то у вас их несколько.
Всего два: мой текущий (я их сменил уже с полдесятка;) и STL. Там где стиль вылезает на поверхность в виде интерфейсов — там "like STL".
__>Лучше все в стиле STL
Со-омнительно... мне он не нравится
__>Кстати лучше не начинать имена с подчеркивания — _Myt, _Pairib, а еще лучше заменить на что-то более информативное, например this_type, pair_type.
Здравствуйте, R.K., Вы писали:
RK>Здравствуйте, _nn_, Вы писали:
__>>Здравствуйте, _nn_, Вы писали:
__>>>Здравствуйте, R.K., Вы писали:
__>>>>>Что-то я недопонял тест, hash_set получается выходит намного быстрее чем pat::trie_set ?
RK>>>>Да. Но hash_set предполагается сравнивать с trie_set<DelusionString>:
__>>>Все встало на места
__>>>Что-то я не нахожу файлы тестов, куда вы их выложили ?
RK>В архиве с 0.19 версией: RK>speed_test.cpp RK>speed_test_pq.cpp
Я не заметил что 0.17 была скачена
Тогда скоро будут тесты с STLPort
__>>>Я хочу сравнить trie_map/set с hash_/map/set от STLPort.
__>>Вдогонку было бы неплохо использовать один стиль именования, а то у вас их несколько.
RK>Всего два: мой текущий (я их сменил уже с полдесятка;) и STL. Там где стиль вылезает на поверхность в виде интерфейсов — там "like STL".
__>>Лучше все в стиле STL
RK>Со-омнительно... мне он не нравится
Ну дело вкуса
__>>Кстати лучше не начинать имена с подчеркивания — _Myt, _Pairib, а еще лучше заменить на что-то более информативное, например this_type, pair_type.
RK>Это да. Будет время — исправлю.
Ждем-с
для удаления сразу всех значений, ключи которых начинаются с заданного префикса. Работает быстро: сначала находит поддерево с заданным префиксом за логарифмическое время, затем вырезает это поддерево из основного дерева за константное время и, наконец, рекурсивно освобождает память, выделенную под узлы в данном поддереве. Если prefixLen задан по умолчанию ~0ul, вырезается максимум один узел с заданным ключом, т.е. поведение в этом случае стандартное. Метод возвращает кол-во удалённых значений.
Эффективно получать следующий после удалённого итератор я так и не смог, но есть надежда сделать erase(iterator,iterator) через последовательное удаление узлов с одинаковым префиксом, пока не реализовано.
Теперь о самом интересном.
В анонсе суффиксных деревьев
Насчёт времени добавления я немного ошибся: на самом деле сложность индексирования всей строки линейно зависит от её длины; получается, что добавление одного суффикса константно лишь в среднем.
Но это не меняет сути дела: в текущую версию дополнительных контейнеров STL добавлен прототип шаблона контейнера suffix_set, реализующий динамическое суффиксное дерево на основе PATRICIA trie.
Интерфейс suffix_set:
// constructor
suffix_set(
key_type keys, // передается указатель на начало суффиксного буфера (на первый суффикс)
size_type size, // размер окна (одновременно индексируемых суффиксов)const bit_compare &bitComp=bit_compare(),
const allocator_type &alloc=allocator_type());
// в конструкторе сразу добавляется первый узел, он же root
// инвариант для suffix_set: в контейнере должен быть хотя бы один суффикс
// вставляем очередной суффикс (следующий по возрастанию адресов)void push_back();
// удаляем наиболее ранний суффикс (первый по порядку добавления)void pop_front(); // именно этот метод делает suffix_set динамическимbool full() const; // true, если нет места для добавления очередного суффикса
size_type size() const; // возвращает текущее кол-во суффиксов
Подобный интерфейс, привычный для очереди (push_back/pop_front), обусловлен необходимостью последовательного (без пропусков) добавления/удаления суффиксов, а также произвольного доступа (по порядку добавления) к узлам дерева и к соответствующим им суффиксам, которые играют роль ключей.
Для реализации внутреннего хранения узлов цифрового дерева был выбран кольцевой буфер (см. ring_buffer.h), как структура, свойства которой наиболее соответствуют требованиям дерева суффиксов.
Требования к памяти: <индексируемая строка> + <размер окна suffix_set>*sizeof(Node). sizeof(Node) при включенном PAT_ALIGNHACK составляет 16 байт.
Все возможности, доступные в trie_set, доступны и в suffix_set (пока есть const_(iterator|partimator)), за исключением тех, которые основаны на конечности ключей, ибо суффиксы обязаны быть бесконечными. Ещё одно отличие состоит в том, что, во избежание избыточности, в узлах suffix_set не хранятся указатели на суффиксы, но на внешнем интерфейсе это не отражается.
Пример работы с suffix_set см. test_suffix.cpp. На моём P4-2.8 1G RAM файл в 33М полностью индексировался (размер окна == длине файла) за 18 сек., исп. ОЗУ — 570М. Файл 75М с окном 4М динамически индексировался за 60 сек., исп. ОЗУ — 140М.
Основной причиной реализации этого прототипа была необходимость проверить, будут ли работать мои, до сей поры чисто умозрительные, алгоритмы на практике. Так что ещё предстоит довести дерево суффиксов до нормального состояния, обобщив его с trie_* контейнерами, сделать suffix_map (трудно представить, как его можно использовать, но на всякий случай;). Также стоит подумать об уменьшении объема используемого ОЗУ и об алгоритмах, эффективно реализуемых с suffix trie.
(trie_|suffix_)(map|set), v0.29 (MSVC 7.1, GCC 3.2, Comeau 4.3).
Пришлось переписать заново практически всё. По основным пунктам:
Бит-компараторы теперь существуют для std::basic_string<T>, т.е. как минимум для std::string и std::wstring; также для любого указателя вида T*.
Decision-функторы PartialMatch и HammingDistance теперь специализируются типом контейнера, отчего стало возможным применять их с любыми возможными бит-компараторами. При тестировании с суффиксными контейнерами была обнаружена и исправлена ошибка в механизме партиматоров, заодно избавился от довольно глубокой рекурсии. Сейчас в работе итераторов частичного совпадения есть только одно ограничение: функтор HammingDistance оказалось невозможным применять с реверсивными итераторами, потому что он содержит mutable-члены. В остальных случаях — это действительно работает! Проверено.
В результате обобщения всех контейнеров образовался дополнительный внутренний класс impl::assoc_generic, в который вынесены все итераторы ([const_][reverse_](iterator|partimator)) и методы, общие для всех четырёх контейнеров: [r](begin|end), find, (lower_|upper_)bound, equal_range, count.
Из прототипа suffix_set предыдущей версии образовались два новых шаблонных контейнера:
template <
typename Type,
int delta=1, // расстояние в bitT между соседними суффиксамиtypename BitComp=bit_comparator<Type>,
typename Allocator=std::allocator<Type> >
class suffix_set;
template <
typename Type,
typename Datum,
int delta=1, // расстояние в bitT между соседними суффиксамиtypename BitComp=bit_comparator<Type>,
typename Allocator=std::allocator<Type> >
class suffix_map;
Краткое описание всех методов этих шаблонов — в suffix_set.h. Недостаточно потестированные методы, возможны баги: pop_front, pop_back.
Некоторые примеры использования суффиксных деревьев: линейный поиск множества ключей (multi_search.cpp) и преобразование BWT (или лексическая сортировка, сложность также линейная), см. usort3.cpp. Для сравнения приведёна версия 2.33 USORT, применяющая для сортировки обычное дерево PATRICIA (сложность приблизительно O(N*log N)).
Обобщить suffix_map и suffix_set мне так и не удалось. Несмотря на практически полную идентичность интерфейса, контейнеры эти различаются в некоторых внутренних деталях. Также очень не нравится обилие protected-членов в промежуточных классах — есть подозрение, что можно сделать лучше. Если кто-нибудь сможет помочь исправить ситуацию, буду дико благодарен Готов пойти на любые издержки — вплоть до переписывания всего кода (нам не привыкать).
Документацию решил сделать автоматически генерируемой doxygen'ом. Пока, только ради эксперимента, потренировался на algorithm.h. Опять же, замечания приветствуются.