Разбираемся с двоичными деревьями поиска

0
627
views

Перевод статьи «Understanding Binary Search Trees».

В этой статье мы рассмотрим такую структуру данных как дерево. При объяснении будут использоваться примеры из предыдущей статьи — про рекурсию, так что советую с ней тоже ознакомиться.

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

Двоичное дерево и двоичное дерево поиска

У узла двоичного дерева есть самое большее два потомка: левый и правый. Это определение позволяет нам более эффективно писать алгоритмы для вставки, поиска и удаления узлов. Обратите внимание на иллюстрацию ниже. На примере этого двоичного дерева мы рассмотрим словарь ключевых слов, которые далее будут использоваться в статье.

Как вы, вероятно, догадались, двоичное дерево поиска (binary search tree, BST) это подвид двоичного дерева. Ключевое отличие BST состоит в том, что здесь узлы хранятся особым образом: узлы с меньшими значениями хранятся слева, а с большими — справа. Если вы сами не заметили, обращаю ваше внимание, что на рисунке изображено именно двоичное дерево поиска. Возможно, вам пока сложно понять, как упорядочены узлы, но не волнуйтесь, мы рассмотрим это подробнее в следующих разделах!

Создание классов Node и BST

Как обычно, я призываю читателей писать код вместе со мной и проверять все, о чем говорится. Для начала мы создадим наш класс Node, который будет представлять узлы нашего двоичного дерева поиска (BST):

class Node {
    constructor(data) {
        this.data = data; // node value
        this.left = null;   // left node child reference
        this.right = null; // right node child reference
    }
}

Далее мы объявим базовую структуру нашего класса BinarySearchTree:

class BinarySearchTree {
    constructor() {
        this.root = null; // root of bst
    }
}

Наш следующий шаг — реализация нескольких методов. А именно:

  • insert(data)
  • inOrderTraverse()
  • preOrderTraverse()
  • postOrderTraverse()
  • search(data)
  • remove(data)

Вставка узла в BST

Чтобы вставить в дерево новый узел, нужно пройти два шага:

  1. Проверить, является ли наша вставка особым случаем. Другими словами, нам нужно проверить, не должен ли быть первым в нашем дереве тот узел, который мы хотим добавить. Если он таки должен быть первым, нам просто нужно направить root на этот новый узел, создав экземпляр класса Node и назначив его свойству root.
  2. Добавить узел на другую позицию (не root).
insert(data) {
    let newNode = new Node(data);

    if(this.root === null) {
        this.root = newNode;
    } else {
        this.insertNode(this.root, newNode); // helper method below
    }
}

insertNode(node, newNode) {
    if(newNode.data < node.data) {
        if(node.left === null) {
            node.left = newNode;
        } else {
            this.insertNode(node.left, newNode);
        }
    } else {
        if(node.right === null) {
            node.right = newNode;
        } else {
            this.insertNode(node.right, newNode);
        }
    }
}

В общем, insert(data) создает новый Node со значением data, и если дерево пустое, этот узел устанавливается в качестве root. В противном случае вызывается insertNode(this.root, newNode).

insertNode(node, newNode) это наш вспомогательный метод, ответственный за сравнение данных нового узла с данными текущего узла и соответственное смещение влево или вправо. Это делается рекурсивно, пока не будет найден подходящий узел с нулевым значением, куда может быть добавлен новый узел.

Рассмотрим пример. Если мы выполним следующий код…

const BST = new BinarySearchTree();
BST.insert(11); // establishes root node 
BST.insert(7);
BST.insert(9);
BST.insert(15);
...
BST.insert(6);

то сможем проиллюстрировать последнюю вставку такой диаграммой:

Обход двоичного дерева поиска

Обход дерева это процедура посещения всех узлов дерева и осуществления операций с каждым узлом. Главный вопрос состоит в том, как нам идти. Есть три основных подхода: центрированный (in-order), прямой (pre-order) и обратный (post-order).

