Tại sao Hashset <Point> chậm hơn nhiều so với Hashset <chuỗi>?


165

Tôi muốn lưu trữ một số vị trí pixel mà không cho phép trùng lặp, vì vậy điều đầu tiên xuất hiện là HashSet<Point>hoặc các lớp tương tự. Tuy nhiên điều này dường như là rất chậm so với một cái gì đó như HashSet<string>.

Ví dụ: mã này:

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

mất khoảng 22,5 giây.

Mặc dù đoạn mã sau (không phải là lựa chọn tốt vì lý do rõ ràng) chỉ mất 1,6 giây:

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

Vì vậy, câu hỏi của tôi là:

  • Có một lý do cho điều đó? Tôi đã kiểm tra câu trả lời này , nhưng 22,5 giây là nhiều hơn những con số trong câu trả lời đó.
  • Có cách nào tốt hơn để lưu trữ điểm mà không trùng lặp?


Những "lý do rõ ràng" này cho việc không sử dụng các chuỗi nối là gì? Cách tốt hơn để làm điều đó là gì nếu tôi không muốn triển khai IEqualityComparer của riêng mình?
Ivan Yurchenko

Câu trả lời:


290

Có hai vấn đề hoàn hảo gây ra bởi cấu trúc Point. Một cái gì đó bạn có thể thấy khi bạn thêm Console.WriteLine(GC.CollectionCount(0));vào mã kiểm tra. Bạn sẽ thấy rằng bài kiểm tra Điểm yêu cầu ~ 3720 bộ sưu tập nhưng kiểm tra chuỗi chỉ cần ~ 18 bộ sưu tập. Không miễn phí. Khi bạn thấy một loại giá trị tạo ra rất nhiều bộ sưu tập thì bạn cần phải kết luận "uh-oh, quá nhiều quyền anh".

Vấn đề là HashSet<T>cần IEqualityComparer<T>phải hoàn thành công việc của mình. Vì bạn không cung cấp một cái, nó cần phải quay lại một cái được trả về EqualityComparer.Default<T>(). Phương pháp đó có thể làm tốt công việc cho chuỗi, nó thực hiện IEquitable. Nhưng không phải cho Point, nó là một loại gây ra từ .NET 1.0 và không bao giờ có được tình yêu chung chung. Tất cả những gì nó có thể làm là sử dụng các phương thức Object.

Vấn đề khác là Point.GetHashCode () không thực hiện công việc xuất sắc trong thử nghiệm này, quá nhiều va chạm, do đó, nó cản trở Object.Equals () khá nặng nề. Chuỗi có một triển khai GetHashCode tuyệt vời.

Bạn có thể giải quyết cả hai vấn đề bằng cách cung cấp cho Hashset một bộ so sánh tốt. Giống như cái này:

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

Và sử dụng nó:

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

Và bây giờ nó nhanh hơn khoảng 150 lần, dễ dàng đánh bại bài kiểm tra chuỗi.


26
+1 để cung cấp triển khai phương thức GetHashCode. Chỉ vì tò mò, làm thế nào bạn đi kèm với obj.X << 16 | obj.Y;việc thực hiện cụ thể .
Akash KC

32
Nó được truyền cảm hứng từ cách con chuột vượt qua vị trí của nó trong các cửa sổ. Nó là một hàm băm hoàn hảo cho bất kỳ bitmap nào bạn muốn hiển thị.
Hans Passant

2
Thật tốt khi biết điều đó. Bất kỳ tài liệu hoặc hướng dẫn tốt nhất để viết mã băm như của bạn? Trên thực tế, tôi vẫn muốn biết liệu mã băm ở trên có đi kèm với kinh nghiệm của bạn hay bất kỳ hướng dẫn nào mà bạn tuân theo hay không.
Akash KC

