stringtranslate.com

árbol de expansión

Un árbol de distribución es un árbol de búsqueda binario con la propiedad adicional de que los elementos a los que se accedió recientemente son de acceso rápido nuevamente. Al igual que los árboles de búsqueda binarios autoequilibrados , un árbol splay realiza operaciones básicas como inserción, búsqueda y eliminación en tiempo amortizado O (log n ) . Para patrones de acceso aleatorio extraídos de una distribución aleatoria no uniforme, su tiempo de amortización puede ser más rápido que el logarítmico, proporcional a la entropía del patrón de acceso. Además, para muchos patrones de operaciones no aleatorias, los árboles de dispersión pueden tardar más que el tiempo logarítmico, sin requerir un conocimiento previo del patrón. Según la conjetura de optimización dinámica no probada, su desempeño en todos los patrones de acceso está dentro de un factor constante del mejor desempeño posible que podría lograr cualquier otro árbol de búsqueda binario autoajustable, incluso uno seleccionado para ajustarse a ese patrón. El árbol splay fue inventado por Daniel Sleator y Robert Tarjan en 1985. [1]

Todas las operaciones normales en un árbol de búsqueda binario se combinan con una operación básica, llamada extensión . Al desplegar el árbol para un determinado elemento, se reorganiza el árbol de modo que el elemento se coloque en la raíz del árbol. Una forma de hacer esto con la operación de búsqueda básica es realizar primero una búsqueda de árbol binario estándar para el elemento en cuestión y luego usar rotaciones de árbol de una manera específica para llevar el elemento a la cima. Alternativamente, un algoritmo de arriba hacia abajo puede combinar la búsqueda y la reorganización del árbol en una sola fase.

Ventajas

El buen rendimiento de un árbol splay depende del hecho de que se optimiza automáticamente, en el sentido de que los nodos a los que se accede con frecuencia se acercarán a la raíz, donde se puede acceder a ellos más rápidamente. La altura en el peor de los casos, aunque poco probable, es O( n ), siendo el promedio O(log n ). Tener nodos de uso frecuente cerca de la raíz es una ventaja para muchas aplicaciones prácticas (consulte también localidad de referencia ) y es particularmente útil para implementar cachés y algoritmos de recolección de basura .

Las ventajas incluyen:

Desventajas

La desventaja más importante de los árboles extendidos es que la altura de un árbol extendido puede ser lineal. [2] : 1  Por ejemplo, este será el caso después de acceder a los n elementos en orden no decreciente. Dado que la altura de un árbol corresponde al tiempo de acceso en el peor de los casos, esto significa que el coste real de una sola operación puede ser elevado. Sin embargo, el costo de acceso amortizado de este peor caso es logarítmico, O(log n ). Además, el costo de acceso esperado se puede reducir a O (log n ) mediante el uso de una variante aleatoria. [4]

La representación de los árboles de distribución puede cambiar incluso cuando se accede a ellos en forma de "sólo lectura" (es decir, mediante operaciones de búsqueda ). Esto complica el uso de dichos árboles extendidos en un entorno de subprocesos múltiples. Específicamente, se necesita administración adicional si se permite que varios subprocesos realicen operaciones de búsqueda al mismo tiempo. Esto también los hace inadecuados para uso general en programación puramente funcional, aunque incluso allí pueden usarse de manera limitada para implementar colas de prioridad.

Finalmente, cuando el patrón de acceso es aleatorio, los gastos generales adicionales añaden un factor constante significativo al costo en comparación con alternativas menos dinámicas.

Operaciones

esparciendo

Cuando se accede a un nodo x , se realiza una operación de expansión en x para moverlo a la raíz. Una operación de expansión es una secuencia de pasos de expansión , cada uno de los cuales acerca x a la raíz. Al realizar una operación de expansión en el nodo de interés después de cada acceso, los nodos a los que se accedió recientemente se mantienen cerca de la raíz y el árbol permanece aproximadamente equilibrado, por lo que proporciona los límites de tiempo de amortización deseados.

Cada paso en particular depende de tres factores:

