Модул 10

Речници и още колекции (Dictionary, HashSet)

Списъкът не е единствената колекция. Dictionary съхранява двойки ключ-стойност за мигновено търсене по ключ, HashSet пази само уникални стойности, а Queue и Stack дават ред на обработката.

Теория

Dictionary<TKey, TValue> съхранява двойки ключ → стойност: телефонен указател (име → номер), ценоразпис (продукт → цена), дневник (студент → оценка). Достъпът по ключ е почти мигновен, независимо колко записа има — за разлика от списък, който трябва да се обходи.

Синтаксисът наподобява масив, но в скобите стои ключ вместо индекс: prices["banana"] = 2.50; записва, prices["banana"] чете. Внимание: четене на несъществуващ ключ хвърля KeyNotFoundException — затова първо проверяваме с ContainsKey() или ползваме TryGetValue().

HashSet<T> е колекция, която пази само уникални стойности — повторното Add на същата стойност просто не прави нищо (и връща false). Идеален за "виждали ли сме това вече?" задачи, без ръчни проверки с Contains по списък.

Queue<T> (опашка) и Stack<T> (стек) определят реда на обработка: опашката е FIFO — първият влязъл излиза първи (като на каса в магазин), стекът е LIFO — последният влязъл излиза първи (като купчина чинии). Срещат се по-рядко в началото, но е добре да знаете, че съществуват.

Стойността в речника може да бъде каквото и да е — включително друга колекция: Dictionary<string, List<string>> е телефонен указател, в който едно име има няколко номера. Най-честият учебен шаблон обаче е броячът: Dictionary<string, int>, в който ключът е "какво броим", а стойността — "колко пъти сме го видели".

Златни правила

Ключовете са уникални

В Dictionary всеки ключ може да се среща само веднъж. Присвояване на съществуващ ключ (dict[key] = value) заменя старата стойност; Add() със съществуващ ключ хвърля грешка.

Проверявайте преди четене

Четенето на несъществуващ ключ (dict["липсващ"]) хвърля KeyNotFoundException. Преди четене проверявайте с ContainsKey() или използвайте TryGetValue().

Изберете правилната колекция

Наредени елементи по позиция → List. Търсене по ключ → Dictionary. Само уникални стойности → HashSet. Ред на обработка → Queue/Stack.

Обхождане с KeyValuePair

При foreach върху речник всеки елемент е KeyValuePair<TKey, TValue> — двойката се достъпва с .Key и .Value.

Бърз справочник

Основни операции с Dictionary

ОперацияКакво прави?Пример
ДеклариранеПразен речник ключ→стойностvar prices = new Dictionary<string, double>();
dict[key] = valueДобавя или заменя стойност по ключprices["banana"] = 2.50;
dict[key]Чете стойност (гърми при липсващ ключ!)double p = prices["banana"];
ContainsKey()Има ли такъв ключ (връща bool)if (prices.ContainsKey("apple"))
TryGetValue()Безопасно четене без изключениеif (prices.TryGetValue("apple", out double p))
Remove()Премахва запис по ключprices.Remove("banana");
CountБрой записиprices.Count
.Keys / .ValuesКолекция от всички ключове / стойностиforeach (string k in prices.Keys)

Коя колекция кога?

КолекцияКакво пази?Типична употреба
List<T>Наредени елементи, дубликати са ОКСписък с имена, оценки, продукти
Dictionary<K, V>Двойки ключ → стойност, уникални ключовеЦеноразпис, телефонен указател, броячи
HashSet<T>Само уникални стойности, без редПосетени елементи, премахване на дубликати
Queue<T>FIFO — пръв влязъл, пръв излязълОпашка от задачи, ред на обслужване
Stack<T>LIFO — последен влязъл, пръв излязъл"Undo" история, обратен ред

Код за анализ и пренаписване

Ценоразпис с Dictionary и уникални посетители с HashSet

Program.cs
using System;
using System.Collections.Generic;

