В информатике B -дерево — это самобалансирующаяся древовидная структура данных , которая поддерживает отсортированные данные и обеспечивает поиск, последовательный доступ, вставку и удаление за логарифмическое время . B-дерево обобщает двоичное дерево поиска , допуская узлы с более чем двумя дочерними узлами. [2] В отличие от других самобалансирующихся двоичных деревьев поиска , B-дерево хорошо подходит для систем хранения, которые читают и записывают относительно большие блоки данных, таких как базы данных и файловые системы .
B-деревья были изобретены Рудольфом Байером и Эдвардом М. МакКрайтом во время работы в исследовательских лабораториях Boeing с целью эффективного управления индексными страницами для больших файлов с произвольным доступом. Основное предположение заключалось в том, что индексы будут настолько объемными, что только небольшие фрагменты дерева смогут поместиться в основной памяти. Статья Байера и МакКрайта «Организация и обслуживание больших упорядоченных индексов» [1] была впервые распространена в июле 1970 года и позже опубликована в Acta Informatica . [3]
Байер и МакКрайт так и не объяснили, что означает буква B ; Были предложены Боинг , сбалансированный , между , широкий , кустистый и Байер . [4] [5] [6] МакКрайт сказал, что «чем больше вы думаете о том, что означает буква B в B-деревьях, тем лучше вы понимаете B-деревья». [5]
Согласно определению Кнута , B-дерево порядка m — это дерево, удовлетворяющее следующим свойствам: [7]
Ключи каждого внутреннего узла действуют как значения разделения, разделяющие его поддеревья. Например, если внутренний узел имеет 3 дочерних узла (или поддерева), то у него должно быть 2 ключа : 1 и 2 . Все значения в крайнем левом поддереве будут меньше 1 , все значения в среднем поддереве будут находиться в диапазоне от 1 до 2 , а все значения в крайнем правом поддереве будут больше 2 .
B-дерево глубины n +1 может содержать примерно в U раз больше элементов, чем B-дерево глубины n , но стоимость операций поиска, вставки и удаления растет с глубиной дерева. Как и в любом сбалансированном дереве, стоимость растет гораздо медленнее, чем количество элементов.
Некоторые сбалансированные деревья хранят значения только в конечных узлах и используют разные типы узлов для конечных и внутренних узлов. B-деревья сохраняют значения в каждом узле дерева, кроме конечных узлов.
Литература по B-деревьям неоднородна в терминологии. [8]
Байер и МакКрайт (1972), [3] Комер (1979), [2] и другие определяют порядок B-дерева как минимальное количество ключей в некорневом узле. Фолк и Зеллик [9] отмечают, что терминология неоднозначна, поскольку неясно максимальное количество ключей. B-дерево порядка 3 может содержать максимум 6 или максимум 7 ключей. Кнут (1998) избегает этой проблемы, определяя порядок как максимальное количество дочерних элементов (которое на один больше, чем максимальное количество ключей). [7]
Термин «лист» также противоречив. Байер и МакКрайт (1972) [3] считали листовой уровень самым низким уровнем клавиш, но Кнут считал, что листовой уровень находится на один уровень ниже самых нижних клавиш. [10] Существует много возможных вариантов реализации. В некоторых конструкциях листья могут содержать всю запись данных; в других конструкциях листья могут содержать только указатели на записи данных. Этот выбор не является фундаментальным для идеи B-дерева. [11]
Для простоты большинство авторов предполагают, что в узле имеется фиксированное количество ключей. Основное предположение заключается в том, что размер ключа фиксирован и размер узла фиксирован. На практике могут использоваться ключи переменной длины. [12]
Как и другие деревья, B-деревья можно представить как совокупность узлов трех типов: корневых , внутренних (так называемых внутренних) и листовых .
Обратите внимание на следующие определения переменных:
В B-деревьях для этих узлов сохраняются следующие свойства:
Каждый внутренний узел B-дерева имеет следующий формат:
Каждый листовой узел B-дерева имеет следующий формат:
Границы узлов приведены в таблице ниже:
Чтобы поддерживать заранее определенный диапазон дочерних узлов, внутренние узлы можно объединять или разделять.
Обычно количество ключей выбирается в пределах от и , где - минимальное количество ключей, а - минимальная степень или коэффициент ветвления дерева. Коэффициент 2 гарантирует, что узлы можно разделить или объединить.
Если у внутреннего узла есть ключи, то добавление ключа к этому узлу можно выполнить, разделив гипотетический ключевой узел на два ключевых узла и переместив ключ, который находился бы посередине, в родительский узел. Каждый узел разделения имеет необходимое минимальное количество ключей. Аналогично, если у внутреннего узла и его соседа есть ключи, то ключ можно удалить из внутреннего узла, объединив его с его соседом. Удаление ключа приведет к тому, что ключи появятся у внутреннего узла; присоединение к соседу приведет к добавлению ключей плюс еще один ключ, полученный от родителя соседа. В результате получается полностью полный узел ключей.
B-дерево сохраняется сбалансированным после вставки путем разделения потенциально переполненного узла ключей на два родственных узла с -ключами и вставки ключа среднего значения в родительский узел. Глубина увеличивается только тогда, когда корень расщепляется, сохраняя баланс. Аналогично, B-дерево сохраняется сбалансированным после удаления за счет слияния или перераспределения ключей между одноуровневыми узлами для поддержания минимума -key для некорневых узлов. Слияние уменьшает количество ключей в родительском элементе, потенциально вынуждая его объединять или перераспределять ключи с его родственными элементами и так далее. Единственное изменение глубины происходит, когда у корня есть два дочерних элемента, ключи и (переходные) , и в этом случае два родственных элемента и родительский элемент объединяются, уменьшая глубину на один.
Эта глубина будет медленно увеличиваться по мере добавления элементов в дерево, но увеличение общей глубины происходит нечасто и приводит к тому, что все конечные узлы оказываются на один узел дальше от корня.
Поскольку разрешен диапазон дочерних узлов, B-деревья не требуют повторной балансировки так часто, как другие самобалансирующиеся деревья поиска, но могут тратить некоторое пространство, поскольку узлы не полностью заполнены.
B-деревья имеют существенные преимущества перед альтернативными реализациями, когда время доступа к данным узла значительно превышает время, затрачиваемое на обработку этих данных, поскольку тогда стоимость доступа к узлу может быть амортизирована за счет нескольких операций внутри узла. Обычно это происходит, когда данные узла находятся во вторичном хранилище , например на дисках . За счет максимального увеличения количества ключей внутри каждого внутреннего узла уменьшается высота дерева и уменьшается количество дорогостоящих обращений к узлу. Кроме того, ребалансировка дерева происходит реже. Максимальное количество дочерних узлов зависит от информации, которую необходимо хранить для каждого дочернего узла, а также размера полного дискового блока или аналогичного размера во вторичном хранилище. Хотя 2–3 B-дерева объяснить проще, практические B-деревья, использующие вторичное хранилище, требуют большого количества дочерних узлов для повышения производительности.
Термин B-дерево может относиться к конкретному проекту или к общему классу проектов. В узком смысле B-дерево хранит ключи в своих внутренних узлах, но не обязано хранить эти ключи в записях на листьях. Общий класс включает такие разновидности, как дерево B+ , дерево B * и дерево B *+ .
Обычно алгоритмы сортировки и поиска характеризуются количеством операций сравнения, которые необходимо выполнить с использованием порядковой записи . Например, двоичный поиск в отсортированной таблице с N записями можно выполнить примерно за ⌈ log 2 N ⌉ сравнений . Если бы в таблице было 1 000 000 записей, то конкретную запись можно было бы найти не более чем с 20 сравнениями: ⌈ log 2 (1 000 000) ⌉ = 20 .
Большие базы данных исторически хранились на дисках. Время чтения записи на диске намного превышает время, необходимое для сравнения ключей после того, как запись станет доступна. Время чтения записи с жесткого диска включает время поиска и задержку вращения. Время поиска может составлять от 0 до 20 или более миллисекунд, а задержка вращения составляет в среднем около половины периода вращения. Для привода со скоростью 7200 об/мин период вращения составляет 8,33 миллисекунды. Для такого накопителя, как Seagate ST3500320NS, время поиска между дорожками составляет 0,8 миллисекунды, а среднее время поиска при чтении — 8,5 миллисекунды. [18] Для простоты предположим, что чтение с диска занимает около 10 миллисекунд.
Тогда, по наивности, время, чтобы найти одну запись из миллиона, заняло бы 20 операций чтения с диска, умноженных на 10 миллисекунд на каждое чтение с диска, что составляет 0,2 секунды.
Время будет не таким уж плохим, поскольку отдельные записи группируются в дисковый блок . Блок диска может иметь размер 16 килобайт. Если каждая запись имеет размер 160 байт, то в каждом блоке можно хранить 100 записей. Вышеупомянутое время чтения диска фактически относилось к целому блоку. Как только головка диска окажется на месте, один или несколько блоков диска можно будет прочитать с небольшой задержкой. При 100 записях в блоке последние 6 или около того сравнений не требуют чтения с диска — все сравнения выполняются в пределах последнего прочитанного блока диска.
Чтобы ускорить поиск, необходимо ускорить первые 13–14 сравнений (каждое из которых требует доступа к диску).
Значительного улучшения производительности можно добиться с помощью индекса B-дерева . Индекс B-дерева создает многоуровневую древовидную структуру, которая разбивает базу данных на блоки или страницы фиксированного размера. Каждый уровень этого дерева можно использовать для связи этих страниц через адрес, позволяя одной странице (известной как узел или внутренняя страница) ссылаться на другую с конечными страницами на самом низком уровне. Одна страница обычно является начальной точкой дерева или «корнем». Именно здесь начнется поиск определенного ключа, проходя путь, заканчивающийся листом. Большинство страниц в этой структуре будут листовыми страницами, которые в конечном итоге ссылаются на определенные строки таблицы.
Поскольку каждый узел (или внутренняя страница) может иметь более двух дочерних элементов, индекс B-дерева обычно имеет меньшую высоту (расстояние от корня до самого дальнего листа), чем двоичное дерево поиска. В приведенном выше примере начальное чтение с диска сузило диапазон поиска в два раза. Это можно существенно улучшить, создав вспомогательный индекс, содержащий первую запись в каждом блоке диска (иногда называемый разреженным индексом ). Этот вспомогательный индекс будет составлять 1% от размера исходной базы данных, но поиск по нему можно выполнить быстрее. Обнаружение записи во вспомогательном индексе подскажет нам, какой блок искать в основной базе данных; после поиска по вспомогательному индексу нам придется искать только этот один блок основной базы данных — ценой еще одного чтения с диска. Индекс будет содержать 10 000 записей, поэтому потребуется не более 14 сравнений. Как и в основной базе данных, последние шесть или около того сравнений во вспомогательном индексе будут находиться в одном и том же дисковом блоке. Поиск по индексу можно было выполнить примерно за восемь операций чтения с диска, а доступ к нужной записи можно было получить за 9 операций чтения с диска.
Трюк с созданием вспомогательного индекса можно повторить, чтобы создать вспомогательный индекс для вспомогательного индекса. В результате получится индекс aux-aux, который потребует всего 100 записей и поместится в один дисковый блок.
Вместо чтения 14 дисковых блоков для поиска нужной записи нам нужно прочитать всего 3 блока. Эта блокировка является основной идеей создания B-дерева, в котором блоки диска заполняют иерархию уровней, образуя индекс. Чтение и поиск первого (и единственного) блока индекса aux-aux, который является корнем дерева, идентифицирует соответствующий блок в индексе aux-aux на уровне ниже. Чтение и поиск в этом блоке вспомогательного индекса идентифицирует соответствующий блок для чтения до тех пор, пока последний уровень, известный как конечный уровень, не идентифицирует запись в основной базе данных. Вместо 150 миллисекунд нам нужно всего 30 миллисекунд, чтобы получить запись.
Вспомогательные индексы превратили задачу поиска из двоичного поиска, требующего примерно log 2 N операций чтения с диска, в задачу, требующую только log b N операций чтения с диска, где b — коэффициент блокировки (количество записей в блоке: b = 100 записей на блок в нашем пример: log 100 1 000 000 = 3 чтения).
На практике, если в основной базе данных часто выполняется поиск, индекс aux-aux и большая часть индекса aux могут находиться в дисковом кэше , поэтому они не будут требовать чтения с диска. B-дерево остается стандартной реализацией индекса почти во всех реляционных базах данных , и многие нереляционные базы данных также используют его. [19]
Если база данных не изменяется, то составить индекс просто, и индекс никогда не нужно менять. Если есть изменения, то управление базой данных и ее индексом усложняется.
Удалить записи из базы данных относительно легко. Индекс может остаться прежним, а запись можно просто пометить как удаленную. База данных остается в отсортированном порядке. Если происходит большое количество ленивых удалений , то поиск и хранение становятся менее эффективными. [20]
Вставки в отсортированный последовательный файл могут быть очень медленными, поскольку необходимо освободить место для вставленной записи. Вставка записи перед первой записью требует сдвига всех записей на одну вниз. Такая операция слишком дорога, чтобы быть практичной. Одно из решений — оставить немного пробелов. Вместо того, чтобы плотно упаковывать все записи в блок, в блоке может быть некоторое свободное пространство для последующих вставок. Эти пробелы будут помечены как «удаленные» записи.
И вставка, и удаление выполняются быстро, пока в блоке доступно место. Если вставка не помещается в блок, то необходимо найти свободное место в каком-нибудь соседнем блоке и откорректировать вспомогательные индексы. Есть надежда, что поблизости будет достаточно места, и многие блоки не придется реорганизовывать. В качестве альтернативы можно использовать некоторые дисковые блоки, расположенные вне последовательности. [19]
B-дерево использует все идеи, описанные выше. В частности, B-дерево:
Кроме того, B-дерево минимизирует потери, гарантируя, что внутренние узлы заполнены как минимум наполовину. B-дерево может обрабатывать произвольное количество вставок и удалений. [19]
Пусть h ≥ –1 будет высотой классического B-дерева (см. Дерево (структура данных) § Терминология определения высоты дерева). Пусть n ≥ 0 — количество записей в дереве. Пусть m — максимальное количество дочерних элементов, которое может иметь узел. Каждый узел может иметь не более m −1 ключей.
Можно показать (например, по индукции), что B-дерево высоты h со всеми полностью заполненными узлами имеет n = m h +1 –1 записей. Следовательно, наилучшая высота (т.е. минимальная высота) B-дерева равна:
Пусть — минимальное количество дочерних элементов, которое должно иметь внутренний (некорневой) узел. Для обычного B-дерева
Комер (1979) и Кормен и др. (2001) дают высоту наихудшего случая (максимальную высоту) B-дерева как [21]
Поиск аналогичен поиску в бинарном дереве поиска. Начиная с корня, дерево рекурсивно обходит сверху вниз. На каждом уровне поиск сводит свое поле зрения до дочернего указателя (поддерева), диапазон которого включает искомое значение. Диапазон поддерева определяется значениями или ключами, содержащимися в его родительском узле. Эти предельные значения также известны как значения разделения.
Бинарный поиск обычно (но не обязательно) используется внутри узлов для поиска значений разделения и интересующего дочернего дерева.
Все вставки начинаются с листового узла. Чтобы вставить новый элемент, выполните поиск в дереве, чтобы найти листовой узел, куда следует добавить новый элемент. Вставьте новый элемент в этот узел, выполнив следующие действия:
Если разделение идет вплоть до корня, создается новый корень с одним значением разделителя и двумя дочерними узлами, поэтому нижняя граница размера внутренних узлов не применяется к корню. Максимальное количество элементов на узел равно U −1. При разделении узла один элемент перемещается к родительскому элементу, но добавляется один элемент. Таким образом, должна быть возможность разделить максимальное количество элементов U −1 на два допустимых узла. Если это число нечетное, то U =2 L и один из новых узлов содержит ( U −2)/2 = L −1 элементов и, следовательно, является допустимым узлом, а другой содержит еще один элемент и, следовательно, законный тоже. Если U −1 четно, то U =2 L −1, поэтому в узле 2 L −2 элемента. Половина этого числа равна L −1, что представляет собой минимальное количество элементов, разрешенное на узел.
Альтернативный алгоритм поддерживает один проход вниз по дереву от корня до узла, в который будет осуществляться вставка, упреждающе разбивая любые полные узлы, встречающиеся на пути. Это предотвращает необходимость вызова родительских узлов в память, что может быть дорогостоящим, если узлы находятся во вторичном хранилище. Однако, чтобы использовать этот алгоритм, мы должны иметь возможность отправить один элемент родительскому элементу и разделить оставшиеся элементы U -2 на два допустимых узла без добавления нового элемента. Для этого требуется U = 2 L , а не U = 2 L −1, что объясняет, почему некоторые [ какие? ] учебники налагают это требование при определении B-деревьев.
Существует две популярные стратегии удаления из B-дерева.
Алгоритм ниже использует прежнюю стратегию.
При удалении элемента следует учитывать два особых случая:
Порядок действий в этих случаях приведен ниже.
Каждый элемент во внутреннем узле действует как значение разделения для двух поддеревьев, поэтому нам нужно найти замену разделения. Обратите внимание, что самый большой элемент в левом поддереве все еще меньше разделителя. Аналогично, наименьший элемент в правом поддереве по-прежнему больше разделителя. Оба этих элемента находятся в конечных узлах, и любой из них может быть новым разделителем двух поддеревьев. Алгоритмически описано ниже:
Восстановление баланса начинается с листа и продолжается по направлению к корню, пока дерево не будет сбалансировано. Если удаление элемента из узла привело к тому, что его размер стал меньше минимального, то некоторые элементы необходимо перераспределить, чтобы довести все узлы до минимального. Обычно перераспределение включает в себя перемещение элемента из одноуровневого узла, число узлов которого превышает минимальное. Эта операция перераспределения называется ротацией . Если ни один из одноуровневых узлов не может сохранить элемент, то дефектный узел необходимо объединить с одноуровневым. Слияние приводит к потере родительского элемента-разделителя, поэтому родительский элемент может стать несовершенным и потребовать повторной балансировки. Слияние и ребалансировка могут продолжаться вплоть до корня. Поскольку минимальное количество элементов не применимо к корню, сделать корень единственным недостающим узлом не является проблемой. Алгоритм ребалансировки дерева следующий:
Хотя недавно загруженные базы данных, как правило, имеют хорошее последовательное поведение, такое поведение становится все труднее поддерживать по мере роста базы данных, что приводит к увеличению числа случайных операций ввода-вывода и проблемам с производительностью. [23]
Распространенным особым случаем является добавление большого количества предварительно отсортированных данных в изначально пустое B-дерево. Хотя вполне возможно просто выполнить серию последовательных вставок, вставка отсортированных данных приводит к созданию дерева, почти полностью состоящего из наполовину заполненных узлов. Вместо этого можно использовать специальный алгоритм «массовой загрузки» для создания более эффективного дерева с более высоким коэффициентом ветвления.
Когда входные данные отсортированы, все вставки находятся на самом правом краю дерева, и, в частности, каждый раз, когда узел разбивается, мы гарантируем, что в левой половине больше не будет вставок. При массовой загрузке мы этим пользуемся и вместо того, чтобы разбивать переполняющиеся узлы поровну, разбиваем их максимально неравномерно : оставляем левый узел полностью заполненным и создаем правый узел с нулевыми ключами и одним дочерним (в нарушение обычного B- правила дерева).
В конце массовой загрузки дерево почти полностью состоит из полностью заполненных узлов; только самый правый узел на каждом уровне может быть менее заполнен. Поскольку эти узлы также могут быть заполнены менее чем наполовину , чтобы восстановить обычные правила B-дерева, объедините такие узлы с их (гарантированно заполненными) левыми братьями и сестрами и разделите ключи, чтобы получить два узла, заполненных как минимум наполовину. Единственный узел, у которого отсутствует полный левый брат, — это корень, который может быть заполнен менее чем наполовину.
Помимо использования в базах данных, B-дерево (или § варианты) также используется в файловых системах для обеспечения быстрого произвольного доступа к произвольному блоку в конкретном файле. Основная проблема заключается в преобразовании адреса блока файла в адрес блока диска.
Некоторые операционные системы требуют, чтобы пользователь выделил максимальный размер файла при его создании. Затем файл можно разместить в виде смежных дисковых блоков. В этом случае, чтобы преобразовать адрес блока файла в адрес блока диска, операционная система просто добавляет адрес блока файла к адресу первого блока диска, составляющего файл. Схема проста, но файл не может превышать созданный размер.
Другие операционные системы позволяют файлу расти. Получающиеся в результате блоки диска могут не быть смежными, поэтому сопоставление логических блоков с физическими блоками является более сложным.
MS-DOS , например, использовала простую таблицу размещения файлов (FAT). В FAT есть запись для каждого дискового блока [примечание 1] , и эта запись определяет, используется ли этот блок файлом, и если да, то какой блок (если есть) является следующим дисковым блоком того же файла. Таким образом, размещение каждого файла представлено в виде связанного списка в таблице. Чтобы найти дисковый адрес блока файла , операционная система (или дисковая утилита) должна последовательно следовать связанному списку файла в FAT. Хуже того, чтобы найти свободный блок диска, ему приходится последовательно сканировать FAT. Для MS-DOS это не было большим штрафом, поскольку диски и файлы были небольшими, а в FAT было мало записей и относительно короткие цепочки файлов. В файловой системе FAT12 (использовавшейся на дискетах и первых жестких дисках) было не более 4080 [примечание 2] записей, и FAT обычно находилась в памяти. По мере того, как диски становились больше, архитектура FAT начала сталкиваться со штрафами. На большом диске с использованием FAT может потребоваться выполнить чтение с диска, чтобы узнать расположение на диске блока файла, который нужно прочитать или записать.
В TOPS-20 (и, возможно, TENEX ) использовалось дерево уровней от 0 до 2, имеющее сходство с B - деревом . Дисковый блок состоял из 512 36-битных слов. Если файл помещается в блок слов размером 512 (2 9 ), то каталог файла будет указывать на этот физический блок диска. Если файл умещается в 218 слов , то каталог будет указывать на дополнительный индекс; 512 слов этого индекса будут либо иметь значение NULL (блок не выделен), либо указывать на физический адрес блока. Если файл умещается в 2 27 слов, то каталог будет указывать на блок, содержащий индекс aux-aux; каждая запись будет либо иметь значение NULL, либо указывать на дополнительный индекс. Следовательно, блок физического диска для файла размером 2 27 слов может быть расположен в двух операциях чтения с диска и прочитан в третьей.
Файловые системы HFS+ и APFS от Apple , NTFS от Microsoft , [24] AIX (jfs2) и некоторые файловые системы Linux , такие как Bcachefs , Btrfs и ext4 , используют B-деревья.
B * -деревья используются в файловых системах HFS и Reiser4 .
Файловая система HAMMER DragonFly BSD использует модифицированное B+-дерево. [25]
B-дерево растет медленнее с ростом объема данных, чем линейность связанного списка. По сравнению со списком пропуска обе структуры имеют одинаковую производительность, но B-дерево лучше масштабируется для роста n . T -дерево для систем баз данных в основной памяти аналогично, но более компактно.
Леман и Яо [26] показали, что всех блокировок чтения можно избежать (и, таким образом, значительно улучшить одновременный доступ), связывая блоки дерева на каждом уровне вместе указателем «следующий». В результате получается древовидная структура, в которой операции вставки и поиска спускаются от корня к листу. Блокировки записи требуются только при изменении блока дерева. Это максимизирует параллельный доступ нескольких пользователей, что важно для баз данных и/или других методов хранения ISAM на основе B-дерева . Цена, связанная с этим улучшением, заключается в том, что пустые страницы невозможно удалить из btree во время обычных операций. (Однако различные стратегии реализации слияния узлов и исходный код см. в [ 27 ] ).
Патент США 5283894, выданный в 1994 году, по-видимому, показывает способ использования «метода метадоступа» [29] для обеспечения одновременного доступа и модификации дерева B+ без блокировок. Этот метод осуществляет доступ к дереву «вверх» как для поиска, так и для обновления с помощью дополнительных индексов в памяти, которые указывают на блоки на каждом уровне блочного кэша. Никакой реорганизации для удаления не требуется, и в каждом блоке нет указателей «следующий», как в Lehman и Yao.
Поскольку B-деревья по структуре аналогичны красно-черным деревьям , параллельные алгоритмы для красно-черных деревьев можно применять и к B-деревьям.
Дерево Maple — это B-дерево, разработанное для использования в ядре Linux для уменьшения конфликтов блокировок при управлении виртуальной памятью. [30] [31] [32]
Массовая загрузка