De hecho, considero que los contenedores de conjuntos estándar son en su mayoría inútiles y prefiero usar matrices, pero lo hago de una manera diferente.
Para calcular las intersecciones establecidas, recorro la primera matriz y marco los elementos con un solo bit. Luego itero a través de la segunda matriz y busco elementos marcados. Voila, establezca la intersección en tiempo lineal con mucho menos trabajo y memoria que una tabla hash, por ejemplo, las uniones y las diferencias son igualmente simples de aplicar utilizando este método. Ayuda que mi base de código gira en torno a la indexación de elementos en lugar de duplicarlos (duplico índices a elementos, no a los datos de los elementos en sí mismos) y rara vez necesita algo para ordenar, pero no he usado una estructura de datos establecida en años un resultado.
También tengo un código C malvado que manipula los bits, incluso cuando los elementos no ofrecen ningún campo de datos para tales fines. Implica usar la memoria de los elementos mismos estableciendo el bit más significativo (que nunca uso) con el propósito de marcar elementos atravesados. Eso es bastante asqueroso, no haga eso a menos que realmente esté trabajando a nivel de ensamblaje cercano, pero solo quería mencionar cómo puede ser aplicable incluso en los casos en que los elementos no proporcionan algún campo específico para el recorrido si puede garantizar que ciertos bits nunca serán utilizados. Puede calcular una intersección establecida entre 200 millones de elementos (alrededor de 2,4 gigas de datos) en menos de un segundo en mi dinky i7. Intente hacer una intersección establecida entre dos std::set
instancias que contengan cien millones de elementos cada una al mismo tiempo; Ni siquiera se acerca.
Eso aparte ...
Sin embargo, también podría hacerlo agregando cada elemento a otro vector y verificando si el elemento ya existe.
Esa comprobación para ver si un elemento ya existe en el nuevo vector generalmente será una operación de tiempo lineal, lo que hará que la intersección del conjunto sea una operación cuadrática (cantidad explosiva de trabajo cuanto mayor sea el tamaño de entrada). Recomiendo la técnica anterior si solo desea utilizar vectores o matrices antiguos simples y hacerlo de una manera que se amplíe maravillosamente.
Básicamente: ¿qué tipos de algoritmos requieren un conjunto y no deberían hacerse con ningún otro tipo de contenedor?
Ninguno si me pregunta mi opinión parcial si lo está hablando a nivel de contenedor (como en una estructura de datos implementada específicamente para proporcionar operaciones de conjunto de manera eficiente), pero hay muchos que requieren una lógica de conjunto a nivel conceptual. Por ejemplo, supongamos que desea encontrar las criaturas en un mundo de juego que son capaces de volar y nadar, y tiene criaturas voladoras en un conjunto (ya sea que use o no un contenedor de conjunto) y otras que puedan nadar en otro . En ese caso, desea una intersección establecida. Si quieres criaturas que pueden volar o son mágicas, entonces usas una unión fija. Por supuesto, en realidad no necesita un contenedor de conjuntos para implementar esto, y la implementación más óptima generalmente no necesita o desea un contenedor específicamente diseñado para ser un conjunto.
Saliendo de la tangente
Muy bien, recibí algunas buenas preguntas de JimmyJames con respecto a este enfoque de intersección establecida. Se está desviando un poco del tema, pero bueno, estoy interesado en ver a más personas usar este enfoque intrusivo básico para establecer la intersección, de modo que no estén construyendo estructuras auxiliares completas como árboles binarios equilibrados y tablas hash solo con el propósito de establecer operaciones. Como se mencionó, el requisito fundamental es que las listas de elementos de copia superficial para que estén indexando o señalando a un elemento compartido que se puede "marcar" como atravesado por el paso a través de la primera lista o matriz no ordenada o lo que sea que luego elija en el segundo pasar por la segunda lista.
Sin embargo, esto se puede lograr prácticamente incluso en un contexto de subprocesos múltiples sin tocar los elementos siempre que:
- Los dos agregados contienen índices de los elementos.
- El rango de índices no es demasiado grande (digamos [0, 2 ^ 26), no miles de millones o más) y están razonablemente densamente ocupados.
Esto nos permite usar una matriz paralela (solo un bit por elemento) con el fin de establecer operaciones. Diagrama:

La sincronización de subprocesos solo necesita estar allí cuando se adquiere una matriz de bits paralela del grupo y se vuelve a liberar al grupo (hecho implícitamente cuando se sale del alcance). Los dos bucles reales para realizar la operación de configuración no necesitan involucrar ninguna sincronización de subprocesos. Ni siquiera necesitamos usar un grupo de bits paralelos si el subproceso solo puede asignar y liberar los bits localmente, pero el grupo de bits puede ser útil para generalizar el patrón en bases de código que se ajustan a este tipo de representación de datos donde a menudo se hace referencia a elementos centrales por índice para que cada hilo no tenga que molestarse con una gestión eficiente de la memoria. Los principales ejemplos de mi área son los sistemas de entidad-componente y las representaciones de malla indexadas. Con frecuencia, ambos necesitan establecer intersecciones y tienden a referirse a todo lo almacenado centralmente (componentes y entidades en ECS y vértices, bordes,
Si los índices no están densamente ocupados y escasamente dispersos, esto todavía es aplicable con una implementación razonablemente escasa de la matriz booleana / bit paralela, como una que solo almacena memoria en fragmentos de 512 bits (64 bytes por nodo desenrollado que representa 512 índices contiguos ) y omite la asignación de bloques contiguos completamente vacíos. Lo más probable es que ya esté usando algo como esto si sus estructuras de datos centrales están escasamente ocupadas por los propios elementos.

... idea similar para un conjunto de bits disperso para servir como una matriz de bits paralela. Estas estructuras también se prestan a la inmutabilidad, ya que es fácil copiar bloques gruesos que no necesitan ser copiados en profundidad para crear una nueva copia inmutable.
Una vez más, se pueden establecer intersecciones entre cientos de millones de elementos en menos de un segundo utilizando este enfoque en una máquina muy promedio, y eso está dentro de un solo hilo.
También se puede hacer en menos de la mitad del tiempo si el cliente no necesita una lista de elementos para la intersección resultante, como si solo quisieran aplicar algo de lógica a los elementos que se encuentran en ambas listas, momento en el que simplemente pueden pasar un puntero de función o functor o delegado o lo que sea que se vuelva a llamar para procesar rangos de elementos que se cruzan. Algo a este efecto:
// 'func' receives a range of indices to
// process.
set_intersection(func):
{
parallel_bits = bit_pool.acquire()
// Mark the indices found in the first list.
for each index in list1:
parallel_bits[index] = 1
// Look for the first element in the second list
// that intersects.
first = -1
for each index in list2:
{
if parallel_bits[index] == 1:
{
first = index
break
}
}
// Look for elements that don't intersect in the second
// list to call func for each range of elements that do
// intersect.
for each index in list2 starting from first:
{
if parallel_bits[index] != 1:
{
func(first, index)
first = index
}
}
If first != list2.num-1:
func(first, list2.num)
}
... o algo por el estilo. La parte más cara del pseudocódigo en el primer diagrama está intersection.append(index)
en el segundo bucle, y eso se aplica incluso para los std::vector
reservados al tamaño de la lista más pequeña de antemano.
¿Qué sucede si copio todo en profundidad?
Bueno, para eso! Si necesita establecer intersecciones, significa que está duplicando datos para intersecarlos. Lo más probable es que incluso sus objetos más pequeños no sean más pequeños que un índice de 32 bits. Es muy posible reducir el rango de direccionamiento de sus elementos a 2 ^ 32 (2 ^ 32 elementos, no 2 ^ 32 bytes) a menos que realmente necesite más de ~ 4,3 mil millones de elementos instanciados, momento en el que se necesita una solución totalmente diferente ( y eso definitivamente no está usando contenedores de conjuntos en la memoria).
Partidos clave
¿Qué hay de los casos en los que necesitamos realizar operaciones de configuración donde los elementos no son idénticos pero podrían tener claves coincidentes? En ese caso, la misma idea que la anterior. Solo necesitamos asignar cada clave única a un índice. Si la clave es una cadena, por ejemplo, las cadenas internas pueden hacer exactamente eso. En esos casos, se requiere una estructura de datos agradable, como un trie o una tabla hash, para asignar claves de cadena a índices de 32 bits, pero no necesitamos tales estructuras para realizar las operaciones establecidas en los índices de 32 bits resultantes.
Se abren una gran cantidad de soluciones algorítmicas y estructuras de datos muy baratas y sencillas como estas cuando podemos trabajar con índices a elementos en un rango muy razonable, no el rango completo de direccionamiento de la máquina, por lo que a menudo vale la pena ser capaz de obtener un índice único para cada clave única.
¡Amo los índices!
Me encantan los índices tanto como la pizza y la cerveza. Cuando tenía 20 años, realmente me metí en C ++ y comencé a diseñar todo tipo de estructuras de datos totalmente compatibles con los estándares (incluidos los trucos involucrados para desambiguar un controlador de relleno de un controlador de rango en tiempo de compilación). En retrospectiva, fue una gran pérdida de tiempo.
Si gira su base de datos para almacenar elementos centralmente en matrices e indexarlos en lugar de almacenarlos de una manera fragmentada y potencialmente en todo el rango direccionable de la máquina, entonces puede terminar explorando un mundo de posibilidades algorítmicas y de estructura de datos con solo el diseño de los contenedores y algoritmos que giran en torno a secas int
o int32_t
. Y descubrí que el resultado final era mucho más eficiente y fácil de mantener donde no estaba transfiriendo constantemente elementos de una estructura de datos a otra a otra.
Algunos ejemplos usan casos en los que puede suponer que cualquier valor único de T
tiene un índice único y tendrá instancias que residen en una matriz central:
Tipos de radios multiproceso que funcionan bien con enteros sin signo para índices . De hecho, tengo un tipo de matriz multiproceso que tarda aproximadamente 1/10 del tiempo en clasificar cien millones de elementos como el propio sistema paralelo de Intel, y el de Intel ya es 4 veces más rápido que std::sort
para entradas tan grandes. Por supuesto, Intel es mucho más flexible, ya que se basa en la comparación y puede clasificar las cosas lexicográficamente, por lo que compara manzanas con naranjas. Pero aquí a menudo solo necesito naranjas, como podría hacer un pase de clasificación de radix solo para lograr patrones de acceso a memoria amigables con la caché o filtrar duplicados rápidamente.
Capacidad para construir estructuras vinculadas como listas vinculadas, árboles, gráficos, tablas hash de encadenamiento separadas, etc. sin asignaciones de montón por nodo . Simplemente podemos asignar los nodos en masa, paralelos a los elementos, y vincularlos con índices. Los nodos se convierten en un índice de 32 bits para el siguiente nodo y se almacenan en una gran matriz, de esta manera:

Amigable para el procesamiento en paralelo. A menudo, las estructuras vinculadas no son tan amigables para el procesamiento en paralelo, ya que al menos es incómodo tratar de lograr el paralelismo en el árbol o el recorrido de la lista vinculada en lugar de, por ejemplo, simplemente hacer un bucle paralelo para una matriz. Con la representación de matriz índice / central, siempre podemos ir a esa matriz central y procesar todo en bucles paralelos gruesos. Siempre tenemos esa matriz central de todos los elementos que podemos procesar de esta manera, incluso si solo queremos procesar algunos (en ese momento, puede procesar los elementos indexados por una lista ordenada por radix para un acceso amigable a la caché a través de la matriz central).
Puede asociar datos a cada elemento sobre la marcha en tiempo constante . Al igual que en el caso de la matriz paralela de bits anterior, podemos asociar de manera fácil y extremadamente barata datos paralelos a elementos para, por ejemplo, el procesamiento temporal. Esto tiene casos de uso más allá de los datos temporales. Por ejemplo, un sistema de malla podría permitir que los usuarios adjunten tantos mapas UV a una malla como quieran. En tal caso, no podemos simplemente codificar cuántos mapas UV habrá en cada vértice y cara utilizando un enfoque AoS. Necesitamos poder asociar dichos datos sobre la marcha, y las matrices paralelas son útiles allí y mucho más baratas que cualquier tipo de contenedor asociativo sofisticado, incluso tablas hash.
Por supuesto, las matrices paralelas están mal vistas debido a su naturaleza propensa a errores de mantener las matrices paralelas sincronizadas entre sí. Cada vez que eliminamos un elemento en el índice 7 de la matriz "raíz", por ejemplo, también tenemos que hacer lo mismo para los "hijos". Sin embargo, en la mayoría de los idiomas es bastante fácil generalizar este concepto a un contenedor de propósito general para que la lógica difícil de mantener las matrices paralelas sincronizadas entre sí solo exista en un lugar en toda la base de código, y dicho contenedor de matriz paralela puede utilice la implementación de matriz dispersa anterior para evitar desperdiciar mucha memoria para espacios vacantes contiguos en la matriz que se reclamarán en inserciones posteriores.
Más elaboración: árbol escaso de Bitset
Muy bien, recibí una solicitud para elaborar un poco más, lo que creo que fue sarcástico, ¡pero lo haré de todos modos porque es muy divertido! Si la gente quiere llevar esta idea a niveles completamente nuevos, entonces es posible realizar intersecciones establecidas sin siquiera pasar en bucle lineal a través de elementos N + M. Esta es mi última estructura de datos que he estado usando durante años y básicamente modelos set<int>
:

La razón por la que puede realizar intersecciones de conjuntos sin siquiera inspeccionar cada elemento en ambas listas es porque un solo bit de conjunto en la raíz de la jerarquía puede indicar que, digamos, un millón de elementos contiguos están ocupados en el conjunto. Simplemente inspeccionando un bit, podemos saber que N índices en el rango [first,first+N)
están en el conjunto, donde N podría ser un número muy grande.
De hecho, utilizo esto como un optimizador de bucle cuando recorro índices ocupados, porque digamos que hay 8 millones de índices ocupados en el conjunto. Bueno, normalmente tendríamos que acceder a 8 millones de enteros en la memoria en ese caso. Con este, puede inspeccionar algunos bits y generar rangos de índice de índices ocupados para recorrer. Además, los rangos de índices que se presentan están ordenados, lo que permite un acceso secuencial muy amigable con la caché en lugar de, por ejemplo, iterar a través de una matriz no clasificada de índices utilizados para acceder a los datos del elemento original. Por supuesto, esta técnica es peor para los casos extremadamente escasos, con el peor de los casos como que cada índice sea un número par (o cada uno sea impar), en cuyo caso no hay regiones contiguas en absoluto. Pero en mis casos de uso al menos,