Прелюдия
Эта статья посвящена бинарным деревьям поиска. Недавно делал статью про сжатие данных методом Хаффмана. Там я не очень обращал внимание на бинарные деревья, ибо методы поиска, вставки, удаления не были актуальны. Теперь решил написать статью именно про деревья. Пожалуй, начнем.
Дерево — структура данных, состоящая из узлов, соединенных ребрами. Можно сказать, что дерево — частный случай графа. Вот пример дерева:
Это не бинарное дерево поиска! Все под кат!
Терминология
Корень
Корень дерева — это самый верхний его узел. В примере — это узел A. В дереве от корня к любому другому узлу может вести только один путь!
Родители/потомки
Все узлы, кроме корневого, имеют ровно одно ребро, ведущее вверх к другому узлу. Узел, расположенный выше текущего, называется родителем этого узла. Узел, расположенный ниже текущего, и соединенный с ним называется потомком этого узла. Давайте на примере. Возьмем узел B, тогда его родителем будет узел A, а потомками — узлы D, E и F.
Лист
Узел, у которого нет потомков, будет называться листом дерева. В примере листьями будут являться узлы D, E, F, G, I, J, K.
Это основная терминология. Другие понятия будут разобраны далее. Итак, бинарное дерево — дерево, в котором каждый узел будет иметь не более двух потомков. Как вы догадались, дерево из примера не будет являться бинарным, ибо узлы B и H имеют более двух потомков. Вот пример бинарного дерева:
В узлах дерева может находиться любая информация. Двоичное дерево поиска — это двоичное дерево, для которого характерны следующие свойства:
- Оба поддерева — левое и правое — являются двоичными деревьями поиска.
- У всех узлов левого поддерева произвольного узла X значения ключей данных меньше, нежели значение ключа данных самого узла X.
- У всех узлов правого поддерева произвольного узла X значения ключей данных больше либо равны, нежели значение ключа данных самого узла X.
Представление дерева
По мере продвижения я буду приводить некоторые(возможно, неполные) куски кода, для того, чтобы улучшить ваше понимание. Полный код будет в конце статьи.
Дерево состоит из узлов. Структура узла:
public class Node {
private int data;
private Node leftChild;
private Node rightChild;
public Node(int newData) {
data = newData;
}
//методы узла
}
Каждый узел имеет двух потомков(вполне возможно, потомки leftChild и/или rightChild будут содержать значение null). Вы, наверное, поняли, что в данном случае число data — ключ узла.
Ключ и информация в узле могут не совпадать! Например, вы могли сделать так:
public class Node {
private int key;
private Data myData;
private Node leftChild;
private Node rightChild;
public Node(int newData) {
data = newData;
}
public Node getLeftChild() {
return leftChild;
}
public Node getRightChild() {
return rightChild;
}
//методы узла
}
class Data {
private String s;
public Data(String s) {
this.s = s;
}
public String getString() {
return s;
}
}
Здесь myData хранит информацию узла дерева. Важно понимать, что узел дерева может хранить любую информацию.
С узлом разобрались, теперь поговорим
public class BinaryTree {
private Node root;
//методы дерева
}
Как поле класса нам понадобится только корень дерева, ибо от корня с помощью методов getLeftChild() и getRightChild() можно добраться до любого узла дерева.
Алгоритмы в дереве
Поиск
Допустим, у вас есть построенное дерево. Как найти элемент с ключом key? Нужно последовательно двигаться от корня вниз по дереву и сравнивать значение key с ключом очередного узла: если key меньше, чем ключ очередного узла, то перейти к левому потомку узла, если больше — к правому, если ключи равны — искомый узел найден! Соответствующий код:
public Node find(int findData) {
Node current = root;
while (current.getData() != findData) {
if (findData < current.getData()) //идем налево
current = current.getLeftChild();
else //идем направо(ситуацию равенства проверяет цикл)
current = current.getRightChild();
if (current == null)//если потомка не существует, значит в дереве нет элемента с ключом findData
return null;
}
return current;
}
Если current становится равным null, значит, перебор достиг конца дерева(на концептуальном уровне вы находитесь в несуществующем месте дерева — потомке листа).
Рассмотрим эффективность алгоритма поиска на сбалансированном дереве(дереве, в котором узлы распределены более-менее равномерно). Тогда эффективность поиска будет O(log(n)), причем логарифм по основанию 2. Смотрите: если в сбалансированном дереве n элементов, то это значит, что будет log(n) по основанию 2 уровней дерева. А в поиске, за один шаг цикла, вы спускаетесь на один уровень.
Вставка
Если вы уловили суть поиска, то понять вставку не составит вам труда. Надо просто спуститься до листа дерева(по правилам спуска, описанным в поиске) и стать его потомком — левым, или правым, в зависимости от ключа. Реализация:
public void insert(int insertData) {
Node current = root;
Node parent;//родитель текущего узла
Node newNode = new Node(insertData);
if (root == null)
root = newNode;
else {
while (true) {
parent = current;
if (insertData < current.getData()) {
current = current.getLeftChild();
if (current == null) {
parent.setLeftChild(newNode);
return;
}
}
else {
current = current.getRightChild();
if (current == null) {
parent.setRightChild(newNode);
return;
}
}
}
}
}
В данном случае надо, помимо текущего узла, хранить информацию о родителе текущего узла. Когда current станет равным null, в переменной parent будет лежать нужный нам лист.
Эффективность вставки, очевидно, будет такой же как и у поиска — O(log(n)).
Удаление
Удаление — самая сложная операция, которую надо будет провести с деревом. Понятно, что сначала надо будет найти элемент, который мы собираемся удалять. Но что потом? Если просто присвоить его ссылке значение null, то мы потерям информацию о поддереве, корнем которого является этот узел. Методы удаления дерева разделяют на три случая.
Первый случай. Удаляемый узел не имеет потомков
Если удаляемый узел не имеет потомков, то это значит, что он является листом. Следовательно, можно просто полям leftChild или rightChild его родителя присвоить значение null. В зависимости от того, является ли удаляемый узел левым или правым потомком своего родителя, переменная isLeftChild будет принимать значение true или false соответственно.
public boolean delete(int deleteData) {
Node current = root;
Node parent = current;
boolean isLeftChild = false;//информация о местоположении удаляемого листа по отношению к его родителю
while (current.getData() != deleteData) {
parent = current;
if (deleteData < current.getData()) {
current = current.getLeftChild();
isLeftChild = true;
} else {
isLeftChild = false;
current = current.getRightChild();
}
if (current == null)
return false;
}
if (current.getLeftChild() == null && current.getRightChild() == null) {//если удаляем лист
if (current == root)//если он корень, то достаточно сделать его равным null
current = null;
else if (isLeftChild)//удалем некорневой лист, являющийся
parent.setLeftChild(null);//левым потомком своего родителя
else
parent.setRightChild(null);//правым потомком своего родителя
}
...
Второй случай. Удаляемый узел имеет одного потомка
Этот случай тоже не очень сложный. Вернемся к нашему примеру. Допустим, надо удалить элемент с ключом 14. Согласитесь, что так как он — правый потомок узла с ключом 10, то любой его потомок(в данном случае правый) будет иметь ключ, больший 10, поэтому можно легко его «вырезать» из дерева, а родителя соединить напрямую с потомком удаляемого узла, т.е. узел с ключом 10 соединить с узлом 13. Аналогичной была бы ситуация, если бы надо было удалить узел, который является левым потомком своего родителя. Подумайте об этом сами — точная аналогия.
Код:
//продолжение метода delete
else if (current.getRightChild() == null) {//Если удаляемый узел имеет левого потомка
if (current == root)//удаляемый узел - корень
root = current.getLeftChild();//сделать корнем его (единственный) левый потомок
else if (isLeftChild)//Если удаляемый узел является
parent.setLeftChild(current.getLeftChild());//левым потомком своего родителя
else
current.setRightChild(current.getLeftChild());//правым потомком своего родителя
} else if (current.getLeftChild() == null) {//Если удаляемый узел имеет правого потомка
if (current == root)//удаляемый узел - корень
root = current.getRightChild();//сделать корнем его (единственный) правый потомок
else if (isLeftChild)//если удаляемый узел является
parent.setLeftChild(current.getRightChild());//левым потомком своего родителя
else
parent.setRightChild(current.getRightChild());//правым потомком своего родителя
}
...
Третий случай. Узел имеет двух потомков
Наиболее сложный случай. Разберем на новом примере.
Поиск преемника
Допустим, надо удалить узел с ключом 25. Кого поставим на его место? Кто-то из его последователей(потомков или потомков потомков) должен стать преемником(тот, кто займет место удаляемого узла).
Как понять, кто должен стать преемником? Интуитивно понятно, что это узел в дереве, ключ которого — следующий по величине от удаляемого узла. Алгоритм заключается в следующем. Надо перейти к его правому потомку(всегда к правому, ибо уже говорилось, что ключ преемника больше ключа удаляемого узла), а затем пройтись по цепочке левых потомков этого правого потомка. В примере мы должны перейти к узлу с ключом 35, а затем пройтись до листа вниз по цепочке его левых потомков — в данном случае, эта цепочка состоит только из узла с ключом 30. Строго говоря, мы ищем наименьший узел в наборе узлов, больших искомого узла.
Код:
public Node getSuccessor(Node deleteNode) {
Node parentSuccessor = deleteNode;//родитель преемника
Node successor = deleteNode;//преемник
Node current = successor.getRightChild();//просто "пробегающий" узел
while (current != null) {
parentSuccessor = successor;
successor = current;
current = current.getLeftChild();
}
//на выходе из цикла имеем преемника и родителя преемника
if (successor != deleteNode.getRightChild()) {//если преемник не совпадает с правым потомком удаляемого узла
parentSuccessor.setLeftChild(successor.getRightChild());//то его родитель забирает себе потомка преемника, чтобы не потерять его
successor.setRightChild(deleteNode.getRightChild());//связываем преемника с правым потомком удаляемого узла
}
return successor;
}
Теперь можем закончить метод delete:
//продолжение метода delete
else {
Node successor = getSuccessor(current);//получить преемника
if (current == root)//если удаляемый элемент - корень дерева
root = successor;//то сделать его преемника корнем
else if (isLeftChild)//если удаляемый элемент является
parent.setLeftChild(successor);//левым потомком своего родителя
else
parent.setRightChild(successor);//правым потомком своего родителя
}
return true;
}
Полный код метода delete:
public boolean delete(int deleteData) {
Node current = root;
Node parent = current;
boolean isLeftChild = false;
while (current.getData() != deleteData) {
parent = current;
if (deleteData < current.getData()) {
current = current.getLeftChild();
isLeftChild = true;
} else {
isLeftChild = false;
current = current.getRightChild();
}
if (current == null)//удаляемый элемент не найден
return false;
}
if (current.getLeftChild() == null && current.getRightChild() == null) {
if (current == root)
current = null;
else if (isLeftChild)
parent.setLeftChild(null);
else
parent.setRightChild(null);
}
else if (current.getRightChild() == null) {
if (current == root)
root = current.getLeftChild();
else if (isLeftChild)
parent.setLeftChild(current.getLeftChild());
else
current.setRightChild(current.getLeftChild());
} else if (current.getLeftChild() == null) {
if (current == root)
root = current.getRightChild();
else if (isLeftChild)
parent.setLeftChild(current.getRightChild());
else
parent.setRightChild(current.getRightChild());
}
else {
Node successor = getSuccessor(current);
if (current == root)
root = successor;
else if (isLeftChild)
parent.setLeftChild(successor);
else
parent.setRightChild(successor);
}
return true;
}
Сложность может быть аппроксимирована к O(log(n)).
Поиск максимума/минимума в дереве
Очевидно, как найти минимальное/максимальное значение в дереве — надо последовательно переходить по цепочке левых/правых элементов дерева соответственно; когда доберетесь до листа, он и будет минимальным/максимальным элементом.
public Node getMinimum(Node startPoint) {
Node current = startPoint;
Node parrent = current;
while (current != null) {
parrent = current;
current = current.getLeftChild();
}
return parrent;
}
public Node getMaximum(Node startPoint) {
Node current = startPoint;
Node parrent = current;
while (current != null) {
parrent = current;
current = current.getRightChild();
}
return parrent;
}
Сложность — O(log(n))
Симметричный обход
Обход — посещение каждого узла дерева с целью сделать с ним какую-то функцию.
Алгоритм рекурсивного симметричного обхода:
- Сделать действие с левым потомком
- Сделать действие с собой
- Сделать действие с правым потомком
Код:
public void inOrder(Node current) {
if (current != null) {
inOrder(current.getLeftChild());
System.out.println(current.getData() + " ");//Здесь может быть все, что угодно
inOrder(current.getRightChild());
}
}
Заключение
Наконец-то! Если я что-то недообъяснил или есть какие-либо замечания, то жду в комментариях. Как обещал, привожу полный код.
Node.java:
public class Node {
private int data;
private Node leftChild;
private Node rightChild;
public Node(int newData) {
data = newData;
}
public void setLeftChild(Node newNode) {
leftChild = newNode;
}
public void setRightChild(Node newNode) {
rightChild = newNode;
}
public Node getLeftChild() {
return leftChild;
}
public Node getRightChild() {
return rightChild;
}
public int getData() {
return data;
}
}
BinaryTree.java:
public class BinaryTree {
private Node root;
public Node find(int findData) {
Node current = root;
while (current.getData() != findData) {
if (findData < current.getData())
current = current.getLeftChild();
else
current = current.getRightChild();
if (current == null)
return null;
}
return current;
}
public void insert(int insertData) {
Node current = root;
Node parrent;
Node newNode = new Node(insertData);
if (root == null)
root = newNode;
else {
while (true) {
parrent = current;
if (insertData < current.getData()) {
current = current.getLeftChild();
if (current == null) {
parrent.setLeftChild(newNode);
return;
}
}
else {
current = current.getRightChild();
if (current == null) {
parrent.setRightChild(newNode);
return;
}
}
}
}
}
public Node getMinimum(Node startPoint) {
Node current = startPoint;
Node parrent = current;
while (current != null) {
parrent = current;
current = current.getLeftChild();
}
return parrent;
}
public Node getMaximum(Node startPoint) {
Node current = startPoint;
Node parrent = current;
while (current != null) {
parrent = current;
current = current.getRightChild();
}
return parrent;
}
public Node getRoot() {
return root;
}
public Node getSuccessor(Node deleteNode) {
Node parrentSuccessor = deleteNode;
Node successor = deleteNode;
Node current = successor.getRightChild();
while (current != null) {
parrentSuccessor = successor;
successor = current;
current = current.getLeftChild();
}
if (successor != deleteNode.getRightChild()) {
parrentSuccessor.setLeftChild(successor.getRightChild());
successor.setRightChild(deleteNode.getRightChild());
}
return successor;
}
public boolean delete(int deleteData) {
Node current = root;
Node parent = current;
boolean isLeftChild = false;
while (current.getData() != deleteData) {
parent = current;
if (deleteData < current.getData()) {
current = current.getLeftChild();
isLeftChild = true;
} else {
isLeftChild = false;
current = current.getRightChild();
}
if (current == null)
return false;
}
if (current.getLeftChild() == null && current.getRightChild() == null) {
if (current == root)
current = null;
else if (isLeftChild)
parent.setLeftChild(null);
else
parent.setRightChild(null);
}
else if (current.getRightChild() == null) {
if (current == root)
root = current.getLeftChild();
else if (isLeftChild)
parent.setLeftChild(current.getLeftChild());
else
current.setRightChild(current.getLeftChild());
} else if (current.getLeftChild() == null) {
if (current == root)
root = current.getRightChild();
else if (isLeftChild)
parent.setLeftChild(current.getRightChild());
else
parent.setRightChild(current.getRightChild());
}
else {
Node successor = getSuccessor(current);
if (current == root)
root = successor;
else if (isLeftChild)
parent.setLeftChild(successor);
else
parent.setRightChild(successor);
}
return true;
}
public void inOrder(Node current) {
if (current != null) {
inOrder(current.getLeftChild());
System.out.println(current.getData() + " ");
inOrder(current.getRightChild());
}
}
}
Main.java:
import java.util.Scanner;
public class Main {
public static void main (String[] args) {
Scanner scanner = new Scanner(System.in);
BinaryTree binaryTree = new BinaryTree();
int operationCommand = 0;
int data = 0;
while (true) {
System.out.println("Input 1 to add element, 2 to delete, 3 to search, 4 to get max, 5 to get min, 0 to exit");
operationCommand = scanner.nextInt();
switch (operationCommand) {
case 0:
scanner.close();
return;
case 1:
System.out.println("Input element");
data = scanner.nextInt();
binaryTree.insert(data);
break;
case 2:
System.out.println("Input element");
data = scanner.nextInt();
binaryTree.delete(data);
break;
case 3:
System.out.println("Input element");
data = scanner.nextInt();
if (binaryTree.find(data) != null)
System.out.println("OK");
else
System.out.println("Not found");
break;
case 4:
System.out.println(binaryTree.getMaximum(binaryTree.getRoot()));
break;
case 5:
System.out.println(binaryTree.getMinimum(binaryTree.getRoot()));
break;
default:
System.out.println("Another key to continue");
break;
}
}
}
}
Комментарии (21)
BugM
02.03.2019 16:39+1Двоичное дерево поиска — дерево, в котором ключ левого потомка узла меньше, чем ключ его родителя, а ключ правого узла — больше ключа его родителя.
Двоичное дерево поиска это такое дерево где ключи всех узлов левого поддерева любого узла меньше чем ключ узла, а правого больше. У вас неверное определение дано.
while (current.getData() != findData) {
if (findData < current.getData())
Код для поиска узлов тоже неверный. Вы сравниваете Data, а не Key. Это просто работать не будет.
Дальше код тоже весь нерабочий. Та же ошибка поиска есть везде.
Аккуратнее надо. А лучше скройте в черновики и перепишите. Так чтобы определения были верными, а код рабочим.GodCoder Автор
02.03.2019 16:46-11)Серьезно? Приведите контрпример.
2)Читали бы вы статью внимательнее, заметили бы, что я указал, что ключ узла в данном случае совпадает с данными, хранящимися в узле.BugM
02.03.2019 16:55+1По вашему определению это бинарное дерево поиска. На самом деле нет.
5
/
1
\
10
Допустим, у вас есть построенное дерево. Как найти элемент с ключом key? Нужно последовательно двигаться от корня вниз по дереву и сравнивать значение key с ключом очередного узла: если key меньше, чем ключ очередного узла, то перейти к левому потомку узла, если больше — к правому, если ключи равны — искомый узел найден! Соответствующий код: <поиск по Data>
Словами вы пишите про ключи, а в коде ищите по данным.
GodCoder Автор
02.03.2019 20:491)Замечание принимается
2)Вы, наверное, поняли, что в данном случае число data — ключ узла.
Ключ и информация в узле могут не совпадать!zagayevskiy
02.03.2019 20:51тогда надо было реализовывать интерфейс SortedSet, думаю, претензий было бы меньше.
BugM
02.03.2019 22:42+1В статье для начинающих не должно быть таких разночтений. Им и так сложно и непонятно. Все должно быть однозначно. Пишите про поиск по ключам называйте поле key. Пишите про данные называйте поле data. Хотите абстрактно называйте val.
А не так: тут пишем, а тут рыбу заворачиваем.
PS: И заодно, если статья не про стиль кода, никаких приватный полей и геттеров. Все поля паблик. Читается гораздо проще.
million
02.03.2019 18:03Пример работы с деревьями лучше показывать реализовав интерфейс Collection или List
zagayevskiy
02.03.2019 20:45+1Лучше Map(SortedMap). Collection и List тут вообще никаким боком.
million
02.03.2019 22:04-1А причем тут Map? Ведь бинарные деревья — это коллекция.
В JavaSE есть TreeSet — это чистой воды бинарное дерево.zagayevskiy
02.03.2019 22:22+1Никогда не возникало желание заглянуть внутрь реализации TreeSet(который реализует NavigableSet, который расширяет SortedSet)? Там обычно лежит NavigableMap, который обычно TreeMap, который реализуется красно-черным деревом.
А оно всё тут при том, что даже ТС упоминал ключи и значения. По ключам сортируют, а значения хранят. ТС совместил это и хранит инты, но вообще-то это не особо круто.
Бинарный деревья это не коллекции. Тот же TreeSet не имплементит Collection(догадаетесь, почему?)
А реализовывать List на BST… Ну это надо вообще глупо.Prototik
02.03.2019 22:38+1Тот же TreeSet не имплементит Collection
Фигассе, а кто-нибудь ещё об этом в курсе?
void_one
03.03.2019 00:34Статья ок, но в классических курсах сразу же определяют стоимость операций и из-за нее вводят понятие балансированного дерева (red-black, avl, 2-3). От небалансированного толку немного.
ericgrig
03.03.2019 00:56Спасибо за статью.
Лаконичное и простое изложение. Некоторые «издержки» в тексте не нарушают общее
положительное восприятие.
MikailBag
03.03.2019 09:12По поводу корня: корень — это выделенная вершина дерева. Мы можем подвесить дерево за любую вершину, можем в разные моменты вешать дерево за разные вершины, а можем не рассматривать корень вообще. В случае бинарных деревьев корень — это любая такая вершина, относительно которой инварианты верны.
Еще отмечу, что на хабре уже была статья про декартово дерево, которое уже обеспечивает логарифмическую асимптотику (в среднем)
vdovin75
03.03.2019 12:39Очень интересно! А что становится значением корня дерева? Первое добавленное значение? Среднее значение некоторого диапазона? Если — первое добавленное значение, то получается, что если, например, это будет «2», то вся масса данных будет сосредоточена в правой ветке, идущей от корня, и дерево получится кривобокое.
GodCoder Автор
03.03.2019 12:44Да, друг, вы совершенно правы! Я говорил, что мои оценки скорости выполнения алгоритма, т.е. O(log(n)) для поиска, вставки и удаления действительны в случае сбалансированного дерева. В несбалансированном дереве, или, как вы выразились «кривобоком», оценки растут до O(n). В этом заключается главный недостаток бинарных деревьев в сравнении с красно-черными деревьями — здесь проблемы несбалансированности решаются своеобразными поворотами дерева.
Videoman
03.03.2019 16:56+2Тут нужно быть аккуратнее с определениями, а-то можно всех окончательно запутать, на мой взгляд. Сбалансированность дерева не как не связана с его арностью. Дерево любой арности может быть либо сбалансированным, либо нет. Как уже писали выше, дерево это просто граф, от каждой до каждой вершины которого существует только один единственный пусть. Вы рассматриваете только BST деревья (Binary-Search — деревья поиска). Красно-черное дерево также является бинарным деревом, где каждой вершине сопоставлен цвет (бит). Также существуют B-деревья — это N-арные деревья которые всегда сбалансированы. Строго говоря, красно-черные деревья — это 2-3-4 деревья (N-арные деревья, где каждая вершина может иметь от 2-х до 4-х потомков) представленное в виде двоичного эквивалентного дерева. Дело в том что любое N-арное дерево можно представить в виде эквивалентного ему бинарного.
Все это не плохо было было бы указать в начале статьи и ограничить рамки структуры которую вы рассматриваете — несбалансированное бинарное дерево.
wataru
03.03.2019 14:52+2Вы везде пишете про сложность O(log n). Но у вас не балансируемое дерево поиска. Высота, в общем случае — O(n). И все операции в дереве будут выполнятся за O(n) а не за логарифм. Вы только вводите людей в заблуждение.
Вы в тексте пару раз упоминаете, что у вас оценки для балансированного дерева, но ни слова о балансировке нет. Таким образом, у вас приведена некоторая структура данных и даны какие-то оценки какого-то, не относящегося к структуре, случая.
А еще, когда балансировка добавляется, все операции сильно меняются. Например, в декартовом дереве (Treap), которое балансируется, все операции делаются совсем по другому, через Merge и Split. В итоге реализация delete и insert в Treap раза в 2-3 короче, чем то, что у Вас.
maxkomp
Хотелось бы немножко уточнить определения:
Это общее определение произвольного графа.
Дерево- это, как совершенно верно указано в статье, частный случай графа.
Строгое определение: дерево- это такой связный граф, в котором не существует ни одного цикла.
В теории графов доказывается теорема, что в дереве, между любыми двумя узлами, существует один и только один путь.
Именно этот факт и позволяет использовать древовидные структуры в алгоритмах сортировки и поиска.
Отсюда следует, что в реальной жизни при работе с древовидными структурами будет не лишним выполнить дополнительные проверки. (чтобы не получить в результате ошибку, когда на какой-то узел одновременно ссылаются слишком много других узлов).
И еще про необходимость такой операции, как «балансировка» деревьев поиска, тоже хотелось бы услышать в продолжении.
Но это я уже так, придираюсь.
Спасибо за статью.