Dictionary i HashSet — wyszukuj po kluczu, pilnuj unikalności

Wypożyczalnia szkolna sprawdza numer urządzenia, a lista obecności nie powinna liczyć tej samej osoby dwa razy. Dwa problemy, których lista nie rozwiązuje dobrze — i dwie kolekcje stworzone właśnie do tego.

C# Dictionary HashSet TryGetValue 45–90 min
CEL LEKCJI

Czego się dziś nauczysz

  • Utworzysz słownik i dodasz do niego wpisy dwoma sposobami.
  • Odczytasz wartość bezpiecznie, metodą TryGetValue.
  • Wyjaśnisz, dlaczego klucz musi być unikalny.
  • Użyjesz HashSet do usuwania powtórzeń.
  • Dobierzesz listę, słownik albo zbiór do konkretnego zadania.

Przygotowanie: lekcje 01–31. Przewidywany czas: 45–90 minut z zadaniami. Przykłady wymagają .NET 8 lub nowszego, z włączonymi ImplicitUsings i Nullable.

TEORIA

Szukanie po numerze zamiast po kolei

Wypożyczalnia szkolna ma sto urządzeń. Uczeń podaje numer, a program ma powiedzieć, co to za sprzęt. Z listą wygląda to tak:

dwie listy, które trzeba trzymać w zgodzie
List<string> numery = new List<string> { "LT-01", "LT-02", "PR-07" };
List<string> nazwy = new List<string> { "laptop Dell", "laptop Lenovo", "drukarka" };

string szukany = "PR-07";
for (int i = 0; i < numery.Count; i++)
{
    if (numery[i] == szukany)
    {
        Console.WriteLine(nazwy[i]);
        break;
    }
}

Dwa problemy naraz. Po pierwsze: przy stu urządzeniach program przegląda listę element po elemencie. Po drugie, gorsze: dwie listy trzeba trzymać w tej samej kolejności — jedno usunięcie w niewłaściwym miejscu i numery przestają pasować do nazw.

Słownik łączy jedno z drugim w jednej kolekcji:

Program.cs
Dictionary<string, string> sprzet = new Dictionary<string, string>();
sprzet["LT-01"] = "laptop Dell";
sprzet["LT-02"] = "laptop Lenovo";
sprzet["PR-07"] = "drukarka";

Console.WriteLine(sprzet["PR-07"]);    // drukarka

Dlaczego słownik jest szybki

Słownik nie przegląda wpisów po kolei. Z klucza wylicza skrót, który wskazuje miejsce w pamięci — i sięga tam od razu. Dlatego wyszukanie w słowniku o stu wpisach trwa tyle samo, co w słowniku o stu tysiącach. Przy liście czas rośnie razem z liczbą elementów.

TEORIA

Klucz jest jeden, wartości mogą się powtarzać

Zapis Dictionary<string, int> czytamy: „słownik, w którym kluczem jest napis, a wartością liczba”. Pierwszy typ to klucz, drugi to wartość.

Program.cs
Dictionary<string, int> punkty = new Dictionary<string, int>();

punkty["Ala"] = 8;
punkty["Bartek"] = 5;
punkty["Cezary"] = 8;      // ta sama WARTOSC co Ala - to w porzadku

punkty["Ala"] = 10;        // ten sam KLUCZ - zastepuje poprzednia wartosc

Console.WriteLine(punkty["Ala"]);     // 10
Console.WriteLine(punkty.Count);      // 3
KluczWartość
Czy może się powtórzyćnie — drugi taki sam zastępuje pierwszytak, dowolnie wiele razy
Po czym szukamypo kluczu
Typowy przykładnumer urządzenia, imię, kod przedmiotunazwa, liczba punktów, cena

Dwa sposoby dodawania — i różnica między nimi

Program.cs
Dictionary<string, int> punkty = new Dictionary<string, int>();

punkty["Ala"] = 8;        // dodaje albo NADPISUJE, nigdy nie zglasza bledu
punkty.Add("Bartek", 5);  // dodaje; przy istniejacym kluczu -> ArgumentException

// punkty.Add("Ala", 9);  // WYJATEK: klucz "Ala" juz istnieje

Który wybrać