Hay tres tipos de pasos abiertos, cada uno de los cuales tiene dos variantes simétricas: para zurdos y para diestros. En aras de la brevedad, sólo se muestra uno de estos dos para cada tipo. (En los siguientes diagramas, los círculos indican nodos de interés y los triángulos indican subárboles de tamaño arbitrario). Los tres tipos de pasos de separación son:

Paso de zig: este paso se realiza cuando p es la raíz. El árbol se gira en el borde entre x y p . Los pasos en zig existen para solucionar el problema de la paridad, se realizarán solo como último paso en una operación de expansión y solo cuando x tenga una profundidad impar al comienzo de la operación.

Paso en zig-zig: este paso se realiza cuando p no es la raíz y x y p son ambos hijos derechos o ambos hijos izquierdos. La siguiente imagen muestra el caso en el que x y p son hijos restantes. El árbol se gira en el borde que une p con su padre g , luego se gira en el borde que une x con p . Los pasos en zig-zig son lo único que diferencia los árboles extendidos del método de rotación hasta raíz introducido por Allen y Munro [5] antes de la introducción de los árboles extendidos.

Paso en zig-zag: este paso se realiza cuando p no es la raíz y x es un hijo derecho y p es un hijo izquierdo o viceversa ( x es la izquierda, p es la derecha). El árbol se gira en el borde entre p y x y luego se gira en el borde resultante entre x y g .

Unirse

Dados dos árboles S y T tales que todos los elementos de S son más pequeños que los elementos de T, se pueden utilizar los siguientes pasos para unirlos a un solo árbol:

Dividir

Dado un árbol y un elemento x , devuelve dos nuevos árboles: uno que contiene todos los elementos menores o iguales a x y el otro que contiene todos los elementos mayores que x . Esto se puede hacer de la siguiente manera:

Inserción

Para insertar un valor x en un árbol de expansión:

Como resultado, el nodo x recién insertado se convierte en la raíz del árbol.

Alternativamente:

Supresión

Para eliminar un nodo x , utilice el mismo método que con un árbol de búsqueda binario:

De esta forma, el borrado se reduce al problema de eliminar un nodo con 0 o 1 hijos. A diferencia de un árbol de búsqueda binario, en un árbol extendido después de la eliminación, colocamos el padre del nodo eliminado en la parte superior del árbol.

Alternativamente:

Implementación y variantes.

La expansión, como se mencionó anteriormente, se realiza durante un segundo paso de abajo hacia arriba sobre la ruta de acceso de un nodo. Es posible registrar la ruta de acceso durante el primer paso para usarla durante el segundo, pero eso requiere espacio adicional durante la operación de acceso. Otra alternativa es mantener un puntero principal en cada nodo, lo que evita la necesidad de espacio adicional durante las operaciones de acceso, pero puede reducir la eficiencia general del tiempo debido a la necesidad de actualizar esos punteros. [1]

Otro método que se puede utilizar se basa en el argumento de que el árbol se puede reestructurar durante el recorrido por el camino de acceso en lugar de realizar una segunda pasada. Esta rutina de distribución de arriba hacia abajo utiliza tres conjuntos de nodos: árbol izquierdo, árbol derecho y árbol central. Los dos primeros contienen todos los elementos del árbol original que se sabe que son menores o mayores que el elemento actual, respectivamente. El árbol del medio consta del subárbol con raíz en el nodo actual. Estos tres conjuntos se actualizan a lo largo de la ruta de acceso mientras se mantienen bajo control las operaciones de expansión. Otro método, el semisplaying, modifica el caso en zig-zig para reducir la cantidad de reestructuración realizada en todas las operaciones. [dieciséis ]

A continuación se muestra una implementación de árboles extendidos en C++, que utiliza punteros para representar cada nodo del árbol. Esta implementación se basa en una versión de distribución ascendente y utiliza el segundo método de eliminación en un árbol de distribución. Además, a diferencia de la definición anterior, esta versión de C++ no muestra el árbol en las búsquedas; solo lo muestra en las inserciones y eliminaciones, y la operación de búsqueda, por lo tanto, tiene una complejidad de tiempo lineal.

