Структуры данных

Словарь Dictionary в C#: полное руководство

Обновлено: · 6 мин чтения

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

Словарь Dictionary в C#: полное руководство
Содержание
  1. Что такое Dictionary и когда он нужен
  2. Создание и инициализация
  3. Добавление: Add, индексатор и TryAdd
  4. Чтение значений: индексатор, TryGetValue и ContainsKey
    1. Счётчики и группировка
  5. Перебор словаря через foreach
  6. Изменение и удаление
  7. Сложность операций
  8. SortedDictionary и ConcurrentDictionary
    1. SortedDictionary
    2. ConcurrentDictionary
  9. Типичные ошибки
  10. Частые вопросы

Что такое 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, ContainsKeyO(1)O(n)
Add, индексатор для записи, TryAddO(1)O(n) при расширении таблицы
RemoveO(1)O(n)
ContainsValueO(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?

Когда несколько потоков одновременно читают и меняют один словарь. Для данных, которые заполняются один раз и затем только читаются, достаточно обычного словаря.