C# HashSet and SortedSet

The `HashSet<T>` and `SortedSet<T>` classes in C# are part of the `System.Collections.Generic` namespace and represent collections of unique elements. While both ensure that no duplicates are present, they differ in how elements are stored and accessed. Understanding these collections is essential for scenarios requiring efficient data management, uniqueness enforcement, and specific ordering of elements.

Table of Contents

  1. Introduction to HashSet<T> and SortedSet<T>
  2. - What is HashSet<T>?
    - What is SortedSet<T>?
    - Benefits of Using HashSet<T> and SortedSet<T>
  3. Declaring and Initializing HashSet<T> and SortedSet<T>
  4. - Declaration Syntax
    - Initialization Methods
    - Collection Initializers
  5. Adding and Removing Elements
  6. - Adding Elements
    - Adding Range of Elements
    - Removing Elements
    - Clearing the Set
  7. Accessing Elements
  8. - Accessing by Enumeration
    - Converting to Array
  9. Iterating Through HashSet<T> and SortedSet<T>
  10. - Using foreach Loop
    - Using LINQ Queries
  11. Searching Elements
  12. - Contains Method
    - Overlap and Symmetric Difference
  13. Capacity and Performance
  14. - Understanding Capacity vs. Count
    - Managing Capacity
    - Performance Considerations
  15. Common Methods and Properties
  16. - Key Methods
    - Important Properties
  17. Best Practices
  18. - Choosing Between HashSet<T> and SortedSet<T>
    - Using Appropriate Equality Comparers
    - Handling Large Datasets
    - Leveraging Set Operations
  19. Common Mistakes with HashSet<T> and SortedSet<T>
  20. - Ignoring Case Sensitivity
    - Not Utilizing Set Operations
    - Using Mutable Types as Elements
  21. Advanced Topics
  22. - Custom Comparers
    - Thread Safety
    - Serialization
    - Immutable Sets
  23. Real-World Example
  24. Summary

1. Introduction to HashSet<T> and SortedSet<T>

What is HashSet<T>?

`HashSet<T>` is a generic collection that contains no duplicate elements and provides high-performance set operations. It uses a hash table for storage, allowing for near O(1) time complexity for add, remove, and lookup operations.

Syntax:
using System.Collections.Generic;

HashSet<T> hashSet = new HashSet<T>();

What is SortedSet<T>?

`SortedSet<T>` is a generic collection that contains no duplicate elements and maintains its elements in a sorted order. It is implemented as a binary search tree, providing O(log n) time complexity for add, remove, and lookup operations.

Syntax:
using System.Collections.Generic;

SortedSet<T> sortedSet = new SortedSet<T>();

Benefits of Using HashSet<T> and SortedSet<T>

- Uniqueness Enforcement: Automatically ensures that all elements are unique.

- Performance: Provides efficient operations for adding, removing, and checking existence.

- Set Operations: Supports operations like union, intersection, and difference.

- Type Safety: Generic implementation ensures all elements are of a specific type.

- Ordering (SortedSet<T>): Maintains elements in a sorted order, useful for ordered data processing.

2. Declaring and Initializing HashSet<T> and SortedSet<T>

2.1 Declaration Syntax

HashSet<T> Declaration:
using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        HashSet<int> numbersHashSet = new HashSet<int>();
    }
}

SortedSet<T> Declaration:
using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        SortedSet<string> fruitsSortedSet = new SortedSet<string>();
    }
}
Explanation:
- Namespace: Ensure `System.Collections.Generic` is included.
- Type Parameters: Replace `int` and `string` with desired element types.

2.2 Initialization Methods

Default Initialization:
HashSet<string> uniqueNames = new HashSet<string>();
SortedSet<int> sortedNumbers = new SortedSet<int>();

Initialization with Capacity (HashSet<T> Only): Specifying an initial capacity can optimize performance by reducing the number of resizing operations.

HashSet<string> uniqueNames = new HashSet<string>(100);

Initialization with Comparer (Both): You can provide a custom comparer to define how elements are compared.
HashSet<string> caseInsensitiveSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
SortedSet<string> customSortedSet = new SortedSet<string>(new CustomComparer());