#incluir <funcional> #ifndef SPLAY_TREE #define SPLAY_TREEplantilla < nombre de tipo T , nombre de tipo Comp = std :: menos < T >> clase splay_tree { privado : Comp comp ; p_size largo sin firmar ; nodo de estructura { nodo * izquierda , * derecha ; nodo * padre ; tecla T ; nodo ( const T & init = T ()) : izquierda ( nullptr ), derecha ( nullptr ), padre ( nullptr ), clave ( init ) { } ~ nodo () {                                      } } * raíz ; void left_rotate ( nodo * x ) { nodo * y = x -> derecha ; if ( y ) { x -> derecha = y -> izquierda ; si ( y -> izquierda ) y -> izquierda -> padre = x ; y -> padre = x -> padre ; } if ( ! x -> padre ) raíz = y ; de lo contrario si ( x == x -> padre -> izquierda ) x -> padre -> izquierda = y ; else x -> padre -> derecha = y ; si ( y ) y -> izquierda = x ; x -> padre = y ; } void right_rotate ( nodo * x ) { nodo * y = x -> izquierda ; if ( y ) { x -> izquierda = y -> derecha ; si ( y -> derecha ) y -> derecha -> padre = x ; y -> padre = x -> padre ; } if ( ! x -> padre ) raíz = y ; de lo contrario si ( x == x -> padre -> izquierda ) x -> padre -> izquierda = y ; más x ->                                                                                            padre -> derecha = y ; si ( y ) y -> derecha = x ; x -> padre = y ; } void splay ( nodo * x ) { while ( x -> padre ) { if ( ! x -> padre -> padre ) { if ( x -> padre -> izquierda == x ) right_rotate ( x -> padre ); de lo contrario left_rotate ( x -> padre ); } else if ( x -> padre -> izquierda == x && x -> padre -> padre -> izquierda == x -> padre ) { right_rotate ( x -> padre -> padre ); rotación_derecha ( x -> padre ); } else if ( x -> padre -> derecha == x && x -> padre -> padre -> derecha == x -> padre ) { left_rotate ( x -> padre -> padre ); left_rotate ( x -> padre ); } else if ( x -> padre -> izquierda == x && x -> padre -> padre -> derecha == x -> padre ) { right_rotate ( x -> padre ); left_rotate ( x -> padre ); } demás {                                                                        left_rotate ( x -> padre ); rotación_derecha ( x -> padre ); } } } void reemplazar ( nodo * u , nodo * v ) { if ( ! u -> padre ) raíz = v ; de lo contrario si ( u == u -> padre -> izquierda ) u -> padre -> izquierda = v ; de lo contrario u -> padre -> derecha = v ; si ( v ) v -> padre = u -> padre ; } nodo * subárbol_mínimo ( nodo * u ) { mientras ( u -> izquierda ) u = u -> izquierda ; devolverte ;} nodo * subárbol_máximo ( nodo * u ) { mientras ( u -> derecha ) u = u -> derecha ; devolverte ;} público : splay_tree () : raíz ( nullptr ), p_size ( 0 ) { } inserción vacía ( const T & clave ) { nodo * z = raíz ; nodo * p = nullptr ; mientras ( z ) { p = z ; if ( comp ( z -> clave , clave )) z = z -> derecha ; de lo contrario z = z ->                                                                                                 izquierda ; } z = nuevo nodo ( clave ); z -> padre = p ; si ( ! p ) raíz = z ; de lo contrario si ( comp ( p -> clave , z -> clave )) p -> derecha = z ; de lo contrario p -> izquierda = z ; extender ( z ); p_tamaño ++ ; } nodo * buscar ( const T & clave ) { nodo * z = raíz ; mientras ( z ) { if ( comp ( z -> clave , clave )) z = z -> derecha ; de lo contrario si ( comp ( tecla , z -> tecla )) z = z -> izquierda ; de lo contrario , devuelve z ; } devolver nuloptr ; } borrado vacío ( const T & clave ) { nodo * z = buscar ( clave ); si ( ! z ) regresa ; extender ( z ); si ( ! z -> izquierda ) reemplace ( z , z -> derecha ); de lo contrario , si ( ! z -> derecha ) reemplace ( z , z -> izquierda ); else { nodo * y = subárbol_mínimo ( z -> derecha ); si ( y ->                                                                                                padre ! = z ) { reemplazar ( y , y -> derecha ); y -> derecha = z -> derecha ; y -> derecha -> padre = y ; } reemplazar ( z , y ); y -> izquierda = z -> izquierda ; y -> izquierda -> padre = y ; } eliminar z ; tamaño_p -- ; }                          /* //la implementación alternativa  void erase(const T &key) {  node *z = find(key);  si (!z) regresa;  reproducir(z);  nodo *s = z->izquierda;  nodo *t = z->derecha;  eliminar z;  nodo *sMax = NULL;  si (s) {  s->padre = NULL;  sMax = subárbol_máximo(s);  desplegar(sMax);  raíz = sMax;  }  si (t) {  si (s)  sMax->derecha = t;  de lo contrario  raíz = t;  t->padre = sMax;  }  p_size--;  } */ const T & mínimo () { return subtree_minimum ( raíz ) -> clave ; } const T & máximo () { return subtree_maximum ( raíz ) -> clave ; } bool vacío () const { return raíz == nullptr ; } tamaño largo sin firmar () const { return p_size ; } };                                     #endif // SPLAY_TREE