Add wybieraj tam, gdzie powtórzony klucz oznacza błąd w danych i chcesz się o nim dowiedzieć. Indeksatora słownik[klucz] = wartość — tam, gdzie nadpisanie jest normalne, na przykład przy aktualizacji wyniku. Świadomy wybór jednego z nich to połowa poprawnego programu na słownikach.

TEORIA

Odczyt, który nie wysadzi programu

Odczyt przez indeksator wygląda niewinnie, ale dla nieistniejącego klucza kończy się wyjątkiem:

Program.cs
Dictionary<string, int> punkty = new Dictionary<string, int> { ["Ala"] = 8 };

Console.WriteLine(punkty["Ala"]);       // 8
// Console.WriteLine(punkty["Zenon"]);  // KeyNotFoundException

Są trzy bezpieczne drogi. Najważniejsza jest pierwsza.

Program.cs
// 1. TryGetValue - sprawdza i oddaje wartosc w jednym ruchu
if (punkty.TryGetValue("Zenon", out int wynik))
{
    Console.WriteLine($"Zenon ma {wynik} pkt");
}
else
{
    Console.WriteLine("Nie ma takiej osoby");
}

// 2. ContainsKey - czytelne, ale przeszukuje slownik dwa razy
if (punkty.ContainsKey("Ala"))
{
    Console.WriteLine(punkty["Ala"]);
}

// 3. wartosc zastepcza, gdy brak klucza nie jest bledem
int ile = punkty.GetValueOrDefault("Zenon", 0);

Wartość z out tylko w gałęzi powodzenia

Gdy TryGetValue zwróci false, zmienna wynik dostaje wartość domyślną typu — dla int jest to zero. Program się skompiluje i wypisze „Zenon ma 0 pkt”, co wygląda jak prawdziwy wynik. Zera z nieudanego odczytu nie wolno używać poza gałęzią if.

Przechodzenie po słowniku

Program.cs
foreach (KeyValuePair<string, int> wpis in punkty)
{
    Console.WriteLine($"{wpis.Key}: {wpis.Value}");
}

foreach (string imie in punkty.Keys) { }
foreach (int wartosc in punkty.Values) { }

Kolejność nie jest gwarantowana

Słownik nie obiecuje żadnej kolejności wpisów i może ją zmienić po dodaniu elementu. Jeśli wynik ma być uporządkowany, posortuj go jawnie przy wypisywaniu — albo użyj SortedDictionary, który trzyma klucze w porządku rosnącym kosztem wolniejszego dodawania.

TEORIA

HashSet — zbiór bez powtórzeń

Drugi problem: lista obecności, na której nikt nie może figurować dwa razy. Z listą trzeba przed każdym dodaniem sprawdzać Contains. HashSet<T> robi to sam.

Program.cs
HashSet<string> obecni = new HashSet<string>();

Console.WriteLine(obecni.Add("Ala"));      // True  - dodano
Console.WriteLine(obecni.Add("Bartek"));   // True
Console.WriteLine(obecni.Add("Ala"));      // False - juz byl, nic sie nie zmienilo

Console.WriteLine(obecni.Count);           // 2
Console.WriteLine(obecni.Contains("Ala")); // True

Wynik Add to gotowa odpowiedź na pytanie „czy to pierwsze zgłoszenie tej osoby”. Nie trzeba niczego sprawdzać wcześniej.

Usuwanie powtórzeń jednym ruchem

Program.cs
List<string> zgloszenia = new List<string> { "Ala", "Bartek", "Ala", "Cezary", "Bartek" };

HashSet<string> unikalne = new HashSet<string>(zgloszenia);

Console.WriteLine(zgloszenia.Count);   // 5
Console.WriteLine(unikalne.Count);     // 3
KolekcjaCzym się wyróżniaKiedy wybrać
List<T>kolejność i indeksy, powtórzenia dozwolonegdy liczy się kolejność albo numer pozycji
Dictionary<K, V>szybkie szukanie po kluczugdy masz identyfikator i chcesz po nim sięgać
HashSet<T>brak powtórzeń, szybkie Containsgdy interesuje cię tylko „jest czy nie ma”

Zbiór nie ma indeksów

Zapis obecni[0] nie skompiluje się — HashSet nie jest listą i nie ma pojęcia „pierwszy element”. Kolejność przy wypisywaniu też nie jest gwarantowana. Gdy potrzebujesz jednocześnie unikalności i kolejności, trzymaj listę i zbiór obok siebie: lista przechowuje kolejność, zbiór pilnuje powtórzeń.

