
Тестовая программа
Проверим работу реализованного BST при помощи несложной тестовой программы:
#include "bstree.hpp"
#include <cassert>
#include <iostream>
// Функция обратного вызова для симметричного обхода: печатает ключ в узле
void printNode ( const BSTree::Node & _node )
{
std::cout << _node.m_key << ' ';
}
int main ()
{
// Создаем объект-BST
BSTree * pTree = BSTreeCreate();
// Определяем последовательность ключей для вставки
int keys[] = { 5, 2, 7, 3, 9, 0, 1, 4, 6, 8 };
int nKeys = sizeof( keys ) / sizeof( int );
// Вставляем ключи в BST в определенном выше порядке
for ( int i = 0; i < nKeys; i++ )
BSTreeInsertKey( * pTree, keys[ i ] );
// Каждый из вставленных ключей точно находится в дереве
for ( int i = 0; i < nKeys; i++ )
assert( BSTreeHasKey( * pTree, keys[ i ] ) );
// Сопоставляем минимальный и максимальный ключи с ожидаемыми
assert( BSTreeMinimum( * pTree ) == 0 );
assert( BSTreeMaximum( * pTree ) == 9 );
// Выполняем симметричных обход с распечаткой ключей на консоли
BSTreeSymmetricWalk( * pTree, & printNode );
std::cout << std::endl;
// Тестируем удаление ключей
for ( int i = 0; i < nKeys; i++ )
{
// Удаляем очередной ключ
BSTreeDeleteKey( * pTree, keys[ i ] );
// После удаления ключ не должен быть представлен во множестве
assert( ! BSTreeHasKey( * pTree, keys[ i ] ) );
// Другие, еще не удаленные ключи, должны остаться во множестве
for ( int k = i + 1; k < nKeys; k++ )
assert( BSTreeHasKey( * pTree, keys[ k ] ) );
}
// Уничтожаем объект-BST
BSTreeDestroy( pTree );
}
При вызове данной программы, убеждаемся в упорядоченности вывода ключей при обходе:
Несбалансированные bst и вращение
Бинарное дерево поиска, у которого для любого узла-родителя количество дочерних узлов в левом и правом поддереве приблизительно равны, называется сбалансированным. Обратный случай, когда имеется существенный перевес у одного из двух поддеревьев, образует несбалансированное дерево. Вычислительная сложность процедуры поиска имеет логарифмическую сложность лишь для сбалансированных деревьев. Если баланс поддеревьев нарушен, сложность процедуры поиска в худшем случае вырождается до линейной.
Худшим случаем для работы BST является ввод уже упорядоченной последовательности, например:
0 1 2 3 4 5 6 7 8 9
Описанный выше алгоритм вставки для такой последовательности сформирует крайне неудачное с точки зрения поиска бинарное дерево - абсолютно несбалансированное BST, несмотря на соблюдение характеристического свойства:
Фактически, такое дерево превращается в односвязный список с линейной вычислительной сложностью поиска. Очевидно, в базовой форме деревья бинарного поиска не могут успешно обрабатывать уже упорядоченные последовательности.
Существует ряд алгоритмов автоматической балансировки деревьев, дополняющих базовую идею BST. Большинство из них основано на применении операции вращения. Суть вращения состоит в повороте одной из связей (оси вращения) в дереве по часовой (правое вращение) или против часовой стрелки (левое вращение) в зависимости от текущей потребности алгоритма. Вращение в конкретной области дерева помогает уменьшить возможный дисбаланс между высотами левого и правого поддеревьев. В какой именно момент времени и какую связь следует повернуть зависит от используемого алгоритма. Ниже рассмотрены сами операции вращения.
Предположим имеется следующее дерево, и тот или иной алгоритм принимает решение осуществить правое вращение связи между узлами-полюсами B и D. Выбранные узлы можно поменять местами, при условии, что характеристическое свойство BST не будет нарушено:
В начале узлы-полюса B и D вокруг оси вращения следует расцепить:
Затем, правое поддерево C левого полюса B следует перекрепить к освободившемуся месту в левом поддереве правого полюса D:
Затем можно перекрепить левый полюс B с родительским узлом правого полюса D, поскольку после расцепления полюсов эта связь является свободной. В частном случае, родительской связи может не быть, в таком случае D является корневым узлом дерева, а новым корнем станет B:
Наконец, можно прикрепить правый полюс D к левому полюсу B со свободной правой стороны, тем самым завершая операцию вращения без нарушения характеристического свойства BST:
Любого из соседних узлов - A, C, E, а также родительской связи в дереве может не быть, что не изменит сути рассмотренного алгоритма, лишь упростив его шаги.
Левое вращение реализуется аналогично и симметрично, фактически, следует выполнить шаги алгоритма правого вращения в обратном порядке.
Следует отметить, что операция вращения является локальной относительно выбранной оси. Модифицируются лишь связи узлов-полюсов B и D, а также родительская связь узла-поддерева C, ключ которого находится между ключами во вращаемых узлах-полюсах. Состояние других узлов не модифицируется, а значит данная операция обладает высоким быстродействием.
Ниже приведен код функций, реализующий левое и правое вращение:
void BSTreeLeftRotate ( BSTree & _tree, BSTree::Node * _l )
{
// l - левый полюс вращения, r - правый полюс вращения.
// Ось вращения - связь между l и r
BSTree::Node* r = _l->m_pRight;
// Если правого полюса нет, вращать нечем
if ( ! r )
return;
// Перекрепляем промежуточный узел к правой ветви левого полюса
_l->m_pRight = r->m_pLeft;
if ( _l->m_pRight )
_l->m_pRight->m_pParent = _l;
// Перекрепляем родительскую связь с левого полюса на правый
// Случай 1: левый полюс - корень, правый полюс становится новым корнем
// Случай 2: левый полюс является левым ребенком своего родителя
// Случай 3: левый полюс является правым ребенком своего родителя
r->m_pParent = _l->m_pParent;
if ( ! r->m_pParent )
_tree.m_pRoot = r;
else if ( _l == _l->m_pParent->m_pLeft )
_l->m_pParent->m_pLeft = r;
else if ( _l == _l->m_pParent->m_pRight )
_l->m_pParent->m_pRight = r;
else
// Иная ситуация невозможна
assert( 0 );
// Воссоздаем ось между полюсами в противоположном направлении
r->m_pLeft = _l;
_l->m_pParent = r;
}
void BSTreeRightRotate ( BSTree & _tree, BSTree::Node * _r )
{
// l - левый полюс вращения, r - правый полюс вращения.
// Ось вращения - связь между l и r
BSTree::Node* l = _r->m_pLeft;
// Если левого полюса нет, вращать нечем
if ( ! l )
return;
// Перекрепляем промежуточный узел к левой ветви правого полюса
_r->m_pLeft = l->m_pRight;
if ( _r->m_pLeft )
_r->m_pLeft->m_pParent = _r;
// Перекрепляем родительскую связь с правого полюса на левый
// Случай 1: правый полюс - корень, левый полюс становится новым корнем
// Случай 2: правый полюс является левым ребенком своего родителя
// Случай 3: правый полюс является правым ребенком своего родителя
l->m_pParent = _r->m_pParent;
if ( ! l->m_pParent )
_tree.m_pRoot = l;
else if ( _r == _r->m_pParent->m_pLeft )
_r->m_pParent->m_pLeft = l;
else if ( _r == _r->m_pParent->m_pRight )
_r->m_pParent->m_pRight = l;
else
// Иная ситуация невозможна
assert( 0 );
// Воссоздаем ось между полюсами в противоположном направлении
l->m_pRight = _r;
_r->m_pParent = l;
}
Красно-черные деревья
Одним из наиболее популярных алгоритмов автоматической балансировки BST является алгоритм красно-черных деревьев. Обычно именно этот алгоритм используется при реализации стандартных библиотечных средств для отображений и множеств на основе BST.
Идея данного алгоритма состоит в расширении объекта-узла еще одной переменной, обозначающей цвет узла: красный или черный. Выбор таких названий является условным, смысл состоит в разделении всех узлов на 2 типа:
struct RBTree
{
struct Node
{
int m_key;
enum Color { RED, BLACK } m_color;
Node * m_pParent;
Node * m_pLeft;
Node * m_pRight;
};
Node * m_pRoot;
};
В любом состоянии красно-черное дерево должно сохранять следующие свойства, помимо характеристического свойства BST:
-
Корень дерева является черным узлом.
-
Если узел красный, его дочерние узлы могут быть только черными.
-
Для каждого промежуточного узла все пути от него до его листьев содержат одинаковое количество черных узлов.
Исходя из условий 2 и 3, можно сделать вывод, что при соблюдении всех условий, высота левого и правого поддерева в самом худшем случае отличается не более чем в 2 раза (за счет возможных дополнительных красных промежуточных узлов между черными узлами). Из этой структурной особенности вытекает, что операции вставки, поиска и удаления будут характеризоваться логарифмической вычислительной сложностью, а самои дерево будет приближенно сбалансированным.
Реализация функций создания и уничтожения дерева, поиска интересующего ключа, минимального и максимального ключей, вращения узлов, обхода - ни чем не отличается от рассмотренной выше базовой реализации бинарных деревьев поиска. Принципиально отличаются только операции вставки и удаления узлов, поскольку должны сохраняться дополнительные, по сравнению с BST, свойства.
Подробно рассмотрим алгоритм вставки. Каждый новый вставляемый узел должен быть изначально назначен красным. В начале выполняются обычные действия по вставке ключа в BST, как и в базовой реализации, а затем запускается дополнительная процедура, обеспечивающая сохранение специальных свойств красно-черных деревьев. Собственно, после вставки необходимо выявлять возможные проблемы двух типов:
-
корень красного цвета;
-
красный дочерний узел прикреплен к красному узлу-родителю.
Первая проблема решается путем перекраски корневого узла в черный цвет. Вторая проблема сложнее, и следует рассмотреть несколько случаев ее решения:
-
Новый красный узел A прикрепляется к родительскому красному узлу B слева, а также имеется узел-”дядя” D, который также является красным. Следует перекрасить узлы B и D в черный цвет, а узел-”дед” C - наоборот в красный:
-
Новый красный узел B прикрепляется к родительскому красному узлу A справа, а также имеется узел-”дядя” D, который также является красным. Аналогично, следует перекрасить узлы A и D в черный цвет, а узел-”дед” C - в красный:
-
Новый красный узел A прикрепляется к родительскому красному узлу B слева, а узла-”дяди” D в дереве либо нет, либо он является черным. Следует осуществить вращение по оси между узлами B и C, поменяв их цветами:
-
Новый красный узел B прикрепляется к родительскому красному узлу A справа, , а узла-”дяди” D в дереве нет, либо он является черным. Следует осуществить вращение по оси между узлами A и B, а затем свести задачу к предыдущему случаю:
Возможны также зеркально симметричные случаи, которые разрешаются аналогично.
В результате применения правил коррекции №1 и №2 на основе вращений, нарушение свойств красно-черного дерева может повториться для узлов, находящихся выше по иерархии, т.к. в результате получается поддерево с красным корнем. В таком случае все корректирующие операции алгоритма следует повторно применить на следующем уровне и так далее, пока дерево в итоге не стабилизируется.
Ниже представлен программный код, реализующий описанный алгоритм вставки с коррекциями:
// Функция создания нового элемента красно-черного дерева
RBTree::Node * RBTreeCreateNode ( int _key )
{
RBTree::Node * pNewNode = new RBTree::Node;
pNewNode->m_key = _key;