En informática , una estructura de datos de conjunto disjunto , también llamada estructura de datos de unión-búsqueda o conjunto de fusión-búsqueda , es una estructura de datos que almacena una colección de conjuntos disjuntos (no superpuestos) . De manera equivalente, almacena una partición de un conjunto en subconjuntos disjuntos . Proporciona operaciones para agregar nuevos conjuntos, fusionar conjuntos (reemplazándolos por su unión ) y encontrar un miembro representativo de un conjunto. La última operación permite averiguar de manera eficiente si dos elementos están en el mismo conjunto o en conjuntos diferentes.
Si bien existen varias formas de implementar estructuras de datos de conjuntos disjuntos, en la práctica a menudo se las identifica con una implementación particular llamada bosque de conjuntos disjuntos . Este es un tipo especializado de bosque que realiza uniones y hallazgos en un tiempo amortizado casi constante . Para realizar una secuencia de m operaciones de adición, unión o hallazgo en un bosque de conjuntos disjuntos con n nodos se requiere un tiempo total O ( m α( n )) , donde α( n ) es la función de Ackermann inversa de crecimiento extremadamente lento . Los bosques de conjuntos disjuntos no garantizan este rendimiento por operación. Las operaciones de unión y hallazgo individuales pueden tardar más que un tiempo constante multiplicado por α( n ) , pero cada operación hace que el bosque de conjuntos disjuntos se ajuste a sí mismo para que las operaciones sucesivas sean más rápidas. Los bosques de conjuntos disjuntos son asintóticamente óptimos y prácticamente eficientes.
Las estructuras de datos de conjuntos disjuntos desempeñan un papel fundamental en el algoritmo de Kruskal para encontrar el árbol de expansión mínimo de un grafo. La importancia de los árboles de expansión mínimos significa que las estructuras de datos de conjuntos disjuntos son la base de una amplia variedad de algoritmos. Además, las estructuras de datos de conjuntos disjuntos también tienen aplicaciones en el cálculo simbólico, así como en los compiladores, especialmente para problemas de asignación de registros .
Los bosques de conjuntos disjuntos fueron descritos por primera vez por Bernard A. Galler y Michael J. Fischer en 1964. [2] En 1973, su complejidad temporal fue limitada a , el logaritmo iterado de , por Hopcroft y Ullman . [3] En 1975, Robert Tarjan fue el primero en demostrar el límite superior ( función de Ackermann inversa ) de la complejidad temporal del algoritmo, . [4] También demostró que era estricto. En 1979, demostró que este era el límite inferior para una cierta clase de algoritmos, que incluyen la estructura de Galler-Fischer. [5] En 1989, Fredman y Saks demostraron que cualquier estructura de datos de conjunto disjunto debe acceder a palabras (amortizadas) de bits por operación, [6] demostrando así la optimalidad de la estructura de datos en este modelo.
En 1991, Galil e Italiano publicaron un estudio de estructuras de datos para conjuntos disjuntos. [7]
En 1994, Richard J. Anderson y Heather Woll describieron una versión paralelizada de Union–Find que nunca necesita bloquearse. [8]
En 2007, Sylvain Conchon y Jean-Christophe Filliâtre desarrollaron una versión semipersistente de la estructura de datos del bosque de conjuntos disjuntos y formalizaron su corrección utilizando el asistente de prueba Coq . [9] "Semipersistente" significa que las versiones anteriores de la estructura se conservan de manera eficiente, pero el acceso a versiones anteriores de la estructura de datos invalida las posteriores. Su implementación más rápida logra un rendimiento casi tan eficiente como el algoritmo no persistente. No realizan un análisis de complejidad.
También se han considerado variantes de estructuras de datos de conjuntos disjuntos con un mejor rendimiento en una clase restringida de problemas. Gabow y Tarjan demostraron que si las uniones posibles se restringen de ciertas maneras, entonces es posible un algoritmo de tiempo verdaderamente lineal. [10]
Cada nodo de un bosque de conjuntos disjuntos consta de un puntero y cierta información auxiliar, ya sea un tamaño o un rango (pero no ambos). Los punteros se utilizan para crear árboles de punteros primarios , donde cada nodo que no es la raíz de un árbol apunta a su padre. Para distinguir los nodos raíz de otros, sus punteros primarios tienen valores no válidos, como una referencia circular al nodo o un valor centinela. Cada árbol representa un conjunto almacenado en el bosque, y los miembros del conjunto son los nodos del árbol. Los nodos raíz proporcionan representantes de conjuntos: dos nodos están en el mismo conjunto si y solo si las raíces de los árboles que contienen los nodos son iguales.
Los nodos del bosque se pueden almacenar de cualquier forma que resulte conveniente para la aplicación, pero una técnica común es almacenarlos en una matriz. En este caso, los padres se pueden indicar mediante su índice de matriz. Cada entrada de la matriz requiere Θ(log n ) bits de almacenamiento para el puntero padre. Se requiere una cantidad de almacenamiento comparable o menor para el resto de la entrada, por lo que la cantidad de bits necesarios para almacenar el bosque es Θ( n log n ) . Si una implementación utiliza nodos de tamaño fijo (lo que limita el tamaño máximo del bosque que se puede almacenar), entonces el almacenamiento necesario es lineal en n .
Las estructuras de datos de conjuntos disjuntos admiten tres operaciones: crear un nuevo conjunto que contenga un nuevo elemento, encontrar el representante del conjunto que contiene un elemento dado y fusionar dos conjuntos.
La MakeSet
operación añade un nuevo elemento a un nuevo conjunto que contiene únicamente el nuevo elemento, y el nuevo conjunto se añade a la estructura de datos. Si, en cambio, la estructura de datos se considera como una partición de un conjunto, la MakeSet
operación amplía el conjunto añadiendo el nuevo elemento y extiende la partición existente colocando el nuevo elemento en un nuevo subconjunto que contiene únicamente el nuevo elemento.
En un bosque de conjuntos disjuntos, MakeSet
inicializa el puntero principal del nodo y el tamaño o rango del nodo. Si una raíz está representada por un nodo que apunta a sí mismo, entonces la adición de un elemento se puede describir utilizando el siguiente pseudocódigo:
función MakeSet( x ) es si x no está ya en el bosque entonces x .parent := x x .size := 1 // si los nodos almacenan el tamaño x .rank := 0 // si los nodos almacenan el rango fin si fin de la función
Esta operación tiene una complejidad temporal lineal. En particular, inicializar un bosque de conjuntos disjuntos con n nodos requiere tiempo O ( n ) .
La falta de un padre asignado al nodo implica que el nodo no está presente en el bosque.
En la práctica, MakeSet
debe ir precedida de una operación que asigne memoria para almacenar x . Mientras la asignación de memoria sea una operación de tiempo constante amortizada, como lo es para una buena implementación de matriz dinámica , no cambia el rendimiento asintótico del bosque de conjuntos aleatorios.
La Find
operación sigue la cadena de punteros padre desde un nodo de consulta especificado x hasta que alcanza un elemento raíz. Este elemento raíz representa el conjunto al que pertenece x y puede ser el propio x . Find
devuelve el elemento raíz al que llega.
La ejecución de una Find
operación presenta una oportunidad importante para mejorar el bosque. El tiempo de una Find
operación se emplea en buscar punteros principales, por lo que un árbol más plano conduce a Find
operaciones más rápidas. Cuando Find
se ejecuta a, no hay una forma más rápida de llegar a la raíz que seguir cada puntero principal en sucesión. Sin embargo, los punteros principales visitados durante esta búsqueda se pueden actualizar para que apunten más cerca de la raíz. Debido a que cada elemento visitado en el camino hacia una raíz es parte del mismo conjunto, esto no cambia los conjuntos almacenados en el bosque. Pero hace que Find
las operaciones futuras sean más rápidas, no solo para los nodos entre el nodo de consulta y la raíz, sino también para sus descendientes. Esta actualización es una parte importante de la garantía de rendimiento amortizado del bosque de conjuntos disjuntos.
Existen varios algoritmos Find
que logran la complejidad temporal óptima de forma asintótica. Una familia de algoritmos, conocida como compresión de ruta , hace que cada nodo entre el nodo de consulta y la raíz apunte a la raíz. La compresión de ruta se puede implementar utilizando una recursión simple de la siguiente manera:
función Find( x ) es si x .parent ≠ x entonces x .parent := Find( x .parent) devuelve x .parent de lo contrario devuelve x fin si fin de la función
Esta implementación realiza dos pasadas, una hacia arriba del árbol y otra hacia abajo. Requiere suficiente memoria temporal para almacenar la ruta desde el nodo de consulta hasta la raíz (en el pseudocódigo anterior, la ruta se representa implícitamente mediante la pila de llamadas). Esto se puede reducir a una cantidad constante de memoria realizando ambas pasadas en la misma dirección. La implementación de memoria constante recorre desde el nodo de consulta hasta la raíz dos veces, una para encontrar la raíz y otra para actualizar los punteros:
función Find( x ) es raíz := x mientras raíz . padre ≠ raíz hacer raíz := raíz . padre fin mientras mientras x .parent ≠ root hacer padre := x .parent x .parent := root x := padre fin mientras función de retorno raíz final
Tarjan y Van LeeuwenFind
también desarrollaron algoritmos de una sola pasada que conservan la misma complejidad del peor de los casos, pero son más eficientes en la práctica. [4] Estos se denominan división de ruta y reducción a la mitad de ruta. Ambos actualizan los punteros principales de los nodos en la ruta entre el nodo de consulta y la raíz. La división de ruta reemplaza cada puntero principal en esa ruta por un puntero al nodo principal del nodo:
función Find( x ) es mientras x .parent ≠ x hacer ( x , x .parent) := ( x .parent, x .parent.parent) fin mientras devuelve x fin de la función
La reducción a la mitad de la ruta funciona de manera similar, pero reemplaza solo todos los demás punteros principales:
función Find( x ) es mientras x .parent ≠ x hacer x .parent := x .parent.parent x := x .parent fin mientras devuelve x fin de la función
La operación reemplaza el conjunto que contiene a x y el conjunto que contiene a y con su unión. primero se utiliza para determinar las raíces de los árboles que contienen a x e y . Si las raíces son las mismas, no hay nada más que hacer. De lo contrario, los dos árboles deben fusionarse. Esto se hace estableciendo el puntero padre de la raíz de x en y o estableciendo el puntero padre de la raíz de y en x.Union(x, y)
Union
Find
La elección de qué nodo se convierte en el padre tiene consecuencias para la complejidad de futuras operaciones en el árbol. Si se hace sin cuidado, los árboles pueden llegar a ser excesivamente altos. Por ejemplo, supongamos que Union
siempre hizo que el árbol que contiene x fuera un subárbol del árbol que contiene y . Comience con un bosque que acaba de ser inicializado con elementos y ejecute , , ..., . El bosque resultante contiene un solo árbol cuya raíz es n , y la ruta de 1 a n pasa por cada nodo del árbol. Para este bosque, el tiempo de ejecución es O ( n ) .Union(1, 2)
Union(2, 3)
Union(n - 1, n)
Find(1)
En una implementación eficiente, la altura del árbol se controla mediante la unión por tamaño o la unión por rango . Ambas requieren un nodo para almacenar información además de su puntero padre. Esta información se utiliza para decidir qué raíz se convierte en la nueva raíz padre. Ambas estrategias garantizan que los árboles no se vuelvan demasiado profundos.
En el caso de la unión por tamaño, un nodo almacena su tamaño, que es simplemente su número de descendientes (incluido el propio nodo). Cuando se fusionan los árboles con raíces x e y , el nodo con más descendientes se convierte en el padre. Si los dos nodos tienen el mismo número de descendientes, entonces cualquiera de ellos puede convertirse en el padre. En ambos casos, el tamaño del nuevo nodo padre se establece en su nuevo número total de descendientes.
función Unión( x , y ) es // Reemplazar nodos por raíces x := Find( x ) y := Find( y ) si x = y entonces retorna // x e y ya están en el mismo conjunto fin si // Si es necesario, intercambie las variables para garantizar que x tenga al menos tantos descendientes como y if x .size < y .size then ( x , y ) := ( y , x ) end if // Hacer que x sea la nueva raíz y .parent := x // Actualizar el tamaño de x x .size := x .size + y .size fin de la función
La cantidad de bits necesarios para almacenar el tamaño es claramente la cantidad de bits necesarios para almacenar n . Esto agrega un factor constante al almacenamiento requerido del bosque.
Para la unión por rango, un nodo almacena su rango , que es un límite superior para su altura. Cuando se inicializa un nodo, su rango se establece en cero. Para fusionar árboles con raíces x e y , primero compare sus rangos. Si los rangos son diferentes, entonces el árbol de rango más grande se convierte en el padre, y los rangos de x e y no cambian. Si los rangos son los mismos, entonces cualquiera de los dos puede convertirse en el padre, pero el rango del nuevo padre se incrementa en uno. Si bien el rango de un nodo está claramente relacionado con su altura, almacenar rangos es más eficiente que almacenar alturas. La altura de un nodo puede cambiar durante una Find
operación, por lo que almacenar rangos evita el esfuerzo adicional de mantener la altura correcta. En pseudocódigo, la unión por rango es:
función Unión( x , y ) es // Reemplazar nodos por raíces x := Find( x ) y := Find( y ) si x = y entonces retorna // x e y ya están en el mismo conjunto fin si // Si es necesario, cambie el nombre de las variables para garantizar que x tenga un rango al menos tan grande como el de y if x .rank < y .rank then ( x , y ) := ( y , x ) end if // Convierte a x en la nueva raíz y .parent := x // Si es necesario, incrementa el rango de x if x .rank = y .rank then x .rank := x .rank + 1 end if end function
Se puede demostrar que cada nodo tiene rango o menos. [11] En consecuencia, cada rango se puede almacenar en O (log log n ) bits y todos los rangos se pueden almacenar en O ( n log log n ) bits. Esto hace que los rangos sean una porción asintóticamente insignificante del tamaño del bosque.
De las implementaciones anteriores se desprende claramente que el tamaño y el rango de un nodo no importan a menos que el nodo sea la raíz de un árbol. Una vez que un nodo se convierte en un nodo secundario, nunca más se vuelve a acceder a su tamaño y rango.
Una implementación de bosque de conjuntos disjuntos en la que Find
no se actualizan los punteros principales y en la que Union
no se intenta controlar la altura de los árboles puede tener árboles con una altura de O ( n ) . En tal situación, las operaciones Find
y requieren un tiempo de O ( n ) .Union
Si una implementación utiliza únicamente compresión de ruta, entonces una secuencia de n MakeSet
operaciones, seguida de hasta n − 1 Union
operaciones y f Find
operaciones, tiene un tiempo de ejecución en el peor de los casos de . [11]
Usando la unión por rango, pero sin actualizar los punteros padre durante Find
, se obtiene un tiempo de ejecución de forma m operaciones de cualquier tipo, hasta n de las cuales son operaciones. [11]MakeSet
La combinación de compresión, división o reducción a la mitad de la trayectoria, con unión por tamaño o por rango, reduce el tiempo de ejecución para m operaciones de cualquier tipo, de las cuales hasta n son MakeSet
operaciones, a . [4] [5] Esto hace que el tiempo de ejecución amortizado de cada operación sea . Esto es asintóticamente óptimo, lo que significa que cada estructura de datos de conjunto disjunto debe usar tiempo amortizado por operación. [6] Aquí, la función es la función de Ackermann inversa . La función de Ackermann inversa crece extraordinariamente lentamente, por lo que este factor es 4 o menos para cualquier n que realmente se pueda escribir en el universo físico. Esto hace que las operaciones de conjunto disjunto sean prácticamente un tiempo constante amortizado.
El análisis preciso del rendimiento de un bosque de conjuntos disjuntos es algo complejo. Sin embargo, existe un análisis mucho más simple que demuestra que el tiempo amortizado para cualquier m Find
o Union
operaciones en un bosque de conjuntos disjuntos que contenga n objetos es O ( m log * n ) , donde log * denota el logaritmo iterado . [12] [13] [14] [15]
Lema 1: A medida que la función de búsqueda sigue el camino hacia la raíz, el rango del nodo que encuentra aumenta.
Afirmamos que, a medida que se aplican las operaciones de búsqueda y unión al conjunto de datos, este hecho sigue siendo cierto a lo largo del tiempo. Inicialmente, cuando cada nodo es la raíz de su propio árbol, es trivialmente cierto. El único caso en el que se puede cambiar el rango de un nodo es cuando se aplica la operación de unión por rango. En este caso, un árbol con un rango menor se adjuntará a un árbol con un rango mayor, en lugar de lo contrario. Y durante la operación de búsqueda, todos los nodos visitados a lo largo de la ruta se adjuntarán a la raíz, que tiene un rango mayor que sus hijos, por lo que esta operación tampoco cambiará este hecho.
Lema 2: Un nodo u que es raíz de un subárbol con rango r tiene al menos nodos.
Inicialmente, cuando cada nodo es la raíz de su propio árbol, es trivialmente cierto. Supongamos que un nodo u con rango r tiene al menos 2 r nodos. Luego, cuando se fusionan dos árboles con rango r utilizando la operación Unión por rango, resulta un árbol con rango r + 1 , cuya raíz tiene al menos nodos.
Lema 3: El número máximo de nodos de rango r es como máximo
Del lema 2, sabemos que un nodo u que es raíz de un subárbol con rango r tiene al menos nodos. Obtendremos el número máximo de nodos de rango r cuando cada nodo con rango r sea la raíz de un árbol que tenga exactamente nodos. En este caso, el número de nodos de rango r es
En cualquier punto particular de la ejecución, podemos agrupar los vértices del grafo en "cubos", según su rango. Definimos los rangos de los cubos de forma inductiva, de la siguiente manera: El cubo 0 contiene vértices de rango 1. El cubo 1 contiene vértices de rango 2 y 3. En general, si el cubo B -ésimo contiene vértices con rangos del intervalo , entonces el cubo (B+1)º contendrá vértices con rangos del intervalo
Para , sea . Entonces el cubo tendrá vértices con rangos en el intervalo .
Podemos hacer dos observaciones sobre el tamaño de los cubos.
Sea F la lista de operaciones de "búsqueda" realizadas y sea
Entonces el costo total de m hallazgos es
Dado que cada operación de búsqueda realiza exactamente un recorrido que conduce a una raíz, tenemos T 1 = O ( m ) .
Además, a partir del límite anterior sobre el número de cubos, tenemos T 2 = O ( m log * n ) .
Para T 3 , supongamos que estamos recorriendo una arista desde u hasta v , donde u y v tienen rango en el contenedor [ B , 2 B − 1] y v no es la raíz (en el momento de este recorrido, de lo contrario el recorrido se contabilizaría en T 1 ). Fijemos u y consideremos la secuencia que toma el papel de v en diferentes operaciones de búsqueda. Debido a la compresión de la ruta y al no tener en cuenta la arista hacia una raíz, esta secuencia contiene solo nodos diferentes y debido al Lema 1 sabemos que los rangos de los nodos en esta secuencia son estrictamente crecientes. Al estar ambos nodos en el contenedor, podemos concluir que la longitud k de la secuencia (el número de veces que el nodo u está unido a una raíz diferente en el mismo contenedor) es como máximo el número de rangos en los contenedores B , es decir, como máximo
Por lo tanto,
De las observaciones 1 y 2, podemos concluir que
Por lo tanto,
El tiempo de peor caso de la Find
operación en árboles con Unión por rango o Unión por peso es (es decir, es y este límite es estricto). En 1985, N. Blum proporcionó una implementación de las operaciones que no utiliza compresión de trayectorias, sino que comprime los árboles durante . Su implementación se ejecuta en tiempo por operación, [16] y, por lo tanto, en comparación con la estructura de Galler y Fischer, tiene un mejor tiempo de peor caso por operación, pero un tiempo amortizado inferior. En 1999, Alstrup et al. proporcionaron una estructura que tiene un tiempo de peor caso óptimo junto con un tiempo amortizado de Ackermann inverso. [17]
La implementación regular como bosques de conjuntos disjuntos no reacciona favorablemente a la eliminación de elementos, en el sentido de que el tiempo de ejecución Find
no mejorará como resultado de la disminución en el número de elementos. Sin embargo, existen implementaciones modernas que permiten la eliminación en tiempo constante y donde el tiempo de ejecución Find
depende del número actual de elementos [18] [19]
Las estructuras de datos de conjuntos disjuntos modelan la partición de un conjunto , por ejemplo, para realizar un seguimiento de los componentes conectados de un gráfico no dirigido . Este modelo se puede utilizar para determinar si dos vértices pertenecen al mismo componente o si agregar una arista entre ellos daría como resultado un ciclo. El algoritmo Union–Find se utiliza en implementaciones de alto rendimiento de unificación . [20]
La biblioteca Boost Graph utiliza esta estructura de datos para implementar su funcionalidad de componentes conectados incrementales. También es un componente clave para implementar el algoritmo de Kruskal para encontrar el árbol de expansión mínimo de un gráfico.
El algoritmo Hoshen-Kopelman utiliza un Union-Find en el algoritmo.
Teorema 5: Cualquier implementación CPROBE(log n ) del problema de unión de conjuntos requiere Ω( m α( m , n )) tiempo para ejecutar m Hallazgos y n −1 Uniones, comenzando con n conjuntos singleton.