Explanation:
- Capacity: Helps in optimizing performance for large datasets.
- Comparer: Customizes how elements are compared for uniqueness and ordering.

2.3 Collection Initializers

Allows initializing a set with elements at the time of creation.

HashSet<T> Initializer:
HashSet<string> uniqueFruits = new HashSet<string> { "Apple", "Banana", "Cherry" };

SortedSet<T> Initializer:
SortedSet<string> sortedFruits = new SortedSet<string> { "Apple", "Banana", "Cherry" };

Explanation:
- Readability: Enhances code clarity by initializing with predefined elements.
- Uniqueness: Automatically removes any duplicate elements during initialization.

3. Adding and Removing Elements

3.1 Adding Elements

HashSet<T> Example:
HashSet<string> uniqueFruits = new HashSet<string>();
uniqueFruits.Add("Apple");
uniqueFruits.Add("Banana");
uniqueFruits.Add("Cherry");
uniqueFruits.Add("Apple"); // Duplicate, won't be added

SortedSet<T> Example:
SortedSet<int> sortedNumbers = new SortedSet<int>();
sortedNumbers.Add(5);
sortedNumbers.Add(3);
sortedNumbers.Add(8);
sortedNumbers.Add(3); // Duplicate, won't be added

Explanation:
- Add Method: Adds an element if it doesn't already exist in the set.
- Duplicate Handling: Attempting to add a duplicate element has no effect.

3.2 Adding Range of Elements

HashSet<T> Adding Range: `HashSet<T>` does not have a built-in `AddRange` method, but you can add multiple elements using `UnionWith`.

HashSet<string> uniqueFruits = new HashSet<string>();
uniqueFruits.Add("Apple");
uniqueFruits.Add("Banana");

List<string> moreFruits = new List<string> { "Cherry", "Date", "Apple" };
uniqueFruits.UnionWith(moreFruits); // Adds "Cherry" and "Date", ignores "Apple"

SortedSet<T> Adding Range: Similarly, `SortedSet<T>` uses `UnionWith` to add multiple elements.
SortedSet<int> sortedNumbers = new SortedSet<int> { 1, 2, 3 };
int[] moreNumbers = { 3, 4, 5 };
sortedNumbers.UnionWith(moreNumbers); // Adds 4 and 5, ignores 3
Explanation:
- UnionWith Method: Adds elements from another collection, ignoring duplicates.

3.3 Removing Elements

HashSet<T> Example:
uniqueFruits.Remove("Banana"); // Removes "Banana"
uniqueFruits.RemoveWhere(f => f.StartsWith("C")); // Removes "Cherry"

SortedSet<T> Example:
sortedNumbers.Remove(2); // Removes 2
sortedNumbers.RemoveWhere(n => n > 4); // Removes 5 and 8

Explanation:
- Remove Method: Removes a specific element if it exists.
- RemoveWhere Method: Removes all elements that match a specified predicate.

3.4 Clearing the Set

HashSet<T> and SortedSet<T> Example:
uniqueFruits.Clear(); // Removes all elements
sortedNumbers.Clear(); // Removes all elements

Explanation:
- Clear Method: Empties the set but retains the existing capacity for future additions.

4. Accessing Elements

4.1 Accessing by Enumeration

Both `HashSet<T>` and `SortedSet<T>` support enumeration, allowing you to iterate through elements.

foreach(var fruit in uniqueFruits)
{
    Console.WriteLine(fruit);
}

foreach(var number in sortedNumbers)
{
    Console.WriteLine(number);
}
Explanation:
- foreach Loop: Provides a way to access each element in the set.

4.2 Converting to Array

You can convert a set to an array for indexed access or other array-specific operations.

string[] fruitsArray = uniqueFruits.ToArray();
int[] numbersArray = sortedNumbers.ToArray();

Console.WriteLine("Fruits Array:");
foreach(var fruit in fruitsArray)
{
    Console.WriteLine(fruit);
}

Console.WriteLine("Numbers Array:");
foreach(var number in numbersArray)
{
    Console.WriteLine(number);
}

Sample Output:
Fruits Array:
Apple
Cherry
Numbers Array:
1
2
3
4


Explanation:
- ToArray Method: Creates a snapshot of the set as an array.
- Order: `HashSet<T>` does not maintain order, while `SortedSet<T>` maintains a sorted order.

