En informática , los algoritmos de árboles basados en uniones son una clase de algoritmos para árboles de búsqueda binarios autoequilibrados . Este marco tiene como objetivo diseñar algoritmos altamente paralelizados para varios árboles de búsqueda binarios equilibrados. El marco algorítmico se basa en una única operación join . [1] En este marco, la operación join captura todos los criterios de equilibrio de diferentes esquemas de equilibrio, y todas las demás funciones join tienen una implementación genérica en diferentes esquemas de equilibrio. Los algoritmos basados en uniones se pueden aplicar a al menos cuatro esquemas de equilibrio: árboles AVL , árboles rojo-negros , árboles equilibrados por peso y treaps .
La operación de unión toma como entrada dos árboles binarios balanceados y del mismo esquema de balanceo, y una clave , y genera como salida un nuevo árbol binario balanceado cuyo recorrido en orden es el recorrido en orden de , luego el recorrido en orden de . En particular, si los árboles son árboles de búsqueda , lo que significa que el orden de los árboles mantiene un ordenamiento total en claves, debe satisfacer la condición de que todas las claves en sean menores que y todas las claves en sean mayores que .
La operación de unión fue definida por primera vez por Tarjan [2] en árboles rojo-negros , que se ejecuta en tiempo logarítmico de peor caso. Más tarde, Sleator y Tarjan [3] describieron un algoritmo de unión para árboles splay que se ejecuta en tiempo logarítmico amortizado. Más tarde, Adams [4] extendió la unión a árboles con equilibrio de peso y lo utilizó para funciones rápidas de conjunto a conjunto, incluyendo unión , intersección y diferencia de conjuntos . En 1998, Blelloch y Reid-Miller extendieron la unión en treaps , y demostraron que el límite de las funciones de conjunto es para dos árboles de tamaño y , que es óptimo en el modelo de comparación. También plantearon el paralelismo en el algoritmo de Adams utilizando un esquema de divide y vencerás . En 2016, Blelloch et al. propusieron formalmente los algoritmos basados en unión y formalizaron el algoritmo de unión para cuatro esquemas de equilibrio diferentes: árboles AVL , árboles rojo-negros , árboles con equilibrio de peso y treaps . En el mismo trabajo demostraron que los algoritmos de Adams sobre unión, intersección y diferencia son óptimos en los cuatro esquemas de equilibrio.
La función join considera el reequilibrio del árbol y, por lo tanto, depende del esquema de equilibrio de entrada. Si los dos árboles están equilibrados, join simplemente crea un nuevo nodo con subárbol izquierdo t 1 , raíz k y subárbol derecho t 2 . Supongamos que t 1 es más pesado (este "más pesado" depende del esquema de equilibrio) que t 2 (el otro caso es simétrico). Join sigue la columna vertebral derecha de t 1 hasta un nodo c que está equilibrado con t 2 . En este punto, se crea un nuevo nodo con hijo izquierdo c , raíz k y hijo derecho t 2 para reemplazar a c. El nuevo nodo puede invalidar el invariante de equilibrio. Esto se puede solucionar con rotaciones.
A continuación se muestran los algoritmos de unión en diferentes esquemas de equilibrio.
El algoritmo de unión para árboles AVL :
función joinRightAVL(T L , k, T R ) (l, k', c) := exponer(T L ) si h(c) ≤ h(T R ) + 1 T' := Nodo(c, k, T R ) si h(T') ≤ h(l) + 1 devuelve Nodo(l, k', T') de lo contrario devuelve rotateLeft(Nodo(l, k', rotateRight(T'))) de lo contrario T' := joinRightAVL(c, k, T R ) T : = Nodo(l, k', T') si h(T') ≤ h(l) + 1 devuelve T de lo contrario devuelve rotateLeft(T)función joinLeftAVL(T L , k, T R ) /* simétrico para joinRightAVL */función join(T L , k, T R ) si h(T L ) > h(T R ) + 1 devuelve joinRightAVL(T L , k, T R ) de lo contrario si h(T L ) > h(T L ) + 1 devuelve joinLeftAVL(T L , k, T R ) de lo contrario devuelve Node(T L , k, T R )
Dónde:
El algoritmo de unión para árboles rojo-negros :
función joinRightRB(T L , k, T R ) si T L .color = negro y ĥ(T L ) = ĥ(T R ) devuelve Nodo(T L , ⟨k, rojo⟩, T R ) de lo contrario (L', ⟨k', c'⟩, R') := exponer(T L ) T' := Nodo(L', ⟨k', c'⟩, joinRightRB(R', k, T R )) si c' = negro y T'.right.color = T'.right.right.color = rojo T'.right.right.color := negro devuelve rotateLeft(T') de lo contrario devuelve T'función joinLeftRB(T L , k, T R ) /* simétrico para unirse a RightRB */función join(T L , k, T R ) si ĥ(T L ) > ĥ(T R ) T' := joinRightRB(T L , k, T R ) si (T'.color = rojo) y (T'.right.color = rojo) T'.color := negro devuelve T' de lo contrario si ĥ(T R ) > ĥ(T L ) /* simétrico */ de lo contrario, si T L .color = negro y T R = negro, devuelve Nodo(T L , ⟨k, rojo⟩, T R ) de lo contrario, devuelve Nodo(T L , ⟨k, negro⟩, T R )
Dónde:
El algoritmo de unión para árboles con equilibrio de peso :
función joinRightWB(T L , k, T R ) (l, k', c) := exponer(T L ) si w(T L ) = α w(T R ) devolver Nodo(T L , k, T R ) de lo contrario T' := unirDerechaWB(c, k, T R ) (l 1 , k 1 , r 1 ) := exponer(T') si w(l) = α w(T') devuelve Nodo(l, k', T') de lo contrario si w(l) = α w(l 1 ) y w(l)+w(l 1 ) = α w(r 1 ) devuelve rotarIzquierda(Nodo(l, k', T')) de lo contrario devuelve rotarIzquierda(Nodo(l, k', rotarDerecha(T'))función joinLeftWB(T L , k, T R ) /* simétrico para unirse a RightWB */función join(T L , k, T R ) si w(T L ) > α w(T R ) retorna joinRightWB(T L , k, T R ) de lo contrario si w(T R ) > α w(T L ) retorna joinLeftWB(T L , k, T R ) de lo contrario retorna Node(T L , k, T R )
Dónde:
A continuación, extrae el hijo izquierdo , la clave y el hijo derecho del nodo en una tupla . crea un nodo con hijo izquierdo , clave y hijo derecho . " " significa que dos declaraciones y pueden ejecutarse en paralelo.
Para dividir un árbol en dos árboles, aquellos más pequeños que la clave x y aquellos más grandes que la clave x , primero dibujamos una ruta desde la raíz insertando x en el árbol. Después de esta inserción, todos los valores menores que x se encontrarán a la izquierda de la ruta, y todos los valores mayores que x se encontrarán a la derecha. Al aplicar Join , todos los subárboles del lado izquierdo se fusionan de abajo hacia arriba usando claves en la ruta como nodos intermedios de abajo hacia arriba para formar el árbol izquierdo, y la parte derecha es asimétrica. Para algunas aplicaciones, Split también devuelve un valor booleano que indica si x aparece en el árbol. El costo de Split es , orden de la altura del árbol.
El algoritmo de división es el siguiente:
función split(T, k) si (T = nulo) devuelve (nulo, falso, nulo) de lo contrario (L, m, R) := exponer(T) si k < m (L', b, R') := dividir(L, k) devuelve (L', b, join(R', m, R)) de lo contrario si k > m (L', b, R') := dividir(R, k) devuelve (join(L, m, L'), b, R')) de lo contrario devuelve (L, true, R)
Esta función se define de forma similar a join pero sin la clave intermedia. Primero, divide la última clave del árbol izquierdo y luego une la parte restante del árbol izquierdo con el árbol derecho con . El algoritmo es el siguiente:
función splitLast(T) (L, k, R) := exponer(T) si R = nulo devuelve (L, k) de lo contrario (T', k') := dividirÚltimo(R) devolver (unir(L, k, T'), k')función join2(L, R) si L = nulo devuelve R de lo contrario (L', k) := dividirÚltimo(L) devolver unirse(L', k, R)
El costo es para un árbol de tamaño .
Los algoritmos de inserción y eliminación, cuando se hace uso de join , pueden ser independientes de los esquemas de balanceo. Para una inserción, el algoritmo compara la clave que se va a insertar con la clave en la raíz, la inserta en el subárbol izquierdo/derecho si la clave es menor/mayor que la clave en la raíz y une los dos subárboles nuevamente con la raíz. Una eliminación compara la clave que se va a eliminar con la clave en la raíz. Si son iguales, devuelve join2 en los dos subárboles. De lo contrario, elimina la clave del subárbol correspondiente y une los dos subárboles nuevamente con la raíz. Los algoritmos son los siguientes:
función insertar(T, k) si T = nulo devuelve Nodo(nil, k, nulo) de lo contrario (L, k', R) := exponer(T) si k < k' retorna join(insert(L,k), k', R) de lo contrario si k > k' retorna join(L, k', insert(R, k)) de lo contrario retorna Tfunción eliminar(T, k) si T = nulo devuelve nulo en caso contrario (L, k', R) := exponer(T) si k < k' retorna join(eliminar(L, k), k', R) de lo contrario si k > k' retorna join(L, k', eliminar(R, k)) de lo contrario retorna join2(L, R)
Tanto la inserción como la eliminación requieren tiempo si .
Se han definido varias operaciones de conjuntos en árboles con ponderación equilibrada: unión , intersección y diferencia de conjuntos . La unión de dos árboles con ponderación equilibrada t 1 y t 2 que representan los conjuntos A y B es un árbol t que representa A ∪ B. La siguiente función recursiva calcula esta unión:
función unión(t 1 , t 2 ) si t 1 = nulo devuelve t 2 de lo contrario si t 2 = nulo devuelve t 1 de lo contrario (l 1 , k 1 , r 1 ) := exponer(t 1 ) (t < , b, t > ) := dividir(t 2 , k 1 ) l' := unión(l 1 , t < ) || r' := unión(r 1 , t > ) devolver unión(l', k 1 , r')
De manera similar, los algoritmos de intersección y diferencia de conjuntos son los siguientes:
función intersección(t 1 , t 2 ) si t 1 = nulo o t 2 = nulo devuelve nulo de lo contrario (l 1 , k 1 , r 1 ) := exponer(t 1 ) (t < , b, t > ) = dividir(t 2 , k 1 ) l' := intersección(l 1 , t < ) || r' := intersección(r 1 , t > ) si b devuelve join(l', k 1 , r') de lo contrario devuelve join2(l', r')función diferencia(t 1 , t 2 ) si t 1 = nulo devuelve nulo de lo contrario si t 2 = nulo devuelve t 1 de lo contrario (l 1 , k 1 , r 1 ) := exponer(t 1 ) (t < , b, t > ) := dividir(t 2 , k 1 ) l' = diferencia(l 1 , t < ) || r' = diferencia(r 1 , t > ) si b devuelve join2(l', r') de lo contrario devuelve join(l', k 1 , r')
La complejidad de cada una de las uniones, intersecciones y diferencias es para dos árboles con pesos equilibrados de tamaños y . Esta complejidad es óptima en términos de la cantidad de comparaciones. Más importante aún, dado que las llamadas recursivas a uniones, intersecciones o diferencias son independientes entre sí, se pueden ejecutar en paralelo con una profundidad paralela . [1] Cuando , la implementación basada en uniones aplica el mismo cálculo que en una inserción o eliminación de un solo elemento si se usa la raíz del árbol más grande para dividir el árbol más pequeño.
El algoritmo para construir un árbol puede utilizar el algoritmo de unión y utilizar el esquema de dividir y vencer:
función construir(A[], n) si n = 0 devuelve nil de lo contrario si n = 1 devuelve Nodo(nil, A[0], nil) de lo contrario l' := construir(A, n/2) || r' := (A+n/2, nn/2) Unión de retorno (L, R)
Este algoritmo requiere trabajo y tiene profundidad. Un algoritmo más eficiente utiliza un algoritmo de ordenamiento paralelo.
función buildSorted(A[], n) si n = 0 devuelve nil de lo contrario si n = 1 devuelve Node(nil, A[0], nil) de lo contrario l' := construir(A, n/2) || r' := (A+n/2+1, nn/2-1) devolver unión(l', A[n/2], r')función construir(A[], n) A' := ordenar(A, n) devuelve buildSorted(A, n)
Este algoritmo cuesta trabajo y tiene profundidad, asumiendo que el algoritmo de clasificación tiene trabajo y profundidad.
Esta función selecciona todas las entradas de un árbol que satisfacen un predicado y devuelve un árbol que contiene todas las entradas seleccionadas. Filtra de forma recursiva los dos subárboles y los une con la raíz si esta satisface ; de lo contrario, une2 los dos subárboles.
función filter(T, p) si T = nulo devuelve nulo en caso contrario (l, k, r) := exponer(T) l' := filtro(l, p) || r' := filtro(r, p) si p(k) devuelve join(l', k, r') de lo contrario devuelve join2(l', R)
Este algoritmo cuesta trabajo y profundidad en un árbol de tamaño , asumiendo que tiene un costo constante.
Los algoritmos basados en uniones se aplican para soportar interfaces para conjuntos , mapas y mapas aumentados [5] en bibliotecas como Hackage , SML/NJ y PAM . [5]