Árboles B Inserción Y División

Árboles B: Inserción y División

Inserción

Una inserción en un nodo de árbol B implica:

  • Los elementos se insertan en una hoja (salvo excepciones).
    • Si el nodo no es una hoja, tendremos que irnos al hijo más prometedor e intentarlo de nuevo
    • La inserción en una hoja es un proceso sencillo, en el que se inserta de manera ordenada el valor en el array de claves
  • Hay que respetar el grado del árbol: si al meter el elemento, el array de claves tiene demasiadas claves, hay que dividir el nodo

En un árbol B dependiendo de la implementación se permite guardar elementos duplicados o no

Al insertar un elemento hay que tener en cuenta las propiedades de un árbol B vistas anteriormente, aquí te muestro un ejemplo de el número de hijos y de claves mínimos para diferentes árboles de grado 5, 6, y 7.

// acordarse que cuando es decimal
//  el redondeo es hacia arriba
N = 5 
- min punteros: N / 2 = 2.5 -> 3
- min claves: N / 2 - 1 = 1.5 -> 2

N = 6
- min punteros: N / 2 = 3
- min claves: N / 2 - 1 = 2

N = 7
- min punteros: N / 2 = 3.5 -> 4
- min claves: N / 2 - 1 = 2.5 -> 3

N = 5 
- Max punteros: = 5
- max claves: 5 - 1 = 4

N = 6
- max punteros = 6
- min claves = 6 - 1 = 5

N = 7
- max punteros = 7
- min claves = 6

Inserción en nodo hoja

Cuando el nodo, es un nodo hoja, es tan fácil como recorrer las claves e introducir la nueva clave en la posición correcta (para que las claves sigan ordenadas)

image.png

image.png

Inserción en nodo con hijos

Si su nodo, no es un nodo hoja, no podemos realizar una inserción directa en ese nodo, en su lugar lo que hay que hacer es buscar para el nodo, el nodo hijo más prometedor y realizar esto de manera recursiva en el nuevo nodo elegido.

Buscamos cual es el nodo más prometedor, para ello se pueden definir distintos algoritmos para recorrer el árbol, por simplicidad se sigue mostrando como ejemplo la búsqueda linea.

Al igual que hacíamos en la búsqueda, iteramos sobre las claves accediendo a los hijos dependiendo de si la clave es mayor o menor que la siguiente. En este caso al llegar a la clave 90 y al ser mayor, accederíamos al hijo izquierdo de este cuyas claves son menores

image.png

En el proceso de inserción podemos llegar al caso, en que la clave ya exista en el nodo. Y según como funcione nuestro árbol(si acepta duplicados o no) el comportamiento será diferente.

image.png

Árbol sin duplicados

En un árbol sin duplicados no se puede tener dos claves que tengan el mismo valor. Por lo tanto si nuestro árbol no acepta duplicados y nos encontramos en este caso, es que el nodo ya tiene una clave que vale igual a lo que queremos insertar, así que se rechaza la operación y se lanza una excepción dado que no se permite.

image.png

Árbol con duplicados

Un árbol que acepta duplicados es un árbol en el que puedes insertar múltiples ocurrencias de una misma clave dentro del árbol sin que pase nada.

Un aspecto interesante de la inserción con duplicado es que cuando decidimos cual es el nodo descendiente sobre el que tenemos que hacer la inserción, si somos consistentes, dará bastante igual si nos vamos por el hijo derecho o por el hijo izquierdo. Porque el descendiente izquierdo contendrá elementos entre 54 y 74 y el hijo derecho valores entre el 74 y el 90 que también nos vale, para ser consistentes siempre tendremos que tirar por la misma estrategia

Si sabemos que esto de que se repitan nodos se va a dar de manera frecuente quizás esta estrategia no sea la mejor

Dado que un árbol B es ideal cuando vamos a almacenar información en circunstancias en las que nos interesa es el menor número posible de accesos a disco para ir sacando la información. Por ejemplo los árboles B se usan mucho en base de datos donde cada uno de estos nodos es un sector del disco duro, el cual lleva un tiempo de acceso que aunque no sea mucho si que influye bastante en la operación de búsqueda y puede hacer que sea mucho más lenta la ejecución de una query.

Por esta razón insertar duplicados en un árbol lo hará crecer hacia abajo y lo único que provocará es que el número de nodos que hay que emplear para almacenar esta estructura crezca bastante, lo que se acaba traduciendo en un mayor espacio en disco y mayor tiempo de acceso, por otro lado aumenta la cantidad de operaciones de división al hacer que los nodos crezcan, y si podemos evitarlo pues será mejor.

En vez de estar insertando claves, nos sale mas economico tener un metadato adicional en nuestra estructura, en el que se indique el número de repeticiones que aparece una clave dentro de un nodo

image.png

Ahora cada clave tiene el número de repeticiones de cada clave

Ahora cuando se encuentre una clave con ese valor, lo único que habría que hacer es actualizar el metadato de esa clave aumentando el número de repeticiones de esta

image.png

En este ejemplo queremos insertar el valor 86 en este árbol B