5. Iterating Through HashSet<T> and SortedSet<T>

5.1 Using foreach Loop

The most common way to iterate through a set.
HashSet<string> uniqueFruits = new HashSet<string> { "Apple", "Banana", "Cherry" };
SortedSet<int> sortedNumbers = new SortedSet<int> { 1, 2, 3 };

Console.WriteLine("Iterating through HashSet:");
foreach(var fruit in uniqueFruits)
{
    Console.WriteLine(fruit);
}

Console.WriteLine("Iterating through SortedSet:");
foreach(var number in sortedNumbers)
{
    Console.WriteLine(number);
}

Sample Output:
Iterating through HashSet:
Apple
Banana
Cherry
Iterating through SortedSet:
1
2
3

Explanation:
- HashSet<T>: Iterates in an undefined order.
- SortedSet<T>: Iterates in a sorted order.

5.2 Using LINQ Queries

Leverage LINQ for more declarative iteration and data manipulation.

Example: Selecting Specific Elements
using System.Linq;

var fruitsWithA = uniqueFruits.Where(f => f.Contains("a") || f.Contains("A"));
var evenNumbers = sortedNumbers.Where(n => n % 2 == 0);

Console.WriteLine("Fruits containing 'a' or 'A':");
foreach(var fruit in fruitsWithA)
{
    Console.WriteLine(fruit);
}

Console.WriteLine("Even Numbers:");
foreach(var number in evenNumbers)
{
    Console.WriteLine(number);
}

Sample Output:
Fruits containing 'a' or 'A':
Apple
Banana
Even Numbers:
2
4

Explanation:
- Where Clause: Filters elements based on the specified condition.
- Deferred Execution: The query is evaluated when iterated over.

6. Searching Elements

6.1 Contains Method

Checks if the set contains a specific element.

HashSet<T> Example:
bool hasApple = uniqueFruits.Contains("Apple");
Console.WriteLine($"Contains Apple: {hasApple}"); // Output: True

SortedSet<T> Example:
bool hasTwo = sortedNumbers.Contains(2);
Console.WriteLine($"Contains 2: {hasTwo}"); // Output: True

Explanation:
- Contains Method: Efficiently checks for the existence of an element with O(1) time complexity for `HashSet<T>` and O(log n) for `SortedSet<T>`.

6.2 Overlap and Symmetric Difference

Perform set operations to find common or distinct elements between sets.

Example: Intersection (Overlap):
HashSet<string> setA = new HashSet<string> { "Apple", "Banana", "Cherry" };
HashSet<string> setB = new HashSet<string> { "Banana", "Date", "Elderberry" };

setA.IntersectWith(setB); // setA now contains "Banana"

Console.WriteLine("Intersection of setA and setB:");
foreach(var fruit in setA)
{
    Console.WriteLine(fruit);
}

Sample Output:
Intersection of setA and setB:
Banana


Example: Symmetric Difference:
HashSet<int> setC = new HashSet<int> { 1, 2, 3, 4 };
HashSet<int> setD = new HashSet<int> { 3, 4, 5, 6 };

setC.SymmetricExceptWith(setD); // setC now contains 1, 2, 5, 6

Console.WriteLine("Symmetric Difference of setC and setD:");
foreach(var number in setC)
{
    Console.WriteLine(number);
}

Sample Output:
Symmetric Difference of setC and setD:
1
2
5
6

Explanation:
- IntersectWith: Retains only elements that are present in both sets.
- SymmetricExceptWith: Retains elements that are in either set but not in both.

7. Capacity and Performance

7.1 Understanding Capacity vs. Count

- Count: The number of elements currently in the set.
- Capacity: The number of elements the set can hold before needing to resize.

Example:
HashSet<int> numbersHashSet = new HashSet<int>();
Console.WriteLine($"Initial Count: {numbersHashSet.Count}"); // Output: 0

numbersHashSet.Add(1);
Console.WriteLine($"Count after adding one element: {numbersHashSet.Count}"); // Output: 1

numbersHashSet.Add(2);
numbersHashSet.Add(3);
Console.WriteLine($"Count after adding three elements: {numbersHashSet.Count}"); // Output: 3
Explanation:
- Dynamic Resizing: The set automatically adjusts its capacity as elements are added or removed.
- Count Property: Reflects the current number of elements.