5
@AkashKC Tôi không có nhiều kinh nghiệm với C # nhưng theo tôi biết số nguyên nói chung là 32 bit. Trong trường hợp này, bạn muốn hàm băm gồm 2 số và bằng cách dịch chuyển trái một 16 bit, bạn đảm bảo 16 bit "thấp hơn" của mỗi số không "ảnh hưởng" đến số kia |. Đối với 3 số, có thể có ý nghĩa khi sử dụng 22 và 11 làm ca. Đối với 4 số sẽ là 24, 16, 8. Tuy nhiên, sẽ vẫn có va chạm nhưng chỉ khi các số đó lớn. Nhưng nó cũng chủ yếu phụ thuộc vào việc HashSetthực hiện. Nếu nó sử dụng địa chỉ mở với "cắt ngắn bit" (tôi không nghĩ vậy!) Thì cách tiếp cận dịch chuyển trái có thể là xấu.
MSeifert

3
@HansPassant: Tôi tự hỏi nếu sử dụng XOR thay vì OR trong GetHashCode có thể tốt hơn một chút - trong trường hợp tọa độ điểm có thể vượt quá 16 bit (có lẽ không phải trên màn hình chung, nhưng trong tương lai gần). // XOR thường tốt hơn trong các hàm băm so với OR, vì nó mất ít thông tin hơn, là đảo ngược, v.v. // ví dụ: Nếu tọa độ âm được cho phép, hãy xem xét điều gì xảy ra với đóng góp X nếu Y âm.
Krazy Glew

85

Lý do chính cho việc giảm hiệu suất là tất cả các quyền anh đang diễn ra (như đã được giải thích trong câu trả lời của Hans Passant ).

Ngoài ra, thuật toán mã băm làm trầm trọng thêm vấn đề, bởi vì nó gây ra nhiều cuộc gọi hơn Equals(object obj)do đó làm tăng số lượng chuyển đổi quyền anh.

Cũng lưu ý rằng mã băm củaPoint được tính bằng x ^ y. Điều này tạo ra rất ít sự phân tán trong phạm vi dữ liệu của bạn và do đó, các nhóm của HashSetquá đông dân cư - điều không xảy ra với string, trong đó độ phân tán của băm lớn hơn nhiều.

Bạn có thể giải quyết vấn đề đó bằng cách triển khai Pointcấu trúc (tầm thường) của riêng bạn và sử dụng thuật toán băm tốt hơn cho phạm vi dữ liệu dự kiến ​​của bạn, ví dụ: bằng cách thay đổi tọa độ:

(x << 16) ^ y

Để biết một số lời khuyên tốt khi nói về mã băm, hãy đọc bài đăng trên blog của Eric Lippert về chủ đề này .


4
Nhìn vào nguồn tham khảo của Point các màn GetHashCodetrình diễn: unchecked(x ^ y)trong khi stringnó có vẻ phức tạp hơn nhiều ..
Gilad Green

2
Hmm .. tốt, để kiểm tra xem giả định của bạn có đúng không, tôi chỉ thử sử dụng HashSet<long>()thay thế và sử dụng list.Add(unchecked(x ^ y));để thêm giá trị cho Hashset. Điều này thực sự thậm chí còn nhanh hơn HashSet<string> (345 ms) . Đây có phải là một số khác với những gì bạn mô tả?
Ahmed Abdelhameed

4
@AhmedAbdelhameed có lẽ là do bạn đang thêm ít thành viên vào bộ băm của mình hơn bạn nhận ra (một lần nữa do sự phân tán khủng khiếp của thuật toán mã băm). Số lượng listkhi bạn hoàn thành việc điền nó là gì?
Vào giữa

4
@AhmedAbdelhameed Bài kiểm tra của bạn sai. Bạn đang thêm nhiều lần lặp đi lặp lại, vì vậy thực sự chỉ có một vài yếu tố bạn đang chèn. Khi chèn point, HashSetsẽ gọi nội bộ GetHashCodevà cho từng điểm có cùng mã băm, sẽ gọi Equalsđể xác định xem nó có tồn tại hay không
Ofir Winegarten

49
Không cần phải thực hiện Pointkhi bạn có thể tạo một lớp thực hiện IEqualityComparer<Point>và duy trì khả năng tương thích với những thứ khác hoạt động Pointtrong khi vẫn có được lợi ích của việc không có người nghèo GetHashCodevà cần phải tham gia Equals().
Jon Hanna
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.