Бор
Обратимся снова к поиску слова в словаре. Во всех нормальных словарях на правой границе написаны буквы (а в некоторых даже вырублены засечки), с помощью которых можно мгновенно найти место, где начинаются слова на определенную букву. Я однажды увидел справочник, в котором справа были вырублены первые буквы слов, а сверху — вторые. Таким образом в этом справочнике можно мгновенно найти блок слов по первой букве, а в нём подблок слов по второй букве. У книги три свободных стороны... теоретически, можно сделать книгу, в которой можно будет искать слова до третьей буквы!
Эта идея очень просто обобщается, и из нее создается простая и лаконичная структура данных, позволяющая очень быстро искать информацию — бор. Задача поиска данных описанным методом называется лучевым поиском.
Задача лучевого поиска. Есть алфавит размера . Строки будут являться ключами, с которыми ассоциированы записи . Нужно по заданной строке найти ассоциированную с ней запись .
Будем действовать точно так же, как в тех книжках с буквенными метками. Сделаем бор.
Бор — дерево, каждое ребро которого помечено символом из . Некоторые узлы помечены терминальными, и в них хранятся записи (или ссылки на записи). Вообще, бор полностью совпадает с деревом решений при поиске в словаре по буквенным меткам.
Вот пример бора, построенного для слов a, an, ani, anime, ant, day, done, doc, dock, have, her, him.
Хранение
Разберемся пока с абстрактным представлением. В каждом узле нам нужно хранить ссылок, ассоциированных с символами из алфавита . Получается, в каждом узле нам нужно хранить ассоциативный массив размера , ключами которого служат символы из алфавита , а значениями — ссылки на следующие узлы.
Структура узлов получается следующая
struct node:
assoc[char: node] children
bool is_terminal
some additional data if is_terminalНа каждую вершину приходится ссылок, возможно пустых. Дополнительные данные типа ссылок на записи, флаг терминальности и прочее пока не рассматриваем. Получается, что бор занимает , где — количество узлов.
Поиск
При поиске в боре мы повторяем те же действия, что делали бы при поиске в словаре с буквенными метками.
Начиная с корня, мы выбираем ссылку на следующий узел на основе символа в искомой строке . Заканчиваем тогда, когда закончится строка или когда нам будет некуда идти. Если мы дошли до конца строки и в итоге попали в терминальную вершину, значит строка в боре есть, и мы сможем вытащить ассоциированную с ней запись, если надо.
function find(node root, string key) -> node:
i = 0
while root != none and i < length(key):
root = root.children[key[i]]
i += 1
if root == none:
return not found
else if root.is_terminal:
return root
else:
return not foundПусть длина искомой строки равна . Во время поиска строки мы потратим максимум переходов по ассоциированным ссылкам.
Вставка
При вставке данных в бор мы делаем всё то же самое, что и при поиске. В какой-то момент узлы могут закончится, вот вставляемая строка нет. Тогда мы продолжаем двигаться вперед, создавая себе новые вершины на пути.
В самом конце мы пометим вершину как терминальную и сохраним в ней всю нужную информацию, например, ссылку на запись.
function insert(node root, string key) -> node:
for i = 0; i < length(key):
if root.children[key[i]] == none:
root.children[key[i]] = node()
root = root.children[key[i]]
root.is_terminal = true
save some additional data at rootАнализ строк
Рассмотрим бор на алфавите размера , в котором хранится строк. Давайте проанализируем количество узлов, занимаемую бором память и смежные вопросы.
Количество ветвящихся узлов
Каждый узел бора соответствует уникальному префиксу какой-то строки. При этом ветвящийся узел соответствует ситуации, когда у каких-то двух строк совпадают префиксы. Понятно, что совпадение здесь надо считать по наибольшему общему префиксу.
С точки зрения полезного анализа (сейчас и для будущего) имеет смысл рассматривать только ветвящиеся узлы. Количество узлов интересно нам в первую очередь с точки зрения оценки затрат памяти. Как мы увидим в дальнейшем, неветвящиеся хвосты и промежуточные участки имеют маленькое влияние. А их еще можно сжимать, оставляя существенными только ветвящиеся узлы.
Рассмотрим какой-то ветвящийся узел, у которого подборов, в которых суммарно хранится строк.
Вероятность того, что строк хранится в первом подборе, строк хранится во втором подборе, ..., строк хранится в -м подборе, выражается через мультиномиальный коэффициент
Зная это, можно вычислить среднее количество ветвящихся узлов бора — . При этом , и для
Формула симметрична относительно перестановок , а значит
С помощью трюка с биномиальными преобразованиями последовательностей можно получить выражение без рекурсии
Поразрядная сортировка
Если на алфавите задан линейный порядок, то на естественным образом задаётся лексикографический порядок. Получается, мы можем сформулировать задачу сортировки строк из .
Вообще, такая задача и такой подход встречаются очень часто, ведь большое количество данных имеют именно такую структуру. За примером далеко ходить не надо: обычные человеческие слова очень часто приходится сортировать в лексикографическом порядке.
Итак, на алфавите задан порядок. А значит, в ассоциативном массиве мы можем получать ассоциированные с ключами ссылки по возрастанию. Если структура ассоциативного массива не предполагает извлечение колючей по порядку, можно предварительно отсортировать ключи, а в ассоциативный массиве брать ссылки по ключам в этом порядке.
Поместим все строки, которые мы хотим отсортировать, в бор. А дальше просто рекурсивно ходим по бору в соответствии с заданным порядком.
Цифровой бор
Возьмем алфавит из чисел . Тогда можно взять язык . На самом деле это просто по-умному записанная фраза «представим все числа в системе счисления по основанию и будем рассматривать их как строки над алфавитом ».
Теперь бор может хранить натуральные числа. Во время вставки, удаления и поиска числа нам нужно сделать переходов по ассоциированным ссылкам. Получается, эти операции работают за время .
Разберем подробнее двоичную систему счисления, . Бор здесь превращается в бинарное дерево: левому ребенку соответствует цифра , а правому .