Che tipo di algoritmo richiede un set?


10

Nei miei primi corsi di programmazione mi è stato detto che avrei dovuto usare un set ogni volta che dovevo fare cose come rimuovere duplicati di qualcosa. Ad esempio: per rimuovere tutti i duplicati da un vettore, scorrere attraverso detto vettore e aggiungere ogni elemento a un set, quindi ti rimangono delle occorrenze uniche. Tuttavia, potrei anche farlo aggiungendo ogni elemento a un altro vettore e verificando se l'elemento esiste già. Presumo che a seconda della lingua utilizzata ci possa essere una differenza nelle prestazioni. Ma c'è un motivo per usare un set diverso da quello?

Fondamentalmente: quali tipi di algoritmi richiedono un set e non dovrebbero essere fatti con nessun altro tipo di contenitore?


2
Puoi essere più specifico su cosa intendi quando usi il termine "set?" Ti riferisci a un set C ++?
Robert Harvey,

Sì, in realtà, la definizione "set" sembra essere abbastanza simile nella maggior parte delle lingue: un contenitore che accetta solo elementi unici.
Floella,

6
"aggiungendo ogni elemento a un altro vettore e controllando se l'elemento esiste già" - questo sta solo implementando un set da soli. Quindi ti stai chiedendo perché utilizzare una funzione integrata quando puoi scriverne una tu stesso a mano?
Jacques B,

Risposte:


8

Stai chiedendo dei set in particolare, ma penso che la tua domanda riguardi un concetto più ampio: l'astrazione. Hai assolutamente ragione di poter usare un Vector per fare questo (se stai usando Java, usa invece ArrayList.) Ma perché fermarsi qui? A cosa ti serve il Vector? Puoi fare tutto questo con array.

Ogni volta che devi aggiungere un elemento all'array, puoi semplicemente passare in rassegna ogni elemento e se non è presente, lo aggiungi alla fine. Ma, in realtà, devi prima controllare se c'è spazio nell'array. In caso contrario, è necessario creare un nuovo array più grande e copiare tutti gli elementi esistenti dal vecchio array al nuovo array, quindi è possibile aggiungere il nuovo elemento. Naturalmente, è anche necessario aggiornare ogni riferimento al vecchio array per puntare a quello nuovo. Hai fatto tutto ciò? Grande! Ora, cosa stavamo cercando di realizzare di nuovo?

Oppure, invece, potresti usare un'istanza Set e semplicemente chiamare add(). La ragione per cui esistono gli insiemi è che sono un'astrazione utile per molti problemi comuni. Ad esempio, supponiamo che tu voglia tenere traccia degli elementi e reagire quando ne viene aggiunto uno nuovo. Chiamiadd() un set e viene restituito trueo in falsebase alla modifica del set. Potresti scriverlo tutto a mano usando i primitivi ma perché?

In realtà potrebbe esserci un caso in cui si dispone di un elenco e si desidera rimuovere i duplicati. L'algoritmo che proponi è praticamente il modo più lento di farlo. Esistono un paio di modi più rapidi comuni: inserirli in secchi o ordinarli. In alternativa, è possibile aggiungerli a un set che implementa uno di quegli algoritmi.

All'inizio della tua carriera / educazione l'attenzione è focalizzata sulla costruzione di questi algoritmi e sulla loro comprensione ed è importante farlo. Ma non è quello che fanno normalmente gli sviluppatori professionisti. Usano questi approcci per costruire cose molto più interessanti e l'uso di implementazioni precostruite e affidabili consente di risparmiare un sacco di tempo.


23

Presumo che a seconda della lingua utilizzata ci possa essere una differenza nelle prestazioni. Ma c'è un motivo per usare un set diverso da quello?

Oh sì, (ma non è prestazione.)

Usa un set quando puoi usarne uno perché non usarlo significa che devi scrivere un codice extra. L'uso di un set semplifica la lettura delle tue attività. Tutti i test per la logica di unicità sono nascosti da qualche altra parte in cui non devi pensarci. È in un posto che è già stato testato e puoi fidarti che funziona.

Scrivi il tuo codice per farlo e devi preoccupartene. Bleh. Chi vuole farlo?

Fondamentalmente: quali tipi di algoritmi richiedono un set e non dovrebbero essere fatti con nessun altro tipo di contenitore?

Non esiste un algoritmo che "non dovrebbe essere fatto con nessun altro tipo di contenitore". Esistono semplicemente algoritmi che possono sfruttare i set. È bello quando non devi scrivere altro codice.

Ora non c'è nulla di particolarmente speciale riguardo a questo riguardo. Dovresti sempre usare la collezione più adatta alle tue esigenze. In java ho trovato questa immagine utile per prendere quella decisione. Noterai che ha tre diversi tipi di set.

inserisci qui la descrizione dell'immagine

