.NET Tutorial
Junior7 мин чтенияВопрос 10 из 17

Коллекции: Array, List, Dictionary, HashSet, Queue, Stack — что выбрать и какая сложность?

Сложность операций

Коллекция Доступ по индексу Поиск значения Добавить в конец Вставка/удаление в середине По ключу
T[] O(1) O(n) — (фиксированный размер) — —
List<T> O(1) O(n) O(1) амортизированно O(n) —
LinkedList<T> O(n) O(n) O(1) O(1) при известном узле —
Dictionary<K,V> — O(n) по значению O(1) — O(1)
HashSet<T> — O(1) Contains O(1) — —
SortedDictionary<K,V> — — O(log n) — O(log n)
Queue<T> / Stack<T> — O(n) O(1) Enqueue/Push — —

Как устроен List

Add(5): места нет

Insert(0, x)

Capacity 4
[1, 2, 3, 4]

Новый массив Capacity 8
копирование 4 элементов

[1, 2, 3, 4, 5, _, _, _]

Сдвиг всех элементов
вправо — O(n)

Add(5): места нет

Insert(0, x)

Capacity 4
[1, 2, 3, 4]

Новый массив Capacity 8
копирование 4 элементов

[1, 2, 3, 4, 5, _, _, _]

Сдвиг всех элементов
вправо — O(n)

List — массив, который растёт удвоением
C#
var list = new List<int>(capacity: 1000);   // задайте ёмкость, если размер известен — меньше копирований
list.Add(1);
list.AddRange([2, 3, 4]);
int first = list[0];                          // O(1)
bool has = list.Contains(3);                  // O(n) — перебор
list.Remove(3);                               // O(n): поиск + сдвиг
list.RemoveAt(list.Count - 1);                // O(1) с конца
list.Sort();                                  // O(n log n)

Как устроен Dictionary

Ключ 'apple'

GetHashCode() = 93029210

Индекс корзины =
хэш % число корзин = 2

Корзина 2

('apple', 5)

('grape', 7)
коллизия: проверка Equals

Ключ 'apple'

GetHashCode() = 93029210

Индекс корзины =
хэш % число корзин = 2

Корзина 2

('apple', 5)

('grape', 7)
коллизия: проверка Equals

Хэш-таблица: ключ → хэш → корзина
C#
var stock = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
    ["apple"] = 5,
    ["pear"] = 3,
};

stock["banana"] = 10;                        // добавить или заменить
stock.Add("kiwi", 1);                        // ArgumentException, если ключ уже есть
int n = stock["apple"];                      // KeyNotFoundException, если ключа нет!

if (stock.TryGetValue("mango", out var count))   // безопасное чтение — одна операция поиска
    Console.WriteLine(count);

stock.TryAdd("pear", 100);                   // false — уже есть, значение не меняется
stock.Remove("pear");
foreach (var (fruit, qty) in stock) Console.WriteLine($"{fruit}: {qty}");   // порядок не гарантирован

HashSet

C#
var seen = new HashSet<string>();
foreach (var email in emails)
    if (!seen.Add(email))                    // Add возвращает false для дубликата
        Console.WriteLine($"Дубликат: {email}");

var a = new HashSet<int> { 1, 2, 3 };
var b = new HashSet<int> { 2, 3, 4 };
a.IntersectWith(b);   // {2, 3} — также UnionWith, ExceptWith, IsSubsetOf

Классическая оптимизация: проверка list.Contains(x) внутри цикла — O(n²); замена списка на HashSet даёт O(n).

Queue и Stack

Queue — FIFO

Enqueue(1), Enqueue(2), Enqueue(3)

Dequeue() → 1, затем 2, затем 3

Stack — LIFO

Push(1), Push(2), Push(3)

Pop() → 3, затем 2, затем 1

Queue — FIFO

Enqueue(1), Enqueue(2), Enqueue(3)

Dequeue() → 1, затем 2, затем 3

Stack — LIFO

Push(1), Push(2), Push(3)

