As árvores B são estruturas de armazenamento em memória secundária que tem por objetivo melhorar a eficiência das operações em Arquivo. As árvores B são uma forma bastante comum de implementação de índices em SGBDs
Árvores B são sempre balanceadas, afim de garantir um melhor desempenho. Note que, como a árvore é balanceada, todos os nós folha estão no mesmo nível da árvore. Cada nó tem o tamanho de uma página de disco. Isso garante que é necessário um único acesso a disco para carregar um nó da árvore na memória principal.
Além disso, as árvores B devem, por definição, manter as seguintes propriedades:
- Cada nó armazena chaves sempre em ordem crescente, tal que .
- Cada nó possui um atributo que indica se o dado nó é um nó interno ou uma folha da árvore.
- Cada nó interno possui ponteiros para os nós filhos.
- As chaves separam os intervalos de chaves armazenados em cada subárvore. Dessa forma, um filho entre as chaves e armazena as chaves no intervalo .
- Todos os nós folha estão no mesmo nível da árvore.
- Uma árvore B é definida por uma ordem mínima , cujo valor depende do tamanho da página de disco.
- Todo nó, exceto a raiz, deve conter no mínimo chaves, e portanto deve ter no mínimo filhos.
- Todo nó pode conter no máximo chaves, e portanto pode ter no máximo filhos.
O número de acessos a disco feito pelas operações de uma árvore B é proporcional à altura da árvore. Dessa forma, dada uma árvore B com chaves, altura e ordem mínima , então:
Busca
A busca em árvores B se dá de maneira muito similar à busca em árvores binárias. A principal diferença é que, enquanto a busca em uma árvore binária consiste em tomar uma decisão binária em cada nó da árvore (pois há apenas dois filhos para se escolher), a busca em uma árvore B consiste em uma decisão entre os nós filhos de cada nó.
O algoritmo de busca pode ser descrito na forma de pseudocódigo como a seguir:
b-tree-search(root, key)
i = 1
while i <= root.n and key > root.keys[i]
i = i + 1
if i <= root.n and key = root.keys[i] then
return (root, i)
if root.leaf then
return NIL
else
read(root.children[i])
return b-tree-search(root.children[i])
A busca de uma chave key
em uma subárvore root
consiste em primeiramente fazer uma busca entre as chaves do nó atual. Caso a chave seja encontrada o ponteiro para o nó e o índice da chave no nó são retornados. Caso a busca não encontre a chave no nó, é necessário decidir para qual filho (caso o nó não seja um nó folha, pois nesse caso a chave não está presente na árvore e a busca retorna NIL
) a busca prosseguirá, o que pode ser feito encontrando o intervalo entre duas chaves no qual a chave a ser buscada está contida. Note que, antes de continuar a busca para o próximo nó, é necessário ler o nó do disco.
O número de páginas de disco acessadas pela busca é proporcional à altura da árvore, ou seja, . Note que a busca dentro do nó tem uma complexidade no caso da busca sequencial, portanto a complexidade total do algoritmo é dada por .
Criação
A criação de uma árvore B vazia é tão simples quanto alocar um novo nó, setar os parâmetros corretamente e escrever o nó na memória secundária.
b-tree-create(tree)
x = alloc-node()
x.leaf = TRUE
x.n = 0
write(x)
tree.root = x
Note que essa operação pode ser realizada em tempo constante tanto em acessos a disco quanto em complexidade do algoritmo.
Inserção
A inserção de uma nova chave em uma árvore B é um processo complexo, que envolve não só adicionar a nova chave em um nó, mas fazer isso mantendo a árvore balanceada.
A inserção não deve criar um novo nó folha, pois isso tornaria a árvore desbalanceada. Tendo isso em vista, as chaves são sempre inseridas nos nós folha existentes. Entretanto, quando um nó folha está cheio não é possível adicionar mais chaves a ele, nesse caso é necessário dividir o nó para então adicionar a chave em um dos nós resultantes da divisão.
Note que, para manter a árvore balanceada, uma árvore B só cresce em altura pela raiz, nunca pelas folhas. A única maneira de aumentar a altura de uma árvore B é dividindo sua raiz e adicionando um novo nó pai para a raiz.
Dado um nó cheio (com chaves), é possível dividi-lo em sua chave mediana em dois nós com chaves cada. A chave mediana é movida para o nó pai, identificando o ponto de divisão entre os dois novos nós. Caso o nó pai esteja também cheio, é necessário propagar a operação para o nó pai. Note que o processo de divisão pode se propagar até a raiz da árvore, o que pode tornar necessário percorrer a árvore duas vezes para uma inserção: uma primeira vez para encontrar o nó folha a inserir a chave, e uma segunda vez para dividir os nós pais até a raiz.
Para garantir que todas as inserções percorrem a árvore apenas uma vez, é possível dividir os nós cheios durante a busca pelo nó folha no qual a chave será inserida. Dessa forma, todas as operações de divisão de nós podem assumir que o nó pai não está cheio, pois sempre que um nó cheio é visitado no processo de inserção ele é dividido.
b-tree-split(parent, node, i)
newnode = alloc-node()
newnode.leaf = node.leaf
newnode.n = t - 1
for j = 1 to t - 1 do
newnode.keys[j] = node.keys[j + t]
if not node.leaf then
for j = 1 to t do
newnode.children[j] = node.children[j + t]
node.n = t - 1
for j = parent.n + 1 downto t do
parent.children[j + 1] = parent.children[j]
parent.children[i + 1] = newnode
for j = parent.n downto i do
parent.keys[j + 1] = parent.keys[j]
parent.keys[i] = node.keys[t]
parent.n = parent.n + 1
write(node)
write(newnode)
write(parent)
Primeiramente definimos um algoritmo para dividir um nó. São necessários três parâmetros, o nó pai do nó sendo dividido parent
, o nó a ser dividido node
e o índice do nó a ser dividido na lista de nós filhos do nó pai. A ideia do algoritmo é construir um novo nó newnode
com metade das chaves de node
, adicionar a chave mediana na lista de chaves do nó pai parent
para ser a chave que divide os nós node
e newnode
na lista de filhos, e então adicionar o novo nó à lista de filhos do nó pai.
Definido o procedimento de divisão de um nó, podemos definir a inserção de fato:
b-tree-insert(tree, key)
root = T.root
if root.n = 2t - 1 then
newroot = alloc-node()
newroot.leaf = FALSE
newroot.n = 0
newroot.children[1] = root
T.root = newroot
b-tree-split(newroot, root, 1)
b-tree-insert-nonfull(newroot, key)
else
b-tree-insert-nonfull(root, key)
O algoritmo principal de inserção recebe como parâmetros a árvore tree
e a chave key
a ser inserida. Note que as inserções sempre começam a busca pela raiz da árvore, portanto é necessário lidar com o caso particular de a raiz da árvore estar cheia, e nesse caso ela deve ser dividida.
Agora é necessário definir o algoritmo b-tree-insert-nonfull
, que adiciona chaves em uma subárvore presumindo que o nó pai nunca está cheio.
b-tree-insert-nonfull(node, key)
i = node.n
if node.leaf then
while i >= 1 and key < node.keys[i] do
node.keys[i + 1] = node.keys[i]
i = i - 1
node.keys[i + 1] = key
node.n = node.n + 1
write(node)
else
while i >= 1 and key < node.keys[i] do
i = i - 1
i = i + 1
read(node.children[i])
if(node.children[i].n = 2t - 1) then
b-tree-split-child(node, node.children[i], i)
if key > node.keys[i] then
i = i + 1
b-tree-insert-nonfull(node.children[i], key)
Caso o nó atual seja uma folha, então a chave deve ser inserida nesse mesmo nó. Caso contrário, é necessário determinar qual nó filho de node
será escolhido para continuar a busca. O nó escolhido deve então ser lido da memória secundária e então, caso ele esteja cheio, dividido. Após isso, o algoritmo continua a busca recursivamente no nó filho.
O número de páginas de disco acessadas na operação de inserção é proporcional à altura da árvore, ou seja, . Note que a busca dentro do nó tem uma complexidade no caso da busca sequencial, portanto a complexidade total do algoritmo é dada por .
Remoção
A remoção de chaves de uma árvore B é um processo complexo, sendo necessário tratar muitos casos para garantir o balanceamento da árvore.
Ao contrário da inserção, a remoção não ocorre apenas nas folhas, isto é, qualquer chave de qualquer nó pode ser removida. Além disso, é necessário garantir que uma chave não seja removida de um nó que já possui o número mínimo de chaves.
Para manter a propriedade de que todo nó (exceto o nó raiz) deve conter no mínimo chaves, a operação de remoção deve ser estrutura de forma a garantir que, quando aplicada a um nó da árvore, este nó sempre contém no mínimo chaves. Diversos casos precisam ser tratados para garantir essa propriedade, manipulando a árvore de forma a sempre garantir que o procedimento só será aplicado recursivamente em um nó que satisfaça a propriedade de conter no mínimo chaves.
Os possíveis casos para a remoção de uma chave em uma árvore B são listados a seguir:
- Se a chave está no nó e o nó é um nó folha, remova a chave de .
- Se a chave está no nó e o nó é um nó interno, então:
- Se o nó filho que precede no nó tem pelo menos chaves, encontre a chave predecessora de na subárvore enraizada em . Remova recursivamente e substitua por em .
- Simetricamente, se o nó filho que sucede no nó tem pelo menos chaves, encontre a chave sucessora de na subárvore enraizada em . Remova recursivamente e substitua por em .
- Caso contrário, se tanto quanto tiverem apenas chaves, concatene e todas as chaves de com o nó , fazendo com que perca tanto a chave quanto o ponteiro para . Agora, com contendo chaves, remova o nó da memória e remova recursivamente a chave no nó .
- Se a chave não está no nó , determine em qual nó filho de a subárvore que contém está enraizada. Se contém apenas chaves, execute os casos ou conforme necessário para garantir que contenha no mínimo chaves. Então continue o procedimento recursivamente para remover de .
- Se tem apenas chaves mas há um irmão imediato com no mínimo chaves, adicione uma nova chave a movendo uma chave de para , e então mova uma chave do irmão imediato de para , e ajuste os ponteiros de acordo.
- Se e ambos os seus irmãos imediatos tem apenas chaves, concatene com um de seus irmãos, movendo também a chave que separava os nós em para o nó resultante, que vai atuar como chave mediana do nó.
Veja então que é possível remover qualquer chave de uma árvore B passando apenas uma vez pela árvore, sem retornar para nós superiores (com exceção dos casos e ). Note também que no caso é possível que seja o nó raiz da árvore; nesse caso, se o nó raiz armazenar apenas uma chave, ele é removido e a altura da árvore é reduzida.
Vale notar que como a maioria das chaves de uma árvore B estão nas folhas, em média as operações de remoção ocorrem com mais frequência nas folhas.
O número de páginas de disco acessadas na operação de remoção é proporcional à altura da árvore, ou seja, . Note que a busca dentro do nó tem uma complexidade no caso da busca sequencial, portanto a complexidade total do algoritmo é dada por .