В последнем посте из этой серии мы рассмотрели основные методы (вставка, удаление, поиск) для Троичных деревьев поиска ; до этого мы уже обсуждали, как работает tries |/. Теперь пришло время перейти к деньгам и обсудить то, что делает обе эти структуры данных чрезвычайно ценными во многих областях: поиск по префиксу.
Есть (по крайней мере) два дополнительных способа отказаться от этой операции:
- Нахождение самого длинного префикса заданной строки
s
который хранится в нашем контейнере; - Поиск всех ключей, хранящихся в контейнере, который начинается с заданного префикса.
Конечно, обе операции могут быть реализованы в любом контейнере: тривиально, мы можем просто перебрать все записи и выбрать результат или результаты. Разница в том, что троичные деревья поиска, такие как деревья и деревья оснований, позволяют выполнять эту операцию гораздо эффективнее.
Давайте начнем с первой операции: у нас есть строка s
(содержащий m
символов) и контейнер T
хранение определенного количества ключей – скажем, n
ключей.
Мы хотели бы знать, какой самый длинный префикс п
из с
который хранится в T
.
Эта операция довольно полезна во многих практических ситуациях; даже без привлечения биоинформатики (где необходимо эффективно находить самый длинный общий префикс последовательностей ДНК), ее можно использовать в маршрутизаторах, где, учитывая IP-адрес, нам нужно найти самый длинный префикс того адреса, для которого доступна информация о маршрутизации, но также в кластеризации сетевых данных и управлении телефонной сетью.
Но его также можно использовать с деревьями суффиксов для реализации эффективного поиска по подстрокам, и как таковой он очень полезен в различных областях, от полнотекстового поиска в текстово-ориентированных поисковых системах до биоинформатики.
Дерево суффиксов – это ключ-значение true, где ключи – это все суффиксы данного текста, а значения, сохраняемые каждым ключом, – это позиции каждого суффикса в тексте.
Абстрактно говоря, способ работы алгоритма прост: мы сканируем входную строку ключ
символ за символом и параллельно проходим по дереву, начиная с корня. В любой заданной точке мы достигнем узла n
в дереве после успешного сопоставления подстроки s
входной строки.
Предполагая, что следующий символ во входной строке – c
, мы проверяем, есть ли в дереве (или, скорее, в поддереве с корнем в текущем узле) путь левых/правых ссылок на этот символ – если мы не можем его найти, то нет префикса входной строки, которая включает c
, и s
– это самый длинный префикс входной строки key
, для которой есть путь в дереве.
Теперь есть два способа, которыми мы могли бы рассмотреть этот результат, что приводит к двум разным версиям алгоритма.
- Вам все равно, сохранен префикс или нет, вам нужно знать, какая самая длинная общая часть между двумя строками: это имеет место, например, в маршрутизаторах, если для адреса типа
192.168.1.1
вас устраивает такой результат, как192.16
; - Возможно, вам понадобится только самый длинный префикс, который фактически хранится в дереве: это может иметь место, если мы ищем допустимые слова, которые являются префиксами к более длинному слову, или, возвращаясь к примеру маршрутизаторов, вы можете представить себе принудительное использование только полноблочных совпадений, и поэтому
192
или192.168
были бы приемлемыми результатами, но не192.16
.
Ниже я реализовал вторую версию, где префикс должен храниться в дереве, но его легко изменить, чтобы возвращать только самый длинный пройденный путь, или принять параметр, чтобы решить, какая версия предпочтительнее (подсказка: найдите два места, где this.storesKey()
проверяется).
Ниже я реализовал вторую версию, где префикс должен храниться в дереве, но его легко изменить, чтобы возвращать только самый длинный пройденный путь, или принять параметр, чтобы решить, какая версия предпочтительнее (подсказка: найдите два места, где this.storesKey()
проверяется). Еще одна деталь: если вы помните, в последнем посте мы обсуждали, как было бы эффективнее передавать индекс следующего символа для проверки во входной строке, вместо того, чтобы прибегать к вызову
public String longestPrefixOf(String key) { return this.longestPrefixOf(key, 0); } private String longestPrefixOf(String key, int charIndex) { if (charIndex >= key.length()) { return null; } String result = null; Character c = key.charAt(charIndex); if (c.equals(this.character)) { if (charIndex == key.length() - 1) { // case A.1 return this.storesKey() ? key : null; } else { // case A.2 result = middle == null ? null / case A.2.a : middle.longestPrefixOf(key, charIndex + 1); // case A.2.b if (result == null && this.storesKey()) { result = key.substring(0, charIndex + 1); // case A.2.c } } } else if (c.compareTo(this.character) < 0) { result = left == null ? null // case B.1 : left.longestPrefixOf(key, charIndex); // case B.2 } else { result = right == null ? null // case C.1 : right.longestPrefixOf(key, charIndex); // case C.2 } return result; }
Ниже я реализовал вторую версию, где префикс должен храниться в дереве, но его легко изменить, чтобы возвращать только самый длинный пройденный путь, или принять параметр, чтобы решить, какая версия предпочтительнее (подсказка: найдите два места, где this.storesKey()
проверяется). Еще один нюанс: если вы помните, в последнем посте мы обсуждали, как было бы эффективнее передавать индекс следующего символа для этого метода, это решение также упрощает возврат finLet см. Пример, чтобы пояснить, как работает метод. результат, и поэтому это тот, который я представляю. после каждого матча. r для проверки входной строки, вместо того, чтобы прибегать к вызову substring Ниже я реализовал вторую версию, где префикс должен храниться в дереве, но его легко изменить, чтобы возвращать только самый длинный пройденный путь, или принять параметр, чтобы решить, какая версия предпочтительнее (подсказка: найдите два места, где
this.storesKey()
- Ниже я реализовал вторую версию, где префикс должен храниться в дереве, но его легко изменить, чтобы возвращать только самый длинный пройденный путь, или принять параметр, чтобы решить, какая версия предпочтительнее (подсказка: найдите два места, где
this.storesKey()
проверяется). Еще один нюанс: если вы помните, в последнем посте мы обсуждали, как было бы эффективнее передавать индекс следующего символа для этого метода, это решение также упрощает возврат finLet см. Пример, чтобы пояснить, как работает метод. al reМы начинаем с корня, ищем первый символ,
‘s’ - (A) корневой узел не совпадает, и нам нужно повернуть направо, потому что он содержит символ
'o'
, который меньше, чем
- Ниже я реализовал вторую версию, где префикс должен храниться в дереве, но его легко изменить, чтобы возвращать только самый длинный пройденный путь, или принять параметр, чтобы решить, какая версия предпочтительнее (подсказка: найдите два места, где
this.storesKey()
проверяется). Еще один нюанс: если вы помните, в последнем посте мы обсуждали, как было бы эффективнее передавать индекс следующего символа для этого метода, это решение также упрощает возврат finLet см. Пример, чтобы пояснить, как работает метод. al re - Мы начинаем с корня, ищем первый символ,
's'
(А) корневой узел не совпадает, и нам нужно повернуть направо,(Б) со второй попытки нам повезло больше, мы сопоставляем искомый (В) Другой совпадение, поэтому мы снова проходим среднюю ссылку и в то же время переходим к следующему символу во входной строке,'o char, чтобы мы могли пройти среднюю ссылку и продвинуть указатель в строке: теперь мы будем искать символ
‘h’
- Ниже я реализовал вторую версию, где префикс должен храниться в дереве, но его легко изменить, чтобы возвращать только самый длинный пройденный путь, или принять параметр, чтобы решить, какая версия предпочтительнее (подсказка: найдите два места, где
this.storesKey()
проверяется). Еще один нюанс: если вы помните, в последнем посте мы обсуждали, как было бы эффективнее передавать индекс следующего символа для этого метода, это решение также упрощает возврат finLet см. Пример, чтобы пояснить, как работает метод. al re - Мы начинаем с корня, ищем первый символ,
's'
(A) корневой узел не совпадает, и нам нужно повернуть направо,(B) со второй попытки нам повезло больше, мы соответствуем искомому |(E) Еще одно совпадение, | , поэтому мы переходим по правой ссылке, все еще ища следующий'o'
в поддереве. На этот раз нам не повезло,
Ниже я реализовал вторую версию, где префикс должен храниться в дереве, но его легко изменить, чтобы возвращать только самый длинный пройденный путь, или принять параметр, чтобы решить, какая версия предпочтительнее (подсказка: найдите два места, где this.storesKey()
проверяется). Еще один нюанс: если вы помните, в последнем посте мы обсуждали, как было бы эффективнее передавать индекс следующего символа для этого метода, это решение также упрощает возврат finLet см. Пример, чтобы пояснить, как работает метод. al re Мы начинаем с корня, ищем первый символ, ‘s’ (А) корневой узел не совпадает, и нам нужно повернуть направо,(Б) со второй попытки нам повезло больше, мы сопоставляем искомое | с текущим деревом, и если мы ищем самый длинный префикс, фактически сохраненный в дереве, метод вернет значение null (в дереве не хранится префикс
“shop” , и фактически единственный ключевой узел, пройденный в том, который соответствует "she"
, это не префикс ввода). `, но нет левой ссылки для обхода, поэтому поиск здесь прекращается, и метод начинает обратный поиск. (F) Опять несоответствие, но этот случай интересен: символ, удерживаемый этим узлом,
Ниже я реализовал вторую версию, где префикс должен храниться в дереве, но его легко изменить, чтобы возвращать только самый длинный пройденный путь, или принять параметр, чтобы решить, какая версия предпочтительнее (подсказка: найдите два места, где this.storesKey()
проверяется). Еще один нюанс: если вы помните, в последнем посте мы обсуждали, как было бы эффективнее передавать индекс следующего символа для этого метода, это решение также упрощает возврат finLet см. Пример, чтобы пояснить, как работает метод. al re Мы начинаем с корня, ищем первый символ,
‘s’ (А) корневой узел не совпадает, и нам нужно повернуть направо,(Б) со второй попытки нам повезло больше, мы сопоставляем искомый | Итак, пустьначнем с конца: в (F) у нас было несоответствие на последнем посещенном узле, и у этого узла не было дочерних элементов; таким образом, мы находимся в случае B.1 в приведенном выше коде, и мы вернем
null . Чтобы проиллюстрировать более интересный сценарий, давайте теперь немного развернемся и рассмотрим дерево, в котором также хранится ключ "sh"
, проиллюстрированный на следующем рисунке. С текущим деревом, и если мы ищем самый длинный префикс, фактически сохраненный в дереве, метод вернет значение null (префикс "shop"
не хранится
Ниже я реализовал вторую версию, где префикс должен храниться в дереве, но его легко изменить, чтобы возвращать только самый длинный пройденный путь, или принять параметр, чтобы решить, какая версия предпочтительнее (подсказка: найдите два места, где this.storesKey()
проверяется). Еще один нюанс: если вы помните, в последнем посте мы обсуждали, как было бы эффективнее передавать индекс следующего символа для этого метода, это решение также упрощает возврат finLet см. Пример, чтобы пояснить, как работает метод. al re
Ниже я реализовал вторую версию, где префикс должен храниться в дереве, но его легко изменить, чтобы возвращать только самый длинный пройденный путь, или принять параметр, чтобы решить, какая версия предпочтительнее (подсказка: найдите два места, где || this.storesKey() || проверяется). Еще один нюанс: если вы помните, в последнем посте мы обсуждали, как было бы эффективнее передавать индекс следующего символа для этого метода, это решение также упрощает возврат finLet см. Пример, чтобы пояснить, как работает метод. al re|| Мы начинаем с корня, ищем первый символ, || ‘s’ ||(А) корневой узел не совпадает, и нам нужно повернуть направо,(Б) со второй попытки нам повезло больше, мы сопоставляем искомый |Этот метод очень полезно, например, для создания систем автозаполнения: пока пользователь вводит текст (ну, в идеале не после каждого нажатия клавиши, но это история для другого времени), нам нужно выполнить быстрый поиск в контейнере, чтобы предложить некоторый текст, который начинается с того, что уже было введено – так что этот поиск в идеале должно быть довольно быстро, и контейнеры, подобные trie, имеют первостепенное значение. ; эта коллекция, конечно, может быть пустой, если ни один элемент, хранящийся в дереве, не начинается с входной строки. Итерируемый || (набор, в более общих терминах) строк Второй метод, который мы собираемся обсудить, принимает строку и возвращает || В (D) мы были в случае C.2, поэтому мы будем следовать результату рекурсивного вызова, который по-прежнему |/null || ; наконец, когда мы возвращаемся к вызову, показанному в (C), мы переходим к регистру A.2.b и возвращаем строку || ‘sh’ || в качестве самого длинного общего префикса. но поскольку мы запрашиваем самый длинный префикс для сохранения в дереве, этот вызов также вернет || null || (случай A.2). Рекурсия возвращается к предыдущему вызову (показано в (E)), где у нас было совпадение по || ‘o’ || , Итак, давайте начнем с конца: в (F) у нас было несоответствие на последнем посещенном узле, и у этого узла не было дочерних элементов; таким образом, мы находимся в случае B.1 в приведенном выше коде, и мы вернем || null || . Чтобы проиллюстрировать более интересный сценарий, давайте теперь немного развернемся и рассмотрим дерево, в котором также хранится ключ || “sh” ||, проиллюстрированный на следующем рисунке. С текущим деревом, и если мы ищем самый длинный префикс, фактически сохраненный в дереве, метод вернет значение null (префикс || “shop” || не хранится || в дереве, и фактически единственный ключевой узел, пройденный в том, который соответствует || “она” || , это не префикс ввода). `, но нет левой ссылки для обхода, поэтому поиск здесь прекращается, и метод начинает обратный поиск. (F) Опять несоответствие, но этот случай интересен: символ, удерживаемый этим узлом, || ‘r’ || больше, чем || ‘p || поэтому мы снова проходим среднюю ссылку и в то же время переходим к следующему символу во входной строке, || ‘p || `. (E) Еще одно совпадение, | , поэтому мы переходим по правильной ссылке, все еще ища следующий || ‘o’ || в поддереве. На этот раз нам не повезло, || ‘e’ < ‘o’ (C) Еще одно совпадение, поэтому мы снова проходим среднюю ссылку и в то же время переходим к следующему символу во входной строке, || ‘o char, чтобы мы могли пройти среднюю ссылку и продвинуться вперед указатель в строке: теперь мы будем искать символ || ‘h’ || . потому что он содержит символ || ‘o’ || , который меньше, чем || ‘s’ || ;: Предположим, мы ищем самый длинный префикс строки || shop sult, и поэтому я представляю именно его. || после каждого матча. r для проверки входной строки, вместо того, чтобы прибегать к вызову || substring
Ниже я реализовал вторую версию, где префикс должен храниться в дереве, но его легко изменить, чтобы возвращать только самый длинный пройденный путь, или принять параметр, чтобы решить, какая версия предпочтительнее (подсказка: найдите два места, где || this.storesKey() || проверяется). Еще один нюанс: если вы помните, в последнем посте мы обсуждали, как было бы эффективнее передавать индекс следующего символа для этого метода, это решение также упрощает возврат finLet см. Пример, чтобы пояснить, как работает метод. al re|| Мы начинаем с корня, ищем первый символ, || ‘s’ ||(А) корневой узел не совпадает, и нам нужно повернуть направо,(Б) со второй попытки нам повезло больше, мы соответствуем искомому | Метод такой, на высоком уровне, очень легко описывается: нам нужно выполнить поиск префикса (в частности, для узла, на котором заканчивается путь, соответствующий префиксу, независимо от того, хранит ли он ключ или нет), а затем вернуть все ключи, хранящиеся в поддереве с корнем в этом узле. Этот метод очень полезен, например, для создания систем автозаполнения: пока пользователь вводит текст (ну, в идеале не после каждого нажатия клавиши, но это история для другого времени), нам нужно выполнить быстрый поиск в контейнере, чтобы предложить некоторый текст, который начинается с того, что уже было введено – так этот поиск в идеале должен быть довольно быстрым, и контейнеры, подобные trie, имеют первостепенное значение. ; эта коллекция, конечно, может быть пустой, если ни один элемент, хранящийся в дереве, не начинается с входной строки. Итерируемый || (набор, в более общих терминах) строк Второй метод, который мы собираемся обсудить, принимает строку и возвращает || В (D) мы были в случае C.2, поэтому мы будем следовать результату рекурсивного вызова, который по-прежнему |/null || ; наконец, когда мы возвращаемся к вызову, показанному в (C), мы переходим к регистру A.2.b и возвращаем строку || ‘sh’ || в качестве самого длинного общего префикса. но поскольку мы запрашиваем самый длинный префикс для сохранения в дереве, этот вызов также вернет || null || (случай A.2). Рекурсия возвращается к предыдущему вызову (показано в (E)), где у нас было совпадение по || ‘o’ || , Итак, давайте начнем с конца: в (F) у нас было несоответствие на последнем посещенном узле, и у этого узла не было дочерних элементов; таким образом, мы находимся в случае B.1 в приведенном выше коде, и мы вернем || null || . Чтобы проиллюстрировать более интересный сценарий, давайте теперь немного развернемся и рассмотрим дерево, в котором также хранится ключ || “sh” ||, проиллюстрированный на следующем рисунке. С текущим деревом, и если мы ищем самый длинный префикс, фактически сохраненный в дереве, метод вернет значение null (префикс || “shop” || не хранится || в дереве, и фактически единственный ключевой узел, пройденный в том, который соответствует || “она” || , это не префикс ввода). `, но нет левой ссылки для обхода, поэтому поиск здесь прекращается, и метод начинает обратный поиск. (F) Опять несоответствие, но этот случай интересен: символ, удерживаемый этим узлом, || ‘r’ || больше, чем || ‘p || поэтому мы снова проходим среднюю ссылку и в то же время переходим к следующему символу во входной строке, || ‘p || `. (E) Еще одно совпадение, | , поэтому мы переходим по правильной ссылке, все еще ища следующий || ‘o’ || в поддереве. На этот раз нам не повезло, || ‘e’ < ‘o’ (C) Еще одно совпадение, поэтому мы снова проходим среднюю ссылку и в то же время переходим к следующему символу во входной строке, || ‘o char, чтобы мы могли пройти среднюю ссылку и продвинуться вперед указатель в строке: теперь мы будем искать символ || ‘h’ || . потому что он содержит символ || ‘o’ || , который меньше, чем || ‘s’ || ;: Предположим, мы ищем самый длинный префикс строки || shop sult, и поэтому я представляю именно его. || после каждого матча. r для проверки входной строки, вместо того, чтобы прибегать к вызову || substring
public IterablekeysWithPrefix(String prefix) { // Invariant: prefix is not empty TstNode node = this.search(prefix); return node == null ? new HashSet<>() : node.keys().stream() // All keys in node.keys already include the last character in prefix .map(s -> prefix.substring(0, prefix.length() - 1) + s) .collect(Collectors.toSet()); }
Ниже я реализовал вторую версию, где префикс должен храниться в дереве, но его легко изменить, чтобы возвращать только самый длинный пройденный путь, или принять параметр, чтобы решить, какая версия предпочтительнее (подсказка: найдите два места, где || this.storesKey() || проверяется). Еще один нюанс: если вы помните, в последнем посте мы обсуждали, как было бы эффективнее передавать индекс следующего символа для этого метода, это решение также упрощает возврат finLet см. Пример, чтобы пояснить, как работает метод. al re|| Мы начинаем с корня, ищем первый символ, || ‘s’ ||(А) корневой узел не совпадает, и нам нужно повернуть направо,(Б) со второй попытки нам повезло больше, мы соответствуем искомому | коду выше предполагается, что у нас уже есть два метода: метод поиска (см. Предыдущий пост в серии) и метод, возвращающий все ключи, хранящиеся в поддереве, который мы опишем далее. Метод на высоком уровне очень легко описать: нам нужно выполнить поиск префикса (в частности, для узла, на котором заканчивается путь, соответствующий префиксу, независимо от того, хранит ли он ключ или нет), а затем вернуть все ключи, хранящиеся в корневом поддереве в этом узле. Этот метод очень полезен, например, для создания систем автозаполнения: пока пользователь вводит текст (ну, в идеале не после каждого нажатия клавиши, но это история для другого времени), нам нужно выполнить быстрый поиск в контейнере, чтобы предложить некоторый текст, который начинается с того, что уже было введено – так этот поиск в идеале должен быть довольно быстрым, и контейнеры, подобные trie, имеют первостепенное значение. ; эта коллекция, конечно, может быть пустой, если ни один элемент, хранящийся в дереве, не начинается с входной строки. Итерируемый || (набор, в более общих терминах) строк Второй метод, который мы собираемся обсудить, принимает строку и возвращает || В (D) мы были в случае C.2, поэтому мы будем следовать результату рекурсивного вызова, который по-прежнему |/null || ; наконец, когда мы возвращаемся к вызову, показанному в (C), мы переходим к регистру A.2.b и возвращаем строку || ‘sh’ || в качестве самого длинного общего префикса. но поскольку мы запрашиваем самый длинный префикс для сохранения в дереве, этот вызов также вернет || null || (случай A.2). Рекурсия возвращается к предыдущему вызову (показано в (E)), где у нас было совпадение по || ‘o’ || , Итак, давайте начнем с конца: в (F) у нас было несоответствие на последнем посещенном узле, и у этого узла не было дочерних элементов; таким образом, мы находимся в случае B.1 в приведенном выше коде, и мы вернем || null || . Чтобы проиллюстрировать более интересный сценарий, давайте теперь немного развернемся и рассмотрим дерево, в котором также хранится ключ || “sh” ||, проиллюстрированный на следующем рисунке. С текущим деревом, и если мы ищем самый длинный префикс, фактически сохраненный в дереве, метод вернет значение null (префикс || “shop” || не хранится || в дереве, и фактически единственный ключевой узел, пройденный в том, который соответствует || “она” || , это не префикс ввода). `, но нет левой ссылки для обхода, поэтому поиск здесь прекращается, и метод начинает обратный поиск. (F) Опять несоответствие, но этот случай интересен: символ, удерживаемый этим узлом, || ‘r’ || больше, чем || ‘p || поэтому мы снова проходим среднюю ссылку и в то же время переходим к следующему символу во входной строке, || ‘p || `. (E) Еще одно совпадение, | , поэтому мы переходим по правильной ссылке, все еще ища следующий || ‘o’ || в поддереве. На этот раз нам не повезло, || ‘e’ < ‘o’ (C) Еще одно совпадение, поэтому мы снова проходим среднюю ссылку и в то же время переходим к следующему символу во входной строке, || ‘o char, чтобы мы могли пройти среднюю ссылку и продвинуться вперед указатель в строке: теперь мы будем искать символ || ‘h’ || . потому что он содержит символ || ‘o’ || , который меньше, чем || ‘s’ || ;: Предположим, мы ищем самый длинный префикс строки || shop sult, и поэтому я представляю именно его. || после каждого матча. r для проверки входной строки, вместо того, чтобы прибегать к вызову || substring
Ниже я реализовал вторую версию, где префикс должен храниться в дереве, но его легко изменить, чтобы возвращать только самый длинный пройденный путь, или принять параметр, чтобы решить, какая версия предпочтительнее (подсказка: найдите два места, где this.storesKey()
проверяется). Еще один нюанс: если вы помните, в последнем посте мы обсуждали, как было бы эффективнее передавать индекс следующего символа для этого метода, это решение также упрощает возврат finLet см. Пример, чтобы пояснить, как работает метод. al re
Ниже я реализовал вторую версию, где префикс должен храниться в дереве, но его легко изменить, чтобы возвращать только самый длинный пройденный путь, или принять параметр, чтобы решить, какая версия предпочтительнее (подсказка: найдите два места, где this.storesKey()
проверяется). Еще один нюанс: если вы помните, в последнем посте мы обсуждали, как было бы эффективнее передавать индекс следующего символа для этого метода, это решение также упрощает возврат finLet см. Пример, чтобы пояснить, как работает метод. al re Мы начинаем с корня, ищем первый символ,
‘s’ (А) корневой узел не совпадает, и нам нужно повернуть направо,(Б) со второй попытки нам повезло больше, мы соответствуем искомому | Есть еще один вещь, с которой нам нужно быть осторожными: если мы начнем собирать ключи с узла
n , удерживая символ
, если мы начнем собирать ключи не из корня дерева, а из случайного узла, тогда мы вернем только суффиксы фактически сохраненных ключей (если только мы настраиваем древовидную структуру или метод, чтобы запомнить полную строку, которой соответствует узел в полном дереве, но это усложнило бы и нарушило бы индуктивное определение TSTs). Что ж, единственный шаг, который требует тщательности в приведенном выше коде, – это сопоставление строк, возвращаемых методом keys
: прежде всего, зачем нам это нужно? Приведенный выше код предполагает, что у нас уже есть два метода: метод поиска (см. Предыдущий пост в серии) и метод, возвращающий все ключи, хранящиеся в поддереве, который мы опишем далее. Метод на высоком уровне очень легко описать: нам нужно выполнить поиск префикса (в частности, для узла, на котором заканчивается путь, соответствующий префиксу, независимо от того, хранит ли он ключ или нет), а затем вернуть все ключи, хранящиеся в корневом поддереве в этом узле. Этот метод очень полезен, например, для создания систем автозаполнения: пока пользователь вводит текст (ну, в идеале не после каждого нажатия клавиши, но это история для другого времени), нам нужно выполнить быстрый поиск в контейнере, чтобы предложить некоторый текст, который начинается с того, что уже было введено – так этот поиск в идеале должен быть довольно быстрым, и контейнеры, подобные trie, имеют первостепенное значение. ; эта коллекция, конечно, может быть пустой, если ни один элемент, хранящийся в дереве, не начинается с входной строки. Итерируемый
Оба аспекта более четко проиллюстрированы на следующем рисунке.
Теперь у нас осталась еще одна задача: реализация в методе, который собирает все ключи, хранящиеся в (вложенном) дереве. В этом случае также высокоуровневый дизайн метода не особенно сложен: нам просто нужно пройти все ветви, все узлы дерева и добавить найденные ключи в список.
Чтобы упростить вам жизнь, мы можем написать открытый метод, который позаботится об инициализации этого списка и запуске обхода, и закрытый метод, который принимает этот список (который Java автоматически передает по ссылке ) и добавляет к нему ключи, как только они будут найдены; альтернатива (слияние списки из рекурсивных вызовов дочерним элементам узла) были бы намного дороже.
public Listkeys() { List keys = new ArrayList<>(); this.keys("", keys); return keys; } private void keys(String currentPath, List keys) { if (this.storesKey()) { keys.add(currentPath + this.character); // case 0 } // For left and right branches, we must not add this node's character to the path if (left != null) { left.keys(currentPath, keys); // case A } if (right != null) { right.keys(currentPath, keys); // case B } // For the middle child, instead, this node's character is part of the path forward if (middle != null) { middle.keys(currentPath + character, keys); //case C } }
Другим шансом для оптимизации была бы замена конкатенации строк в рекурсивных вызовах, currentPath + character
: мы могли бы передать currentPath как список символов, который получит новый элемент только тогда, когда мы перейдем по средней ссылке (регистр (C) в приведенном выше коде – просто знайте, что он должен затем будет извлечен после возврата вызова!), и строка будет создана из списка только тогда, когда мы найдем ключ, хранящийся в дереве (случай (0) выше). Это, однако, сделало бы код менее чистым и потенциально проблематичным, если этот код будет изменен для распространения рекурсивных вызовов по разным потокам.
Если мы собираемся реализовать автозаполнение, скорее всего, нам нужно показать только несколько результатов; у нас могут быть предпочтительные критерии для возвращаемых ключей (например, сначала самые длинные результаты или выбор их с использованием лексикографического порядка), но в большинстве случаев мы могли бы избежать обхода всего дерево, и остановимся, как только у нас будет достаточно результатов, чтобы показать. Это экономит важные ресурсы, особенно при первых нажатиях клавиш, где префикс короче, а количество совпадающих результатов может быть огромным: нет смысла возвращать тысячу возможных значений, если пользователю можно показать только 5 или 6 – это было бы просто пустой тратой вычислительных ресурсов и, потенциально, из-за пропускной способности (но в этом случае также были бы другие ошибки в проектировании серверной части и связи между сервером и клиентом).
Изменение кода, показанного выше, чтобы учесть пороговое значение для количества возвращаемых результатов, не слишком сложно: нам просто нужно проверить
private void keys(String currentPath, Listkeys, int maxResults) { if (this.storesKey()) { keys.add(currentPath + this.character); if (keys.size >= maxResults) { return; } } if (left != null) { left.keys(currentPath, keys, maxResults); } if (right != null) { right.keys(currentPath, keys, maxResults); } if (middle != null) { middle.keys(currentPath + character, keys, maxResults); } }
Поиск по префиксу – это реальное “конкурентное преимущество” попыток и тестов по сравнению с другими структурами данных. Но вы не должны верить мне на слово: в следующем посте мы будем использовать профилировщик, чтобы показать разницу во времени выполнения между этими двумя структурами данных и бинарными деревьями поиска , а также их влияние на оперативную память.
Оригинал: “https://dev.to/mlarocca/prefix-search-with-ternary-search-trees-java-implementation-2jdh”