namespace ModuleTen
{
    class Program
    {
        static void Main(string[] args)
        {
            // Речник: продукт -> цена
            Dictionary<string, double> prices = new Dictionary<string, double>();
            prices["banana"] = 2.50;
            prices["apple"] = 1.20;
            prices["orange"] = 3.10;

            Console.Write("Кой продукт търсиш? ");
            string product = Console.ReadLine();

            // Безопасно четене с TryGetValue
            if (prices.TryGetValue(product, out double price))
            {
                Console.WriteLine($"{product} струва {price} лв.");
            }
            else
            {
                Console.WriteLine($"Нямаме '{product}' в ценоразписа.");
            }

            // Обхождане на целия речник
            Console.WriteLine("--- Ценоразпис ---");
            foreach (KeyValuePair<string, double> item in prices)
            {
                Console.WriteLine($"{item.Key}: {item.Value} лв.");
            }

            // HashSet: пази само уникални стойности
            HashSet<string> visitors = new HashSet<string>();
            visitors.Add("Иван");
            visitors.Add("Мария");
            visitors.Add("Иван"); // дубликат - игнорира се!

            Console.WriteLine($"Уникални посетители: {visitors.Count}"); // 2
        }
    }
}

Какво се случва тук?

  • prices["banana"] = 2.50; добавя запис — синтаксисът е като при масив, но "индексът" е низ-ключ. Ако ключът вече съществува, стойността просто се заменя.
  • TryGetValue(product, out double price) опитва да прочете: ако ключът съществува, връща true и записва стойността в price; ако не — връща false, без да гърми. Това е същият шаблон като int.TryParse от Модул 1.
  • Директното prices[product] при липсващ продукт щеше да хвърли KeyNotFoundException — затова в реален код четем безопасно.
  • При foreach върху речника всеки елемент е KeyValuePair<string, double> — двойка с .Key (продукта) и .Value (цената).
  • Третото visitors.Add("Иван") тихо се игнорира — HashSet не допуска дубликати, затова Count е 2, а не 3. Със List щеше да се наложи ръчна проверка с Contains.

Шаблонът "брояч": колко пъти се среща всяка буква

Program.cs
using System;
using System.Collections.Generic;

namespace ModuleTenExtra
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.Write("Въведи текст: ");
            string text = Console.ReadLine().ToLower();

            Dictionary<char, int> counts = new Dictionary<char, int>();

            foreach (char c in text)
            {
                if (c == ' ')
                {
                    continue; // интервалите не ни интересуват
                }

                if (counts.ContainsKey(c))
                {
                    counts[c]++;    // виждали сме я -> увеличаваме
                }
                else
                {
                    counts[c] = 1;  // първа среща -> създаваме запис
                }
            }

            Console.WriteLine("--- Резултат ---");
            foreach (KeyValuePair<char, int> pair in counts)
            {
                Console.WriteLine($"'{pair.Key}' се среща {pair.Value} пъти");
            }
        }
    }
}

Какво се случва тук?

  • Това е най-важният шаблон с речници — броене на срещания. Същата логика брои думи, гласове, продажби, оценки…
  • Низът се обхожда с foreach (char c in text) — низът е последователност от символи, затова това просто работи.
  • ToLower() на входа гарантира, че "А" и "а" се броят заедно, а continue прескача интервалите.
  • Сърцето: ако ключът съществува → counts[c]++, ако не → counts[c] = 1. Без проверката ContainsKey редът counts[c]++ щеше да гръмне с KeyNotFoundException при първата среща на буквата.
  • Финалното обхождане с KeyValuePair<char, int> показва и ключа (буквата), и стойността (броя) — .Key и .Value.

Внимавай! Чести грешки

Грешките, които почти всеки прави в тази тема — виж разликата между грешния и правилния код.

Четене на липсващ ключ

Грешно

Dictionary<string, double> prices = new Dictionary<string, double>();
double p = prices["banana"];
// KeyNotFoundException

Правилно

if (prices.TryGetValue("banana", out double p))
{
    Console.WriteLine(p);
}
else
{
    Console.WriteLine("Няма такъв продукт.");
}