Análisis

Se puede realizar un análisis amortizado simple de árboles de dispersión estáticos utilizando el método potencial . Definir:

Φ tenderá a ser alta en árboles mal equilibrados y baja en árboles bien equilibrados.

Para aplicar el método del potencial , primero calculamos ΔΦ: el cambio en el potencial causado por una operación de separación. Comprobamos cada caso por separado. Denota por rango la función de rango después de la operación. x, p y g son los nodos afectados por la operación de rotación (ver figuras arriba).

Paso en zigzag

Paso en zig-zig

Paso en zigzag

El coste amortizado de cualquier operación es ΔΦ más el coste real. El coste real de cualquier operación en zig-zig o zig-zag es 2 ya que hay dos rotaciones que realizar. Por eso:

Cuando se suma toda la operación de dispersión, esto se reduce a 1 + 3(rango(raíz)−rango( x )) que es O(log n ), ya que usamos la operación Zig como máximo una vez y el costo amortizado de zig es de más 1+3(rango'( x )−rango( x )).

Ahora sabemos que el tiempo total amortizado para una secuencia de m operaciones es:

Para pasar del tiempo amortizado al tiempo real, debemos sumar la disminución de potencial desde el estado inicial antes de realizar cualquier operación (Φ i ) al estado final después de completar todas las operaciones (Φ f ).

donde la última desigualdad proviene del hecho de que para cada nodo x , el rango mínimo es 0 y el rango máximo es log( n ).

Ahora finalmente podemos limitar la hora real:

Análisis ponderado

El análisis anterior se puede generalizar de la siguiente manera.

Se aplica el mismo análisis y el costo amortizado de una operación de esparcimiento es nuevamente:

donde W es la suma de todos los pesos.

La disminución del potencial inicial al final está limitada por:

ya que el tamaño máximo de cualquier nodo es W y el mínimo es w(x) .

Por tanto, el tiempo real está limitado por:

Teoremas de rendimiento

Existen varios teoremas y conjeturas sobre el peor tiempo de ejecución para realizar una secuencia S de m accesos en un árbol splay que contiene n elementos.

Teorema del equilibrio  :  el costo de realizar la secuencia S es .

Prueba

Tome un peso constante, por ejemplo, para cada nodo x . Entonces .

Este teorema implica que los árboles splay funcionan tan bien como los árboles de búsqueda binarios equilibrados estáticos en secuencias de al menos n accesos. [1]

Teorema de optimización estática  :  sea el número de veces que se accede al elemento x en S. Si se accede a cada elemento al menos una vez, entonces el costo de realizar S es

Prueba

Dejar . Entonces .

Este teorema implica que los árboles splay funcionan tan bien como un árbol de búsqueda binario estático óptimo en secuencias de al menos n accesos. [7] Dedican menos tiempo a los elementos más frecuentes. [1] Otra forma de expresar el mismo resultado es que, en secuencias de entrada donde los elementos se extraen independientemente al azar de una distribución de probabilidad no uniforme en n elementos, el costo amortizado esperado ( caso promedio ) de cada acceso es proporcional al entropía de la distribución. [8]

Teorema del dedo estático  :  suponga que los elementos están numerados del 1 al n en orden ascendente. Sea f cualquier elemento fijo (el 'dedo'). Entonces el costo de realizar S es .

Prueba

Dejar . Entonces . La caída de potencial neta es O ( n log n ) ya que el peso de cualquier artículo es al menos . [1]

Teorema dinámico del dedo  :  suponga que el 'dedo' de cada paso que accede a un elemento y es el elemento al que se accedió en el paso anterior, x . El costo de realizar S es . [9] [10]

Teorema del conjunto de trabajo  :  en cualquier momento durante la secuencia, sea el número de elementos distintos a los que se accedió antes de que se accediera al elemento x anterior. El costo de realizar S es

Prueba

Dejar . Tenga en cuenta que aquí los pesos cambian durante la secuencia. Sin embargo, la secuencia de pesos sigue siendo una permutación de . Así como antes . La caída de potencial neta es O ( n log n ).

Este teorema es equivalente a que los árboles splay tengan optimidad independiente de la clave . [1]

Teorema de exploración  :  también conocido como teorema de acceso secuencial o teorema de cola . Acceder a los n elementos de un árbol de distribución en orden simétrico lleva O ( n ) tiempo, independientemente de la estructura inicial del árbol de distribución. [11] El límite superior más estricto demostrado hasta ahora es . [12]

Conjetura de optimización dinámica

Problema no resuelto en informática :

¿Los árboles de distribución funcionan tan bien como cualquier otro algoritmo de árbol de búsqueda binario?

Además de las garantías de rendimiento comprobadas para los árboles esparcidos, existe una conjetura no comprobada de gran interés procedente del artículo original de Sleator y Tarjan. Esta conjetura se conoce como conjetura de optimización dinámica y básicamente afirma que los árboles splay funcionan tan bien como cualquier otro algoritmo de árbol de búsqueda binario hasta un factor constante.

Conjetura de Optimidad Dinámica: [1] Sea cualquier algoritmo de árbol de búsqueda binario que acceda a un elemento recorriendo el camino desde la raíz hasta con un costo de , y que entre accesos pueda realizar cualquier rotación en el árbol con un costo de 1 por rotación. Sea el costo de realizar la secuencia de accesos. Entonces el costo de que un árbol splay realice los mismos accesos es .

Hay varios corolarios de la conjetura de la optimización dinámica que aún no se han demostrado:

Conjetura transversal: [1] Sean y dos árboles extendidos que contengan los mismos elementos. Sea la secuencia obtenida visitando los elementos en preorden (es decir, primer orden de búsqueda en profundidad). El coste total de realizar la secuencia de accesos es .
Conjetura de Deque: [11] [13] [14] Sea una secuencia de operaciones de cola de doble extremo (empujar, hacer estallar, inyectar, expulsar). Entonces el costo de actuar en un árbol extendido es .
Conjetura dividida: [6] Sea cualquier permutación de los elementos del árbol splay. Entonces el costo de eliminar los elementos del pedido es .

Variantes

Para reducir el número de operaciones de reestructuración, es posible sustituir el ensanchamiento por un semiensanchamiento , en el que un elemento se ensancha sólo hasta la mitad de la raíz. [1] [2]

Otra forma de reducir la reestructuración es realizar una expansión completa, pero solo en algunas de las operaciones de acceso, solo cuando la ruta de acceso es más larga que un umbral, o solo en las primeras m operaciones de acceso. [1]

Utilizando técnicas de compresión de punteros, [15] es posible construir un árbol de distribución sucinto .

Ver también

Notas

  1. ^ abcdefghijklmn Sleator y Tarjan 1985.
  2. ^ a b C Brinkmann, Degraer y De Loof 2009.
  3. ^ Goodrich, Tamassia y Goldwasser 2014.
  4. ^ Albers y Karpinski 2002.
  5. ^ Allen y Munro 1978.
  6. ^ ab Lucas 1991.
  7. ^ Knuth 1997, pág. 478
  8. ^ Grinberg y col. (1995).
  9. ^ Cole y col. 2000.
  10. ^ Cole 2000.
  11. ^ ab Tarjan 1985.
  12. ^ Elmasry 2004.
  13. ^ Pettie 2008.
  14. ^ Domingo 1992.
  15. ^ Bender y col. 2023.

Referencias

enlaces externos