Dictionary и SortedDictionary
Всем привет. Сегодня я планирую рассказать в общих чертах о Dictionary
и SortedDictionary
в .NET - как они устроены и в чем различие между ними.
Зачем?
Во-первых, меня пару раз об этом спросили на собеседованиях и в первый раз я вообще потерялся с ответом, что было не самым приятным опытом, от которого я хочу вас избавить. Во-вторых, словарь - одна из самых часто используемых структур данных и при разработке бывает полезно понимать, какие у нее есть подводные, а также знать, когда использование SortedDictionary
оправдано.
Dictionary.
Для начала разберемся с Dictionary<TKey, TValue>
. Это коллекция пар ключ-значение. Ключ должен быть уникальным. В среднем получение, добавление, удаления элемента из нее происходит за . Как же это происходит? Давайте разбираться.
Внутри словарь использует структуру под названием Entry
и два массива buckets
и entries
.
private struct Entry {
public int hashCode; // хеш код, вычисленный для ключа
public int next; // индекс следующего элемента с тем же хешем, -1, если текущий элемент последний
public TKey key;
public TValue value;
}
private int[] buckets; // индексы начала бакетов
private Entry[] entries; // элементы словаря
Каждый элемент хранится в бакете, который соответствует остатку от деления хеша от ключа на размер массива buckets
. Отметим, что у нас возможны коллизии, когда два разных ключа дают одинаковый хеш. В этом случае у нас в бакете будет связный список элементов.
Пока мы поддерживаем размеры бакетов небольшими (0-3 элемента) и само их количество соразмерно числу элементов (в идеале каждый бакет содержит 0 или 1 элемент), мы получаем усредненный доступ за , так как внутри мы берем элемент из массива по его индексу. Для реализации подобной структуры (массив, содержащий связные списки) используются два массива buckets
и entries
. В массиве bucket
индекс - номер бакета, а значение - начало связного списка с элементами из entries
.
По мере роста числа элементов, размер массивов так же увеличивается. В каждый момент времени размер массива buckets
является простым числом. Причем тут простые числа? Сейчас узнаем.
Чтобы определить, в какой бакет положить добавляемую пару, внутри словаря вычисляется хеш от ключа [1]. Затем мы берем остаток от деления этого хеша на размер массива buckets
и кладем нашу пару в индекс с полученным значением. Таким образом мы гарантируем, что любой результат хеш функции будет в пределах размера массива buckets
[2]. Однако, если мы будем случайно подбирать размеры массива, мы можем плохо распределять загрузку значений, например, много чисел имеет одинаковый остаток от деления на 2 или другую степень этого замечательного числа. В противовес этому, у простых чисел по определению только два делителя единица и само число, поэтому вероятность, что остаток от деления двух разных чисел на третье простое число окажется одинаковым очень мала. За счет этого мы гарантируем, что при нормально работающей хеш функции, бакеты будут заполнены равномерно.
Отдельно заметим, что в словаре, чтобы различить два объекта с одинаковым хешем, при запросе значения используется метод Equals
.
На этом я закончу с разбором механизма работыDictionary
. Тут я не упомянул о том, как обрабатывается удаление и затем вставка элементов, но для понимания того, почему в среднем словарь работает за это не нужно.
Сейчас поговорим немного о потокобезопасности. Пока разные потоки только читают данные из словаря, он будет потокобезопасным. Если же мы хотим как-то модифицировать словарь, то нам надо использовать ConcurrentDictionary
, или ImmutableDictionary
, или свой собственный механизм синхронизации доступа к данным. Важный момент, если при параллельном проходе по словарю через Parallel.ForEach
(или каким-то другим способом) мы как-то модифицируем текущее значение, то такая операция не будет потокобезопасной.
Промежуточные итоги и рекомендации:
Эффективность работы словаря зависит от качества хеш функции ключа. Если хеш функция дает много коллизий, словарь нас не спасет. При переопределении
GetHashCode
для использования объектов класса в качестве ключа следует помнить об этом.Если у нас постоянно растет число элементов в словаре, имеет смысл сразу задать какое-то простое число в качестве начальной вместимости словаря. Это поможет избежать нагрузки связанной с пересозданием массивов
buckets
иentries
при расширении.Если два ключа имеют одинаковый результат хеш функции, то дальше они сравниваются по
Equals
.Сам
Dictionary
является потокбезопасным, пока разные потоки только читают данные из него. В других случаях следует обеспечить синхронизацию или использоватьConcurrentDictionary
,ImmutableDictionary
.
SortedDicitonary.
Как следует из названия SortedDictionary<TKey, TValue>
- коллекция пар ключ-значение, которая все время отсортирована по ключам. Ключ, как и в случае с обычным словарем, должен быть уникальным. Однако дальше идут различия. Скорость работы с элементами отсортированного словаря равна , где - количество элементов в словаре, при этом иногда это будет быстрее, чем для обычного словаря. С чем это связано? Со внутренней реализацией.
Внутри SortedDictionary
представляет собой бинарное дерево поиска. Здесь уже не используется GetHashCode
и остатки от деления. Сравнение происходит через стандартный IComparable<TKey>
для TKey
или через переданный в конструкторе для SortedDictionary
объект IComparer<TKey>
. Поэтому SortedDictionary
не страдает от частых коллизий, что даст нам более эффективное взаимодействие нежели с обычным словарем в данном сценарии. Само бинарное дерево поиска - структура данных, для которой верно следующее утверждение:
Каждая вершина имеет от 0 до 2 потомков, все элементы в левом поддереве меньше или равны значению в родительской вершине, а все элементы в правом поддереве больше значения в родительской.
Существуют разные виды бинарных деревьев поиска, отличающихся по подходу к балансировке, в частности в SortedDictionary
используется красно-черное дерево. Но в особенности построения деревьев в данной статье я углубляться не буду, может быть в другой раз.
Для нас главное понимать временную сложность операций в SortedDictionary
, а так же с чем она связана.
Для отсортированного словаря потокобезопасность гарантируется только на чтение данных. Если же мы хотим как-то модифицировать отсортированный словарь или какое-то из значений, нам необходимо реализовывать свой механизм синхронизации или блокировать коллекцию целиком на время, когда возможны изменения.
Промежуточные итоги и рекомендации:
Если для нас важен порядок ключей в словаре, следует использовать
SortedDictionary
. Главное помнить, что сложность выполнения операций в среднем .Если нам нужен только отстортированный вывод, а все остальное время порядок ключей не играет роли, можно подумать в сторону обычного словаря.
Если нам нужна коллекция пар ключ-значение, но хеши ключей часто дают коллизии, имеет смысл использовать
SortedDictionary
вместо обычного. Так же это важно, когда речь идет о предсказуемости сложности операций, для хеша она зависит от хеш функции, а порой нам важнее предсказуемость сложности, нежели скорость выполнения операций.Так как
SortedDictionary
поддерживает порядок ключей постоянным, операции выборки диапазонов ключей из него могут выполняться быстрее, нежели для обычного словаря.
Заключение.
После того, как мы узнали, как работают внутри Dictionary
и SortedDictionary
, мы можем понять особенности работы с каждой из этих структур данных. Надеюсь, эта статья была вам полезна и вы смогли чуть лучше понять, как и когда стоит использовать эти структуры данных.
Комментарии (10)
dopusteam
08.01.2024 08:03+2Если же нам важно поддерживать порядок ключей в словаре ВСЕ ВРЕМЯ работы с ним, то нам следует использовать SortedDictionary
Было бы полезно описать, когда именно это может быть нужно, в каких кейсах? Потому что сейчас это выглядит как капитанский совет, для новичка абсолютно бесполезный, имхо
Зачем вы выделили, кстати, 'все время' можно иначе как то порядок поддерживать?
Во-вторых, словарь - одна из самых часто используемых структур данных и при разработке бывает полезно понимать, какие у нее есть подводные
Про подводные ни слова не написали в итоге
nronnie
08.01.2024 08:03-2Было бы полезно описать, когда именно это может быть нужно, в каких кейсах?
Например, когда нужно перебирать элементы в порядке значения их ключа (для обычного
Dictionary
порядок элементов не определен). Но, судя по вашей критике, вы и так это знаете :)derpymarine
08.01.2024 08:03Например, когда нужно перебирать элементы в порядке значения их ключа
Для этого есть SortedList. В сравнении с SortedDictionary у сортированного списка есть недостаток в виде O(n) на добавление/удаление (против O(log n) у сортированного словаря), но он шустрее в перечислениях и памяти меньше ест так как внутренне он не на базе bst, а на базе двух массивов реализован.
AndiDieDm Автор
08.01.2024 08:03"Все время" я выделил, так как порой мы можем выполнить все необходимые операции над обычным словарем, а потом уже вернуть отсортированную через
OrderBy
версию. Из-за разницы между и нам не стоит поддерживать сортировку все время, если это важно только для вывода. Хотя возможно тут очевидный пример, который не вписывается в случаи примененияSortedDictionary
.
Под подводными я отметил, что эффективностьDictionary
зависит от качества хеш функции, и что при частом росте числа элементов, мы будем все время спотыкаться об изменение размера бакетов, что замедлит наши операции вставки. СSortedDictionary
основным подводным является сложность выполнения операций, про которую я писал.
Если под ожидаемыми подводными имелась ввиду потокобезопасность, пожалуй да, моя недоработка, сегодня допишу. Спасибо.
Постараюсь так же более явно выделить подводные описанные выше.
Mingun
08.01.2024 08:03Интересно, кстати, почему в
SortedDictionary
внутренние объекты реализаций коллекций ключей и значенийKeys
иValues
(SortedDictionary.KeyCollection
иSortedDictionary.ValueCollection
) не являются реализациямиIList
? В некоторых случаях бывало бы удобно обращаться напрямую по индексу внутрь этих коллекций, но не дают!Deosis
08.01.2024 08:03Коллекция ключей может быть реализована как тонкая обертка над самим словарем, которая при перечислении вместо пары ключ-значение возвращает просто ключ. Это сильно экономит память, но не дает обращаться по индексу.
dimaaannn
08.01.2024 08:03меня пару раз об этом спросили на собеседованиях и в первый раз я вообще потерялся с ответом
Программист не обязан знать вообще всё.
SortedDictionary я на своей практике пытался использовать ровно один раз. И то в итоге выбросил код, т.к. реализовать решение другим образом оказалось эффективнее.
posledam
08.01.2024 08:03Было бы крайне интересно познакомиться с каким-нибудь настоящим практическим примером, где выбор SortedDictionary позволил эффективно решить задачу, которая плохо решалась стандартным словарём.
Теоретически оно понятно, но кто ж будет бенчмаркать всё подряд, на всякий случай подставляя другую реализацию словаря, а вдруг повезёт? :)
Mingun
08.01.2024 08:03+2Так тут, скорее, вопрос не в «плохо решалась» а в «не подходит». То есть, он нужен в случаях, когда необходимо поддерживать упорядоченность ключей в словаре. Например, у меня это были даты
DateTime
и их нужно было хранить в упорядоченном виде, потому что алгоритм предполагал их перебор по порядку.
vvdev
08.01.2024 08:03+1В качестве обмена опытом: в сценариях, где важна [даже относительно] высокая производительность SortedDictionary не подходит: производительность добавления ожидаемая, но на каждое добавление происходить выделение памяти, скорость поиска ожидаемо невысокая, перечисление медленное, при перечислении выделяет память.
SortedList, к слову, тоже так себе. Не помню уже кто был быстрее, кто медленнее, помоему SortedList побыстрее, но точно размещает меньше индивидуальных инстанцев в куче.
При необходимости рекомендую проанализировать сценарий и написать свой специализированный компонент.
С другой стороны, в клиентском приложении на ЮИ не на горячем пути вполне применим.
Mingun
Так тут, скорее, вопрос не в «плохо решалась» а в «не подходит». То есть, он нужен в случаях, когда необходимо поддерживать упорядоченность ключей в словаре. Например, у меня это были даты
DateTime
и их нужно было хранить в упорядоченном виде, потому что алгоритм предполагал их перебор по порядку.