Индексаторът за четене изисква ключът да съществува. TryGetValue проверява и чете в една стъпка — без изключение и без двойно търсене.

Add при вече съществуващ ключ

Грешно

dict.Add("ivan", 5);
dict.Add("ivan", 6); // ArgumentException

Правилно

dict["ivan"] = 5;
dict["ivan"] = 6; // просто заменя стойността

Add отказва дубликати с изключение. Когато замяната е очаквано поведение (обновяване на стойност), индексаторът е правилният инструмент.

Триене от речник по време на foreach

Грешно

foreach (var pair in counts)
{
    if (pair.Value == 0)
    {
        counts.Remove(pair.Key); // InvalidOperationException
    }
}

Правилно

List<char> toRemove = new List<char>();
foreach (var pair in counts)
{
    if (pair.Value == 0) toRemove.Add(pair.Key);
}
foreach (char key in toRemove)
{
    counts.Remove(key);
}

Както при списъците: колекция не се променя по време на обхождане. Съберете ключовете за триене в отделен списък и ги премахнете след това.

Задачи за самостоятелна работа

Опитай първо сам — подсказката е там само ако наистина закъсаш.

Задача 1: Брояч на думи

Потребителят въвежда изречение. Пребройте колко пъти се среща всяка дума и отпечатайте резултата във формат "дума: брой".

Задача 2: Телефонен указател

Направете програма-указател: потребителят добавя записи "име номер", а при въвеждане само на име програмата показва номера или "Няма такъв контакт". Командата "край" спира програмата.

Задача 3: Гласуване

Потребителите въвеждат име на кандидат (по един на ред), а командата "край" спира въвеждането. Отпечатайте колко гласа е получил всеки кандидат и обявете победителя.

Мини-тест за проверка

Отговори си наум (или на глас!) и чак тогава разкрий отговора.

1. Какво ще се случи при опит да прочетем несъществуващ ключ: prices["липсващ"]?

2. Каква е основната разлика между List<T> и Dictionary<TKey, TValue>?

3. Какво прави HashSet<T> при опит да се добави стойност, която вече съществува?

4. Каква е разликата между dict[key] = value и dict.Add(key, value)?

Речник на термините

Виж пълния речник на курса
Речник (Dictionary)
Колекция от двойки ключ → стойност с мигновено търсене по ключ: Dictionary<string, int>.
Ключ (Key)
"Адресът" на записа — уникален в рамките на речника. По него се чете и пише.
Стойност (Value)
Данните, съхранени срещу ключа. Може да е всякакъв тип, включително списък или обект.
KeyValuePair
Една двойка от речника при обхождане: pair.Key и pair.Value.
TryGetValue
Безопасно четене: връща true/false и подава стойността през out — без изключения.
Множество (HashSet)
Колекция само от уникални стойности — дубликатите при Add тихо се игнорират.
Опашка (Queue) / Стек (Stack)
Колекции с ред на обработка: опашката е FIFO (пръв влязъл — пръв излязъл), стекът е LIFO (последен влязъл — пръв излязъл).

Провери знанията

Бърз тест с избор от отговори върху термините на модула — с точки и моментална обратна връзка.

Провери знанията си

7 въпроса върху термините от модула. Показваме определение — ти избираш правилния термин.

Препоръки за този модул

  • Мислете за речника като за истински речник: думата е ключът, дефиницията е стойността — и думите не се повтарят.
  • Класическа задача за упражнение: преброяване на срещанията на думи в текст — Dictionary<string, int> + Split от Модул 9.
  • Когато се хванете да търсите в List с цикъл по "име", спрете и помислете: нямаше ли да е по-добре Dictionary?
  • Нарочно прочетете несъществуващ ключ, за да видите KeyNotFoundException на живо — после го оправете с TryGetValue.

Начален курс по C# — учебен наръчник и бърз справочник за студенти.

Натисни Ctrl K или / отвсякъде, за да търсиш.