HashSet<T> это коллекция, в которой каждый элемент встречается не больше одного раза. У неё нет индекса и гарантированного порядка, а взамен она проверяет принадлежность примерно за постоянное время: найти один тег среди миллиона занимает примерно столько же времени, сколько среди десяти.
Добавление элементов: Add возвращает bool
Вывод:
True
False
3
True
True
False
2
Добавление дубликата не является ошибкой; Add просто возвращает false, а множество не меняется. Это возвращаемое значение самое полезное в методе. Оно объединяет «видел ли я это?» и «запомни это» в один вызов:
Вывод:
Duplicate: ana@x.com
Duplicate: ben@x.com
3 unique
Почему Contains быстрый
List<T>.Contains по очереди сравнивает значение с каждым элементом, поэтому его стоимость растёт вместе со списком. HashSet<T> вычисляет хеш-код элемента, переходит к корзине для этого кода и сравнивает только несколько хранящихся там элементов. Для проверки принадлежности внутри цикла это превращает шаг O(n) в шаг O(1), а вложенный цикл по двум спискам в один проход:
// Slow on large inputs: Contains scans bannedList for every order.
var flagged = orders.Where(o => bannedList.Contains(o.CustomerId));
// Fast: build the set once, then each lookup is constant time.
var banned = new HashSet<int>(bannedList);
var flagged2 = orders.Where(o => banned.Contains(o.CustomerId));
Построение множества стоит одного прохода по списку, поэтому оно окупается, только если вы ищете больше нескольких раз.
Удаление дубликатов из списка
Есть три распространённых способа, и они различаются тем, что происходит с порядком:
Вывод:
Lima, Oslo, Pune, Kyiv
4
Lima, Oslo, Pune, Kyiv
Distinct это правильный выбор по умолчанию, когда на выходе нужен список. Вариант на месте работает, потому что RemoveAll вызывает предикат по одному разу для каждого элемента по порядку: seen.Add возвращает false для второй и последующих копий, и удаляются ровно они.
Операции над множествами: объединение, пересечение, разность
У HashSet<T> есть операции из теории множеств. Методы ...With изменяют множество, у которого вызваны, и ничего не возвращают.
Вывод:
Union: Ana, Ben, Chloe, Dev
Intersect: Ben, Chloe
Except: Ana
Symmetric: Ana, Dev
True
True
False
True
Аргументом может быть любой IEnumerable<T>: массив, список или другое множество. SetEquals не учитывает порядок и дубликаты в аргументе. Вспомогательный метод Show сортирует перед выводом, потому что на порядок перебора множества нельзя полагаться.
У LINQ есть соответствующие методы, которые возвращают новую последовательность и не трогают входные данные: monday.Union(tuesday), monday.Intersect(tuesday), monday.Except(tuesday). Используйте их, когда не хотите изменять множество или когда на входе списки.
Собственное равенство для ваших классов
Множество решает, «тот же ли это элемент», через GetHashCode и Equals. У класса, который их не переопределяет, оба метода основаны на идентичности объекта, поэтому два объекта с равными полями это два разных элемента:
Вывод:
2
1
True
Правило: объекты, равные по Equals, должны возвращать одинаковый GetHashCode. Переопределите только Equals, и множество будет искать не в той корзине и по-прежнему показывать дубликаты. В .NET Core 2.1 и новее HashCode.Combine(X, Y) строит хороший хеш-код без ручной арифметики.
Когда класс изменить нельзя или для одного множества нужно другое понятие «одинаковости», передайте IEqualityComparer<T> в конструктор. Для строк есть готовые компараторы:
Вывод:
True
False
2
В C# 9 и новее record сам генерирует Equals и GetHashCode на основе значений, поэтому record Point(int X, int Y); работает в множестве без дополнительного кода.
Никогда не меняйте поле, участвующее в GetHashCode, пока объект находится в множестве. Объект остаётся в корзине своего старого хеша, поэтому Contains и Remove перестают его находить.
Порядок и SortedSet
HashSet<T> перебирается в порядке, который следует считать произвольным. Если нужны отсортированные элементы, сортируйте при выводе (set.OrderBy(x => x)) или используйте SortedSet<T>, который всегда держит элементы упорядоченными и добавляет Min, Max и запросы по диапазону за O(log n) на операцию:
var ranks = new SortedSet<int> { 30, 10, 20 };
Console.WriteLine(string.Join(", ", ranks)); // 10, 20, 30
Console.WriteLine(ranks.Min); // 10
HashSet, List или Dictionary
| Что нужно | Что использовать |
|---|---|
| Уникальные элементы, быстрая проверка «есть ли?» | HashSet<T> |
| Уникальные элементы, всегда отсортированные | SortedSet<T> |
| Порядок, дубликаты, доступ по индексу | List<T> |
| Значение под каждым ключом | Dictionary<TKey, TValue> |
Множество это словарь с ключами и без значений. Если вы пишете Dictionary<string, bool> только чтобы отслеживать принадлежность, HashSet<string> скажет то же самое понятнее.
Частые ошибки
- Ожидание порядка. У множества нет порядка, на который можно полагаться; сортируйте или используйте
SortedSet<T>. - Собственные классы без
EqualsиGetHashCode. Одинаково выглядящие объекты становятся отдельными элементами. - Переопределение одного
Equals. Всегда переопределяйте вместе с нимGetHashCode. - Изменение элемента после добавления. Множество больше не может его найти.
- Обращение к множеству по индексу.
set[0]не компилируется; индекса нет. Если нужны позиции, преобразуйте черезToList().
Часто задаваемые вопросы
Что такое HashSet в C#?
HashSet<T> это коллекция уникальных элементов без определённого порядка. Добавление элемента, который уже есть, ничего не делает, а Contains отвечает примерно за постоянное время, каким бы большим ни было множество, потому что элементы хранятся по хеш-коду, как ключи Dictionary.
Что возвращает HashSet.Add?
Add возвращает true, когда элемент добавлен, и false, когда он уже был в множестве. Поэтому if (!seen.Add(x)) это проверка на дубликат в одну строку: один и тот же вызов добавляет новые элементы и сообщает о повторных.
Как удалить дубликаты из List в C#?
list.Distinct().ToList() возвращает новый список без дубликатов и сохраняет первое вхождение каждого элемента в исходном порядке. new HashSet<T>(list) тоже убирает дубликаты, но у множества нет гарантированного порядка. Чтобы убрать дубликаты на месте, используйте var seen = new HashSet<T>(); list.RemoveAll(x => !seen.Add(x));.
Когда использовать HashSet вместо List?
Используйте HashSet<T>, когда вы в основном спрашиваете «есть ли этот элемент в коллекции?» или когда элементы должны быть уникальными. List<T>.Contains просматривает все элементы, поэтому замедляется по мере роста списка, а HashSet<T>.Contains нет. Используйте List<T>, когда важны порядок, дубликаты или доступ по индексу.
Почему мой HashSet содержит одинаковые объекты?
Ваш класс не переопределяет Equals и GetHashCode, поэтому множество сравнивает ссылки, и два объекта с одинаковыми значениями полей считаются разными. Переопределите оба метода (всегда вместе) или передайте IEqualityComparer<T> в конструктор множества.