PRZYKŁAD

Przykład: wypożyczalnia i lista obecności

Program.cs
using System.Collections.Generic;

class Program
{
    static void Sprawdz(Dictionary<string, string> sprzet, string numer)
    {
        if (sprzet.TryGetValue(numer, out string? nazwa))
        {
            Console.WriteLine($"{numer}: {nazwa}");
        }
        else
        {
            Console.WriteLine($"{numer}: nie znam takiego numeru");
        }
    }

    static void Main()
    {
        Dictionary<string, string> sprzet = new Dictionary<string, string>
        {
            ["LT-01"] = "laptop Dell",
            ["LT-02"] = "laptop Lenovo",
            ["PR-07"] = "drukarka"
        };

        Sprawdz(sprzet, "LT-02");
        Sprawdz(sprzet, "XX-99");

        sprzet["LT-02"] = "laptop Lenovo (naprawiony)";   // nadpisanie
        Sprawdz(sprzet, "LT-02");

        Console.WriteLine($"\nUrzadzen w bazie: {sprzet.Count}");
        foreach (KeyValuePair<string, string> wpis in sprzet)
        {
            Console.WriteLine($"  {wpis.Key} -> {wpis.Value}");
        }

        Console.WriteLine("\nLISTA OBECNOSCI");
        string[] zgloszenia = { "Ala", "Bartek", "Ala", "Cezary", "Bartek", "Ala" };
        HashSet<string> obecni = new HashSet<string>();

        foreach (string imie in zgloszenia)
        {
            if (!obecni.Add(imie))
            {
                Console.WriteLine($"  {imie} juz sie zapisal - pomijam");
            }
        }

        Console.WriteLine($"Zgloszen: {zgloszenia.Length}, osob: {obecni.Count}");
    }
}
wynik w konsoli
LT-02: laptop Lenovo
XX-99: nie znam takiego numeru
LT-02: laptop Lenovo (naprawiony)

Urzadzen w bazie: 3
  LT-01 -> laptop Dell
  LT-02 -> laptop Lenovo (naprawiony)
  PR-07 -> drukarka

LISTA OBECNOSCI
  Ala juz sie zapisal - pomijam
  Bartek juz sie zapisal - pomijam
  Ala juz sie zapisal - pomijam
Zgloszen: 6, osob: 3

Co dzieje się po kolei

  • TryGetValue obsługuje oba przypadki w jednym miejscu — nieznany numer nie przerywa programu, tylko daje komunikat.
  • Typ string? przy out to typ dopuszczający brak wartości z lekcji 34. Gdy odczyt się nie uda, zmienna dostaje null.
  • Przypisanie do istniejącego klucza LT-02 nadpisuje wartość i nie zwiększa Count — to wciąż trzy urządzenia.
  • Warunek if (!obecni.Add(imie)) czyta się „jeśli nie udało się dodać”, czyli „jeśli już był”. Jedno wywołanie robi dwie rzeczy: sprawdza i dodaje.
  • Sześć zgłoszeń, trzy osoby — zbiór odrzucił trzy powtórzenia, nie zgłaszając żadnego błędu.
ELEMENTY WBUDOWANE

Zestawienie elementów

ElementZwracaDziałanie i ograniczenia
slownik[klucz] = wartoscDodaje albo nadpisuje. Nigdy nie zgłasza błędu przy zapisie.
slownik[klucz] (odczyt)wartośćDla nieistniejącego klucza KeyNotFoundException.
Add(klucz, wartosc)Dodaje; przy istniejącym kluczu ArgumentException.
TryGetValue(klucz, out x)boolNajbezpieczniejszy odczyt. Przy false zmienna ma wartość domyślną — nie używaj jej.
ContainsKey(klucz)boolSprawdza obecność klucza. Razem z odczytem przeszukuje słownik dwa razy.
GetValueOrDefault(klucz, x)wartośćWartość albo podana wartość zastępcza. Wygodne przy zliczaniu.
Remove(klucz)boolUsuwa wpis; false, gdy klucza nie było.
Keys / ValueskolekcjeSame klucze albo same wartości; do przejścia pętlą foreach.
KeyValuePair<K, V>typPara klucz-wartość z pól Key i Value; element słownika w foreach.
HashSet<T>.Add(x)booltrue przy nowym elemencie, false przy powtórzeniu.
HashSet<T>.Contains(x)boolSprawdzenie obecności, szybkie niezależnie od rozmiaru zbioru.
new HashSet<T>(kolekcja)HashSet<T>Kopia bez powtórzeń — najkrótszy sposób na usunięcie duplikatów.
CZĘSTE BŁĘDY

