Mengapa HashSet <Point> jauh lebih lambat dari HashSet <string>?

165

Saya ingin menyimpan beberapa lokasi piksel tanpa mengizinkan duplikat, jadi hal pertama yang terlintas dalam pikiran adalah HashSet<Point>kelas yang serupa. Namun ini sepertinya sangat lambat dibandingkan dengan sesuatu seperti HashSet<string>.

Misalnya, kode ini:

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));
        }
    }
}

membutuhkan waktu sekitar 22,5 detik.

Sementara kode berikut (yang bukan pilihan yang baik karena alasan yang jelas) hanya membutuhkan 1,6 detik:

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);
        }
    }
}

Jadi, pertanyaan saya adalah:

  • Apakah ada alasan untuk itu? Saya memeriksa jawaban ini , tetapi 22,5 detik jauh lebih banyak daripada angka yang ditunjukkan dalam jawaban itu.
  • Apakah ada cara yang lebih baik untuk menyimpan poin tanpa duplikat?
Ahmed Abdelhameed
sumber
Apa "alasan yang jelas" untuk tidak menggunakan string bersambung? Apa cara yang lebih baik untuk melakukannya jika saya tidak ingin mengimplementasikan IEqualityComparer saya sendiri?
Ivan Yurchenko

Jawaban:

290

Ada dua masalah kinerja yang disebabkan oleh struct Point. Sesuatu yang dapat Anda lihat ketika Anda menambah Console.WriteLine(GC.CollectionCount(0));kode tes. Anda akan melihat bahwa tes Point membutuhkan ~ 3720 koleksi tetapi tes string hanya membutuhkan ~ 18 koleksi. Tidak gratis. Ketika Anda melihat tipe nilai menginduksi begitu banyak koleksi maka Anda perlu menyimpulkan "uh-oh, terlalu banyak tinju".

Yang menjadi masalah adalah bahwa HashSet<T>perlu IEqualityComparer<T>untuk menyelesaikan tugasnya. Karena Anda tidak menyediakan satu, itu harus kembali ke satu dikembalikan oleh EqualityComparer.Default<T>(). Metode itu dapat melakukan pekerjaan yang baik untuk string, itu mengimplementasikan IEquatable. Tetapi tidak untuk Point, ini adalah tipe yang berasal dari .NET 1.0 dan tidak pernah mendapatkan cinta generik. Yang bisa dilakukan hanyalah menggunakan metode Object.

Masalah lainnya adalah bahwa Point.GetHashCode () tidak melakukan pekerjaan bintang dalam tes ini, terlalu banyak tabrakan, sehingga memalu Object.Equals () cukup banyak. String memiliki implementasi GetHashCode yang sangat baik.

Anda dapat menyelesaikan kedua masalah dengan menyediakan HashSet dengan pembanding yang baik. Seperti yang ini:

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;
    }
}

Dan gunakan itu:

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

Dan sekarang sekitar 150 kali lebih cepat, dengan mudah mengalahkan tes string.

