Словарь Dictionary в C#: полное руководство
Dictionary в C# хранит пары "ключ - значение" и находит значение по ключу практически мгновенно. Это одна из самых востребованных коллекций: на ней строят кеши, счётчики, индексы и справочники. Ниже разобраны создание и инициализация, методы Add, TryAdd, TryGetValue и ContainsKey, перебор, удаление, сложность операций, а также ConcurrentDictionary и SortedDictionary.

Содержание
Что такое Dictionary и когда он нужен
Класс Dictionary<TKey, TValue> находится в пространстве имён System.Collections.Generic. Внутри он построен на хеш-таблице: ключ превращается в число (хеш-код), по которому определяется ячейка хранения. Поэтому поиск, вставка и удаление в среднем занимают постоянное время и почти не зависят от количества элементов.
Ключи в словаре уникальны, значения могут повторяться. Порядок элементов формально не гарантируется, хотя при отсутствии удалений перебор обычно идёт в порядке добавления. Полагаться на это нельзя.
Словарь выбирают, когда нужно быстро находить объект по идентификатору, считать частоту элементов, группировать данные или кешировать результаты вычислений. Если обращение идёт по номеру позиции, подойдёт List<T>.
Создание и инициализация
Пустой словарь создаётся конструктором. Тип ключа и значения указывают в угловых скобках. Заполнить можно сразу при создании двумя способами: инициализатором коллекции и инициализатором индексов. В C# 12 доступна ещё и короткая запись выражения коллекции для словарей в виде new() при известном типе.
// пустой словарь
var ages = new Dictionary<string, int>();
// инициализатор коллекции (вызывает Add)
var capitals = new Dictionary<string, string>
{
{ "Россия", "Москва" },
{ "Франция", "Париж" }
};
// инициализатор индексов (не бросает исключение при дублях)
var codes = new Dictionary<int, string>
{
[200] = "OK",
[404] = "Not Found",
[500] = "Server Error"
};
// с начальной ёмкостью, чтобы избежать перераспределений
var big = new Dictionary<int, string>(10_000);
// целевой тип
Dictionary<string, double> prices = new();
Если заранее известно приблизительное число элементов, передайте его в конструктор: это уменьшит количество перестроек внутренней таблицы. Словарь можно также получить из последовательности через LINQ.
string[] words = { "яблоко", "груша", "слива" };
Dictionary<string, int> lengths = words.ToDictionary(w => w, w => w.Length);
Console.WriteLine(lengths["груша"]); // 5
Добавление: Add, индексатор и TryAdd
Для добавления есть три способа с разным поведением при повторяющемся ключе. Выбор зависит от того, считается ли дубль ошибкой.
| Способ | Если ключа нет | Если ключ есть |
|---|---|---|
dict.Add(k, v) | добавляет пару | бросает ArgumentException |
dict[k] = v | добавляет пару | заменяет значение |
dict.TryAdd(k, v) | добавляет и возвращает true | ничего не меняет, возвращает false |
var stock = new Dictionary<string, int>();
stock.Add("молоко", 10);
stock["хлеб"] = 5; // добавили
stock["хлеб"] = 8; // перезаписали
bool added = stock.TryAdd("молоко", 99); // false, значение осталось 10
Console.WriteLine($"{added} {stock["молоко"]}");
try
{
stock.Add("хлеб", 1); // дубль
}
catch (ArgumentException)
{
Console.WriteLine("ключ уже существует");
}
Чтение значений: индексатор, TryGetValue и ContainsKey
Чтение через индексатор dict[key] бросает KeyNotFoundException, если ключа нет. Поэтому для данных, которые могут отсутствовать, применяют TryGetValue: он возвращает bool и отдаёт значение через out-параметр. Это единственный поиск по хешу вместо двух.
var users = new Dictionary<int, string> { [1] = "Анна", [2] = "Борис" };
if (users.TryGetValue(2, out string? name))
Console.WriteLine($"Нашли: {name}");
else
Console.WriteLine("Не найден");
// значение по умолчанию без проверки
string who = users.GetValueOrDefault(5, "неизвестно");
// ContainsKey: только проверка наличия
if (users.ContainsKey(1))
Console.WriteLine("ключ 1 есть");
// ContainsValue: перебор всех значений, работает за O(n)
bool hasBoris = users.ContainsValue("Борис");
Типичная ошибка новичков - писать if (dict.ContainsKey(k)) { var v = dict[k]; ... }. Это два поиска вместо одного, а в многопоточном коде ещё и гонка между проверкой и чтением. Заменяйте такую пару на TryGetValue.
Счётчики и группировка
Распространённая задача - посчитать, сколько раз встретился каждый элемент. Для этого удобен метод CollectionsMarshal.GetValueRefOrAddDefault, но на практике хватает простого варианта с TryGetValue.
string text = "кот пёс кот рыба пёс кот";
var counts = new Dictionary<string, int>();
foreach (string word in text.Split(' '))
{
counts.TryGetValue(word, out int current);
counts[word] = current + 1;
}
foreach (var (word, count) in counts)
Console.WriteLine($"{word}: {count}");
// кот: 3, пёс: 2, рыба: 1
Для словаря со списком значений по каждому ключу поможет TryGetValue и создание списка при первом обращении: если ключа нет, создайте new List<T>() и добавьте в словарь. Тот же результат даёт LINQ-метод GroupBy.
Перебор словаря через foreach
При переборе каждый элемент имеет тип KeyValuePair<TKey, TValue> со свойствами Key и Value. Можно перебирать пары, только ключи (Keys) или только значения (Values). Деконструкция пары в (key, value) делает запись короче.
var prices = new Dictionary<string, decimal>
{
["чай"] = 120m,
["кофе"] = 340m,
["какао"] = 210m
};
foreach (KeyValuePair<string, decimal> pair in prices)
Console.WriteLine($"{pair.Key} - {pair.Value}");
foreach (var (name, price) in prices)
Console.WriteLine($"{name}: {price}");
foreach (string key in prices.Keys) Console.Write(key + " ");
foreach (decimal value in prices.Values) Console.Write(value + " ");
// сортированный вывод без изменения самого словаря
foreach (var item in prices.OrderBy(p => p.Value))
Console.WriteLine(item.Key);
Менять словарь во время перебора нельзя: добавление или удаление элемента внутри foreach приведёт к InvalidOperationException. Чтобы удалить часть элементов, сначала соберите ключи в отдельный список, а потом удаляйте. В .NET Core 3.0 и новее Remove во время перебора разрешён, но рассчитывать на это в переносимом коде не стоит.
var toRemove = prices.Where(p => p.Value > 300m).Select(p => p.Key).ToList();
foreach (string key in toRemove)
prices.Remove(key);
Изменение и удаление
Значение заменяют присваиванием по ключу. Если нужно изменить значение, только когда ключ существует, сначала проверьте его через TryGetValue. Для удаления есть метод Remove: он возвращает true, если элемент найден, и false в противном случае, исключений не бросает. Перегрузка с out заодно возвращает удалённое значение. Метод Clear опустошает словарь целиком.
var cart = new Dictionary<string, int> { ["ручка"] = 3, ["тетрадь"] = 2 };
cart["ручка"] += 1; // 4
bool removed = cart.Remove("тетрадь"); // true
bool again = cart.Remove("тетрадь"); // false
if (cart.Remove("ручка", out int quantity))
Console.WriteLine($"Удалили {quantity} шт.");
cart.Clear();
Console.WriteLine(cart.Count); // 0
Сложность операций
Скорость словаря обеспечивает хеш-таблица. Операции работают за константное время в среднем, но при большом количестве коллизий (разные ключи с одинаковым хешем) могут замедляться. Поэтому для собственных типов-ключей важно правильно реализовать GetHashCode и Equals. Для записей и строк это уже сделано.
| Операция | Среднее | Худший случай |
|---|---|---|
| Поиск по ключу, TryGetValue, ContainsKey | O(1) | O(n) |
| Add, индексатор для записи, TryAdd | O(1) | O(n) при расширении таблицы |
| Remove | O(1) | O(n) |
| ContainsValue | O(n) | O(n) |
| Перебор | O(n) | O(n) |
Если словарь нужен для поиска по сложному ключу, используйте запись или кортеж: у них корректные равенство и хеш. Строковые ключи можно сделать нечувствительными к регистру, передав компаратор в конструктор.
var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
map["Apple"] = 1;
Console.WriteLine(map["APPLE"]); // 1
var grid = new Dictionary<(int X, int Y), char>();
grid[(1, 2)] = '#';
Console.WriteLine(grid.ContainsKey((1, 2))); // True
Если интересно, как устроены другие базовые структуры, обратите внимание на статьи про стек и бинарное дерево. Последнее по принципу работы ближе всего к SortedDictionary.
SortedDictionary и ConcurrentDictionary
Обычный словарь не подходит в двух случаях: когда ключи должны идти в отсортированном порядке и когда к словарю обращаются несколько потоков одновременно. Для этого есть отдельные классы.
SortedDictionary
SortedDictionary<TKey, TValue> хранит пары в красно-чёрном дереве и всегда перебирается в порядке возрастания ключей. Поиск, вставка и удаление выполняются за O(log n), то есть медленнее, чем у Dictionary. Выбирайте его, когда сортированный перебор нужен постоянно. Для небольших объёмов есть похожий SortedList<TKey, TValue>, он компактнее, но вставка в середину дороже.
var sorted = new SortedDictionary<string, int>
{
["яблоко"] = 3,
["арбуз"] = 1,
["вишня"] = 7
};
foreach (var (key, value) in sorted)
Console.WriteLine($"{key}: {value}");
// арбуз, вишня, яблоко
ConcurrentDictionary
ConcurrentDictionary<TKey, TValue> из пространства имён System.Collections.Concurrent безопасен для использования из нескольких потоков. Обычный Dictionary при одновременной записи может испортить внутреннее состояние. В потокобезопасном варианте вместо пары "проверить и записать" применяют атомарные методы GetOrAdd, AddOrUpdate и TryUpdate.
using System.Collections.Concurrent;
var hits = new ConcurrentDictionary<string, int>();
Parallel.For(0, 1000, i =>
{
string page = i % 2 == 0 ? "главная" : "каталог";
hits.AddOrUpdate(page, 1, (key, old) => old + 1);
});
Console.WriteLine(hits["главная"]); // 500
var cache = new ConcurrentDictionary<int, string>();
string value = cache.GetOrAdd(42, id => $"данные {id}");
Учтите, что фабрика в GetOrAdd может вызваться более одного раза при гонке, хотя запишется только одно значение. Если создание значения дорогое, храните в словаре Lazy<T>.
Типичные ошибки
- Обращение по индексатору к отсутствующему ключу без проверки:
KeyNotFoundException. - Использование
Addтам, где дубли допустимы. Лучше индексатор илиTryAdd. - Изменяемый объект в роли ключа. Если после добавления поле, влияющее на хеш, изменится, элемент станет недоступным.
- Ключ типа
null: он не допускается и вызываетArgumentNullException. - Попытка использовать обычный Dictionary из нескольких потоков без блокировки.
- Предположение о порядке элементов. Если порядок важен, применяйте SortedDictionary или сортируйте результат.
Частые вопросы
Чем Dictionary отличается от List?
Список хранит элементы по порядковым номерам, поиск значения в нём идёт перебором за O(n). Словарь ищет по ключу за O(1), но занимает больше памяти и не гарантирует порядок.
Что быстрее: ContainsKey или TryGetValue?
Если после проверки вам нужно значение, быстрее TryGetValue, ведь это один поиск. ContainsKey подходит, когда нужен только факт наличия.
Можно ли использовать свой класс в качестве ключа?
Можно, но нужно переопределить Equals и GetHashCode или использовать запись (record), где это сделано автоматически. Иначе сравнение пойдёт по ссылкам.
Как получить первый элемент словаря?
Через LINQ: dict.First() вернёт пару, но "первый" в смысле порядка вставки не гарантирован. Для пустого словаря используйте FirstOrDefault().
Как скопировать словарь?
Передайте исходный словарь в конструктор: new Dictionary<K, V>(original). Копия будет поверхностной: объекты-значения останутся общими.
Когда выбирать ConcurrentDictionary?
Когда несколько потоков одновременно читают и меняют один словарь. Для данных, которые заполняются один раз и затем только читаются, достаточно обычного словаря.