Zanim utkniesz

ZapisProblem
Odczyt slownik[klucz] bez sprawdzeniaKeyNotFoundException przy pierwszym nieznanym kluczu. Użyj TryGetValue.
Add tam, gdzie klucz może się powtórzyćArgumentException. Do aktualizacji służy indeksator.
Użycie zmiennej out poza gałęzią powodzeniaDla int dostaniesz zero, dla napisu null — i to wygląda jak prawdziwy wynik.
Poleganie na kolejności wpisówSłownik i zbiór nie gwarantują kolejności. Sortuj jawnie przy wypisywaniu.
zbior[0]HashSet nie ma indeksatora — to nie lista.
Dodawanie do słownika w trakcie foreach po nimInvalidOperationException, tak samo jak przy liście. Zbierz zmiany obok i zastosuj po pętli.
Dwie listy zamiast słownikaNic nie pilnuje, żeby pozostały zgodne. Jeśli jedna wartość opisuje drugą, to jest słownik.
ZADANIA

Zadania

ZAD 1Kody przedmiotów★☆☆

Utwórz Dictionary<string, string> z pięcioma kodami przedmiotów i ich nazwami. Wypisz wszystkie pary, a potem odczytaj jedną wybraną po kluczu.

ZAD 2Nieznany kod★☆☆

Rozbuduj poprzedni program tak, żeby wczytywał kod od użytkownika i wypisywał nazwę przedmiotu albo komunikat o nieznanym kodzie. Użyj TryGetValue, nie indeksatora.

ZAD 3Bez powtórzeń★☆☆

Mając tablicę imion z powtórzeniami, wypisz liczbę wszystkich zgłoszeń i liczbę różnych osób. Skorzystaj z HashSet utworzonego z tej tablicy.

ZAD 4Zliczanie liter★★☆

Wczytaj zdanie i policz, ile razy występuje każda litera. Wynik trzymaj w Dictionary<char, int> i wypisz tylko litery występujące więcej niż raz. Podpowiedź: GetValueOrDefault upraszcza zwiększanie licznika dla nowego klucza.

ZAD 5Dziennik ocen★★☆

Zbuduj Dictionary<string, List<int>> — imię ucznia i lista jego ocen. Napisz metody dodające ocenę oraz liczące średnią jednej osoby. Dodanie oceny osobie, której jeszcze nie ma w dzienniku, ma utworzyć dla niej nową listę, a nie zgłosić błąd.

ZAD 6Wypożyczalnia z historią★★★

Napisz program wypożyczalni z menu: dodaj sprzęt, wypożycz po numerze, zwróć, pokaż dostępne. Numery trzymaj w słowniku, a zbiór wypożyczonych numerów w HashSet. Program ma odrzucać wypożyczenie sprzętu już wypożyczonego oraz zwrot sprzętu, którego nikt nie wypożyczył — w obu przypadkach z czytelnym komunikatem, bez wyjątku.

PODSUMOWANIE

Co trzeba zapamiętać

  • Słownik łączy klucz z wartością i wyszukuje po kluczu niezależnie od liczby wpisów.
  • Klucz musi być unikalny; wartości mogą się powtarzać.
  • Indeksator dodaje albo nadpisuje, Add zgłasza wyjątek przy istniejącym kluczu — wybieraj świadomie.
  • Odczyt nieznanego klucza przez indeksator kończy się wyjątkiem; TryGetValue jest bezpieczne.
  • Wartości z parametru out używamy tylko wtedy, gdy odczyt się powiódł.
  • HashSet nie przechowuje powtórzeń, a jego Add od razu mówi, czy element był nowy.
  • Ani słownik, ani zbiór nie gwarantują kolejności elementów.

Dokumentacja: Microsoft Learn — temat tej lekcji.