Buscamos cual es el nodo más prometedor, para ello se pueden definir distintos algoritmos para recorrer el árbol, por simplicidad se sigue mostrando como ejemplo la búsqueda linea. En este caso empezaríamos por la raíz recorriendo sus claves y accediendo a los hijos más prometedores

image.png

Empezamos por el nodo raíz y viajaremos por el hijo derecho dado que la clave a insertar es mayor que la clave del nodo raíz

image.png

Volvemos a realizar la misma operación de búsqueda en el nodo hijo. Y en este caso como todas las claves son mejores que la que queremos insertar, nos moveremos al hijo derecho de la última clave

image.png

Al tratarse ya de un nodo hoja, insertamos el elemento

image.png

División

Aquí se explica en términos generales una forma de realizar la división, pero en código no se utiliza las siguientes formas, para ver la implementación de la división a utilizar, mirar en el apartado de pseudo código

N = orden

L = Lower = Mínimo de keys de este árbol

U = Upper = Máximo de keys para este árbol

image.png

Imaginemos que queremos insertar una nueva clave en este nodo, pero el nodo ya tiene el máximo de keys permitidas

image.png

Ahora el nodo supera el máximo de claves permitidas

image.png

Para dividir el nodo buscamos un punto medio al que vamos a llamar pivote, y dividimos el nodo en otros dos subnodos, un subnodo contendrá elementos menores que el valor pivote y otro subnodo contendrá elementos mayores al pivote

image.png

Como cada nodo resultante tiene que ser válido es evidente, que cada nodo tiene que tener el mínimo de claves permitidas (L) al menos.

image.png

Esto significa que la primera clave que se puede usar como pivote es L + 1 = K, por que eso significa que dejará los primeros L elementos como parte del primer subnodo. Por otra parte el subnodo derecho está formado por L + 2 hasta U + 1 (por que teniamos 6 claves máximas y ahora tenemos 7)

image.png

Normalmente nunca vamos a tener esta división tan rara como la que acabamos de hacer, por que nos sale mucho mas económico reutilizar uno de los nodos que ya tenemos y simplemente trasplantar las claves de uno de los subnodos a un nodo recién creado.

image.png

Una vez tengamos esos nodos transplantados en el nuevo nodo

image.png

Los quitaremos del nodo principal

image.png

Luego separaremos del nodo, el pivote, que lo insertaremos como elemento nuevo dentro del nodo padre

image.png

Hasta ahora hemos tratado a los arboles como una estructura recursiva, es que ahora tengo que guardar una referencia a los padres? La respuesta es no, hay un pequeño truco para enviar un elemento al padre sin tener que fabricar una referencia explicita que veremos en el pseudo código.

Inserción pivote (extremo del nodo padre)

Ejemplo:

Imaginamos que queremos insertar el valor 35 dentro de este árbol

image.png

El árbol quedaría así, pero como tiene más claves de las permitidas habrá que hacer una division

image.png

Al hacer la división, se forma un nuevo nodo (35) que de momento no enlaza con nada, y hemos sacado el pivote (28). Para terminar la división insertamos el 28 como nuevo elemento del nodo padre

image.png

Como se muestra, primero se transfiere sin ningún puntero, el resto de elementos que ya estaban deben mantener sus referencias (tanto el hijo izquierdo como el derecho)

image.png

Por último conectamos el nuevo nodo, como hijo derecho del pivote

image.png

Inserción pivote (En medio del nodo padre)

Vamos a ver lo mismo pero ahora tendremos que insertar el pivote en medio de las claves del nodo padre:

Imaginemos que debemos insertar el valor 41 dentro de este nodo padre

image.png

Debemos insertar la clave respetando el orden por lo que la clave 41 se insertará en la posición 3. Todas las claves de la derecha se verán desplazadas

image.png

Pero también todos los hijos derechos deberán ser empujados una posición a la derecha

image.png

Entonces ya podremos meter el pivote

image.png

Por último pondremos el hijo derecho a la nueva clave creada (41)

image.png

Hay que tener en cuenta que el nodo padre también puede tener más claves de las permitidas por culpa de la división realizada en alguno de sus hijos, por lo que este también deberá realizar una operación de división

En este otro ejemplo, que ha superado el máximo de claves permitidas, es necesario realizar una división

image.png

El pivote en este caso es L + 1 que en este caso es 36

image.png

El separar el subnodo derecho hay que tener en cuenta que hay que mover tanto las claves como los hijos que sean superior a L.

División en Raíz

Podemos llegar a tener que realizar una operación en la raíz por alguna de estas dos causas.

  • Al empezar con un árbol vacío y al ir insertando claves, se puede llegar a un desbordamiento del único nodo existe que es la raíz
  • Como hemos visto anteriormente, un nodo hizo puede provocar el desbordamiento de claves en el padre (exceso de claves permitidas) y esto puede llegar recursivamente hasta la propia raíz del árbol

Es un caso especial dado que no podemos mandar el pivote a ningún nodo padre

image.png

En este ejemplo, buscamos el pivote, que resulta ser el 17

Al fabricar el nuevo subnodo derecho