7.2 Managing Capacity

HashSet<T> Managing Capacity:
HashSet<int> numbersHashSet = new HashSet<int>(100);
numbersHashSet.EnsureCapacity(200); // Ensures the set can hold at least 200 elements
numbersHashSet.TrimExcess(); // Reduces capacity to match count

SortedSet<T> Managing Capacity: `SortedSet<T>` does not expose a capacity property, but initializing with appropriate capacity can optimize performance.

SortedSet<int> sortedNumbers = new SortedSet<int>();
// No direct capacity management, but efficient operations are inherent

Explanation:
- EnsureCapacity (HashSet<T>): Prevents frequent resizing by allocating sufficient space upfront.
- TrimExcess (HashSet<T>): Frees unused memory after bulk operations.
- SortedSet<T>: Manages capacity internally without exposing it to the user.

7.3 Performance Considerations

- HashSet<T>:
- Add, Remove, Contains: O(1) time complexity.
- Set Operations: Highly efficient for large datasets.

- SortedSet<T>:
- Add, Remove, Contains: O(log n) time complexity.
- Enumeration: Returns elements in sorted order, which can be advantageous for ordered data processing.

- Memory Usage: `HashSet<T>` may consume more memory due to its hash table implementation, while `SortedSet<T>` uses a binary search tree structure.

8. Common Methods and Properties

8.1 Key Methods

- Add(T item): Adds an item to the set. Returns `true` if the item was added, `false` if it already exists.

- UnionWith(IEnumerable<T> other): Modifies the set to contain all elements that are present in itself or in the specified collection.

- IntersectWith(IEnumerable<T> other): Modifies the set to contain only elements that are also in the specified collection.

- ExceptWith(IEnumerable<T> other): Removes all elements in the specified collection from the set.

- SymmetricExceptWith(IEnumerable<T> other): Modifies the set to contain only elements that are in either the set or the specified collection, but not both.

- Remove(T item): Removes the specified item from the set. Returns `true` if the item was removed.

- Clear(): Removes all elements from the set.

- Contains(T item): Determines whether the set contains a specific item.

- RemoveWhere(Predicate<T> match): Removes all elements that match the conditions defined by the specified predicate.

- ToArray(): Copies the set elements to a new array.

- GetEnumerator(): Returns an enumerator that iterates through the set.

8.2 Important Properties

- Count: Gets the number of elements contained in the set.

- Comparer: Gets the `IEqualityComparer<T>` that is used to determine equality of keys.

- Keys and Values (HashSet<T>): Not applicable as `HashSet<T>` is a single collection of unique elements.

- IsSubsetOf, IsSupersetOf, Overlaps, SetEquals: Methods to compare sets with other collections.

9. Best Practices

9.1 Choosing Between HashSet<T> and SortedSet<T>

- Use `HashSet<T>` When:
- You need high-performance set operations.
- Element ordering is not important.
- You require O(1) time complexity for add, remove, and contains operations.

- Use `SortedSet<T>` When:
- You need elements to be maintained in a sorted order.
- You require ordered enumeration.
- O(log n) time complexity is acceptable for operations.

9.2 Using Appropriate Equality Comparers

Customize how elements are compared by providing an `IEqualityComparer<T>` for `HashSet<T>` or `SortedSet<T>`.

Example: Case-Insensitive HashSet<string>
HashSet<string> caseInsensitiveSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
caseInsensitiveSet.Add("apple");
caseInsensitiveSet.Add("Apple"); // Duplicate, won't be added

Explanation:
- StringComparer.OrdinalIgnoreCase: Ensures that "apple" and "Apple" are considered equal.

9.3 Handling Large Datasets

- Initialize with Capacity: When dealing with large datasets, initialize the set with an appropriate capacity to minimize resizing.

HashSet<int> largeSet = new HashSet<int>(10000);

9.4 Leveraging Set Operations

Utilize built-in set operations for efficient data manipulation.

Example: Union and Intersection
HashSet<int> setA = new HashSet<int> { 1, 2, 3 };
HashSet<int> setB = new HashSet<int> { 3, 4, 5 };