Pop() → 3, затем 2, затем 1

FIFO и LIFO
C#
var tasks = new Queue<string>();
tasks.Enqueue("письмо 1"); tasks.Enqueue("письмо 2");
while (tasks.TryDequeue(out var t)) Send(t);       // обработка в порядке поступления

var undo = new Stack<string>();
undo.Push("ввёл текст"); undo.Push("удалил строку");
var last = undo.Pop();                              // "удалил строку" — отмена последнего действия

var pq = new PriorityQueue<string, int>();          // очередь с приоритетом (.NET 6+)
pq.Enqueue("обычный", 5); pq.Enqueue("срочный", 1);
pq.Dequeue();                                       // "срочный"

Как выбрать

да

нет

да

нет

да

нет

первым пришёл — первым ушёл

последним пришёл — первым ушёл

произвольный доступ

да

нет

Нужен поиск по ключу?

Нужен порядок ключей?

Dictionary

SortedDictionary

Важна уникальность
или быстрый Contains?

HashSet

Порядок обработки?

Queue

Stack

Размер известен
и не меняется?

Array

List

да

нет

да

нет

да

нет

первым пришёл — первым ушёл

последним пришёл — первым ушёл

произвольный доступ

да

нет

Нужен поиск по ключу?

Нужен порядок ключей?

Dictionary

SortedDictionary

Важна уникальность
или быстрый Contains?

HashSet

Порядок обработки?

Queue

Stack

Размер известен
и не меняется?

Array

List

Выбор коллекции

Интерфейсы коллекций

Интерфейс Что даёт Когда принимать/возвращать
IEnumerable<T> Только перебор foreach Параметр метода, если нужно лишь перебрать
IReadOnlyCollection<T> + Count Возврат готовой коллекции без права изменения
IReadOnlyList<T> + доступ по индексу Возврат списка «только для чтения»
ICollection<T> + Add, Remove, Contains Редко в публичных API
IList<T> + индекс, Insert Когда нужен изменяемый список с индексом
IDictionary / IReadOnlyDictionary Доступ по ключу Аналогично для словарей
C#
public IReadOnlyList<OrderLine> Lines => _lines;      // наружу — без права изменения
public decimal Sum(IEnumerable<decimal> values) => values.Sum();   // принимаем минимально необходимое

Вопросы на засыпку

Почему нельзя изменять List внутри foreach по нему?

Перечислитель хранит «версию» коллекции; Add/Remove её меняют, и следующий шаг foreach бросает InvalidOperationException: Collection was modified. Решения: перебор по индексу с конца (for (i = Count - 1; i >= 0; i--)), RemoveAll(predicate), или сбор изменений в отдельный список.

Почему Dictionary в среднем O(1), а в худшем O(n)?

Если много ключей попадает в одну корзину (плохая GetHashCode, например всегда возвращающая 0), поиск превращается в перебор цепочки. Хорошая хэш-функция распределяет ключи равномерно, и в корзине в среднем 0–1 элемент.

Чем Array отличается от List на практике?

Массив фиксированного размера и чуть быстрее; List растёт и имеет удобные методы. В бизнес-коде обычно используют List<T>, массивы — для фиксированных данных, низкоуровневого кода и API, которые их требуют (Span<T>, params).

Сохраняет ли Dictionary порядок добавления?

Не гарантирует. В текущей реализации без удалений порядок часто совпадает с порядком добавления, но полагаться на это нельзя. Для гарантированного порядка — SortedDictionary (по ключу) или OrderedDictionary<TKey, TValue> (.NET 9, по добавлению).

Итог

List<T> — универсальный выбор для упорядоченных данных с доступом по индексу, Dictionary — для быстрого поиска по ключу, HashSet — для уникальности и быстрого Contains, Queue и Stack — для обработки в порядке FIFO/LIFO. Знайте сложность операций: Contains у списка — O(n), у множества — O(1). Наружу отдавайте read-only интерфейсы, а принимайте минимально необходимый (IEnumerable<T>).