Árbol de extensión | |||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Escribe | árbol | ||||||||||||||||||||
Inventado | 1985 | ||||||||||||||||||||
Inventado por | Daniel Dominic Sleator y Robert Endre Tarjan | ||||||||||||||||||||
Complejidad del tiempo en notación O grande | |||||||||||||||||||||
|
Un árbol de splay es un árbol de búsqueda binario con la propiedad adicional de que los elementos a los que se ha accedido recientemente son de acceso rápido de nuevo. Al igual que los árboles de búsqueda binaria autoequilibrados , un árbol de distribución realiza operaciones básicas como la inserción, búsqueda y eliminación en el tiempo amortizado O (log n ) . Para muchas secuencias de operaciones no aleatorias, los árboles de splay funcionan mejor que otros árboles de búsqueda, incluso funcionan mejor que O (log n ) para patrones suficientemente no aleatorios, todo sin requerir un conocimiento previo del patrón. El árbol de extensión fue inventado por Daniel Sleator y Robert Tarjan en 1985. [1]
Todas las operaciones normales en un árbol de búsqueda binaria se combinan con una operación básica, llamada splaying . Extender el árbol para un determinado elemento reorganiza el árbol para 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 parte superior. Alternativamente, un algoritmo de arriba hacia abajo puede combinar la búsqueda y la reorganización del árbol en una sola fase.
El buen rendimiento de un árbol de distribución depende del hecho de que se optimiza automáticamente, ya 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 (ver también la localidad de referencia ) y es particularmente útil para implementar cachés y algoritmos de recolección de basura .
Las ventajas incluyen:
La desventaja más significativa de los árboles esparcidos es que la altura de un árbol esparcido puede ser lineal. Por ejemplo, este será el caso después de acceder a todos 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 costo real de una sola operación puede ser alto. 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. [3]
La representación de los árboles de splay puede cambiar incluso cuando se accede a ellos de forma de "sólo lectura" (es decir, mediante operaciones de búsqueda ). Esto complica el uso de tales árboles de extensión en un entorno de subprocesos múltiples. Específicamente, se necesita una 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í se pueden usar de manera limitada para implementar colas de prioridad.
Finalmente, cuando el patrón de acceso es aleatorio, la sobrecarga de distribución adicional agrega un factor constante significativo al costo en comparación con las alternativas menos dinámicas.
Cuando se accede a un nodo x , se realiza una operación de extensión en x para moverlo a la raíz. Para realizar una operación de extensión, llevamos a cabo una secuencia de pasos de extensión , cada uno de los cuales se acerca x a la raíz. Al realizar una operación de distribució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, de modo que logramos los límites de tiempo de amortización deseados.
Cada paso en particular depende de tres factores:
Es importante recordar configurar gg (el bisabuelo de x) para que ahora apunte ax después de cualquier operación de splay. Si gg es nulo, entonces x obviamente ahora es la raíz y debe actualizarse como tal.
Hay tres tipos de pasos de apertura, cada uno de los cuales tiene dos variantes simétricas: para zurdos y para diestros. En aras de la brevedad, solo 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 extensión son:
Paso Zig: este paso se realiza cuando p es la raíz. El árbol se hace girar en el borde entre x y p . Los pasos de Zig existen para tratar el problema de la paridad, se realizarán solo como el último paso en una operación de separación, y solo cuando x tenga una profundidad impar al comienzo de la operación.
Zig-zig paso: este paso se realiza cuando p no es la raíz y x y p son ambos hijos son correctos o ambos hijos izquierda. La siguiente imagen muestra el caso en el que x y p son ambos hijos dejados. 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 . Tenga en cuenta que los pasos en zig-zig son lo único que diferencia a los árboles de expansión del método de rotación a raíz introducido por Allen y Munro [4] antes de la introducción de los árboles de expansión.
Paso en zig-zag: este paso se realiza cuando p no es la raíz y x es un hijo derecho yp es un hijo izquierdo o viceversa. El árbol se gira en el borde entre p y x, y luego se gira en el borde resultante entre x y g.
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 usar los siguientes pasos para unirlos a un solo árbol:
Dado un árbol y un elemento x , devuelve dos árboles nuevos: uno que contiene todos los elementos menores o iguales ax y el otro que contiene todos los elementos mayores que x . Esto se puede hacer de la siguiente manera:
Para insertar un valor x en un árbol de distribución:
Alternativamente:
Para eliminar un nodo x , use el mismo método que con un árbol de búsqueda binaria:
De esta forma, la eliminación se reduce al problema de eliminar un nodo con 0 o 1 hijos. A diferencia de un árbol de búsqueda binaria, en un árbol de distribución después de la eliminación, colocamos el padre del nodo eliminado en la parte superior del árbol.
Alternativamente:
La propagación, como se mencionó anteriormente, se realiza durante una segunda pasada de abajo hacia arriba sobre la ruta de acceso de un nodo. Es posible registrar la ruta de acceso durante la primera pasada para usarla durante la segunda, 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 podemos reestructurar el árbol en nuestro camino por la ruta de acceso en lugar de hacer una segunda pasada. Esta rutina de distribución de arriba hacia abajo utiliza tres conjuntos de nodos: árbol de la izquierda, árbol de la derecha y árbol del medio. 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 consiste en el subárbol enraizado en el nodo actual. Estos tres conjuntos se actualizan a lo largo de la ruta de acceso mientras se mantienen controladas las operaciones de distribución. Otro método, semisplaying, modifica el caso en zig-zig para reducir la cantidad de reestructuración realizada en todas las operaciones. [1] [5]
A continuación, se muestra una implementación de árboles de distribución en C ++, que utiliza punteros para representar cada nodo del árbol. Esta implementación se basa en la 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 extiende el árbol en los hallazgos, solo se muestra en las inserciones y eliminaciones, y la operación de búsqueda, por lo tanto, tiene una complejidad de tiempo lineal.
#include <funcional> #ifndef SPLAY_TREE #define SPLAY_TREEplantilla < typename T , typename Comp = std :: less < T >> class splay_tree { privado : Comp comp ; unsigned long p_size ; struct nodo { 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 ; si ( y ) { x -> derecha = y -> izquierda ; if ( y -> left ) y -> left -> parent = x ; y -> padre = x -> padre ; } if ( ! x -> padre ) root = y ; otra cosa si ( x == x -> padres -> izquierda ) x -> padres -> izquierda = y ; si no x -> padre -> derecha = y ; si ( y ) y -> izquierda = x ; x -> padre = y ; } void right_rotate ( nodo * x ) { nodo * y = x -> izquierda ; si ( y ) { x -> izquierda = y -> derecha ; if ( y -> derecha ) y -> derecha -> padre = x ; y -> padre = x -> padre ; } if ( ! x -> padre ) root = y ; otra cosa si ( x == x -> padres -> izquierda ) x -> padres -> izquierda = y ; si no x -> padre -> derecha = y ; si ( y ) y -> derecha = x ; x -> padre = y ; } espacio vacío ( nodo * x ) { while ( x -> padre ) { if ( ! x -> padre -> padre ) { if ( x -> padre -> izquierda == x ) right_rotate ( x -> padre ); else left_rotate ( x -> padre ); } más si ( x -> padre -> izquierda == x && x -> padre -> padre -> izquierda == x -> padre ) { right_rotate ( x -> padre -> padre ); right_rotate ( x -> padre ); } más si ( x -> padre -> derecha == x && x -> padre -> padre -> derecha == x -> padre ) { left_rotate ( x -> padre -> padre ); left_rotate ( x -> padre ); } más si ( x -> padre -> izquierda == x && x -> padre -> padre -> derecha == x -> padre ) { right_rotate ( x -> padre ); left_rotate ( x -> padre ); } más { left_rotate ( x -> padre ); right_rotate ( x -> padre ); } } } reemplazo vacío ( nodo * u , nodo * v ) { if ( ! u -> padre ) root = v ; otra cosa si ( u == U -> padres -> izquierda ) u -> padres -> izquierda = v ; si no u -> padre -> derecha = v ; si ( v ) v -> padre = u -> padre ; } nodo * subtree_minimum ( nodo * u ) { while ( u -> izquierda ) u = u -> izquierda ; return u ; } nodo * subtree_maximum ( nodo * u ) { while ( u -> derecha ) u = u -> derecha ; return u ; }publico : splay_tree () : raíz ( nullptr ), p_size ( 0 ) { } inserto vacío ( const T & clave ) { nodo * z = raíz ; nodo * p = nullptr ; while ( z ) { p = z ; if ( comp ( z -> tecla , tecla )) z = z -> derecha ; si no z = z -> izquierda ; } z = nuevo nodo ( clave ); z -> padre = p ; si ( ! p ) raíz = z ; otra cosa si ( borrador ( p -> llave , z -> clave )) p -> derecha = z ; si no p -> izquierda = z ; esparcimiento ( z ); p_size ++ ; } nodo * buscar ( const T & clave ) { nodo * z = raíz ; while ( z ) { if ( comp ( z -> tecla , tecla )) z = z -> derecha ; de lo contrario si ( comp ( tecla , z -> tecla )) z = z -> izquierda ; de lo contrario, devuelve z ; } return nullptr ; } borrado vacío ( tecla T y constante ) { nodo * z = buscar ( clave ); if ( ! z ) return ; esparcimiento ( z ); si ( ! z -> izquierda ) reemplazar ( z , z -> derecha ); otra cosa si ( ! z -> derecha ) reemplazar a ( z , z -> izquierda ); else { nodo * y = subtree_minimum ( z -> derecha ); if ( 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 ; p_size - ; }/ * // la implementación alternativa void erase (const T & key) { node * z = find (key); if (! z) return; esparcimiento (z); nodo * s = z-> izquierda; nodo * t = z-> derecha; eliminar z; nodo * sMax = NULL; if (s) { s-> parent = NULL; sMax = subtree_maximum (s); splay (sMax); root = sMax; } if (t) { if (s) sMax-> right = t; si no root = t; t-> padre = sMax; } p_size--; } * / const T & mínima () { return subtree_minimum ( raíz ) -> clave ; } const T & maximum () { return subtree_maximum ( raíz ) -> clave ; } bool vacío () const { return root == nullptr ; } unsigned long size () const { return p_size ; } };#endif // SPLAY_TREE
Se puede realizar un análisis simple amortizado de árboles de distribución estática utilizando el método de potencial . Definir:
Φ tenderá a ser alto para árboles mal equilibrados y bajo para árboles bien equilibrados.
Para aplicar el método del potencial , primero calculamos ΔΦ: el cambio en el potencial causado por una operación de expansión. Revisamos cada caso por separado. Denote por rango ′ la función de rango después de la operación. x, pyg son los nodos afectados por la operación de rotación (ver figuras arriba).
ΔΦ | = rango ′ ( p ) - rango ( p ) + rango ′ ( x ) - rango ( x ) | [ya que solo pyx cambian de rango] |
= rango ′ ( p ) - rango ( x ) | [desde rango ′ ( x ) = rango ( p )] | |
≤ rango ′ ( x ) - rango ( x ) | [desde rango ′ ( p ) <rango ′ ( x )] |
ΔΦ | = rango ′ ( g ) - rango ( g ) + rango ′ ( p ) - rango ( p ) + rango ′ ( x ) - rango ( x ) | |
= rango ′ ( g ) + rango ′ ( p ) - rango ( p ) - rango ( x ) | [desde rango ′ (x) = rango (g)] | |
≤ rango ′ ( g ) + rango ′ ( x ) - 2 rango ( x ) | [ya que rango ( x ) <rango ( p ) y rango ′ ( x )> rango ′ ( p )] | |
≤ 3 (rango ′ ( x ) −rank ( x )) - 2 | [debido a la concavidad de la función de registro] |
ΔΦ | = rango ′ ( g ) - rango ( g ) + rango ′ ( p ) - rango ( p ) + rango ′ ( x ) - rango ( x ) | |
≤ rango ′ ( g ) + rango ′ ( p ) - 2 rango ( x ) | [ya que rango ′ ( x ) = rango ( g ) y rango ( x ) <rango ( p )] | |
≤ 3 (rango ′ ( x ) −rank ( x )) - 2 | [debido a la concavidad de la función de registro] |
El costo amortizado de cualquier operación es ΔΦ más el costo real. El costo real de cualquier operación en zig-zig o zig-zag es 2 ya que hay dos rotaciones para realizar. Por eso:
coste amortizado | = costo + ΔΦ |
≤ 3 (rango ′ ( x ) −rango ( x )) |
Cuando sumada sobre toda la operación splay, este telescopios a 3 (rango (raíz) -rank ( x )), que es O (log n ). La operación Zig agrega un costo amortizado de 1, pero hay como máximo una operación de este tipo.
Entonces 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 del potencial desde el estado inicial antes de que se realice cualquier operación (Φ i ) al estado final después de que se hayan completado 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 acotar el tiempo real:
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 distribución 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 lo tanto, el tiempo real está limitado por:
Hay varios teoremas y conjeturas con respecto al tiempo de ejecución en el peor de los casos para realizar una secuencia de S de m accesos en un árbol de distribución que contiene n elementos.
Teorema del equilibrio : el costo de realizar la secuencia S es .
Tome un peso constante, por ejemplo, para cada nodo x . Entonces .
Este teorema implica que los árboles de distribución funcionan tan bien como los árboles de búsqueda binarios balanceados estáticos en secuencias de al menos n accesos. [1]
Estático optimalidad Teorema - Dejado sea el número de veces elemento x se accede en S . Si se accede a cada elemento al menos una vez, entonces el costo de realizar S es
Deja . Entonces .
Este teorema implica que los árboles de distribución funcionan tan bien como un árbol de búsqueda binario estático óptimo en secuencias de al menos n accesos. Dedican menos tiempo a los elementos más frecuentes. [1]
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 .
Deja . 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' para cada paso que accede a un elemento y es el elemento al que se accede en el paso anterior, x . El costo de realizar S es . [6] [7]
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 de tiempo x anterior. El costo de realizar S es
Deja . 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 árboles de splay que tienen una optimización 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. [8] El límite superior más estricto probado hasta ahora es . [9]
¿Los árboles de splay 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 de splay, existe una conjetura no probada de gran interés del documento original de Sleator y Tarjan. Esta conjetura se conoce como conjetura de optimización dinámica y básicamente afirma que los árboles de splay funcionan tan bien como cualquier otro algoritmo de árbol de búsqueda binaria hasta un factor constante.
Hay varios corolarios de la conjetura de la optimización dinámica que aún no se han probado:
Con el fin de reducir el número de operaciones de reestructuración, es posible reemplazar el esparcimiento por semiseparado , en el que un elemento se esparce solo hasta la mitad de la raíz. [1] [12]
Otra forma de reducir la reestructuración es hacer una distribució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]
Los resultados muestran que el semi-esparcimiento, que se introdujo en el mismo papel que el esparcimiento, funciona mejor que el esparcimiento en casi todas las condiciones posibles. Esto hace que el semi-esparcimiento sea una buena alternativa para todas las aplicaciones en las que normalmente se aplicaría el esparcimiento. La razón por la que la reproducción se volvió tan prominente mientras que la reproducción parcial es relativamente desconocida y mucho menos estudiada es difícil de entender.