setA.UnionWith(setB); // setA now contains 1, 2, 3, 4, 5
setA.IntersectWith(new HashSet<int> { 2, 3, 6 }); // setA now contains 2, 3

Explanation:
- UnionWith: Combines elements from both sets.
- IntersectWith: Retains only common elements.

9.5 Avoiding Mutable Types as Elements

Use immutable types or ensure that element properties affecting equality and hashing are not modified after adding to the set.

Example: Using Immutable Types
public class Person
{
    public string Name { get; }
    public int Age { get; }

    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }

    public override bool Equals(object obj)
    {
        if(obj is Person other)
        {
            return Name == other.Name && Age == other.Age;
        }
        return false;
    }

    public override int GetHashCode()
    {
        return HashCode.Combine(Name, Age);
    }
}

HashSet<Person> peopleSet = new HashSet<Person>();
peopleSet.Add(new Person("Alice", 30));
Explanation:
- Immutable Properties: Prevent changes that could affect hashing and equality.

10. Common Mistakes with HashSet<T> and SortedSet<T>

10.1 Ignoring Case Sensitivity

When dealing with strings, ignoring case sensitivity can lead to unexpected duplicates or missing elements.

Mistake:
HashSet<string> fruitsSet = new HashSet<string>();
fruitsSet.Add("apple");
fruitsSet.Add("Apple"); // Considered different in default comparer

Solution: Use a case-insensitive comparer.

HashSet<string> fruitsSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
fruitsSet.Add("apple");
fruitsSet.Add("Apple"); // Duplicate, won't be added

10.2 Not Utilizing Set Operations

Failing to leverage built-in set operations can lead to inefficient code.

Mistake:
Manually iterating to find common elements instead of using `IntersectWith`.
foreach(var item in setA)
{
    if(setB.Contains(item))
    {
        commonSet.Add(item);
    }
}

Solution:
Use `IntersectWith` for efficiency.
setA.IntersectWith(setB);

10.3 Using Mutable Types as Elements

Using mutable types can lead to inconsistent behavior as changes to elements can affect their hash codes and equality.

Mistake:
public class Person
{
    public string Name { get; set; }
}

HashSet<Person> peopleSet = new HashSet<Person>();
Person p = new Person { Name = "Alice" };
peopleSet.Add(p);
p.Name = "Alicia"; // Alters hash code if Name is used in hashing

Solution:
Use immutable types or ensure that properties affecting equality and hashing are not modified after adding.

11. Advanced Topics

11.1 Custom Comparers

Implement custom logic for element comparison by creating a class that implements `IEqualityComparer<T>` for `HashSet<T>` or `IComparer<T>` for `SortedSet<T>`.

Example: Custom String Comparer (Case-Insensitive)
public class CaseInsensitiveStringComparer : IEqualityComparer<string>
{
    public bool Equals(string x, string y)
    {
        return string.Equals(x, y, StringComparison.OrdinalIgnoreCase);
    }

    public int GetHashCode(string obj)
    {
        return obj.ToLower().GetHashCode();
    }
}

// Usage
HashSet<string> ciHashSet = new HashSet<string>(new CaseInsensitiveStringComparer());
ciHashSet.Add("Apple");
ciHashSet.Add("apple"); // Duplicate, won't be added

Explanation:
- Customization: Defines how elements are compared and hashed, enabling case-insensitive operations.

11.2 Thread Safety

`HashSet<T>` and `SortedSet<T>` are not thread-safe. For multi-threaded scenarios, use synchronization mechanisms or thread-safe collections like `ConcurrentDictionary<TKey, TValue>` for similar functionality.

Example: Using Lock for Thread Safety
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

class Program
{
    static HashSet<int> numbersHashSet = new HashSet<int>();
    static object lockObj = new object();

    static void Main()
    {
        Parallel.For(0, 1000, i =>
        {
            lock(lockObj)
            {
                numbersHashSet.Add(i);
            }
        });

        Console.WriteLine($"Total Numbers Added: {numbersHashSet.Count}"); // Output: 1000
    }
}

Explanation:
- Locking Mechanism: Ensures that only one thread can modify the set at a time, preventing data corruption.

11.3 Serialization

`HashSet<T>` and `SortedSet<T>` can be serialized using various serializers like JSON, XML, or binary serializers.