image.png

Vamos a fabricar también un nuevo nodo vacío extra que solo va a tener un único elemento, que va a ser precisamente el pivote, y lo convertimos en raíz

image.png

Esta división es un poco especial ya que va a actualizar el puntero root dentro de la clase o estructura árbol.

image.png

Pseudocódigo de las operaciones

Estructura

Como para la operación de división se requiere que el elemento ya este dentro del array aunque este sobrepase el límite de claves permitidas, lo más sencillo en lenguajes como java es aumentar la capacidad del array desde N - 1 a N

image.png

public class Nodo {
    int grado; // Grado del nodo
    int[] claves; // Claves almacenadas en el nodo
    int numClaves; // Número actual de claves
    Nodo[] hijos; // Hijos del nodo
    int numHijos; // numero de hijos
}

Inserción Ordenada

Método para insertar una clave en un nodo de forma que quede de manera ordenada, empujando todos los valores hacia la derecha si hace falta.

El parámetro extra hijo derecho se va a usar luego cuando se inserte un pivote y ese pivote puede que tenga que apuntar luego hacía el elemento nuevo nodo que acabamos de meter, si inserto en un nodo hoja este parámetro será null.

image.png

public void insercionOrdenada(Nodo nodo, int valor, Nodo hijoDerecho) {
    int pivote = 0;

    // Encontrar la posición correcta para insertar el valor
    while (pivote < nodo.numClaves && nodo.claves[pivote] < valor) {
        pivote++;
    }

    // Desplazar las claves hacia la derecha para hacer espacio
    for (int i = nodo.numClaves - 1; i >= pivote; i--) {
        nodo.claves[i + 1] = nodo.claves[i];
    }

    // Desplazar los hijos hacia la derecha si no es hoja
    if (nodo.numHijos > 0) { // Solo si tiene hijos
        for (int i = nodo.numHijos - 1; i >= pivote + 1; i--) {
            nodo.hijos[i + 1] = nodo.hijos[i];
        }
    }

    // Insertar el nuevo valor y el hijo derecho en su posición
    nodo.claves[pivote] = valor;
    nodo.numClaves++;

    if (hijoDerecho != null) { // Si el hijoDerecho no es nulo, ajustar los hijos
        nodo.hijos[pivote + 1] = hijoDerecho;
        nodo.numHijos++;
    }
}

División

Aquí vemos la forma de realizar la división en código que será diferente de la que vimos anteriormente:

En la nueva forma, separamos en el nuevo nodo todo desde el pivote.

image.png

Esto lo vamos a pasar hacia arriba para que sea el padre quien realice la conexión entre el pivote y él. Así no tenemos que mantener una relación hacía arriba

image.png

image.png

public Nodo division(Nodo nodo) {
    // Crear un nuevo nodo
    Nodo nuevoNodo = new Nodo(nodo.grado);
    int j = 0;

    // Dividir las claves
    int inicio = (int) Math.ceil(nodo.numClaves / 2.0); // Calcular el punto de división
    for (int i = inicio; i < nodo.numClaves; i++, j++) {
        nuevoNodo.claves[j] = nodo.claves[i]; // Mover claves al nuevo nodo
        nodo.claves[i] = 0; // Opcional: Limpiar el espacio en el nodo original
    }

    // Si no es hoja, dividir los hijos
    if (nodo.numHijos > 0) {
        j = 0; // Reiniciar índice para los hijos
        for (int i = inicio + 1; i <= nodo.numHijos; i++, j++) {
            nuevoNodo.hijos[j] = nodo.hijos[i]; // Mover hijos al nuevo nodo
            nodo.hijos[i] = null; // Opcional: Limpiar el espacio en el nodo original
        }
        nuevoNodo.numHijos = j; // Actualizar el número de hijos en el nuevo nodo
    }

    // Actualizar el número de claves y el estado de hoja
    nuevoNodo.numClaves = j;
    nuevoNodo.numHijos = j;
    nuevoNodo.hoja = nodo.hoja; // El nuevo nodo será hoja solo si el original también lo es

    // Actualizar el número de claves del nodo original
    nodo.numClaves = inicio;

    // Retornar el nuevo nodo
    return nuevoNodo;
}

Inserción

Método real para realizar una inserción de manera recursiva aceptando un nodo y el valor

image.png

public Nodo insercion(Nodo nodo, int valor) {
    if (nodo.esHoja()) {
        insercionOrdenada(nodo, valor, null);
    } else {
        int i = buscarHijo(nodo, valor);
        Nodo nuevo = insercion(nodo.getHijo(i), valor);
        
        if (nuevo != null) {
            int pivote = nuevo.getClave(0); // Obtener la clave 0 de "nuevo"
            insercionOrdenada(nodo, pivote, nuevo); // Inserción ordenada con el pivote
            nuevo.quitarClave(0); // Quitar la clave 0 de "nuevo"
        }
    }

    if (nodo.getNumeroClaves() > nodo.getN() - 1) {
        return dividir(nodo); // Dividir el nodo si excede el límite
    } else {
        return null; // Retornar null si no es necesario dividir
    }
}