Hans Passant
sumber
26
+1 untuk menyediakan implementasi metode GetHashCode. Hanya untuk rasa ingin tahu, bagaimana Anda datang dengan obj.X << 16 | obj.Y;implementasi khusus .
Akash KC
32
Itu terinspirasi oleh cara mouse melewati posisinya di windows. Ini adalah hash yang sempurna untuk bitmap apa pun yang ingin Anda tampilkan.
Hans Passant
2
Senang mengetahui hal itu. Adakah dokumentasi atau pedoman terbaik untuk menulis kode hash seperti milik Anda? Sebenarnya, saya masih ingin tahu apakah kode hash di atas datang dengan pengalaman Anda atau pedoman apa pun yang Anda ikuti.
Akash KC
5
@ AkashKC Saya tidak terlalu berpengalaman dengan C # tapi sejauh yang saya tahu bilangan bulat umumnya 32bit. Dalam hal ini Anda ingin hash dari 2 angka dan dengan menggeser satu 16bits kiri Anda memastikan 16 bit "yang lebih rendah" dari masing-masing angka tidak "mempengaruhi" yang lain dengan |. Untuk 3 angka, masuk akal untuk menggunakan 22 dan 11 sebagai shift. Untuk 4 angka akan menjadi 24, 16, 8. Namun masih akan ada tabrakan tetapi hanya jika jumlahnya menjadi besar. Tetapi juga sangat tergantung pada HashSetimplementasinya. Jika menggunakan open-adressing dengan "bit truncation" (saya rasa tidak!) Pendekatan shift kiri mungkin buruk.
MSeifert
3
@HansPassant: Saya ingin tahu apakah menggunakan XOR daripada ATAU di GetHashCode mungkin sedikit lebih baik - jika koordinat titik mungkin melebihi 16 bit (mungkin tidak pada tampilan umum, tetapi dalam waktu dekat). // XOR biasanya lebih baik dalam fungsi hash daripada OR, karena kehilangan lebih sedikit informasi, adalah reversibke, dll // misalnya. Jika koordinat negatif diperbolehkan, pertimbangkan apa yang terjadi pada kontribusi X jika Y negatif.
Krazy Glew
85

Alasan utama untuk penurunan kinerja adalah semua tinju terjadi (seperti yang sudah dijelaskan dalam jawaban Hans Passant ).

Terlepas dari itu, algoritma kode hash memperburuk masalah, karena menyebabkan lebih banyak panggilan Equals(object obj)sehingga meningkatkan jumlah konversi tinju.

Perhatikan juga bahwa kode hashPoint dihitung oleh x ^ y. Ini menghasilkan dispersi yang sangat sedikit dalam rentang data Anda, dan oleh karena itu ember dari HashSetpopulasi berlebih - sesuatu yang tidak terjadi string, di mana dispersi hash jauh lebih besar.

Anda dapat memecahkan masalah itu dengan mengimplementasikan Pointstruct Anda sendiri (sepele) dan menggunakan algoritma hash yang lebih baik untuk rentang data yang Anda harapkan, misalnya dengan menggeser koordinat:

(x << 16) ^ y

Untuk beberapa saran yang baik ketika datang ke kode hash, baca posting blog Eric Lippert pada subjek .

Diantara
sumber
4
Melihat sumber referensi Point yang GetHashCodeperform: unchecked(x ^ y)sementara untuk stringitu terlihat jauh lebih rumit ..
Gilad Green
2
Hmm .. yah, untuk memeriksa apakah asumsi Anda benar, saya hanya mencoba menggunakan HashSet<long>(), dan digunakan list.Add(unchecked(x ^ y));untuk menambahkan nilai ke HashSet. Ini sebenarnya bahkan lebih cepat daripada HashSet<string> (345 ms) . Apakah ini berbeda dari yang Anda gambarkan?
Ahmed Abdelhameed
4
@AhmedAbdelhameed itu mungkin karena Anda menambahkan lebih sedikit anggota ke hash set Anda daripada yang Anda sadari (lagi-lagi karena penyebaran mengerikan dari algoritma kode hash). Apa hitungannya listketika Anda selesai mengisinya?
InBetween
4
@AhmedAbdelhameed Tes Anda salah. Anda menambahkan rindu yang sama berulang-ulang, jadi sebenarnya hanya ada beberapa elemen yang Anda sisipkan. Saat memasukkan point, HashSetakan secara internal memanggil GetHashCodedan untuk masing-masing poin dengan kode hash yang sama, akan memanggil Equalsuntuk menentukan apakah sudah ada
Ofir Winegarten
49
Tidak perlu untuk mengimplementasikan Pointketika Anda dapat membuat kelas yang mengimplementasikan IEqualityComparer<Point>dan menjaga kompatibilitas dengan hal-hal lain yang bekerja dengan Pointsementara mendapatkan manfaat dari tidak memiliki yang miskin GetHashCodedan kebutuhan untuk masuk Equals().
Jon Hanna