Приветствую, читатель. Я являюсь автором языка программирования Shar. В стандартном модуле Shar есть несколько реализаций ассоциативных массивов и при написании данного модуль я подумал: "А какую структуру данных для реализации ассоциативных массивов мне выбрать?". Являясь любителем слушать различные конференции по программированию, я периодически натыкаюсь на выступления людей, принимающих участие в разработке популярных языков программирования. На основании таких выступлений, у меня сложилось субъективной восприятие того, что в большинстве случаев для реализации ассоциативных массивов используется хеш-таблица. Хеш таблица действительно является очень хорошим выбором, но тогда почему я начал задумываться о выборе подходящей структуры данных? А причина в том, что в Shar изменение одного объекта, не может изменить другой объект. Поясню на примере, вот код на Python:
a = [1, 2, 3, 4, 5]
b = a
c = b
a[1] = 1
print(a, '\n', b, '\n', c, '\n', sep='')
результат:
[1, 1, 3, 4, 5]
[1, 1, 3, 4, 5]
[1, 1, 3, 4, 5]
а вот похожий код на Shar:
def main()
var a Array = [1, 2, 3, 4, 5]
var b Array = a
var c Array = b
a.setItem(1, 1)
a.println()
b.println()
c.println()
результат:
[1, 1, 3, 4, 5]
[1, 2, 3, 4, 5]
[1, 2, 3, 4, 5]
До момента записи в a, a b c указывают на один и тот же объект, но в момент записи, из-за того, что на объект указывает не только a, то для a создаётся копия объекта. Такое поведение достигается за счёт того, что в Shar для управления памятью используется подсчёт ссылок (reference counting) и если счётчик равен единице, то при попытке записи в объект - запись происходит, иначе создаётся полная, либо частичная копия объекта, и запись происходит в копию. Поясню что значит “частичная копия”, если объект, копия которого создаётся, содержит в себе другие объекты содержащие в себе счётчик ссылок (не у всех он есть, например у строк - есть, а у чисел - нет), то эти “внутренние” объекты не копируются, а их счётчик ссылок просто увеличивается на единицу. Такой подход позволяет нескольким объектам иметь общие данные не копируя их (до момента изменения данных одним из объектов). Но такой подход плохо подходит для хеш-таблицы,так как в ней все данные, кроме ключей и значений, будут полностью скопированы. Так же бывают ситуации, когда нужно создать ассоциативный массив на основе другого массива, но при этом нельзя менять исходный массив. В такой ситуации также придётся полностью копировать структуру хеш-таблицы. Очень часто я размышляю о различных алгоритмах и структурах данных, и в период написания стандартного модуля у меня в голове сидела идея одной структуры данных, которая хорошо поддавалась частичному копированию.
Есть такая структура данных под названием “префиксное дерево” (trie), скорость поиска в такой структуре не зависит от количества элементов в ней, а лишь от размера ключа, что делает её эффективной на большом количестве данных, но у неё есть и недостатки – большое количество потребляемой памяти, а так же сильная фрагментация данных. На малом же количестве данных, большинство структур данных и алгоритмов по скорости не особо сильно отличаются, поэтому можно совместить префиксное дерево с какой либо структурой данных, чтобы нивелировать недостатки префиксного дерева. В какой структуре данных нет сильной фрагментации, а потребление памяти минимально? В массиве! Если отсортировать массив, то в нём для поиска данных можно использовать алгоритм бинарного поиска. Я решил совместить префиксное дерево и массив с бинарным поиском и вот как я придумал это сделать: для каждого ключа высчитывается 24-битный хеш, структура же представляет собой дерево, в корне которого находится отсортированный массив из первых 8-ми бит хеша каждого ключа (без повторов), а так же указатель на ветвь в которой лежат данные ключей, у которых хеш имеет соответствующие 8 бит, а в ветвях находится отсортированный массив из оставшихся 16-ти бит хеша каждого ключа, а так же указатель на массив в котором лежат ключи и значения с соответствующим хешем. Поиск происходит следующим образом:
И вот пришло время заняться данным вопросом, все структуры реализованы, тесты написаны, результаты тестов получены. Тесты написаны в виде интерактивного shell`а в котором пользователь вводит команды, такой способ, а так же некоторый код, нужны лишь для того, чтобы компилятор не мог понять, что по факту, данный код ничего нужного не делает. Для каждого теста программа запускалась заново. Каждый тест производился трижды и если все три теста давали близкий результат, то брался средний из них, если хотя бы один результат сильно отличался, то тест запускался ещё несколько раз и вычислялось средне арифметическое значение всех результатов. Также тесты были написаны на Go, это не в коем случае не сравнение Go и Shar, а только лишь любопытство. Ссылка на исходники тестов будет в конце статьи, но сразу скажу что поскольку в данный момент в Shar нет способа получить время с точностью до микросекунд, для этого использовался “костыль”. Для сокращения я буду помечать свой алгоритм как MO (my old), свой изменённый алгоритм MN (my new), хеш-таблицу (HT). Перед результатами каждого теста, будет находится небольшое описание и список команд которые передавались тесту. Данные для тестов загружались из файла (~600 Мб) который был создан с помощью генератора, исходники которого будут лежать вместе с исходниками тестов. Характеристики ПК на котором производились тесты: CPU - AMD Ryzen 2200G (не разогнанный), память – двухканальная 2933 Mhz 16 Gb. Результаты тестов (от наименее интересных к наиболее):
Добавление и удаление:
В командах указывается количество пар ключ/значение в ассоциативном массиве, а тест создаёт несколько ассоциативных массивов с указанным количеством пар. Суммарное количество пар во всех массивах всегда идентично, меняется лишь количество ассоциативных массивов. Затем происходило удаление каждой пары из каждого ассоциативного массива. Один из тестов длился более 20-ти минут, поэтому его я запускал лишь один раз. MN на небольших количествах пар имеет оверхэд по памяти, и не вмещается в мои 16 Gb, поэтому указаны значения только для большого количества пар.
Команды
load
test add количество пар
test remove количество пар
Результаты добавления:
Результаты удаления:
Потребление памяти:
Тест аналогичен предыдущему, но данные из ассоциативных массивов не удаляются. Как и в предыдущем тесте MN на малом количестве пар не умещается в память. Замер производился утилитой htop. Во время замера ненужные данные были освобождены, а в Go реализации был явным образом запущен сборщик мусора и замер производился после окончания его работы.
Команды
load
test add количество пар
clear numbers
wait
Результаты:
Скорость поиска отсутствующих в массиве данных:
Это как раз тот тест, данные различных запусков которого сильно отличались (например один запуск длится 2.1 сек., а повторный 3.4), поэтому здесь в основ усреднённые данные. Тест создаёт один ассоциативный массив с указанным количеством пар, а в нём происходит поиск данных, количество данных не зависит от размера ассоциативного массива.
Комманды
load
create map количество пар
create notExistedData
test search
Результаты:
Скорость поиска присутствующих в массиве данных:
Как и в предыдущем тесте, создаётся один ассоциативный массив с указанным количеством пар, а в нём происходит поиск данных, количество данных не зависит от размера ассоциативного массива.
Команды
load
create map количество пар
create existedData
test search
Результаты:
Итог:
Результаты показали, что преимущества хеш-таблицы слишком велики, даже с учётом невозможности нормального частичного копирования. В текущей версии (0.3) стандартного модуля Shar, я заменил свою структуру на хеш-таблицу.
Ссылки:
исходники тестов и генератора данных
репозитории связанные с Shar
a = [1, 2, 3, 4, 5]
b = a
c = b
a[1] = 1
print(a, '\n', b, '\n', c, '\n', sep='')
результат:
[1, 1, 3, 4, 5]
[1, 1, 3, 4, 5]
[1, 1, 3, 4, 5]
а вот похожий код на Shar:
def main()
var a Array = [1, 2, 3, 4, 5]
var b Array = a
var c Array = b
a.setItem(1, 1)
a.println()
b.println()
c.println()
результат:
[1, 1, 3, 4, 5]
[1, 2, 3, 4, 5]
[1, 2, 3, 4, 5]
До момента записи в a, a b c указывают на один и тот же объект, но в момент записи, из-за того, что на объект указывает не только a, то для a создаётся копия объекта. Такое поведение достигается за счёт того, что в Shar для управления памятью используется подсчёт ссылок (reference counting) и если счётчик равен единице, то при попытке записи в объект - запись происходит, иначе создаётся полная, либо частичная копия объекта, и запись происходит в копию. Поясню что значит “частичная копия”, если объект, копия которого создаётся, содержит в себе другие объекты содержащие в себе счётчик ссылок (не у всех он есть, например у строк - есть, а у чисел - нет), то эти “внутренние” объекты не копируются, а их счётчик ссылок просто увеличивается на единицу. Такой подход позволяет нескольким объектам иметь общие данные не копируя их (до момента изменения данных одним из объектов). Но такой подход плохо подходит для хеш-таблицы,так как в ней все данные, кроме ключей и значений, будут полностью скопированы. Так же бывают ситуации, когда нужно создать ассоциативный массив на основе другого массива, но при этом нельзя менять исходный массив. В такой ситуации также придётся полностью копировать структуру хеш-таблицы. Очень часто я размышляю о различных алгоритмах и структурах данных, и в период написания стандартного модуля у меня в голове сидела идея одной структуры данных, которая хорошо поддавалась частичному копированию.
Есть такая структура данных под названием “префиксное дерево” (trie), скорость поиска в такой структуре не зависит от количества элементов в ней, а лишь от размера ключа, что делает её эффективной на большом количестве данных, но у неё есть и недостатки – большое количество потребляемой памяти, а так же сильная фрагментация данных. На малом же количестве данных, большинство структур данных и алгоритмов по скорости не особо сильно отличаются, поэтому можно совместить префиксное дерево с какой либо структурой данных, чтобы нивелировать недостатки префиксного дерева. В какой структуре данных нет сильной фрагментации, а потребление памяти минимально? В массиве! Если отсортировать массив, то в нём для поиска данных можно использовать алгоритм бинарного поиска. Я решил совместить префиксное дерево и массив с бинарным поиском и вот как я придумал это сделать: для каждого ключа высчитывается 24-битный хеш, структура же представляет собой дерево, в корне которого находится отсортированный массив из первых 8-ми бит хеша каждого ключа (без повторов), а так же указатель на ветвь в которой лежат данные ключей, у которых хеш имеет соответствующие 8 бит, а в ветвях находится отсортированный массив из оставшихся 16-ти бит хеша каждого ключа, а так же указатель на массив в котором лежат ключи и значения с соответствующим хешем. Поиск происходит следующим образом:
- вычисляется хеш ключа
- если в массиве корня менее 256-ти элементов, то первые 8-бит хеша ключа ищутся с помощью бинарного поиска
- если в массиве корня 256 элементов, то искать ничего ненужно, а нужная ветвь имеет в массиве индекс равный числу хранящемуся в первых 8-ми битах хеша
- переход на ветвь которая соответствует найденной части хэша
- если в массиве ветви менее 65536-ти элементов, то последние 16-бит хеша ключа ищутся с помощью бинарного поиска
- если в массиве ветви 65536 элементов, то искать ничего не нужно, а нужный массив с ключами и значениями имеет в массиве ветви индекс, равный числу хранящемуся в последних 16-ми битах хеша
- переход на массив который соответствует найденной части хэша
- линейный поиск по массиву из ключей и значений
И вот пришло время заняться данным вопросом, все структуры реализованы, тесты написаны, результаты тестов получены. Тесты написаны в виде интерактивного shell`а в котором пользователь вводит команды, такой способ, а так же некоторый код, нужны лишь для того, чтобы компилятор не мог понять, что по факту, данный код ничего нужного не делает. Для каждого теста программа запускалась заново. Каждый тест производился трижды и если все три теста давали близкий результат, то брался средний из них, если хотя бы один результат сильно отличался, то тест запускался ещё несколько раз и вычислялось средне арифметическое значение всех результатов. Также тесты были написаны на Go, это не в коем случае не сравнение Go и Shar, а только лишь любопытство. Ссылка на исходники тестов будет в конце статьи, но сразу скажу что поскольку в данный момент в Shar нет способа получить время с точностью до микросекунд, для этого использовался “костыль”. Для сокращения я буду помечать свой алгоритм как MO (my old), свой изменённый алгоритм MN (my new), хеш-таблицу (HT). Перед результатами каждого теста, будет находится небольшое описание и список команд которые передавались тесту. Данные для тестов загружались из файла (~600 Мб) который был создан с помощью генератора, исходники которого будут лежать вместе с исходниками тестов. Характеристики ПК на котором производились тесты: CPU - AMD Ryzen 2200G (не разогнанный), память – двухканальная 2933 Mhz 16 Gb. Результаты тестов (от наименее интересных к наиболее):
Добавление и удаление:
В командах указывается количество пар ключ/значение в ассоциативном массиве, а тест создаёт несколько ассоциативных массивов с указанным количеством пар. Суммарное количество пар во всех массивах всегда идентично, меняется лишь количество ассоциативных массивов. Затем происходило удаление каждой пары из каждого ассоциативного массива. Один из тестов длился более 20-ти минут, поэтому его я запускал лишь один раз. MN на небольших количествах пар имеет оверхэд по памяти, и не вмещается в мои 16 Gb, поэтому указаны значения только для большого количества пар.
Команды
load
test add количество пар
test remove количество пар
Результаты добавления:
Количество пар | MO (сек.) | MN (сек.) | HT (сек.) | Go (сек.) |
40 | 10.992044 | - | 11.247284 | 5.659392 |
400 | 12.077356 | - | 11.304720 | 5.052673 |
4000 | 12.421139 | - | 10.260807 | 6.145061 |
40000 | 15.832739 | - | 15.020784 | 5.497139 |
400000 | 46.638404 | 14.859387 | 18.082197 | 8.037936 |
1000000 | 132.675941 | 14.965310 | 18.487985 | 12.339484 |
4000000 | 1236.449929 | 20.850806 | 21.568555 | 14.465102 |
Количество пар | MO (сек.) | MN (сек.) | HT (сек.) | Go (сек.) |
40 | 8.730468 | - | 3.094397 | 2.422945 |
400 | 10.173895 | - | 3.794826 | 2.545470 |
4000 | 8.894728 | - | 3.907439 | 2.638010 |
40000 | 9.940065 | - | 4.205317 | 2.928825 |
400000 | 23.924402 | 17.211464 | 8.991155 | 5.420337 |
1000000 | 64.622504 | 18.010321 | 10.541922 | 6.232984 |
4000000 | 297.041553 | 26.556703 | 11.595214 | 6.647685 |
Тест аналогичен предыдущему, но данные из ассоциативных массивов не удаляются. Как и в предыдущем тесте MN на малом количестве пар не умещается в память. Замер производился утилитой htop. Во время замера ненужные данные были освобождены, а в Go реализации был явным образом запущен сборщик мусора и замер производился после окончания его работы.
Команды
load
test add количество пар
clear numbers
wait
Результаты:
Количество пар | MO (MiB) | MN (MiB) | HT (MiB) | Go (MiB) |
40 | 5662 | - | 4594 | 4016 |
400 | 4529 | - | 3810 | 3715 |
4000 | 3490 | - | 3290 | 4885 |
40000 | 3297 | - | 4576 | 4192 |
400000 | 3264 | 7538 | 3865 | 3461 |
1000000 | 3391 | 5155 | 3335 | 4824 |
4000000 | 5458 | 3944 | 3313 | 5358 |
Это как раз тот тест, данные различных запусков которого сильно отличались (например один запуск длится 2.1 сек., а повторный 3.4), поэтому здесь в основ усреднённые данные. Тест создаёт один ассоциативный массив с указанным количеством пар, а в нём происходит поиск данных, количество данных не зависит от размера ассоциативного массива.
Комманды
load
create map количество пар
create notExistedData
test search
Результаты:
Количество пар | MO (сек.) | MN (сек.) | HT (сек.) | Go (сек.) |
40 | 2.804143 | 1.256026 | 1.416872 | 1.116290 |
400 | 4.313626 | 2.061779 | 1.589358 | 1.158843 |
4000 | 2.467728 | 1.303098 | 1.798543 | 0.994061 |
40000 | 4.481285 | 3.524850 | 1.928413 | 1.258895 |
400000 | 6.940616 | 9.329797 | 6.741596 | 4.516057 |
1000000 | 8.916460 | 9.168931 | 8.610942 | 4.425328 |
4000000 | 20.655359 | 19.943797 | 9.472044 | 4.583454 |
Как и в предыдущем тесте, создаётся один ассоциативный массив с указанным количеством пар, а в нём происходит поиск данных, количество данных не зависит от размера ассоциативного массива.
Команды
load
create map количество пар
create existedData
test search
Результаты:
Количество пар | MO (сек.) | MN (сек.) | HT (сек.) | Go (сек.) |
40 | 3.397489 | 2.541828 | 1.591551 | 1.569073 |
400 | 4.665338 | 3.359453 | 1.629905 | 1.602587 |
4000 | 3.198216 | 3.276925 | 1.855068 | 1.654942 |
40000 | 4.988840 | 9.535442 | 1.985005 | 1.974011 |
400000 | 16.994431 | 17.662105 | 8.647473 | 4.412202 |
1000000 | 22.076136 | 19.641871 | 10.377696 | 4.638422 |
4000000 | 34.241356 | 30.616260 | 11.424689 | 4.826485 |
Результаты показали, что преимущества хеш-таблицы слишком велики, даже с учётом невозможности нормального частичного копирования. В текущей версии (0.3) стандартного модуля Shar, я заменил свою структуру на хеш-таблицу.
Ссылки:
исходники тестов и генератора данных
репозитории связанные с Shar
Тесты своей реализации ассоциативных массивов vs хеш-таблица
Приветствую, читатель. Я являюсь автором языка программирования Shar. В стандартном модуле Shar есть несколько реализаций ассоциативных массивов и при написании данного модуля я подумал: "А какую...
habr.com