Example: Serializing to JSON
using System;
using System.Collections.Generic;
using System.Text.Json;

class Program
{
    static void Main()
    {
        HashSet<string> fruitsSet = new HashSet<string> { "Apple", "Banana", "Cherry" };
        SortedSet<int> sortedNumbers = new SortedSet<int> { 1, 2, 3 };

        string jsonFruits = JsonSerializer.Serialize(fruitsSet);
        string jsonNumbers = JsonSerializer.Serialize(sortedNumbers);

        Console.WriteLine(jsonFruits); // Output: ["Apple","Banana","Cherry"]
        Console.WriteLine(jsonNumbers); // Output: [1,2,3]

        // Deserialization
        HashSet<string> deserializedFruits = JsonSerializer.Deserialize<HashSet<string>>(jsonFruits);
        SortedSet<int> deserializedNumbers = JsonSerializer.Deserialize<SortedSet<int>>(jsonNumbers);

        Console.WriteLine("Deserialized Fruits:");
        foreach(var fruit in deserializedFruits)
        {
            Console.WriteLine(fruit);
        }

        Console.WriteLine("Deserialized Numbers:");
        foreach(var number in deserializedNumbers)
        {
            Console.WriteLine(number);
        }
    }
}

Sample Output:
["Apple","Banana","Cherry"]
[1,2,3]
Deserialized Fruits:
Apple
Banana
Cherry
Deserialized Numbers:
1
2
3

Explanation:
- Serialization: Converts the set to a JSON string for storage or transmission.
- Deserialization: Reconstructs the set from the JSON string.

11.4 Immutable Sets

Use immutable collections to ensure that the set cannot be modified after creation, enhancing thread safety and predictability.

Example: Using ImmutableHashSet
using System;
using System.Collections.Immutable;

class Program
{
    static void Main()
    {
        ImmutableHashSet<string> immutableFruits = ImmutableHashSet.Create("Apple", "Banana", "Cherry");
        // immutableFruits.Add("Date"); // Returns a new set, original remains unchanged

        Console.WriteLine("Immutable Fruits:");
        foreach(var fruit in immutableFruits)
        {
            Console.WriteLine(fruit);
        }
    }
}

Sample Output:
Immutable Fruits:
Apple
Banana
Cherry

Explanation:
- Immutability: Ensures that the set cannot be altered after creation, promoting safer code in multi-threaded environments.

12. Real-World Example

Example: Student Enrollment System Using HashSet<T>

This example demonstrates managing student enrollments in courses, ensuring that each student is enrolled only once per course. It utilizes `HashSet<T>` to enforce uniqueness and perform efficient operations.

Code Example:
using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        EnrollmentSystem enrollment = new EnrollmentSystem();

        // Enroll students
        enrollment.EnrollStudent("Alice");
        enrollment.EnrollStudent("Bob");
        enrollment.EnrollStudent("Charlie");
        enrollment.EnrollStudent("Alice"); // Duplicate, won't be enrolled again

        // Display all enrolled students
        Console.WriteLine("Enrolled Students:");
        enrollment.DisplayEnrolledStudents();

        // Check enrollment
        Console.WriteLine($"\nIs Bob enrolled? {enrollment.IsEnrolled("Bob")}");
        Console.WriteLine($"Is David enrolled? {enrollment.IsEnrolled("David")}");

        // Unenroll a student
        enrollment.UnenrollStudent("Charlie");
        Console.WriteLine("\nEnrolled Students after unenrolling Charlie:");
        enrollment.DisplayEnrolledStudents();

        // Attempt to unenroll a non-existent student
        enrollment.UnenrollStudent("Eve");
    }
}

public class EnrollmentSystem
{
    private HashSet<string> enrolledStudents;

    public EnrollmentSystem()
    {
        enrolledStudents = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
    }

    public bool EnrollStudent(string studentName)
    {
        if(enrolledStudents.Add(studentName))
        {
            Console.WriteLine($"Enrolled: {studentName}");
            return true;
        }
        else
        {
            Console.WriteLine($"Student {studentName} is already enrolled.");
            return false;
        }
    }