Центрированный обход

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

inOrderTraverse(node, callback) {
    if(node != null) {
        this.inOrderTraverse(node.left, callback);
        callback(node.data);
        this.inOrderTraverse(node.right, callback);
    }
}

Следующая диаграмма показывает путь нашего inOrderTraverse:

Прямой обход

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

preOrderTraverse(node, callback) {
    if(node != null) {
        callback(node.data);
        this.preOrderTraverse(node.left, callback);
        this.preOrderTraverse(node.right, callback);
    }
}

Обратный обход

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

postOrderTraverse(node, callback) {
    if(node != null) {
        this.postOrderTraverse(node.left, callback);
        this.postOrderTraverse(node.right, callback);
        callback(node.data);
    }
}

Поиск значений в BST

В нашей реализации node представляет текущий узел, а data — искомое значение:

search(node, data) {
    if(node === null) {
        return null;
    } else if(data < node.data) {
        return this.search(node.left, data);
    } else if(data > node.data) {
        return this.search(node.right, data);
    } else {
        return node;
    }
}

Я советую вам протестировать этот ваш код и посмотреть в console.log, какие узлы при этом посещаются. Даже если вы не писали код со мной вместе, изучите одну из диаграмм в статье и попробуйте спрогнозировать путь метода при поиске заданного значения. Вы также заметите, насколько просто найти минимальное и максимальное значение!

Удаление узла из BST

Метод remove — самый сложный из методов, которые мы рассматриваем в этой статье. Его сложность связана с тем, что нам нужно обрабатывать разные сценарии, к тому же рекурсивно.

remove(data) {
    this.root = this.removeNode(this.root, data); // helper method below
}

removeNode(node, data) {
    if(node === null) {
        return null;
    // if data to be deleted is less than the root's data, move to the left subtree
    } else if(data < node.data) {
        node.left = this.removeNode(node.left, data);
        return node;
    // if data to be deleted is greater than the root's data, move to the right subtree
    } else if(data > node.data) {
        node.right = this.removeNode(node.right, data);
        return node;
    // if data is similar to the root's data, delete the node
    } else {
        // delete node with no children (leaf node)
        if(node.left === null && node.right === null) {
            node = null;
            return node;
        }

        // delete node with one child
        if(node.left === null) {
            node = node.right;
            return node;
        } else if(node.right === null) {
            node = node.left;
            return node;
        }

        // delete node with two children
        // minimum node of the right subtree is stored in newNode
        let newNode = this.minNode(node.right);
        node.data = newNode.data;
        node.right = this.removeNode(node.right, newNode.data);
        return node;
    }
}

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

Удаление листового узла

Первый сценарий — с листовым узлом, т. е., таким, у которого нет ни левого, ни правого потомка. В этом случае нам нужно удалить узел, назначив ему null. Но не забудьте, что нужно также позаботиться о ссылках из родительского узла. На диаграмме показано удаление листового узла:

Удаление узла с одним потомком

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

Удаление узла с двумя потомками

Третий и последний сценарий предполагает, что у нашего узла есть оба потомка (левый и правый). Чтобы удалить такой узел, нужно следовать такому алгоритму действий:

  1. Найдя узел, который нужно удалить, нужно найти минимальный узел из его правого поддерева (на диаграмме — затененная часть дерева).
  2. Далее вы можете обновить значение удаляемого узла, назначив ему ключ минимального узла из его правого поддерева. Таким образом вы замените ключ узла, а это означает, что узел будет эффективно удален.
  3. Теперь у вас в дереве есть два узла с одинаковым ключом, чего не должно быть (на диаграмме — 18). Вам нужно удалить минимальный узел из правого поддерева, поскольку теперь он встал на место удаленного узла.
  4. Наконец, надо возвратить ссылку на обновленный узел его родителю.

Заключение

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

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

ОСТАВЬТЕ ОТВЕТ

Please enter your comment!
Please enter your name here