Рубрики
Без рубрики

Троичное Дерево Поиска: Основные методы (Реализация Java)

Чтобы лучше понять ЭТО, важно взглянуть на некоторые конкретные реализации – в частности, I… Теги: алгоритмы, java, 100daysofcode, информатика.

Чтобы лучше понять ЧТО , важно взглянуть на некоторые конкретные реализации – в частности, я решил использовать Java в этой серии.

larocca/Алгоритмы и структуры данных В действии

Расширенная Реализация Структур Данных

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

public class Tst implements StringsTree {

    private TstNode root;
    public Tst() {
        root = null;
    }

    public Optional search(String element) {
            return root == null || root.search(element) == null ? null : element;
    }
    ...
}

Класс Test – это просто оболочка для корня дерева, и все методы просто перенаправляются на их эквиваленты для отдельных узлов.

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

В Тестовый узел класс гораздо интереснее, чем оболочка дерева, потому что он содержит всю логику.

private class TstNode {

    private Character character;
    private boolean storesKey;

    private TstNode left;
    private TstNode middle;
    private TstNode right;

    public TstNode(String key) {
       ...
    }

В нем должны быть поля для трех дочерних элементов узла и для символа, который содержит каждый узел. Также необходим способ пометки узлов, в которых хранится ключ: в этой версии trie/tst контейнеры представляют собой просто наборы, содержащие хранящиеся в них строки, поэтому я использовал логический флаг; однако мы можем легко реализовать эти деревья как словари (которые затем будут связывать значение с каждым сохраненный ключ) путем замены флага storesKey

private class TstNode {

    private Character character;
    private T value = null;

    private TstNode left = null;
    private TstNode middle = null;
    private TstNode right = null;

    public TstNode(String key, T value) {
        if (key.isEmpty() || value == null) {
            throw new IllegalArgumentException();
        }
        character = key.charAt(0);
        if (key.length() > 1) {
            // Stores the rest of the key in a midlle-link chain
            middle = new TstNode(key.substring(1), value);
        } else {
            this.value = value;
        }
    }

Вы также можете увидеть реализацию конструктора для версии словаря выше. Очевидно, что value может быть любым типом, наследуемым от Object .

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

Обратите внимание, что значение будет сохранен только в самом последнем узле цепочки.

Добавление новой Пары Ключ-Значение

После того, как мы создали наш пустой ТЕСТ, нам нужен метод для заполнения его парами ключ/значение. Вероятно, это единственный метод, с которым нам нужно быть более осторожными при написании ТЕСТОВОЙ оболочки, потому что мы можем иметь дело с пустым корнем, и в этом случае мы также назначаем его.

public boolean Tst::add(String element, T value) {
    if (root == null) {
        root = new TstNode(element, value);
        return true;
    } else {
        return root.add(element) != null;
    }
}

Ядро алгоритма, однако, по-прежнему лежит в классе Test node . В списках выше и ниже я опустил некоторые проверки аргументов просто ради места: мы хотим убедиться, что ключ не равен null и не пуст, и что значение не равно null. Мы можем выполнять эти проверки один раз для каждого клиентского вызова в Tst::add .

На рисунке ниже показаны результаты добавления слов в скороговорке "она продает морские раковины на берегу моря" в пустой TST. Все слова добавляются в том порядке, в котором они появляются в предложении, но мы не добавляем дубликатов ( "море" появляется дважды 😉 ).

Поскольку мы начинаем с пустого дерева (т.Е. Tst::root is null ), для первого слова, "she" , метод просто создает цепочку промежуточных звеньев, список узлов, содержащих эти три символа. По мере продвижения создается все больше и больше ветвей, каждая из которых заканчивается общим префиксом существующего слова и вновь вставленного.

Давайте сделаем паузу перед добавлением последнего слова в предложении, "shore" , и рассмотрим, как работают методы add : затем мы можем использовать это слово для детализации пошагового примера.

Первая и самая важная вещь, которую должен проверить метод, – это (при условии, что key не пуст), как первый символ в ключе сравнивается с символом, хранящимся в текущем узле.