E come giustamente sottolinea @germi, se usi la giusta collezione per il lavoro il tuo codice diventa più facile da leggere per gli altri.


6
Ne hai già parlato, ma l'uso di un set rende anche più facile per gli altri ragionare sul codice; non devono vedere come è popolato per sapere che contiene solo oggetti unici.
germi,

14

Tuttavia, potrei anche farlo aggiungendo ogni elemento a un altro vettore e verificando se l'elemento esiste già.

Se lo fai, stai implementando la semantica di un set in cima alla struttura dati vettoriale. Stai scrivendo codice aggiuntivo (che potrebbe contenere errori) e il risultato sarà estremamente lento se hai molte voci.

Perché vorresti farlo utilizzando una implementazione di set esistente, testata ed efficiente?


6

Le entità software che rappresentano entità del mondo reale sono spesso logicamente impostate. Ad esempio, considera un'auto. Le auto hanno identificatori univoci e un gruppo di auto costituisce un set. La nozione impostata funge da vincolo alla raccolta di automobili che un programma potrebbe conoscere e che vincolare i valori dei dati è molto prezioso.

Inoltre, i set hanno un'algebra molto ben definita. Se hai un set di Cars di proprietà di George e un set di proprietà di Alice, allora il sindacato è chiaramente il set di proprietà di George e Alice anche se George e Alice possiedono entrambi la stessa auto. Quindi gli algoritmi che dovrebbero usare gli insiemi sono quelli in cui la logica delle entità coinvolte mostra caratteristiche dell'insieme. Questo risulta essere abbastanza comune.

Come sono implementati gli insiemi e come è garantito il vincolo di unicità è un'altra cosa. Si spera di essere in grado di trovare un'implementazione appropriata per la logica degli insiemi che elimini i duplicati dato che gli insiemi sono così fondamentali per la logica, ma anche se si esegue l'implementazione da soli, la garanzia di unicità è intrinseca all'inserimento di un articolo in un insieme e non dovresti dover "verificare se l'elemento esiste già".


"Controllare se esiste già" è spesso essenziale per la deduplicazione. Spesso gli oggetti vengono creati dai dati. E vuoi solo un oggetto per dati identici, per essere riutilizzato da chiunque crei un oggetto dagli stessi dati. Quindi crei un nuovo oggetto, controlla se è nel set, se è lì prendi l'oggetto dal set, altrimenti inserisci l'oggetto. Se hai appena inserito l'oggetto, avresti comunque un sacco di oggetti identici.
gnasher729,

1
@ gnasher729 la responsabilità dell'implementatore di Set include il controllo dell'esistenza, ma un utente di Set può for 1..100: set.insert(10)e sa ancora che c'è solo un 10 nel set
Caleth

L'utente può creare cento oggetti diversi in dieci gruppi di oggetti uguali. Dopo l'inserimento ci sono dieci oggetti nel set, ma 100 oggetti continuano a fluttuare. Deduplicare significa che ci sono dieci oggetti nel set e tutti usano quei dieci oggetti. Ovviamente non hai solo bisogno di un test: hai bisogno di una funzione che fornisca un oggetto, restituisca l'oggetto corrispondente nel set.
gnasher729,

4

A parte le caratteristiche prestazionali (che sono molto significative e non dovrebbero essere ignorate così facilmente), i set sono molto importanti come una raccolta astratta.

Potresti emulare il comportamento Set (ignorando le prestazioni) con un array? Si assolutamente! Ogni volta che si inserisce, è possibile verificare se l'elemento è già nell'array e quindi aggiungere l'elemento solo se non è già stato trovato. Ma questo è qualcosa di cui devi essere consapevole e che devi ricordare ogni volta che lo inserisci nel tuo set Array-Psuedo. Oh cos'è, hai inserito una volta direttamente, senza prima controllare i duplicati? Welp, il tuo array ha rotto il suo invariante (che tutti gli elementi sono unici ed equivalentemente, che non esistono duplicati).

Quindi cosa faresti per aggirare questo? Dovresti creare un nuovo tipo di dati, chiamarlo (diciamo, PsuedoSet), che avvolge un array interno ed espone insertun'operazione pubblicamente, che imporrà l'univocità degli elementi. Poiché l'array con wrapping è accessibile solo tramite questa insertAPI pubblica , si garantisce che i duplicati non possano mai verificarsi. Ora aggiungi un po 'di hash per migliorare le prestazioni dei containscontrolli e prima o poi ti accorgerai di aver implementato un full-out Set.

Vorrei anche rispondere con una dichiarazione e rispondere alla domanda:

Nei miei primi corsi di programmazione mi è stato detto che avrei dovuto usare un array ogni volta che dovevo fare cose come memorizzare più elementi ordinati di qualcosa. Ad esempio: per archiviare una raccolta di nomi di colleghi. Tuttavia, potrei anche farlo allocando la memoria grezza e impostando il valore dell'indirizzo di memoria dato dal puntatore iniziale + un po 'di offset.

