Сложность операций с коллекциями и выбор структуры

↑ 1.5 Коллекции и основы LINQ · ← Предыдущая

Обязательно~5 мин чтенияУровень: junior

Зачем это на собесе

«Какая сложность у поиска в списке и в словаре?» и «Что выберете для этой задачи?» — стандартные вопросы. Ждут не заученной таблицы, а умения сопоставить операции с нагрузкой: что чаще, чтение или вставка, нужен ли порядок, есть ли параллельный доступ.

Объяснение

Нотация O

Описывает рост числа операций при росте n, отбрасывая константы. Порядок роста: O(1) < O(log n) < O(n) < O(n log n) < O(n²). Амортизированная сложность — усреднённая по серии операций (добавление в List в конце: иногда O(n), в среднем O(1)).

Таблица сложностей (среднее, если не сказано иное)

СтруктураДоступ по индексу/ключуПоиск значенияВставкаУдалениеПорядок
T[]O(1)O(n)— (фикс. размер)—по индексу
List<T>O(1)O(n)конец: O(1) амортиз.; середина: O(n)O(n)по индексу
LinkedList<T>O(n)O(n)по узлу: O(1)по узлу: O(1)порядок вставки
Dictionary<K,V>O(1) по ключуO(n) по значениюO(1)O(1)не гарантирован
HashSet<T>—O(1)O(1)O(1)не гарантирован
SortedDictionary/SortedSetO(log n)O(log n)O(log n)O(log n)по ключу
Queue<T>Peek: O(1)O(n)Enqueue: O(1)Dequeue: O(1)FIFO
Stack<T>Peek: O(1)O(n)Push: O(1)Pop: O(1)LIFO
PriorityQueuePeek: O(1)O(n)O(log n)Dequeue: O(log n)по приоритету

Худший случай для хэш-таблиц — O(n) при массовых коллизиях (плохой GetHashCode).

Как выбирать структуру

  • Нужен доступ по индексу и перебор → List<T> / массив.
  • Быстрый поиск по ключу → Dictionary.
  • Уникальность и проверка «есть ли» → HashSet.
  • Всегда отсортированные данные и диапазонные запросы → SortedDictionary/SortedSet.
  • Очередь задач → Queue; откат/DFS → Stack; «самый приоритетный» → PriorityQueue.
  • Много потоков → ConcurrentDictionary, ConcurrentQueue, Channel<T>.
  • Данные не меняются после загрузки → FrozenDictionary/FrozenSet/ImmutableArray (быстрее чтение).

Типичные оптимизации

// Плохо: O(n·m) — для каждого заказа линейный поиск клиента
foreach (var o in orders)
{
    var c = customers.First(x => x.Id == o.CustomerId);
}
 
// Хорошо: O(n + m)
var byId = customers.ToDictionary(c => c.Id);
foreach (var o in orders)
{
    var c = byId[o.CustomerId];
}
// Проверка вхождения в цикле: список -> множество
var blocked = new HashSet<Guid>(blockedList);
var allowed = users.Where(u => !blocked.Contains(u.Id));

Не только Big-O

  • Локальность кэша: массив и List быстрее связных структур, потому что данные лежат подряд.
  • Аллокации и GC: структуры с узлами (LinkedList, деревья) создают много объектов.
  • Константы: для малых n (десятки элементов) линейный поиск по List может быть быстрее хэш-таблицы.
  • Измеряйте: BenchmarkDotNet вместо догадок.

Нюансы и подводные камни

  • List<T>.Remove/Contains/IndexOf — O(n): в горячем цикле по большим данным это O(n²).
  • LinkedList обычно проигрывает List даже там, где «теоретически» выигрывает.
  • Порядок перебора Dictionary/HashSet не гарантирован: если порядок нужен, используйте List, SortedX или OrderedDictionary (.NET 9).
  • Плохой GetHashCode (постоянное значение, малое число различных хэшей) превращает словарь в список.
  • string.Contains и конкатенация в циклах тоже дают скрытые квадратичные затраты.
  • «Преждевременная оптимизация»: сначала выберите понятную структуру, оптимизируйте после профилирования.

Практика

  • Оценить сложность вложенных циклов и LINQ-запросов в своём проекте и заменить один First внутри цикла на словарь.
  • Сравнить List.Contains и HashSet.Contains на 10, 1 000 и 1 000 000 элементов через BenchmarkDotNet.
  • Реализовать очередь с приоритетами на PriorityQueue для планировщика задач.

Вопросы с ответами

Связанные темы