Perché HashSet <Point> è molto più lento di HashSet <string>?


165

Volevo memorizzare alcune posizioni di pixel senza consentire i duplicati, quindi la prima cosa che mi viene in mente è HashSet<Point>o classi simili. Tuttavia, questo sembra essere molto lento rispetto a qualcosa di simile HashSet<string>.

Ad esempio, questo codice:

HashSet<Point> points = new HashSet<Point>();
using (Bitmap img = new Bitmap(1000, 1000))
{
    for (int x = 0; x < img.Width; x++)
    {
        for (int y = 0; y < img.Height; y++)
        {
            points.Add(new Point(x, y));
        }
    }
}

dura circa 22,5 secondi.

Mentre il codice seguente (che non è una buona scelta per ovvi motivi) richiede solo 1,6 secondi:

HashSet<string> points = new HashSet<string>();
using (Bitmap img = new Bitmap(1000, 1000))
{
    for (int x = 0; x < img.Width; x++)
    {
        for (int y = 0; y < img.Height; y++)
        {
            points.Add(x + "," + y);
        }
    }
}

Quindi, le mie domande sono:

  • C'è una ragione per questo? Ho controllato questa risposta , ma 22,5 secondi è molto più dei numeri mostrati in quella risposta.
  • Esiste un modo migliore per archiviare punti senza duplicati?


Quali sono questi "ovvi motivi" per non usare stringhe concatenate? Qual è il modo migliore per farlo se non voglio implementare il mio IEqualityComparer?
Ivan Yurchenko,

Risposte:


290

Ci sono due problemi perf indotti dalla struttura Point. Qualcosa che puoi vedere quando aggiungi Console.WriteLine(GC.CollectionCount(0));al codice di test. Vedrai che il test Point richiede ~ 3720 raccolte ma il test stringa richiede solo ~ 18 raccolte. Non gratis Quando vedi un tipo di valore indurre così tante raccolte allora devi concludere "uh-oh, troppa boxe".

In questione è che ha HashSet<T>bisogno di un IEqualityComparer<T>per fare il suo lavoro. Dato che non ne hai fornito uno, deve ricorrere a uno restituito da EqualityComparer.Default<T>(). Questo metodo può fare un buon lavoro per la stringa, implementa IEquatable. Ma non per Point, è un tipo che si rifà a .NET 1.0 e non ha mai avuto l'amore dei generici. Tutto ciò che può fare è usare i metodi Object.

L'altro problema è che Point.GetHashCode () non fa un lavoro stellare in questo test, troppe collisioni, quindi martella Object.Equals () piuttosto pesantemente. String ha un'eccellente implementazione di GetHashCode.

È possibile risolvere entrambi i problemi fornendo a HashSet un buon comparatore. Come questo:

class PointComparer : IEqualityComparer<Point> {
    public bool Equals(Point x, Point y) {
        return x.X == y.X && x.Y == y.Y;
    }

    public int GetHashCode(Point obj) {
        // Perfect hash for practical bitmaps, their width/height is never >= 65536
        return (obj.Y << 16) ^ obj.X;
    }
}

E usalo:

HashSet<Point> list = new HashSet<Point>(new PointComparer());

Ed è ora circa 150 volte più veloce, battendo facilmente il test delle stringhe.


26
+1 per fornire l'implementazione del metodo GetHashCode. Solo per curiosità, come sei arrivato con obj.X << 16 | obj.Y;un'implementazione particolare .
Akash KC,

32
È stato ispirato dal modo in cui il mouse passa la sua posizione in Windows. È un hash perfetto per qualsiasi bitmap che vorresti mai visualizzare.
Hans Passant,

2
Buono a sapersi. Qualche documentazione o linea guida migliore per scrivere hashcode come la tua? In realtà, vorrei ancora sapere se sopra l'hashcode viene fornito con la tua esperienza o qualsiasi linea guida che segui.
Akash KC,

5
@AkashKC Non ho molta esperienza con C # ma per quanto ne so gli interi sono generalmente 32 bit. In questo caso si desidera l'hash di 2 numeri e spostando a sinistra di 16 bit si assicura che i 16 bit "inferiori" di ciascun numero non "influenzino" l'altro |. Per 3 numeri potrebbe avere senso usare 22 e 11 come turno. Per 4 numeri sarebbe 24, 16, 8. Tuttavia ci saranno ancora collisioni ma solo se i numeri diventano grandi. Ma dipende anche in modo cruciale HashSetdall'implementazione. Se utilizza un indirizzo aperto con "troncamento dei bit" (non credo lo faccia!), L'approccio con spostamento a sinistra potrebbe essere negativo.
MSeifert,

3
@HansPassant: Mi chiedo se usare XOR anziché OR in GetHashCode potrebbe essere leggermente migliore - nel caso in cui le coordinate del punto possano superare i 16 bit (forse non su schermi comuni, ma nel prossimo futuro). // XOR è di solito migliore nelle funzioni hash di OR, poiché perde meno informazioni, è reversibile, ecc. // Ad esempio, se sono consentite coordinate negative, considera cosa succede al contributo X se Y è negativo.
Krazy Glew,

85

Il motivo principale del calo delle prestazioni è tutto il pugilato in corso (come già spiegato nella risposta di Hans Passant ).

A parte questo, l'algoritmo del codice hash aggrava il problema, perché provoca più chiamate per Equals(object obj)aumentare la quantità di conversioni di boxe.

Si noti inoltre che il codice hash diPoint viene calcolato da x ^ y. Questo produce pochissima dispersione nel tuo intervallo di dati, e quindi i secchi di HashSetsono sovrappopolati - qualcosa che non accade con string, dove la dispersione degli hash è molto più grande.

Puoi risolvere quel problema implementando la tua Pointstruttura (banale) e usando un algoritmo di hash migliore per il tuo intervallo di dati previsto, ad esempio spostando le coordinate:

(x << 16) ^ y

Per qualche buon consiglio quando si tratta di codici hash, leggi il post sul blog di Eric Lippert sull'argomento .


4
Guardando la fonte di riferimento di Point the GetHashCodePerform: unchecked(x ^ y)mentre stringsembra molto più complicato ..
Gilad Green

2
Hmm .. beh, per verificare se la tua assunzione è corretta, ho appena provato a usare HashSet<long>()invece, e ho usato list.Add(unchecked(x ^ y));per aggiungere valori a HashSet. Questo è stato addirittura più veloce di HashSet<string> (345 ms) . È in qualche modo diverso da quello che hai descritto?
Ahmed Abdelhameed,

4
@AhmedAbdelhameed è probabilmente perché stai aggiungendo meno membri al tuo set di hash di quanto pensi (di nuovo a causa della terribile dispersione dell'algoritmo del codice hash). Qual è il conteggio di listquando hai finito di popolarlo?
Tra il

4
@AhmedAbdelhameed Il tuo test è sbagliato. Stai aggiungendo sempre gli stessi lunghi, quindi in realtà ci sono solo pochi elementi che stai inserendo. Durante l'inserimento point, HashSetchiamerà internamente GetHashCodee per ciascuno di quei punti con lo stesso hashcode, chiamerà Equalsper determinare se è già esistente
Ofir Winegarten,

49
Non è necessario implementare Pointquando è possibile creare una classe che implementa IEqualityComparer<Point>e mantenere la compatibilità con altre cose con cui si lavora Pointottenendo il vantaggio di non avere i poveri GetHashCodee la necessità di inscatolarsi Equals().
Jon Hanna,
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.