Potresti usare un puntatore non elaborato e offset compensati per imitare un array? Si assolutamente! Ogni volta che si inserisce, è possibile verificare se l'offset non si allontana dalla fine della memoria allocata con cui si sta lavorando. Ma è qualcosa di cui devi essere consapevole e che devi ricordare ogni volta che lo inserisci nella tua Pseudo-Matrice. Oh cos'è, hai inserito una volta direttamente, senza prima controllare l'offset? Welp, c'è un errore di segmentazione con il tuo nome sopra!

Quindi cosa faresti per aggirare questo? Dovresti creare un nuovo tipo di dati, chiamarlo (diciamo, PsuedoArray), che avvolge un puntatore e una dimensione ed espone insertun'operazione pubblicamente, che imporrà che l'offset non superi la dimensione. Poiché i dati compressi sono accessibili solo tramite questa insertAPI pubblica , si garantisce che non possono verificarsi overflow del buffer. Ora aggiungi alcune altre funzioni utili (ridimensionamento dell'array, eliminazione degli elementi, ecc.) E prima o poi ti accorgerai di aver implementato un full-out Array.


3

Esistono tutti i tipi di algoritmi basati su set, in particolare dove è necessario eseguire intersezioni e unioni di set e avere il risultato come un set.

Gli algoritmi basati su set sono utilizzati pesantemente in vari algoritmi di ricerca di percorsi, ecc.

Per un approfondimento sulla teoria degli insiemi consulta questo link: http://people.umass.edu/partee/NZ_2006/Set%20Theory%20Basics.pdf

Se è necessaria una semantica impostata, utilizzare una serie. Eviterà i bug a causa di duplicati spuri perché a un certo punto ti sei dimenticato di potare il vettore / elenco e sarà più veloce di quanto puoi fare potando costantemente il tuo vettore / elenco.


1

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::setistanze 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:

  1. I due aggregati contengono indici agli elementi.
  2. 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:

inserisci qui la descrizione dell'immagine

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.

inserisci qui la descrizione dell'immagine

... 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::vectorprenotazione 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 into 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 Tabbia 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::sorta 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:

inserisci qui la descrizione dell'immagine

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>:

inserisci qui la descrizione dell'immagine

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,


2
"Per calcolare le intersezioni impostate, eseguo l'iterazione attraverso il primo array e contrassegno gli elementi con un singolo bit. Quindi eseguo l'iterazione attraverso il secondo array e cerco elementi contrassegnati." Li segnate dove, sul secondo array?
JimmyJames l'

1
Oh, vedo, stai "internando" i dati in un singolo oggetto che rappresenta ciascun valore. È una tecnica interessante per un sottoinsieme di casi d'uso per i set. Non vedo alcun motivo per non implementare questo approccio come un'operazione sulla propria classe impostata.
JimmyJames l'

2
"È una soluzione invadente che viola l'incapsulamento in alcuni casi ..." Una volta ho capito cosa intendevi, mi è venuto in mente ma poi penso che non sia necessario. Se si dispone di una classe che ha gestito questo comportamento, gli oggetti indice potrebbero essere indipendenti da tutti i dati degli elementi ed essere condivisi tra tutte le istanze del tipo di raccolta. cioè ci sarebbe un set di dati principale e quindi ogni istanza rimanderebbe al set principale. Il multi-threading avrebbe bisogno di più complessità, ma penso che sarebbe gestibile.
JimmyJames l'

1
Sembra che questo potrebbe essere potenzialmente utile in una soluzione di database ma non so se ce ne siano implementati in questo modo. Grazie per averlo messo qui. Mi hai fatto lavorare la mente.
JimmyJames l'

1
Potresti elaborare un po 'di più? ;) Lo controllerò quando avrò un po '(un sacco) di tempo.
JimmyJames,

-1

Per verificare se un set contenente n elementi contiene un altro elemento X richiede in genere un tempo costante. Per verificare se un array contenente n elementi contiene un altro elemento X richiede in genere O (n) tempo. Questo è male, ma se vuoi rimuovere i duplicati da n elementi, improvvisamente ci vuole O (n) in tempo invece di O (n ^ 2); 100.000 articoli metteranno il tuo computer in ginocchio.

E stai chiedendo altri motivi? "A parte le riprese, ti è piaciuta la serata, signora Lincoln?"


2
Penso che potresti volerlo rileggere. Prendendo il tempo O (n) invece del tempo O (n²) è generalmente considerata una buona cosa.
JimmyJames,

Forse sei rimasto in testa mentre leggevi questo? OP ha chiesto "perché non semplicemente prendere un array".
gnasher729,

2
Perché passare da O (n²) a O (n) porterà un "computer in ginocchio"? Devo averlo perso nella mia classe.
JimmyJames,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.