1. Introducción
Imagínate esta situación: estás desarrollando un sistema para una tienda online y necesitas guardar la lista de todos los códigos únicos de productos que alguna vez se han vendido. O mejor aún, estás programando una app de red social y tienes que comprobar rápido si ya existe ese nombre de usuario. ¿Y si quieres contar cuántas palabras únicas hay en un texto grande?
En todos estos escenarios necesitamos un conjunto de elementos donde cada elemento aparece solo una vez. Y aquí es donde entra en juego HashSet<T>.
HashSet<T> es una colección que guarda un conjunto desordenado de elementos únicos. La palabra clave aquí es únicos. Si intentas añadir un elemento que ya está en el HashSet, simplemente va a ignorar tu intento y no añadirá el duplicado. Es como un club "solo para gente única": si ya estás dentro, no te dejan entrar otra vez.
Características clave de HashSet<T>:
- Unicidad: Garantiza que cada elemento en la colección está solo una vez.
- Rendimiento: Comprueba, añade y borra elementos muy rápido. Normalmente estas operaciones se hacen en tiempo constante (O(1)), ¡da igual cuántos elementos haya! Esto se consigue gracias a un mecanismo llamado hashing.
- Sin orden: A diferencia de List<T>, los elementos en HashSet<T> no tienen ningún orden concreto. No puedes obtener un elemento por índice (por ejemplo, "el quinto elemento").
- Basado en tabla hash: Por dentro, HashSet<T> usa una tabla hash para guardar los elementos, y por eso es tan rápido. No vamos a meternos ahora en cómo funcionan las tablas hash (eso es para otra lección más avanzada), pero imagina que cada elemento se "convierte" en un código numérico especial (hash), y así se encuentra súper rápido.
Vamos a comparar esto con usar List<T> para guardar elementos únicos: tendrías que recorrer toda la lista cada vez para asegurarte de que el elemento no está antes de añadirlo. Eso sería lentísimo para listas grandes. ¡HashSet<T> lo hace al instante!
2. ¿Para qué le sirve HashSet<T> a un programador?
El misterio de las colecciones únicas
En programación muchas veces tienes que guardar elementos sin repeticiones. Por ejemplo, estás parseando una lista de emails de usuarios de una app y quieres asegurarte de que no hay duplicados. O recoges nombres de archivos únicos leídos de una carpeta. La solución más simple: una colección donde no puedas añadir dos veces lo mismo.
Claro, podrías intentar hacerlo con List<T>, comprobando a mano si el elemento ya está antes de añadirlo:
var users = new List<string>();
if (!users.Contains("vasya@example.com"))
users.Add("vasya@example.com");
Pero esto va fatal con muchos datos — la comprobación Contains en List tiene que mirar todos los elementos, y si tienes miles de usuarios, el programa va tan lento como un ordenador viejo con Windows XP.
¿Qué hace HashSet<T>?
HashSet<T>, en cambio, garantiza que cada elemento solo se guarda una vez. Está hecho sobre una tabla hash (como un diccionario), así que las operaciones de añadir, buscar y borrar son súper rápidas — normalmente en tiempo constante, sin mirar todos los elementos.
3. Lo básico de HashSet<T>
Declarar y crear
Para empezar, no hace falta importar ninguna librería extra — la clase ya está en el espacio de nombres System.Collections.Generic.
using System.Collections.Generic;
var emails = new HashSet<string>();
Puedes rellenar la colección con valores iniciales pasándolos al constructor:
var fruits = new HashSet<string> { "manzana", "plátano", "pera", "plátano" };
// "plátano" aparece dos veces, ¡pero solo se guarda una vez!
Añadir elementos
Para añadir elementos se usa el método Add. Si el elemento no estaba, el método devuelve true. Si ya estaba — simplemente no hace nada y devuelve false.
bool added = emails.Add("vasya@example.com"); // true, elemento añadido
added = emails.Add("vasya@example.com"); // false, ya existe, no se añade
Curioso: Puedes llamar a Add cien veces con el mismo valor — HashSet no se enfada, simplemente ignora los repetidos.
Comprobar si existe: Contains
Para comprobar si un elemento está, usa el método Contains:
if (emails.Contains("vasya@example.com"))
Console.WriteLine("¡Ese email ya existe!");
Borrar elementos
Borrar también es rápido:
emails.Remove("vasya@example.com");
Si el elemento no estaba — no pasa nada, el método solo devuelve false.
4. Ejemplo práctico
Vamos a complicar un poco nuestro CRM de estudiantes, que estamos desarrollando durante el curso.
Requisito
Supón que, según las reglas de nuestro sistema, cada usuario debe tener un nombre de usuario único (login). Antes de añadir un usuario nuevo hay que comprobar la unicidad, y si no, avisar.
Ejemplo de código
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// Colección para guardar logins únicos
var userNames = new HashSet<string>();
while (true)
{
Console.Write("Introduce el nombre de usuario (o leave para salir): ");
string name = Console.ReadLine();
if (name == "leave")
break;
if (userNames.Add(name))
{
Console.WriteLine("¡Nombre añadido con éxito!");
}
else
{
Console.WriteLine("Error: ese nombre ya está cogido, prueba otro.");
}
}
Console.WriteLine("Lista de usuarios:");
foreach (var user in userNames)
Console.WriteLine($"- {user}");
// ¡Ojo! El orden de salida puede ser aleatorio.
}
}
Así de fácil aseguramos la unicidad. No hace falta comprobar a mano si existe — HashSet lo hace solo.
5. ¿Cómo funciona HashSet<T> por dentro? ¿Para qué sirve el hash code?
Analogía: casillas para guardar
Imagina que tienes un montón de tarjetas con logins, y una mesa con casillas del 0 al 1000. Cada login lo pones en la casilla cuyo número calcula una función (GetHashCode). Si las tarjetas coinciden — acaban en la misma casilla, y así ves rápido si el login ya existe.
Función GetHashCode
HashSet<T> compara los elementos no solo por valor, sino que primero calcula su hash code usando el método GetHashCode(). Para la mayoría de tipos integrados (int, string, double y otros) esto ya está optimizado.
Dato curioso: Si vas a crear tus propias clases y quieres guardarlas en HashSet<T>, asegúrate de implementar bien los métodos de comparación y hash (Equals y GetHashCode), para que la unicidad funcione bien. Pero eso lo veremos en próximas lecciones.
Algunos errores típicos usando HashSet<T>
Cuando la gente empieza a usar colecciones de valores únicos, suele caer en esta trampa: piensan que HashSet<T> guarda los elementos en el orden en que se añadieron. ¡No es así! Los hash sets no garantizan ningún orden, todo puede salir en orden aleatorio. Si te importa el orden — necesitas otro tipo de colección, como SortedSet<T>, pero eso es otra historia.
Otro error típico — intentar usar un índice:
string name = userNames[0]; // ¡Error! HashSet<T> no tiene índices.
A diferencia de un array o lista, aquí no puedes acceder a un elemento por número. Solo puedes recorrer los elementos con foreach.
La tercera confusión común pasa al serializar o guardar el hash set en un archivo — como el orden no está definido, los elementos pueden salir en distinto orden cada vez que ejecutas el programa.
6. Operaciones de conjuntos: unión, intersección, diferencia
HashSet<T> trae un montón de métodos que hacen que trabajar con él sea como manipular conjuntos matemáticos. Por ejemplo: unión, intersección, diferencia y diferencia simétrica.
Aquí tienes los principales:
| Método | Qué hace |
|---|---|
|
Añade al hash set todos los elementos de other. |
|
Deja solo los elementos que están aquí y en other. |
|
Borra del conjunto actual los elementos de other. |
|
Deja solo los elementos que están aquí o en other, pero no en ambos. |
Ejemplo: intersección y unión
Vamos con un ejemplo. Supón que tienes dos grupos de nombres:
var groupA = new HashSet<string> { "Anya", "Boris", "Vera" };
var groupB = new HashSet<string> { "Vera", "Gleb", "Dasha" };
// Buscamos quién está en ambos grupos
var common = new HashSet<string>(groupA); // copiamos el contenido, ¡si no groupA cambia!
common.IntersectWith(groupB);
Console.WriteLine("En ambos grupos:");
foreach (var name in common)
Console.WriteLine(name); // Muestra "Vera"
// Unir todos los estudiantes de ambos grupos, para que nadie se pierda:
var all = new HashSet<string>(groupA);
all.UnionWith(groupB);
Console.WriteLine("Todos los estudiantes:");
foreach (var name in all)
Console.WriteLine(name); // "Anya", "Boris", "Vera", "Gleb", "Dasha"
7. Métodos y propiedades extra
Count — para saber cuántos elementos hay en el conjunto:
Console.WriteLine(userNames.Count);
Clear — borrar todo (como CTRL+A, DELETE en la vida real):
userNames.Clear();
SetEquals, IsSubsetOf, IsSupersetOf — para comprobar si los conjuntos son iguales, si uno está dentro de otro, etc. Esto mola si juegas (o programas) algo tipo "matemático — quién es más pro".
if (groupA.IsSubsetOf(groupB))
Console.WriteLine("Todos los de grupo A están en grupo B");
8. Guardar tus propios objetos en HashSet<T>
Como ya mencionamos antes, los tipos estándar ya saben calcular bien los hashes y compararse por igualdad.
Pero si decides guardar, por ejemplo, usuarios como objetos, tendrás que asegurarte de que se comparan por algún campo (por ejemplo, el login):
class User
{
public string Login { get; set; }
public override bool Equals(object obj)
{
if (obj is User other)
return Login == other.Login;
return false;
}
public override int GetHashCode()
{
return Login.GetHashCode();
}
}
// Ahora puedes:
var users = new HashSet<User>();
users.Add(new User { Login = "vasya" });
users.Add(new User { Login = "petya" });
users.Add(new User { Login = "vasya" }); // ¡No se añade!
Sin los overrides de Equals y GetHashCode, HashSet considerará que todas las instancias son distintas (aunque el login sea igual), porque por defecto compara las direcciones de memoria.
GO TO FULL VERSION