    public bool UnenrollStudent(string studentName)
    {
        if(enrolledStudents.Remove(studentName))
        {
            Console.WriteLine($"Unenrolled: {studentName}");
            return true;
        }
        else
        {
            Console.WriteLine($"Student {studentName} is not enrolled.");
            return false;
        }
    }

    public bool IsEnrolled(string studentName)
    {
        return enrolledStudents.Contains(studentName);
    }

    public void DisplayEnrolledStudents()
    {
        foreach(var student in enrolledStudents)
        {
            Console.WriteLine(student);
        }
    }
}

Sample Output:
Enrolled: Alice
Enrolled: Bob
Enrolled: Charlie
Student Alice is already enrolled.
Enrolled Students:
Alice
Bob
Charlie
Is Bob enrolled? True
Is David enrolled? False
Unenrolled: Charlie
Enrolled Students after unenrolling Charlie:
Alice
Bob
Student Eve is not enrolled.

Explanation:
- EnrollmentSystem Class: Manages student enrollments using a `HashSet<string>` to ensure each student is enrolled only once.
- EnrollStudent: Adds a student to the set. If the student is already enrolled, it notifies accordingly.
- UnenrollStudent: Removes a student from the set. If the student is not found, it notifies accordingly.
- IsEnrolled: Checks if a student is enrolled.
- DisplayEnrolledStudents: Iterates through the set to display all enrolled students.
- Main Method: Demonstrates enrolling students, checking enrollment, unenrolling students, and handling duplicates and non-existent students.

13. Summary

The `HashSet<T>` and `SortedSet<T>` classes in C# are powerful collections for managing unique elements efficiently. While `HashSet<T>` offers high-performance set operations without maintaining order, `SortedSet<T>` provides ordered collections suitable for scenarios where element sorting is essential. Both collections ensure that duplicates are automatically handled, reducing the need for manual checks and enhancing code reliability.

Key Takeaways:
- Uniqueness Enforcement: Both `HashSet<T>` and `SortedSet<T>` ensure that all elements are unique, eliminating duplicates.

- Performance:
- HashSet<T>: Offers O(1) time complexity for add, remove, and contains operations, making it ideal for large datasets requiring fast access.

- SortedSet<T>: Maintains elements in a sorted order with O(log n) time complexity for operations, suitable for ordered data processing.

- Set Operations: Both collections support essential set operations like union, intersection, difference, and symmetric difference, facilitating efficient data manipulation.

- Type Safety: Being generic, these collections enforce type safety, preventing runtime errors associated with type mismatches.

- Custom Comparers: Allow customization of how elements are compared and hashed, enabling case-insensitive operations or other specific comparison logic.

- Thread Safety: Neither `HashSet<T>` nor `SortedSet<T>` are thread-safe by default. For multi-threaded scenarios, consider using synchronization mechanisms or thread-safe collections like `ConcurrentDictionary<TKey, TValue>`.

- Best Practices:
- Choose the Right Collection: Use `HashSet<T>` for unordered, high-performance scenarios and `SortedSet<T>` when order is crucial.

- Use Immutable Types: To prevent unexpected behavior, especially when using elements that affect hashing and equality.

- Leverage Set Operations: Utilize built-in methods for efficient data processing instead of manual iterations.

- Handle Exceptions Gracefully: Use methods like `TryGetValue`, `TryAdd`, or check conditions before performing operations to avoid runtime exceptions.

- Common Mistakes:
- Ignoring Case Sensitivity: Not using appropriate comparers can lead to unintended duplicates or missing elements.

- Modifying Collections During Iteration: Altering the set while iterating can cause runtime exceptions.

- Using Mutable Types as Elements: Leads to inconsistent behavior due to changes in elements affecting their hashing and equality.

- Advanced Usage: Implement custom behaviors, ensure thread safety, handle serialization for persistent storage or transmission, and use immutable collections for enhanced safety and predictability.

- Real-World Applications: Ideal for scenarios like enrollment systems, task schedulers, caching mechanisms, and any case requiring unique, efficient data storage and manipulation.

By mastering `HashSet<T>` and `SortedSet<T>`, you enhance your ability to manage collections of unique elements effectively, leading to more robust, maintainable, and high-performance C# applications.

Previous: C# Stack | Next: C# I/O Classes

<
>