In realtà trovo che i container standard siano principalmente inutili per me stesso e preferisco usare solo array ma lo faccio in un modo diverso.
Per calcolare le intersezioni impostate, scorrere il primo array e contrassegnare gli elementi con un singolo bit. Quindi eseguo l'iterazione attraverso il secondo array e cerco elementi contrassegnati. Voilà, imposta l'intersezione nel tempo lineare con molto meno lavoro e memoria di una tabella hash, ad es. Unioni e differenze sono ugualmente semplici da applicare usando questo metodo. Aiuta il mio codebase a ruotare attorno agli elementi di indicizzazione piuttosto che a duplicarli (duplico gli indici su elementi, non i dati degli elementi stessi) e raramente ha bisogno di qualcosa da ordinare, ma non uso una struttura di dati impostata da anni un risultato.
Ho anche un po 'di codice C malefico che utilizzo anche quando gli elementi non offrono campi di dati per tali scopi. Implica l'utilizzo della memoria degli elementi stessi impostando il bit più significativo (che non uso mai) allo scopo di contrassegnare gli elementi attraversati. È piuttosto disgustoso, non farlo a meno che tu non stia davvero lavorando a livello di quasi assemblaggio, ma volevo solo menzionare come può essere applicabile anche nei casi in cui gli elementi non forniscono un campo specifico per l'attraversamento se puoi garantire che alcuni bit non verranno mai utilizzati. Può calcolare una intersezione impostata tra 200 milioni di elementi (circa 2,4 GB di dati) in meno di un secondo sul mio iink i7. Prova a fare un'intersezione fissa tra due std::set
istanze contenenti cento milioni di elementi ciascuna contemporaneamente; non si avvicina nemmeno.
A parte questo...
Tuttavia, potrei anche farlo aggiungendo ogni elemento a un altro vettore e verificando se l'elemento esiste già.
Questo controllo per vedere se esiste già un elemento nel nuovo vettore sarà generalmente un'operazione di tempo lineare, che renderà l'intersezione impostata stessa un'operazione quadratica (quantità esplosiva di lavoro maggiore è la dimensione di input). Raccomando la tecnica sopra se vuoi solo usare semplici vecchi vettori o array e farlo in un modo che si ridimensiona meravigliosamente.
Fondamentalmente: quali tipi di algoritmi richiedono un set e non dovrebbero essere fatti con nessun altro tipo di contenitore?
Nessuna se chiedi la mia opinione parziale se ne stai parlando a livello di contenitore (come in una struttura di dati specificamente implementata per fornire operazioni impostate in modo efficiente), ma ci sono molte che richiedono una logica impostata a livello concettuale. Ad esempio, supponiamo che tu voglia trovare le creature in un mondo di gioco che sono in grado sia di volare che di nuotare e che hai creature volanti in un set (indipendentemente dal fatto che tu usi effettivamente un container set) e quelle che possono nuotare in un altro . In tal caso, si desidera un incrocio impostato. Se vuoi creature che possono volare o che sono magiche, allora usi un'unione prestabilita. Ovviamente non è effettivamente necessario un container set per implementarlo, e l'implementazione più ottimale generalmente non ha bisogno o desidera un container specificamente progettato per essere un set.
Uscire dalla tangente
Bene, ho ricevuto alcune belle domande da JimmyJames riguardo a questo approccio all'intersezione. È un po 'deviante dal soggetto ma vabbè, sono interessato a vedere più persone usare questo approccio intrusivo di base per impostare l'intersezione in modo che non stiano costruendo intere strutture ausiliarie come alberi binari bilanciati e tabelle di hash solo allo scopo di operazioni di set. Come accennato, il requisito fondamentale è che elenchi gli elementi di copia superficiale in modo che siano indicizzati o puntati a un elemento condiviso che può essere "contrassegnato" come attraversato dal passaggio attraverso il primo elenco o array non ordinato o qualsiasi altra cosa da raccogliere successivamente nel secondo passare attraverso il secondo elenco.
Tuttavia, ciò può essere realizzato praticamente anche in un contesto multithreading senza toccare gli elementi a condizione che:
- I due aggregati contengono indici agli elementi.
- La gamma di indici non è troppo grande (diciamo [0, 2 ^ 26), non miliardi o più) e sono ragionevolmente occupati.
Questo ci consente di utilizzare un array parallelo (solo un bit per elemento) ai fini delle operazioni impostate. Diagramma:
La sincronizzazione dei thread deve essere presente solo quando si acquisisce un array di bit parallelo dal pool e lo si rilascia nuovamente nel pool (fatto implicitamente quando si esce dall'ambito). I due loop effettivi per eseguire l'operazione impostata non devono comportare alcuna sincronizzazione dei thread. Non abbiamo nemmeno bisogno di usare un pool di bit parallelo se il thread può semplicemente allocare e liberare i bit localmente, ma il pool di bit può essere utile per generalizzare il modello in codebase che si adattano a questo tipo di rappresentazione dei dati in cui gli elementi centrali sono spesso referenziati per indice in modo che ogni thread non debba preoccuparsi di un'efficiente gestione della memoria. I primi esempi per la mia area sono sistemi a componenti di entità e rappresentazioni di mesh indicizzate. Entrambi hanno spesso bisogno di intersezioni fisse e tendono a fare riferimento a tutto ciò che viene memorizzato centralmente (componenti ed entità in ECS e vertici, bordi,
Se gli indici non sono densamente occupati e scarsamente sparsi, questo è ancora applicabile con un'implementazione sparsa ragionevole dell'array bit / booleano parallelo, come uno che memorizza solo la memoria in blocchi a 512 bit (64 byte per nodo non srotolato che rappresenta 512 indici contigui ) e salta allocare blocchi contigui completamente vuoti. È probabile che tu stia già utilizzando qualcosa di simile se le tue strutture di dati centrali sono scarsamente occupate dagli elementi stessi.
... idea simile per un bitset sparse per fungere da array di bit parallelo. Queste strutture si prestano anche all'immutabilità poiché è facile copiare superficialmente blocchi pesanti che non hanno bisogno di essere copiati in profondità per creare una nuova copia immutabile.
Ancora una volta impostare intersezioni tra centinaia di milioni di elementi può essere fatto in meno di un secondo usando questo approccio su una macchina molto media, e questo è all'interno di un singolo thread.
Può anche essere eseguito in meno della metà del tempo se il client non ha bisogno di un elenco di elementi per l'intersezione risultante, come se volesse applicare solo una logica agli elementi presenti in entrambi gli elenchi, a quel punto possono semplicemente passare un puntatore a funzione o un funzione o delegato o qualsiasi cosa da richiamare per elaborare intervalli di elementi che si intersecano. Qualcosa in tal senso:
// '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 qualcosa in tal senso. La parte più costosa dello pseudocodice nel primo diagramma è intersection.append(index)
nel secondo ciclo, e ciò vale anche per la std::vector
prenotazione anticipata delle dimensioni dell'elenco più piccolo.
Cosa succede se copio in profondità tutto?
Bene, basta! Se è necessario impostare intersezioni, significa che si stanno duplicando i dati su cui intersecare. È probabile che anche i tuoi oggetti più piccoli non siano più piccoli di un indice a 32 bit. È molto possibile ridurre l'intervallo di indirizzamento dei tuoi elementi a 2 ^ 32 (2 ^ 32 elementi, non 2 ^ 32 byte) a meno che tu non abbia effettivamente bisogno di più di ~ 4,3 miliardi di elementi istanziati, a quel punto è necessaria una soluzione totalmente diversa ( e che sicuramente non utilizza set container in memoria).
Corrispondenze chiave
Che ne dite di casi in cui dobbiamo fare operazioni in cui gli elementi non sono identici ma potrebbero avere chiavi corrispondenti? In tal caso, stessa idea di cui sopra. Dobbiamo solo mappare ogni chiave univoca a un indice. Se la chiave è una stringa, ad esempio, le stringhe internate possono fare proprio questo. In questi casi è richiesta una bella struttura di dati come un trie o una tabella hash per mappare le chiavi di stringa agli indici a 32 bit, ma non abbiamo bisogno di tali strutture per eseguire le operazioni impostate sugli indici a 32 bit risultanti.
Un sacco di soluzioni algoritmiche e strutture di dati molto economiche e semplici si aprono come queste quando possiamo lavorare con gli indici su elementi in un intervallo molto ragionevole, non l'intero intervallo di indirizzamento della macchina, e quindi spesso vale la pena di esserlo in grado di ottenere un indice univoco per ogni chiave univoca.
Adoro gli indici!
Adoro gli indici tanto quanto la pizza e la birra. Quando avevo 20 anni, mi sono appassionato al C ++ e ho iniziato a progettare tutti i tipi di strutture di dati completamente conformi agli standard (compresi i trucchi per disambiguare un ctor di riempimento da un cact range al momento della compilazione). Col senno di poi è stata una grande perdita di tempo.
Se ruoti il tuo database attorno alla memorizzazione centralizzata degli elementi negli array e alla loro indicizzazione piuttosto che alla loro memorizzazione in un modo frammentato e potenzialmente nell'intero intervallo indirizzabile della macchina, allora puoi finire per esplorare un mondo di possibilità algoritmiche e di struttura dei dati semplicemente progettando contenitori e algoritmi che ruotano attorno a vecchi int
o semplici int32_t
. E ho scoperto che il risultato finale è molto più efficiente e facile da mantenere dove non trasferivo costantemente elementi da una struttura di dati a un'altra a un'altra.
Alcuni esempi usano casi in cui puoi semplicemente supporre che qualsiasi valore univoco di T
abbia un indice univoco e che avrà istanze residenti in un array centrale:
Ordinamenti radix multithread che funzionano bene con numeri interi senza segno per gli indici . In realtà ho un ordinamento radix multithread che impiega circa 1/10 del tempo per ordinare un centinaio di milioni di elementi come l'ordinamento parallelo di Intel, e quello di Intel è già 4 volte più veloce rispetto std::sort
a input così grandi. Ovviamente Intel è molto più flessibile poiché è un ordinamento basato sul confronto e può ordinare le cose lessicograficamente, quindi sta confrontando le mele con le arance. Ma qui ho spesso bisogno solo di arance, come potrei fare un passaggio di ordinamento radix solo per ottenere modelli di accesso alla memoria compatibili con la cache o filtrare i duplicati rapidamente.
Capacità di costruire strutture collegate come liste collegate, alberi, grafici, tabelle hash concatenate separate, ecc. Senza allocazioni di heap per nodo . Possiamo semplicemente allocare i nodi in blocco, parallelamente agli elementi e collegarli insieme agli indici. I nodi stessi diventano semplicemente un indice a 32 bit per il nodo successivo e memorizzati in un array di grandi dimensioni, in questo modo:
Amichevole per l'elaborazione parallela. Spesso le strutture collegate non sono così amichevoli per l'elaborazione parallela, dal momento che è almeno imbarazzante cercare di ottenere il parallelismo nell'albero o l'attraversamento dell'elenco collegato invece, diciamo, semplicemente facendo un parallelo per il ciclo attraverso un array. Con la rappresentazione indice / array centrale, possiamo sempre andare a quell'array centrale ed elaborare tutto in loop paralleli pesanti. Abbiamo sempre quell'array centrale di tutti gli elementi che possiamo elaborare in questo modo, anche se vogliamo solo elaborarne alcuni (a quel punto potresti elaborare gli elementi indicizzati da un elenco ordinato da Radix per un accesso compatibile con la cache attraverso l'array centrale).
Può associare i dati a ciascun elemento al volo in tempo costante . Come nel caso della matrice parallela di bit di cui sopra, possiamo associare in modo semplice ed estremamente economico dati paralleli ad elementi per, ad esempio, l'elaborazione temporanea. Questo ha casi d'uso oltre ai dati temporanei. Ad esempio, un sistema mesh potrebbe voler consentire agli utenti di collegare a una mesh tutte le mappe UV che desiderano. In tal caso, non possiamo semplicemente codificare quante mappe UV ci saranno in ogni singolo vertice e faccia usando un approccio AoS. Dobbiamo essere in grado di associare tali dati al volo e le matrici parallele sono utili lì e molto più economiche di qualsiasi tipo di contenitore associativo sofisticato, persino tabelle di hash.
Ovviamente le matrici parallele sono disapprovate a causa della loro natura soggetta a errori di mantenere le matrici parallele in sincronia tra loro. Ogni volta che rimuoviamo un elemento all'indice 7 dall'array "root", ad esempio, dobbiamo anche fare la stessa cosa per i "figli". Tuttavia, è abbastanza facile nella maggior parte dei linguaggi generalizzare questo concetto a un contenitore per scopi generici in modo che la logica complicata per mantenere le matrici parallele in sincronia tra loro sia necessaria solo in un posto nell'intera base di codice, e tale contenitore di matrici parallele può utilizzare l'implementazione di array sparsi sopra per evitare di sprecare molta memoria per spazi vuoti contigui nell'array da recuperare dopo inserimenti successivi.
Più elaborazione: albero di Bitset sparse
Va bene, ho ricevuto una richiesta per elaborare un po 'di più che penso fosse sarcastico, ma lo farò comunque perché è così divertente! Se le persone vogliono portare questa idea a livelli completamente nuovi, è possibile eseguire intersezioni impostate senza nemmeno scorrere in modo lineare gli elementi N + M. Questa è la mia struttura dati definitiva che utilizzo da anni e fondamentalmente modelli set<int>
:
Il motivo per cui può eseguire intersezioni di set senza nemmeno ispezionare ogni elemento in entrambe le liste è perché un singolo bit di set alla radice della gerarchia può indicare che, per esempio, un milione di elementi contigui sono occupati nel set. Ispezionando solo un bit, possiamo sapere che N indici nell'intervallo [first,first+N)
sono nell'insieme, dove N potrebbe essere un numero molto grande.
In realtà lo uso come ottimizzatore di loop quando si attraversano gli indici occupati, perché supponiamo che ci siano 8 milioni di indici occupati nel set. Bene, normalmente dovremmo accedere a 8 milioni di numeri interi in memoria in quel caso. Con questo, può potenzialmente solo ispezionare alcuni bit e inventare intervalli di indici di indici occupati. Inoltre, gli intervalli di indici che ne derivano sono in ordine ordinato, il che consente un accesso sequenziale molto intuitivo rispetto alla cache, al contrario, per esempio, dell'iterazione attraverso una matrice non ordinata di indici utilizzati per accedere ai dati dell'elemento originale. Ovviamente questa tecnica ha un prezzo peggiore per i casi estremamente rari, con lo scenario peggiore come se ogni singolo indice fosse un numero pari (o ognuno fosse dispari), nel qual caso non ci sono regioni contigue di sorta. Ma almeno nei miei casi d'uso,