  • (A) Если это несоответствие, нам нужно пройти левую (A.1) или правую (A.2) ветвь соответственно (в зависимости от того, меньше она или больше); если ветвь, которую нам нужно пройти, не существует, тогда мы можем просто создать новую TstNode -цепочка, в которой будет храниться остальная часть нашего ключа, подключаем ее к текущему узлу, и все готово.
  • (Б) Если у нас есть совпадение, то ситуация более интересная. Во-первых, закончили ли мы с клавишей ввода, т.Е. Является ли это ее последним символом?
    • (В.1) Если это так, то это узел, где мы будем хранить значение : чтобы различать обновления существующего ключа, мы проверяем, было ли значение уже сохранено в этом узле.
    • (B.2) В противном случае, если в key есть больше символов после текущего, то ситуация может быть обработана так же, как и для левой/правой ветвей, с одним отличием: поскольку у нас есть совпадение, мы должны перейти к следующему символу в клавише ввода.
private TstNode TstNode::add(String key, T value) {
    Character c = key.charAt(0);
    if (character.equals(c)) {                             // case B
        if (key.length() == 1) {                           // case B.1
            boolean updated = this.storesKey();
            this.value = value;
            // We return a non-null node only for insertion, and null for update
            return updated ? null : this;
        } else if (this.middle != null) {                  // case B.2.1
            return middle.add(key.substring(1));
        } else {                                           // case B.2.2
            this.middle = new TstNode(key.substring(1));
            // We need to find the last node in the chain to return it
            return middle.search(key.substring(1));
        }
    } else if (c.compareTo(character) < 0) {               // case A.1
        if (this.left != null) {                           // case A.1.1
            return left.add(key, value);
        } else {                                           // case A.1.2
            left = new TstNode(key, value);
            return left.search(key);
        }
    } else {                                               // case A.2
        if (this.right != null) {                          // case A.2.1
            return right.add(key, value);
        } else {                                           // case A.2.2
            right = new TstNode(key, value);
            return right.search(key);
        }
    }
}

private boolean TstNode::storesKey() {
    return this.value != null;
}

Выше мы также объявляем метод stores Key , который будет полезен для абстрактной проверки, помечен ли узел как “ключевой узел”, независимо от реализации.

Как и было обещано, здесь приведен пример метода в действии с вызовом add("shore") , иллюстрирующий несколько случаев, которые выделены в коде.

Поиск TST

Как мы уже видели, поиск работает аналогично двоичным деревьям поиска: для каждого символа c в нашей строке поиска нам сначала нужно выяснить, есть ли в текущем поддереве узел, содержащий c и доступный только по ссылкам влево/вправо (мы можем переходить только по средним ссылкам после совпадения!).

Таким образом, не теряя общности, мы можем ограничиться случаем, когда мы ищем первый символ в строке, начинающийся с корня дерева. И строки, и Тесты являются рекурсивными структурами: каждый узел в TST является корнем поддерева, а TEST является либо пустым деревом (базовый вариант), либо узлом с левым, средним и правым – возможно, пустыми – поддеревьями).

Чтобы выполнить поиск, мы можем выполнить следующие действия:

  1. Мы сравниваем символ root с первым символом в строке;
  2. Случай (А) Они совпадают: отлично, мы нашли наш string char. Теперь проверьте: был ли это последний символ в строке?
  3. Случай (А.1): Да, это был последний случай. Теперь мы проверяем, есть ли значение, сохраненное в текущем узле. Если это так, мы нашли нашу строку поиска, в противном случае ее нет в дереве.
  4. Дело (A.2) В противном случае мы переходим к следующему символу в строке и…
  5. Случай (A.2.1)… если для этого узла нет средней ссылки (т.Е. это лист), то строка не находится в дереве.
  6. Случай (A.2.2)… если средняя ссылка не равна нулю, мы переходим по ссылке и повторяем шаги, начиная с 1.
  7. Случай (B) Нет совпадения между символом нашей строки поиска и символом нашего узла. Если первый меньше, мы идем налево, если он больше, мы идем направо.
  8. Случай (B.1) Если ссылка, по которой мы должны были перейти, равна нулю (нет левого или правого дочернего элемента соответственно), то наша строка поиска отсутствует в дереве.
  9. Случай (B.2) В противном случае мы просто переходим по ссылке и повторяем шаг 1.

Следующий рисунок иллюстрирует некоторые из приведенных выше случаев на примере trie.

Давайте поместим это в какой-нибудь Java-код:

public TstNode search(String key) {
    if (key.isEmpty()) {
        return null;
    }
    Character c = key.charAt(0);
    if (c.equals(this.character)) {                       // case A
        if (key.length() == 1) {                          // case A.1
            return this;
        } else {                                          //case A.2
            return this.middle == null 
                ? null                                    // case A.2.1
                : this.middle.search(key.substring(1));   // case A.2.2
        }
    } else if (c.compareTo(this.character) < 0) {         // case B.1
        return left == null ? null : left.search(key);
    } else {                                              // case B.2
        return right == null ? null : right.search(key);
    }
}

Теперь, на практике, мы хотели бы избежать всех этих вызовов String::substring , потому что каждому из них нужно создать новую строку, а обход большого дерева становится очень дорогим.

Хорошей новостью является то, что мы можем легко найти обходной путь, просто передав индекс следующего символа для проверки. Это не так элегантно, и нам нужно будет определить частный метод, чтобы поддерживать чистоту общедоступного API, но повышение эффективности, которое мы получаем, действительно того стоит.

public T Tst::search(String key) {
    TstNode node = search(key, 0);
    return  node != null && node.storesKey() ? node.value : null;
}

private TstNode search(String key, int index) {
    if (index >= key.length()) {
        return null;
    }
    Character c = key.charAt(index);
    ...
}

Удалить

Следующая базовая операция, которую мы хотели бы выполнить с контейнером, – это… удаление элементов. Сюрприз сюрприз, этот метод следует той же структуре, что и два других: сравнение первого символа в переданном ключе, а затем выбор правильной ветви для обхода в зависимости от результата.

Если бы не предостережение, мы могли бы реализовать remove почти путем вырезания и вставки поиска. В чем подвох? Ну, если вы подумаете об этом, в чем всегда подвох, в Java, но также и во всех других языках программирования, когда вы удаляете что-то из коллекции?

Конечно, если мы не будем осторожны, мы не освободим ранее использованное пространство.

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

private boolean remove(String key, AtomicBoolean purge) {
    Character c = key.charAt(0);
    if (c.equals(this.character)) {
        if (key.length() == 1) {
            if (this.storesKey()) {
                value = null;
                // If this node stores a key, and it's a leaf, 
                // then the path to this node can be purged.
                purge.set(this.isLeaf());
                return true;
            } else {
                return false;
            }
        } else {
            boolean deleted = this.middle == null
                    ? false
                    : this.middle.remove(key.substring(1), purge);
            if (deleted && purge.get()) {
                this.middle = null;
                purge.set(!this.storesKey && this.isLeaf());
            }
            return deleted;
        }
    } else if (c.compareTo(this.character) < 0) {
        boolean deleted = left == null ? false : left.remove(key, purge);
        if (deleted && purge.get()) {
            this.left = null;
            purge.set(!this.storesKey() && this.isLeaf());
        }
        return deleted;
    } else {
        boolean deleted = right == null ? false : right.remove(key, purge);
        if (deleted && purge.get()) {
            this.right = null;
            purge.set(!this.storesKey() && this.isLeaf());
        }
        return deleted;
    }
}

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

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

Обратите внимание, что при том, как этот метод реализован здесь, он никогда не будет очищать корень дерева. Это не вызовет проблем, но если вы хотите пройти лишнюю милю, вам нужно будет проверить в Tst::remove , если после вызова root.remove возвращает AtomicBoolean флаг purge установлен в true : если это так, вы можете установить root .

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

Почему бы вам не освободить удаленные узлы? Прежде всего, это позволяет вам написать более короткий, чистый и эффективный метод: проверьте разницу в следующем фрагменте кода, где мы назвали этот вариант removeNGC как в “no garbage collection”.

private boolean removeNGC(String key) {
    Character c = key.charAt(0);
    if (c.equals(this.character)) {
        if (key.length() == 1) {
            if (this.storesKey()) {
                value = null;
                return true;
            } else {
                return false;
            }
        } else {
            return this.middle == null
                   ? false
                   : this.middle.removeNGC(key.substring(1), purge);
        }
    } else if (c.compareTo(this.character) < 0) {
        return left == null ? false : left.removeNGC(key, purge);
    } else {
        return right == null ? false : right.removeNGC(key, purge);
    }
}

Более того, дело в том, что если вы ожидаете, что удаленные ключи будут добавлены снова вскоре после этого, то очистка этих узлов и их перераспределение становятся огромной тратой ресурсов. Итак, как вы узнаете, какую версию вам следует внедрить? Ответа нет, кроме как: изучите контекст и требования и выполните некоторое профилирование – пусть данные подскажут вам, что делать.

Если вы прочитали это так далеко, у вас есть представление о том, как работают основные методы для Tests . Пришло время сделать перерыв, чтобы обработать всю эту новую информацию, но следите за обновлениями, я расскажу о поиске по префиксу и о том, как его использовать для таких интересных вещей, как автозаполнение или проверка орфографии, в следующих постах.

Оригинал: “https://dev.to/mlarocca/ternary-search-tree-core-methods-java-implementation-2hlj”