Почему HashSet <Point> намного медленнее, чем HashSet <string>?

165

Я хотел сохранить некоторые пиксельные местоположения, не допуская дублирования, поэтому первое, что приходит на ум, - это HashSet<Point>или подобные классы. Однако это кажется очень медленным по сравнению с чем-то вроде HashSet<string>.

Например, этот код:

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

занимает около 22,5 секунд.

В то время как следующий код (который не является хорошим выбором по понятным причинам) занимает всего 1,6 секунды:

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

Итак, мои вопросы:

  • Есть ли причина для этого? Я проверил этот ответ , но 22,5 секунды - это намного больше, чем числа, показанные в этом ответе.
  • Есть ли лучший способ хранить очки без дубликатов?
Ахмед Абдельхамид
источник
Каковы эти "очевидные причины" для того, чтобы не использовать сцепленные строки? Каков лучший способ сделать это, если я не хочу реализовывать свой собственный IEqualityComparer?
Иван Юрченко

Ответы:

290

Есть две проблемы перфорирования, вызванные структурой Point. Что-то, что вы можете увидеть, когда добавляете Console.WriteLine(GC.CollectionCount(0));тестовый код. Вы увидите, что для теста Point требуется ~ 3720 наборов, а для строкового теста требуется всего ~ 18 наборов. Не бесплатно Когда вы видите, что тип значения вызывает так много коллекций, вам нужно заключить "э-э-э, слишком много бокса".

Вопрос в том, что HashSet<T>нужно, IEqualityComparer<T>чтобы сделать свою работу. Так как вы не предоставили один, он должен вернуться к одному, возвращенному EqualityComparer.Default<T>(). Этот метод может хорошо работать со строками, он реализует IEquatable. Но не для Point, это тип, взятый из .NET 1.0 и никогда не получавший любовь к дженерикам. Все, что он может сделать, это использовать методы Object.

Другая проблема заключается в том, что Point.GetHashCode () не выполняет звездную работу в этом тесте, слишком много коллизий, поэтому он довольно сильно забивает Object.Equals (). String имеет отличную реализацию GetHashCode.

Вы можете решить обе проблемы, предоставив HashSet хороший компаратор. Как этот:

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

И использовать это:

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

И теперь это примерно в 150 раз быстрее, легко обгоняя тест строки.

Ганс Пассант
источник
26
+1 за предоставление реализации метода GetHashCode. Просто для любопытства, как вы пришли с конкретной obj.X << 16 | obj.Y;реализацией.
Акаш КЦ
32
Он был вдохновлен тем, как мышь проходит свое положение в окнах. Это идеальный хеш для любого растрового изображения, которое вы когда-либо захотите отобразить.
Ганс
2
Приятно знать, что. Любая документация или лучшее руководство для написания хэш-кода, как у вас? На самом деле, я все еще хотел бы знать, идет ли приведенный выше хэш-код с вашим опытом или с какими-либо рекомендациями, которым вы следуете.
Акаш КЦ
5
@AkashKC Я не очень разбираюсь в C #, но, насколько я знаю, целые числа обычно 32-битные. В этом случае вам нужен хеш из 2 чисел, и, сдвигая влево на 16 бит, вы убедитесь, что «младшие» 16 бит каждого числа не «влияют» на другие |. Для 3 чисел может иметь смысл использовать 22 и 11 в качестве смены. Для 4 чисел это будет 24, 16, 8. Однако столкновения все же будут, но только если числа станут большими. Но это также в решающей степени зависит от HashSetреализации. Если он использует открытую адресацию с «усечением битов» (я не думаю, что это так!), Подход с левым сдвигом может быть плохим.
MSeifert
3
@HansPassant: Интересно, может быть, использование XOR вместо OR в GetHashCode может быть немного лучше - в случае, если координаты точки могут превышать 16 бит (возможно, не на обычных дисплеях, но в ближайшем будущем). // XOR обычно лучше в хеш-функциях, чем OR, так как он теряет меньше информации, вызывает реверсибке и т. Д. // Например, если допустимы отрицательные координаты, рассмотрим, что происходит с вкладом X, если Y отрицателен.
Крейзи Глеу
85

Основная причина снижения производительности - все происходящее в боксе (как уже объяснялось в ответе Ханса Пассанта ).

Кроме того, алгоритм хеш-кода усугубляет проблему, потому что он вызывает больше вызовов, Equals(object obj)тем самым увеличивая количество преобразований в бокс.

Также обратите внимание, что хэш-кодPoint вычисляется как x ^ y. Это приводит к очень небольшому разбросу в вашем диапазоне данных, и, следовательно, блоки HashSetпереполнены - то, чего не происходит string, где разброс хэшей намного больше.

Вы можете решить эту проблему, реализовав собственную Pointструктуру (тривиальную) и используя лучший алгоритм хеширования для ожидаемого диапазона данных, например, смещая координаты:

(x << 16) ^ y

Чтобы получить полезные советы по хэш-кодам, прочитайте сообщение Эрика Липперта в блоге на эту тему .

Между
источник
4
Глядя на исходный источник Point, он GetHashCodeвыполняет: unchecked(x ^ y)пока stringон выглядит гораздо сложнее ..
Гилад Грин,
2
Хм .. ну, чтобы проверить, правильно ли ваше предположение, я просто попытался использовать HashSet<long>()вместо этого и использовал list.Add(unchecked(x ^ y));для добавления значений в HashSet. Это было даже быстрее, чем HashSet<string> (345 мс) . Это как-то отличается от того, что вы описали?
Ахмед Абдельхамид
4
@AhmedAbdelhameed это, вероятно, потому, что вы добавляете гораздо меньше членов в свой хэш-набор, чем вы думаете (опять же из-за ужасного разброса алгоритма хэш-кода). Какой счет, listкогда вы закончили заполнять его?
период с
4
@AhmedAbdelhameed Ваш тест неверен. Вы добавляете одни и те же длинные снова и снова, так что на самом деле вставляемых элементов мало. При вставке point, то HashSetбудет внутренне позвонить GetHashCodeи для каждой из этих точек с одной и той же хэш - код, позвонит , Equalsчтобы определить , если он уже существует
Офир Winegarten
49
Нет необходимости реализовывать, Pointкогда вы можете создать класс, который реализует IEqualityComparer<Point>и поддерживает совместимость с другими вещами, с которыми вы работаете Point, получая при этом выгоду от отсутствия бедных GetHashCodeи необходимости заниматься боксом Equals().
Джон Ханна