В этой статье хочу поведать вам об одном старом, известном и неумирающем алгоритме поиска — о B-деревьях. B-деревья позволяют быстро находить целые блоки данных по заданному ключу. Кроме того они решают сразу целый букет проблем: поиск, добавление, удаление. Благодаря этим достоинствам, они получили широкую популярность в базах данных, в частности в ISAM и MyISAM от MySQL.
В интернете вы можете найти много информации про B-деревья. Например, хорошие статьи есть на Википедии. Я же постарался рассказать кратко, наглядно и максимально полно о B-деревьях. Итак, поехали…
Вступление
B-дерево – это структура хранения данных, являющаяся разновидностью дерева поиска. Впервые была предложена Р. Бэйером и Е. МакКрейтом в 1970 году. Особенностями В-деревьев является: сбалансированность, ветвистость, отсортированность и логарифмическое время работы всех стандартных операций (поиск, вставка, удаление). Сбалансированность означает, что все листы находятся на одинаковом расстоянии от корня. В отличие от бинарных деревьев В-деревья допускают большое число потомков для любого из узлов. Это свойство называется ветвистостью. Благодаря ветвистости, В-деревья очень удобны для хранения крупных последовательных блоков данных, поэтому такая структура часто находит применение в базах данных и файловых системах.
Описание
Для описания В-деревьев прежде всего придется разобраться с терминологией. К сожалению, в литературе нет устоявшейся нормы. Начнём с того, что определим число m – порядок В-дерева – это максимальное число потомков для любого узла. Кроме узлов в дереве присутствует ещё одна сущность – ключи. Именно в них и содержится вся полезная информация. Каждый узел дерева можно представить в виде упорядоченной последовательности ”потомок1; ключ1; потомок2; ключ2; … потомок(N-1); ключ(N-1); потомокN”. Важно заметить, что ключи располагаются между ссылками на потомков и, таким образом, ключей всегда на 1 меньше. В организации В-дерева можно выделить несколько ключевых правил:
- Каждый узел содержит строго меньше m (порядок дерева) потомков.
- Каждый узел содержит не менее m/2 потомков.
- Корень может содержать меньше m/2 потомков.
- У корневого узла есть хотя бы 2 потомка, если он не является листом.
- Все листья находятся на одном уровне и содержат только данные (ключи).
Алгоритмы для работы с В-деревом
1. Поиск
Поиск – это та самая операция, ради которой всё и затевалось. Благодаря структуре В-дерева и постоянно поддерживаемому балансу, поиск осуществляется за логарифмическое время от количества записей. Сам алгоритм выглядит следующим образом:
Пусть мы ищем значение X в заданном В-дереве.
- Начинаем от корня
- Если X – это один из ключей текущего узла, то СТОП, ключ найден.
- Иначе находим пару соседних ключей Y, Z, таких что X лежит в интервале от Y до Z
- Переходим к потомку между Y и Z
- Идём на шаг 2.
- Если мы дошли до листа и Х не содержится среди ключей, то СТОП, такого значения в дереве нет.
2. Добавление ключа
Введём определение: дерево потомков некоего узла — это поддерево, состоящее из этого узла и его потомков.
Вначале определим функцию, которая добавляет ключ K к дереву потомков узла x. После выполнения функции во всех пройденных узлах, кроме, может быть, самого узла x, будет меньше 2t – 1 (число t связано с m так: m = 2(t – 1)), но не меньше t – 1 ключей.
- Если х — не лист
- Определяем интервал, где должен находиться K. Пусть y — соответствующий сын.
- Рекурсивно добавляем K к дереву потомков y.
- Если узел y полон, то есть содержит 2t − 1 ключей, расщепляем его на два. Узел y1 получает первые t − 1 из ключей y и первые t его потомков, а узел y2 — последние t − 1 из ключей y и последние t его потомков. Медианный из ключей узла y попадает в узел х, а указатель на y в узле x заменяется указателями на узлы y1 и y2.
- Если x — лист, просто добавляем туда ключ K.
Теперь определим добавление ключа K ко всему дереву. Буквой R обозначается корневой узел.
- Добавим K к дереву потомков R.
- Если R содержит теперь 2t − 1 ключей, расщепляем его на два. Узел R1 получает первые t − 1 из ключей R и первые t его потомков, а узел R2 — последние t − 1 из ключей R и последние t его потомков. Медианный из ключей узла R попадает вo вновь созданный узел, который становится корневым. Узлы R1 и R2 становятся его потомками.
На самом деле, вставки это настоящая катастрофа в сортированном последовательном файле, потому что требуется выделять место для каждой вставки нового элемента. Вставка в начало файла приводит к полному сдвигу всех записей. На практике такие операции обходятся довольно дорого и поэтому нежелательны.
Существует одна уловка, позволяющая ускорить в некоторых ситуациях вставку данных: вокруг каждого блока данных можно зарезервировать свободное место. Все такие записи будут помечены как «удалённые». И таким образом, перестраивать дерево придётся гораздо реже – только в тех случаях, когда кончается зарезервированное место.
3. Удаление ключа
При удалении ключа могут возникнуть две проблемы:
- Удалённый ключ мог быть разделителем потомков внутри какого-либо нелистового узла
- После удаления ключа в узле может остаться менее допустимого m/2 числа потомков
Для того чтобы избежать эти проблемы при удалении ключа, также, как и при вставке, требуется перебалансировка дерева. Существуют две стратегии: перебалансировка после удаления ключа и заранее. Мы будем рассматривать первый вариант. Алгоритм:
- Если корень одновременно является листом, то есть в дереве всего один узел, мы просто удаляем ключ из этого узла. В противном случае сначала находим узел, содержащий ключ, запоминая путь к нему. Пусть этот узел — x.
- Если x — лист, удаляем оттуда ключ.
- Если в узле x осталось не меньше t − 1 ключей, мы на этом останавливаемся. Иначе мы смотрим на количество ключей в следующем, а потом в предыдущем узле.
- Если следующий узел есть, и в нём не менее t ключей, мы добавляем в x ключ-разделитель между ним и следующим узлом, а на его место ставим первый ключ следующего узла, после чего останавливаемся.
- Если это не так, но есть предыдущий узел, и в нём не менее t ключей, мы добавляем в x ключ-разделитель между ним и предыдущим узлом, а на его место ставим последний ключ предыдущего узла, после чего останавливаемся.
- Наконец, если и с предыдущим ключом не получилось, мы объединяем узел x со следующим или предыдущим узлом, и в объединённый узел перемещаем ключ, разделяющий два узла. При этом в родительском узле может остаться только t − 2 ключей. Тогда, если это не корень, мы выполняем аналогичную процедуру с ним.
- Если мы в результате дошли до корня, и в нём осталось от 1 до t − 1 ключей, делать ничего не надо, потому что корень может иметь и меньше t − 1 ключей.
- Если же в корне не осталось ни одного ключа, исключаем корневой узел, а его единственный потомок делаем новым корнем дерева.
- Если x — не лист, а K — его i-й ключ, удаляем самый правый ключ из поддерева потомков i-го сына x, или, наоборот, самый левый ключ из поддерева потомков i + 1-го сына x.
- После этого заменяем ключ K удалённым ключом. Удаление ключа происходит так, как описано в п.2.
4. Конструирование с нуля
На практике часто приходится представить уже имеющуюся коллекцию в виде В-дерева, чтобы потом работать с ним при помощи стандартных операций, описанных выше. В таком случае лучше не формировать дерево вставкой по одному элементу, а сразу же строить целое множество узлов по заданному вводу.
Правило: изначально все узлы кроме последнего содержат один дополнительный элемент (то есть m+1 потомков в сумме), который будет использован для построения внутренних узлов.
Для наглядности рассмотрим алгоритм на примере. Пусть количество потомков для каждого листового узла у нас не более 4, то есть число m = 5. На вход подана последовательность чисел от 1 до 24. Разобьём их на блоки по заданному правилу:
1|2|3|4|5 6|7|8|9|10 11|12|13|14|15 16|17|18|19|20 21|22|23|24
Из слоя листовых узлов мы строим следующий уровень путём вынесения туда лишних (в данном случае пятых) элементов на уровень выше. И новый уровень также разбиваем в соответствии с правилом. Пусть для внутренних (нелистовых) узлов у нас будет ограничение 2 на количество потомков. Тогда мы получим:
5|10|15 20
1|2|3|4 6|7|8|9 11|12|13|14 16|17|18|19 21|22|23|24
Продолжаем процесс, пока не получим корень.
15
5|10 20
1|2|3|4 6|7|8|9 11|12|13|14 16|17|18|19 21|22|23|24
Осталось добавить ссылки и получится готовое В-дерево!
Основные достоинства
- Во всех случаях полезное использование пространства вторичной памяти занимает свыше 50 %. С ростом степени полезного использования памяти не происходит снижения качества обслуживания.
- Произвольный доступ к записи реализуется посредством малого количества подопераций (обращения к физическим блокам).
- В среднем достаточно эффективно реализуются операции включения и удаления записей; при этом сохраняется естественный порядок ключей с целью последовательной обработки, а также соответствующий баланс дерева для обеспечения быстрой произвольной выборки.
- Неизменная упорядоченность по ключу обеспечивает возможность эффективной пакетной обработки.
Основной недостаток В-деревьев состоит в отсутствии для них средств выборки данных по вторичному ключу.