Хороший хеш — залог успеха
В этой главе мы выясним:
Как сформировать ассоциативный массив, хранящий пары вида (ключ, значение) и по известному ключу получать такую пару из него1.
Как хранить пары (ключ, значение) в массиве, чтобы при этом поиск по ключу был эффективен — при условии, что размер этого массива достаточно велик по сравнению с количеством хранимых пар.
Как использовать массив списков для хранения и поиска пар (ключ, значение), с возможностью удаления по ключу.
- Как менять размер хеш-таблицы, чтобы не потерять её производительность.
Как оценивать среднее быстродействие алгоритма, если его работа меняется по мере выполнения некоторых действий с данными (как делать амортизационный анализ).
Как довести оценку операции добавления в хеш-таблицу put() до амортизационной сложности O(1) в среднем путём геометрического масштабирования.
Как свойство равномерного распределения значений хеш-функции можно использовать для эффективного размещения ключей в хеш-таблице.
Соответствие значений ключам
Часто набор объектов, которые нужно уметь хранить в памяти, представлен не в виде последовательности, а в виде множества пар (ключ, значение), в котором каждому ключу однозначно соответствует некоторое значение. Такая структура называется ассоциативным массивом. Если наложить некоторые дополнительные требования на свойство ключа, вместо поиска пары (ключ, значение) по всему набору можно использовать гораздо более эффективный приём — хеширование. Поиск по хешу работает быстрее любого изученного нами алгоритма поиска! Ассоциативный массив сохраняет эффективность даже если разрешить при удаление ключей и значений. Порядок следования ключей в такой структуре определяется её реализацией (в словарях Python ключи идут в порядке добавления, в устаревшем Python2 определённый порядок не гарантировался вовсе; в любом случае ключи почти наверняка не будут упорядочены по возрастанию, как индексы массива). Взамен мы получаем великолепную скорость поиска и добавления пары.
Скажем, мы хотим написать функцию print_month(month, year), которая будет выводить календарь на определённый месяц определённого года. С параметрами print_month('February', 2024) функция должна выводить примерно это:
Февраль 2024
Пн Вт Ср Чт Пт Сб Вс
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29Что нам нужно для этого знать? День недели, с которого в этом году начинается месяц (в примере это четверг), а ещё — сколько дней в феврале этого года (28, а если год високосный, как 2024-й, то 29). Для количества дней в месяце заведём числовой массив, каждый элемент которого будет соответствовать месяцу начиная с января:
1 month_length = 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31
Итак, в январе 31 день, поэтому month_length[0] == 31, в феврале — 28, так что следующее значение —28, и так далее до последнего 31, которое соответствует 31 дню в декабре.
Изученные нами ранее примеры позволяют предположить, что нам понадобится также массив той же длинны с названиями месяцев — ключей, по которым мы сможем определить, в по какому индексу в month_length находится искомое количество дней. Вот такой фрагмент программы выводит количество дней в феврале:
Если название месяца в массиве ключей есть, этот фрагмент будет работать. Однако в худшем случае, когда нам нужен 'Декабрь', придётся просмотреть все значения. Если ключа в массиве нет, key_array.index() выдаст исключение, так что предварительно нам придется проверить наличие ключа, например, с помощью key in key_array. Это тоже худший случай, потому что будут просмотрены все строки в key_array. Следовательно, время поиска значения по ключу в изобретённой нами структуре прямо пропорционально количеству ключей. Такая скорость поиска довольно быстро станет медленной до полной неприемлемости. Несмотря на это давайте всё-таки напишем print_month(). В частности, вот возможная реализация, в которой задействованы два стандартных модуля python — datetime (для определения дня недели) и calendar (для определения високосного года).
1 from datetime import date
2 import calendar
3
4 month_length = 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31
5 key_array = ('Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль',
6 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь')
7
8 def print_month(month, year):
9 idx = key_array.index(month) # 1
10 wd = date(year, idx + 1, 1).weekday() # 2
11 days = month_length[idx] # 3
12 if calendar.isleap(year) and idx == 1: # 4
13 days += 1
14
15 print(f"{month} {year}".center(20))
16 print("Пн Вт Ср Чт Пт Сб Вс")
17 print(' ' * wd, end='') # 5
18 for day in range(days):
19 wd = (wd + 1) % 7 # 6
20 eow = " " if wd % 7 else "\n" # 7
21 print(f"{day+1:2}", end=eow)
22 print()
Пример 3-1. Вывод на экран календаря за произвольный год и месяц в удобном формате.
Найдём номер месяца (целое число от 0 до 11) — индекс в month_length.
Вычислим первый день недели данного месяца в данном году, 0 — это понедельник. В функцию date() передаётся idx + 1, потому что месяцы в ней нумеруются с единицы, а не с нуля.
- Определим количество дней в месяце.
В високосный год в феврале 29 дней (мы нумеруем месяцы с нуля, так что февраль — это 1).
Вместо дней прошедшего месяца в первой неделе выведем пробелы, тогда первый день окажется на своём месте.
Вычислим следующий день недели.
Вычислим, что выводить после текущей даты. Если следующий день — не понедельник, т. е. wd не делится на 7, нужно выводить пробел, а если понедельник — конец строки, потому что неделя закончилась.
Чтобы оценить производительность поиска ключа в наборе из N пар (ключ, значение), имеет смысл посчитать количество операций индексирования. Функция key_array.index() для поиска нужной строки может просматривать вплоть до N элементов, так что её сложность — O(N).
![]() |
Тип данных list реализован в Python как динамический массив, размер которого может меняться. Для краткости его часто называют просто список. В этой главе мы будем также использовать термин массив в знак того, что наши методы применимы и к «классическим» массивам неизменяемой длины. Кроме того, если мы не собираемся менять также и сами элементы массива, в программах на Python лучше использовать кортежи (тип данных tuple), как в примерах выше. |
Ассоциативные массивы, хранящие произвольные значения по ключу, реализованы в Python специальным типом данных dict (сокращённо от «dictionary» — «словарь»). Мы можем составить словарь days_in_month, хранящий целые числа — продолжительность месяцев в днях — в соответствии с ключами — названиями месяцев (с заглавной буквы) так:
А вот пример вывода утверждения «в апреле 30 дней»:
print('В апреле', days_in_month['Апрель'], 'дней')В словаре на поиск ключа в среднем уходит O(1) времени, независимо от количества хранящихся в нём пар (ключ, значение). Совершенно удивительный результат, будто фокусник только что достал кролика из шляпы! Словарями мы воспользуемся в восьмой главе, а пока попробуем сами достать этого кролика. Пример ниже предлагает интуитивное описание математического механизма, который мы изучаем в этой главе. Смысл в том, чтобы для начала преобразовать строку в число.
Будем считать, что буква «а» — это 0, буква «б» — 1 и так далее до «я» — 31. С помощью этих 32 «цифр» любое русское слово (увы, без буквы «ё»), записанное строчными буквами, соответствует некоторому числу в системе счисления с основанием 32. Например, «числе» 'июнь' четыре цифры — «и», «ю», «н» и «ь». Если начать переводить из 32-ричной системы счисления в привычную нам десятичную, получится «и»·32³+«ю»·32²+«н»·32¹+«ь»·32⁰, или, после вынесения основания за скобки, 32·(32·(32·«и»+«ю»)+«н»)+«ь». Именно так и работает функкция base32() в примере 3-2. Буква 'a' в примере — русская.
Пример 3-2. Интерпретация слова как числа с основанием 32.
- Преобразуем буквы строки в строчные.
- Вычислим значение очередной цифры.
- Припишем цифру в конец результата
В base32() используется функция ord(), которая возвращает порядковый номер буквы в таблице всех известных Python символов, Unicode2. Буквы от «а» до «я» идут в алфавитном порядке3, ord('а') == 1072, ord('б') == 1073 и т. д. до ord('я') == 1103. Так что для того, чтобы вычислить значение цифры «и», достаточно выесть порядковые номера: ord('и') - ord('а') и получить 8.
Значение base32() очень быстро растёт с ростом длины строки. Уже base32('Июнь') возвращает 293308, а base32('Сентябрь') — так и вовсе 589940360732.
Как бы такие большие числа втиснуть в более приемлемый диапазон? Можно применить операцию «остаток от деления»: в Python, как и во многих языках программирования, это «%». Остаток сколь угодно большого целого числа (например base32(month)) можно взять относительно достаточно малого делителя.
↓ Ниже — ещё один образчик авторского трюизма, который было очень неловко переводить. Возможно, не стоит его включать в перевод? -- FrBrGeorge 2022-01-20 07:50:40
![]() |
Остатком от деления мы пользуемся в жизни постоянно, сами того не замечая. Скажем, наступило 11 утра; какое время будет на часах через 50 часов? Через 24 часа, завтра, снова наступит 11 утра, и через 48, послезавтра, — тоже. Остаётся ещё два часа, стало быть, через 50 часов буде час пополудни. Арифметика проста: 50 % 24 == 2. Другими словами, 50 не делится на 24 нацело, потому что в остатке получается 2. Остаток от деления натурального числа M на натуральное N, M % N, всегда не меньше нуля и не больше N - 1. |
Немного поэкспериментировав, мы можем обнаружить, что для всех названий месяцев значения base32(month) % 35 различны. Например, base32('Август') равно 2215474, а 2215474 % 35 == 9, и для каждого месяца результат будет иным. Теперь, как показано на иллюстрации 3-1, мы можем завести массив на 35 элементов, с помощью которого можно узнать количество дней в данном месяце, за одно обращение, и перебирать все пары (ключ, значение) не нужно. Вычисляем формулу для февраля, получаем 0, на нулевой позиции в day_array стоит 28 — в феврале 28 дней. Проделываем то же самое с ноябрём — получаем позицию 32 и 30 дней.
Давайте ещё раз оценим, что у нас получилось. Вместо цикла по всем ключам в массиве нам нужно проделать несложное преобразование самого ключа, которое даст позицию нужного значения в массиве. Время нашего вычисления зависит от длины ключа, но не зависит от общего количества ключей — это очень большой шаг вперёд!
base32('Февраль') % 35 == 0. Нулевой элемент равен 28. |
day_array = (
|
base32('Ноябрь') % 35 == 32. 32-й элемент равен 30. |
Иллюстрация 3-1. Массив с длинами месяцев; если формула не даёт соответствующего индекса, в массиве записано -1.
Поработать, правда, пришлось изрядно. Во-первых — подыскать формулу однозначного преобразования имени месяца в индекс, а во-вторых — создать массив на 35 элементов, из которых имеет смысл только дюжина, а значит, больше половины этого массива потрачено впустую. В нашем примере объём хранимых данных N равен 12, а объём хранилища M — 35.
Если для некоторой строки s элемент day_array в позиции base32(s) % 35 равен -1, очевидно, эта строка — не название месяца. Этим удобно пользоваться. Может показаться, что если day_array[base32(s)%35] > 0, то любая строка s — это допустимое название месяца, но это, конечно не так. К примеру, base32("Промахнулось") выдаёт то же самое, что и base32("Февраль"); получится, что «Промахнулось» — это такой месяц, и в нём 28 дней! Это довольно неприятное свойство, и нам придётся разобраться, как его преодолеть.
Хеш-функции и хеш-суммы
Формула base32(s) % 35 — пример хеш-функции, которая отображает ключ произвольного размера в хеш-сумму (или просто хеш) в заданном диапазоне значений4. В нашем примере хеш не меньше нуля и не больше 34. Множество хеш-функций возвращают произвольное 32-разрядное целое (в диапазоне от -2 147 483 648 до 2 147 483 647) или произвольное 64-разрядное целое (в диапазоне от -9 223 372 036 854 775 808 до 9 223 372 036 854 775 807). Нетрудно заметить, что хеши бывают и отрицательными.
Задача отображения структурированных данных в фиксированные целые числа, т. е. задача отыскания подходящей хеш-функции, занимает математиков последние несколько десятилетий. Программисты пользуются плодами их трудов: большинство языков программирования включают в себя вычисление хеша произвольного набора данных. В Python функция hash() для неизменяемых объектов в строена в сам язык.
![]() |
Единственное обязательное свойство хеш-функции — однозначность. Хеш, посчитанный от двух одинаковых объектов, должен совпадать; нельзя каждый раз брать первое попавшееся число. Хеш неизменямого объекта (например, string в Питоне) можно вычислять лишь единожды и потом хранить — это снизит общее количество вычислений. |
Хеш-функция не обязана для каждого ключа давать уникальное число, не равное другим значениям функции (впрочем, в конце главы мы рассмотрим идеальные хеши, которые это умеют). Более того, ценой ещё большей неуникальности мы можем резко уменьшить диапазон её значений до 0 … M-1: возьмём остаток от деления на M.
В таблице 3-1 представлено 32-разрядное значение функции hash() для некоторых строк и соответствующий им остаток от деления на 15. Формула hash(key) % 15 обладает свойством однозначности, но если с точки зрения теории вероятности шанс на совпадение значений hash() у двух ключей исчезающе мал, то на малом диапазоне совпадение хешей вполне вероятно. В нашем примере таких совпадений два: «рифмы» — «возьми» со значением 10, и «ее» — «скорей» со значением 3. Несмотря на различие вероятностей, всегда стоит ожидать, что две разные строки могут иметь одинаковый хеш.5
Ключ |
Значение hash(ключ) |
Значение hash(ключ) % 15 |
Читатель |
30711662493708375 |
0 |
ждет |
-8425886308320884491 |
14 |
уж |
4546933634410127049 |
9 |
рифмы |
5680402382273197615 |
10 |
розы |
-2463836295302140937 |
13 |
На |
8516920402432418136 |
6 |
вот |
5909557570350754617 |
12 |
возьми |
8292399267285665935 |
10 |
ее |
2740441431699165093 |
3 |
скорей |
4193948643156477063 |
3 |
Таблица 3-1. Пример значений функции hash() и хеш-суммы с диапазоном на 15 значений
Если использовать остаток от деления на M для того, чтобы вычислить хеш, hash(key) % M, стоит озаботиться тем, чтобы диапазон оказался не меньше, чем общее количество ключей.
Вот эту сноску, относящуюся к ЯП Java, предлагаю не переводить — она частично переврана, и точно только запутает читателя:↓ 5 In Java, if hash(key) is negative, then the % operator will return a negative number, so the formula must be . (key.hashCode() & 0x7fffffff) % M to first convert the negative hash computation into a positive integer before computing modulo M. -- FrBrGeorge 2022-01-21 11:14:49
![]() |
Лет десять назад в Python (а в Java — до сих пор) встроенная функция hash() стабильно возвращала один и тот же конкретный хеш для каждого конкретного параметра. В современном Python в значения хешей при запуске интерпретатора подмешивается «соль» — независимое случайное значение. В течение работы одного процесса Python соль не меняется, но предсказать её значение для очередного запуска интерпретатора невозможно — это повышает безопасность приложений. Если хеш предсказуем, хакер может наполнить словарь заранее просчитанными ключами с одинаковым хешем, это нарушит равномерность их распределения в хеш-таблице, и производительность словаря упадёт до O(N). Таким способом можно организовать т. н. DoS-атаку (от «Denial of Service» — «отказ в обслуживании». |
Больше информации относительно «подсоленного хеша» можно найти тут: https://oreil.ly/C4V0W и в Python-овской документации «PEP 456»; кроме того, в конце главы есть упражнение на эту тему.
Хеш-таблица: хранение данных по ключу
Для удобства работы заведём отдельный тип данных для пары (ключ, значение):
В примере 3-3 определён класс Hashtable, внутри которого в массиве table может храниться не более M объектов. Каждая из M позиций массива table — ячейка хеш-таблицы (по-английски «bucket» — «ведро» или «корзина»). Для начала сделаем так, что каждая ячейка либо пуста, либо хранит ровно одну пару (объект типа Entry).
1 class Hashtable:
2 def __init__(self, M=10):
3 self.table = [None] * M # 1
4 self.M = M
5
6 def get(self, k): # 2
7 hc = hash(k) % self.M
8 return self.table[hc].value if self.table[hc] else None
9
10 def put(self, k, v): # 3
11 hc = hash(k) % self.M
12 entry = self.table[hc]
13 if entry:
14 if entry.key == k:
15 entry.value = v
16 else: # 4
17 raise RuntimeError(f'Key Collision: {k} and {entry.key}')
18 else:
19 self.table[hc] = Entry(k, v)
Пример 3-3. Неэффективная реализация хеш-таблицы
Заводим массив на M объектов.
Метод .get() определяет номер ячейки по ключу k, для которого вычисляется хеш, и возвращает значение, если оно есть.
Метод .put() определяет номер ячейки по ключу k, для которого вычисляется хеш, и перезаписывает хранящееся там значение — или записывает новое, если ячейка была пуста.
Если хеш двух различных ключей приводит к одной и той же ячейке — это коллизия, происходит исключение.
Вот так нашей хеш-таблицей можно пользоваться:
Если всё работает как задумано, значит, в массиве на 1000 элементов нам удалось разместить три пары — объекта типа Entry. Скорость работы .put() и .get() не зависит от количества пар в таблице, так что их быстродействие можно оценить как O(1).
Если .get(key) не находит key в соответствующей хешу ячейке — это промах. Если .get(key) выбрал ячейку по хешу, и ключ находящейся там пары равен key — это попадание. В этих случаях хеш-таблица работает так, как предполагается. Чего нам пока не хватает — это поведения на случай коллизии хеша, когда два или больше ключа имеют одинаковый хеш. Если не обрабатывать коллизии, метод .put(ключ) начнёт удалять содержимое непустых ячеек, хеш ключа которых совпадает с хешем нового ключа. Смысла в Hashtable, который теряет ключи, нет.
Определение коллизий и их разрешение последовательным просмотром
Вполне может случиться, что у ключей двух пар e1 = (key1, val1) и e2 = (key2, val2) окажется одинаковый хеш, несмотря на то, что сами ключи будут разными. В таблице 3-1 и «возьми», и «скорей» имеют хеш равный 10. Допустим, e1 попал в Hashtable первым. Тогда при попытке добавить туда ещё и e2 возникнет коллизия хешей: для e2 будет вычислена та же ячейка, что и для e1, при этом ячейка будет уже занята, а её ключ, key1, не будет равен key2 из e2. Если как-то это противоречие не разрешать, в одном объекте типа Hashtable нельзя будет хранить одновременно e1 и e2.
Справиться с этим помогает стратегия «открытой адресации»: на случай возникновении коллизии (ячейка занята, ключи не совпадают) можно предусмотреть процедуру поиска (просмотра) других незанятых ячеек в таблице. Самый простой способ — последовательный просмотр, когда .put() просто проверяет следующие за вычисленной ячейки table до тех пор, пока не найдёт свободную; если в процессе поиска массив закончится, а все ячейки заняты, .put() начнёт с его начала. Чтобы этот поиск всегда был успешным, будем проверять, что как минимум одна ячейка всё ещё свободна, а попытку записать что-то в последнюю свободную ячейку будем считать ошибкой переполнения хеш-таблицы.
Допустим, мы добавили пару в ячейку, индекс которой не равен хешу ключа, потому что соответствующая ячейка была занята. Чтобы убедиться в том, что так делать вообще можно, надо придумать алгоритм поиска по такому ключу. Для начала договоримся, что удалять пары из Hashtable мы не будем, будем только добавлять. понятно, что теперь чем больше пар мы добавляем в хеш-таблицу, тем длиннее становятся участки занятых ячеек в table. Тогда поиск довольно прост: по хешу ключа определяем первоначальную ячейку, а если ключи не совпадают, начинаем рассматривать все последующие непустые ячейки; если в каком-то из этих просмотров ключи совпадут, нужная ячейка найдена, а если попадётся пустая — соответствующей пары в таблице нет. Т. е. пара e1 найдется либо в ячейке с номером hash(e1.key) % M, либо в последующих непустых ячейках (с учётом перехода между концом и началом таблицы), либо не найдётся.
На иллюстрации 3-2 показано, как в Hashtable размером M == 7 с помощью последовательного просмотра добавляются пять пар с коллизией ключей (показаны только ключи). Заполненные серым ячейки — пустые. Ключ 20 попадает в table[6], потому что 20 % 7 == 6, ключ 15 — в table[1], а ключ 5 — в table[5].
![]() |
| Иллюстрация 3-2. Как выглядит Hashtable после добавления пяти пар (ключ, значение) |
M=7 |
|
Время ↓ |
|
Попытка добавить пару с ключом 26 приведёт к коллизии, потому что ячейка table[5] уже занята (парой с ключом 5; на иллюстрации 3-2 просмотр занятой ячейки выделен тёмным фоном), и запускается последовательный просмотр: сначала table[6], которая тоже занята, затем придётся перевалить через конец таблицы и начать с начала, где для ключа 26 и отыщется первая свободная ячейка — table[0]. Добавление пары с ключом 19 тоже приводит к коллизии и просмотру всех непустых ячеек, вплоть до table[2], которая, наконец, оказывается свободна.
В экземпляр Hashtable с иллюстрации 3-2 можно добавить ещё только одну пару: хотя бы одна ячейка должна оставаться незанятой. Куда попадёт пара с ключом 44? Хеш ключа 44 % 7 == 2, ячейка 2 занята, а 3 свободна, так что пара попадёт в table[3]. Поскольку .get() и .put() пользуются одним и тем же алгоритмом просмотра, впоследствии эту пару можно будет найти с помощью .get(44).
![]() |
Цепочкой для определённого хеша hc в таблицах с открытой адресацией, наподобие нашего Hashtable, называется последовательность ячеек в table. Эта последовательность начинается с table[hc] и продолжается вправо (с переходом из конца в начало table) вплоть до первой неиспользуемой ячейки, не включая неё. На иллюстрации 3-2 цепочка для хеша 5 состоит из пяти элементов, хотя только в трёх из них хеш ключа равен пяти. А для хеша 2 ключей вообще нет, но цепочка уже равна 1 из-за предыдущих коллизий. Длина цепочки не может превышать M-1, потому что по правилам одна ячейка должна быть незанятой. |
В примере 3-4 показано, как добавить открытую адресацию в Hashtable: класс Entry не меняется, а количество сохранённых пар запоминается в счётчике N для проверки того, что всегда есть хотя бы одна свободная ячейка (при этом запоминать, где она расположена, не надо!). Правило «одной свободной ячейки» очень важно, иначе цикл поиска while в .get() и .put() может оказаться вечным.
1 class Hashtable:
2 def __init__(self, M=10):
3 self.table = [None] * M
4 self.M, self.N = M, 0
5
6 def get(self, k):
7 hc = hash(k) % self.M # 1
8 while self.table[hc]:
9 if self.table[hc].key == k: # 2
10 return self.table[hc].value
11 hc = (hc + 1) % self.M # 3
12 return None
13
14 def put(self, k, v):
15 hc = hash(k) % self.M # 1
16 while self.table[hc]:
17 if self.table[hc].key == k: # 5
18 self.table[hc].value = v
19 return
20 hc = (hc + 1) % self.M # 3
21
22 if self.N >= self.M - 1: # 6
23 raise RuntimeError ('Table is Full.')
24
25 self.table[hc] = Entry(k, v) # 7
26 self.N += 1
Пример 3-4. Реализация открытой адресации в Hashtable
Начнём с первой же ячейки, в которой может быть ключ k.
Если ключ найден, вернём соответствующее k значение.
В противном случае просмотрим следующую ячейку, при необходимости продолжая просмотр с начала таблицы.
Если table[hc] не занята, очевидно, k в table нет.
Если ключ найден, обновим value соответствующей k ячейки.
Если k нет в table, а свободная ячейка осталась одна, инициируем исключение RuntimeError.
Запишем пару в свободную ячейку table[hc] и увеличим счётчик ключей N.
Так что, мы изобрели эффективный способ хранения? Теперь быстродействие .put() и .get() не зависит от M, размера таблицы ключей? Не тут-то было!
это большая врезка отдельной страницей -- FrBrGeorge 2022-01-27 17:31:20
Связный список или динамический массив?
В языках программирования с непосредственным выделением памяти часто используется динамическая структура данных под названием связный список. Для массива выделяется сразу один непрерывный блок памяти фиксированного размера, а для связного списка каждый элемент, называемый узлом, располагается в памяти отдельно, так что заведомо известен только начальный узел связного списка, в котором вместе со значением хранится ссылка (в языках такого типа она обычно называется указателем) на следующий узел, и так далее. Для поиска значения по связному списку необходимо проходить по ссылкам в узлах.
Вот этот список содержит три узла, в каждом из которых хранится своё значение. Стрелки на картинке — это ссылки на следующий узел списка (обычно для этого используется специальный тип данных, указатель, традиционное имя для такого указателя — next). Последний узел вместо ссылки содержит специальное «пустое» значение в знак того, что ссылаться больше не на что, отдельная переменная-указатель first хранит ссылку на начальный элемент списка.
first→ |
19 [X]→ |
26 [X]→ |
5 [ ] |
|
Узел 1 |
Узел 2 |
Узел 3 |
Размер связного списка определяется количеством узлов при его просмотре от начала до конца, начиная с указателя first по всем next до тех пор, пока они не пусты. Сами узлы могут появляться в любом месте памяти и в любое время. В объектно-ориентированных языках программирования удобно моделировать тип узла классом. В языках программирования с непосредственным выделением памяти необходимо освобождать память, выделенную под узел, если он больше нигде не используется. В других, например, в Python и Java, выделением и освобождением памяти занимается сама исполняющая система языка.
- Вставка в начало
Чтобы вставить узел в начало списка, надо сначала этот узел, Node0, создать, положить в него нужное значение, и сделать так, чтобы next, указатель на следующий элемент, ссылался на Node1. Кроме того, в переменной first надо переделать указатель на Node1 в указатель на Node0 (а так же во всех других местах, ссылающихся на наш список).
- Добавление в конец
Можно также хранить last, указатель на последний элемент связного списка. В этом случае для добавления значения в конец списка необходимо создать узел, Node4 с пустым полем next, сохранить в узел требуемое значение, сходить по ссылке из last (в нашем случае это будет узел Node3), заменить там поле next указателем на Node4, и сам last тоже переделать в указатель на Node4.
- Вставка в произвольное место
Если заранее известно, что новый узел q необходимо вставить после конкретного узла p, надо создать этот новый узел q с нузным значением и полем next, которое совпадает с полем next узла p. После чего поле next узла p сделать указателем на новый узел q.
- Удаление произвольного элемента
Чтобы удалить некоторое значение из списка, необходимо найти узел, поле next которого указывает на узел, содержащий искомое значение. Тогда значение next искомого узла записывается в next узла-предшественника (где раньше была ссылка на искомый узел). Отдельно обрабатываются случаи, когда искомым оказывается первый мили последний узел; кроме того, надо соблюдать принятые в языке программирования договорённости по освобождению памяти.
Моделирование связного списка — отличное упражнение для изучающих алгоритмы: это довольно хрупкая конструкция, реализация примитивов работы с ней требует внимательности и аккуратного анализа нескольких граничных ситуаций и т. д. Тем не менее именно с помощью связных списков в большинстве учебников программирования реализована работа с динамически изменяющимися объёмами данных: если из встроенных типов данных в языке программирования есть только переменные, массивы, структуры и указатели, иного способа нет.
Связные списки в Python не используются никогда — или почти никогда: вместо них следует применять динамические массивы. Гвидо ван Россум, автор языка Python, даже назвал такой тип данных list, прозрачно намекая на область его применения.
Вот таблица сравнения быстродействия различных операций над связными списками и динамическими массивами:
|
Связные списки |
Динамические массивы |
Поиск элемента |
O(N) |
O(N) |
Двоичный поиск элемента |
Невозможен |
Возможен |
Поиск элемента по индексу |
O(N) |
O(1) |
Добавление и удаление элемента в конце |
O(1) |
O(1) |
Добавление и удаление элемента в произвольном заранее известном месте |
O(1) |
в среднем O(N) |
Добавление и удаление элемента в начале |
O(1) |
O(N) |
Количество операций «выделение памяти» при добавлении N элементов |
O(N) |
O(log N) |
Как видно из таблицы, list в Python работает не хуже или даже лучше, чем связный список, за исключением случая, когда приходится модифицировать размер списка не в его конце. В самом деле, для вставки нового элемента в связный список, допустим, между вторым и третьим элементами достаточно указатель на третий записать в поле next нового элемента, а в поле next второго записать указатель на этот новый. А вот для вставки третьего элемента в динамический массив нужно добавить пустой элемент в его конец (это операция быстрая), а затем переставить все элементы, начиная с последнего, на одну позицию вперёд — N-1-й записать в позицию N, N-2-й — в позицию N-1 и так дальше до 3-го, который записывается в позицию 4, освобождая тем самым место для нового элемента в позиции 3. Эта операция, очевидно, достаточно неэффективна — порядка N.
Поначалу кажется, что такое различие быстродействия при работе с почти любым элементом списка сводит все преимущества list на нет. Однако в действительности важными являются только операции работы с началом списка. Например, эффективно реализовать абстракцию «очередь» (FIFO — «первым вошёл, первым вышел») с помощью list нельзя. Для этого в Python есть отдельный тип данных — collections.deque — в котором операции с обоих концов списка одинаково быстры (за счёт некоторых накладных расходов).
А вот эффективная работа (удаление или добавление) с элементом в произвольной позиции не даёт практически никаких преимуществ связному списку перед list. Для того, чтобы понять, что вставка или удаление должны происходить в позиции k, эту k надо сначала определить. А все операции поиска в связном списке — как по значению, так и по индексу — линейны. Таким образом, например, в операции «поиск + вставка» линейный поиск доминирует над константной вставкой, и порядок её сложности оказывается одинаков и для связных списков, и для динамических массивов (в которых, как мы видим, с поиском ещё и получше).
Не меньшую роль в выборе динамических массивов в качестве базового типа данных в Python сыграла необходимость управлять памятью, под них выделяемой. Выделение и освобождение памяти — функция ядра операционной системы, она может работать быстро, а может и долго. В языках с явным выделением памяти, таких, как Си и отчасти C++, программист сам принимает решение, как часто он может обращаться в алгоритме к системным вызовам. В языках с автоматическим учётом памяти, таких как Java и Python, этим занимается интерпретатор (или исполняющая система) — и возникает возможность реализовать работу с памятью поэффективнее. В Python для изменения размера списка используется стратегия «масштабирования», которую мы опишем ниже.
Посмотрим, что происходит в худшем случае. Поначалу Hashtable на N элементов пуст6, и пара добавляется в пустую ячейку — для определённости пусть в table[0]. Если теперь каждый из оставшихся N-1 запросов на добавление будет приводить к коллизии ключа, сколько всего ячеек придётся за это время просмотреть? Для первого запроса — одну, для для второго — две (из-за коллизий в первой), для третьего — три, и т. д. Очевидно, во время k-го запроса придётся просмотреть k ячеек. Значит, всего просмотров будет 1 + 2 + … + (N - 1), то есть N · (N - 1)/2. Если поделить на количество запросов, N, получится в среднем (N - 1)/2 действий на один .put().
![]() |
Согласно классификации из второй главы, сложность (N - 1)/2 — это O(N). В самом деле, если раскрыть скобки, получится N/2 - ½. Порядок сложности определяется доминирующей составляющей — N/2. Выходит, что в худшем случае количество просмотренных ячеек прямо пропорционально N (в нашем алгоритме равно половине N). |
Итак, мы рассмотрели наихудший случай, в котором среднее количество просмотров ячеек оценивается как O(N). В алгоритме мы оценивали количество просмотров ячеек, а не время работы или количество инструкций, потому что от количества просмотров производительность get() и put() зависит напрямую.
Похоже, мы зашли в тупик. Можно увеличить M, размер table, так, чтобы он значительно превосходил возможное количество хранимых ключей, N — тогда для случайных ключей количество коллизий (а следовательно, и время поиска) в таблице уменьшится7. Однако стоит ошибиться с прогнозами относительно N, то по мере приближения его к M производительность будет становиться всё ужаснее; хуже того, на N = M - 1 переполнится table, и значения вообще нельзя будет добавлять. В таблице 3-2 представлено время вставки N пар в структуру типа «Hashtable с открытой адресацией» размера M. Обратим внимание вот на что:
Для небольшого N, скажем, для 32, среднее время работы (соответствующая строка таблицы) везде примерно одинаково, независимо от M, размера Hashtable, потому что M либо само по себе мало, либо значительно превосходит N.
В любой Hashtable размера M среднее время вставки N ключей постоянно растёт с ростом N — это видно в соответствующем столбце таблицы.
Любая диагональ «с северо-запада на юго-восток» в таблице содержит примерно одинаковое время работы. С учётом того, как меняются M и N в столбцах и строках таблицы, можно сказать, что для того, чтобы в Hashtable можно было вместо N ключей добавлять с той же средней скоростью 2·N ключей, нужно увеличить её размер вдвое.
|
8 192 |
16 384 |
32 768 |
65 536 |
131 072 |
262 144 |
524 288 |
1 048 576 |
32 |
0.048 |
0.036 |
0.051 |
0.027 |
0.033 |
0.034 |
0.032 |
0.032 |
64 |
0.070 |
0.066 |
0.054 |
0.047 |
0.036 |
0.035 |
0.033 |
0.032 |
128 |
0.120 |
0.092 |
0.065 |
0.055 |
0.040 |
0.036 |
0.034 |
0.032 |
256 |
0.221 |
0.119 |
0.086 |
0.053 |
0.043 |
0.038 |
0.035 |
0.033 |
512 |
0.414 |
0.230 |
0.130 |
0.079 |
0.059 |
0.044 |
0.039 |
0.035 |
1 024 |
0.841 |
0.432 |
0.233 |
0.132 |
0.083 |
0.058 |
0.045 |
0.039 |
2 048 |
1.775 |
0.871 |
0.444 |
0.236 |
0.155 |
0.089 |
0.060 |
0.047 |
4 096 |
3.966 |
1.824 |
0.887 |
0.457 |
0.255 |
0.144 |
0.090 |
0.060 |
8 192 |
— |
4.266 |
2.182 |
0.944 |
0.517 |
0.276 |
0.152 |
0.095 |
16 384 |
— |
— |
3.864 |
1.812 |
0.908 |
0.484 |
0.270 |
0.148 |
Таблица 3-2. Среднее время добавления N ключей в таблицу Hashtable размера M (в миллисекундах)
Наша реализация типа данных «хеш-таблица» отлично работает — но только в случае, когда размер хранилища существенно больше предполагаемого количества ключей, которые мы ходим туда поместить. Если ошибиться с оценкой этого количества, быстродействие операция начинает стремительно падать, может и в сто раз уменьшиться. Что ещё неприятнее, мы не предусмотрели удаление ключа из таблицы — такой структурой данных далеко не всегда удобно пользоваться. Самый простой способ побороть эти трудности — хранить цепочки в отдельных списках.
Раздельное хранение цепочек в списках
Давайте перепишем Hashtable так, чтобы в таблице хранились не сами пары, а цепочки пар с совпадающими ключами. Такой способ называется хешированием с раздельными цепочками. В случае последовательного просмотра для размещения пары нам приходилось искать очередную свободную ячейку, теперь же в ячейке table[idx] будет храниться не пара, а весь список пар, хеши ключей которых совпадают с idx; если таких ключей нет, ячейка пуста: содержит пустой список. Количество ячеек для удобства оставим M.
![]() |
Понятие цепочки при раздельном хранении намного очевиднее цепочки в открытой адресации: теперь длина списка в ячейке и есть длина цепочки. |
Как и прежде, хеш-функцией, определяющей номер ячейки для данного ключа, у нас будет формула hash(key) % M. На иллюстрации 3-3 представлена хеш-таблица с семью ячейками; как и прежде, показаны только ключи.
![]() |
| Как выглядят цепочки Hashtable после вставки пяти пар (ключ, значение) |
На иллюстрации 3-3 пары (ключ, значение) добавляются в том же порядке, что и на иллюстрации 3-2. Поначалу все ячейки пустые, это обозначено серым фоном. Первые три пары с ключами 20, 15 и 5 попадают в начало цепочек в соответствующих ячейках хеш-таблицы. Когда добавляется пара с ключом 26, возникает коллизия, и эта пара добавляется в конец цепочки ячейки table[5], и то же самое происходит для ключа 19, так что в результате цепочка, соответствующая хешу 5, содержит три пары.
1 class Hashtable:
2 def __init__(self, M=10):
3 self.table = [[] for i in range(M)] # 1
4 self.M = M
5 self.N = 0
6
7 def get(self, k):
8 hc = hash(k) % self.M # 2
9 for entry in self.table[hc]: # 3
10 if entry.key == k:
11 return entry.value
12 return None
13
14 def put(self, k, v):
15 hc = hash(k) % self.M # 2
16 for entry in self.table[hc]: # 3
17 if entry.key == k: # 4
18 entry.value = v
19 return
20 self.table[hc].append(Entry(k, v)) # 5
21 self.N += 1
Пример 3-6. Вариант хеш таблицы с раздельным хранением цепочек
Написать self.table = [] * M значило бы создать список, все элементы которого являются одним и тем же объектом; нам же надо, чтобы это было M разных объектов (пускай они поначалу все равны []).
Вычисляем номер ячейки hc (остаток от деления хешированного ключа k на размер таблицы).
Ищем совпадение k с ключом одной из пар (быстродействие этой части пропорционально длине цепочки)
Если пара с данным ключом k найдена, изменим хранимое значение
Добавим пару с новым ключом k в конец цепочки
Методы .get() и .put() очень похожи: мы проходим по всем парам в цепочке table[hc] и сравниваем нулевой элемент пары (ключ) с k; если ключ найден — get() вернёт значение, а put() поменяет старое значение на новое, если не найден — get() вернёт None, а put() добавит пару (k, v) в конец цепочки.
![]() |
"Добавить элемент в конец динамического массива типа list можно за одну операцию, так что это самый эффективный способ. Общая вычислительная сложность добавления пары в хеш-таблицу всё равно зависит от длины соответствующей цепочки: чем добавлять пару, надо убедиться, что её в этой цепочке нет." |
Аналогично функции добавления, функция удаления пары по ключу, remove(), будет искать ключ пару с ключом k в соответствующей хешу цепочки таблицы, и удалять, если такая пара нашлась. По договорённости метод .remove() возвращает поле значение удалённой пары или None, если ключ не нашёлся.
Функция enumerate(список) возвращает последовательность пар индекс элемента, элемент; индекс нам может потребоваться для удаления
- Динамический массив позволяет удалять любой элемент по его индексу
- Если ключ найден, возвращаем значение из удалённой пары
Если удаления не было, возвращаем None
Стоит заметить, что операции над динамическим массивом (тип list), которые работают не с концом этого массива, обычно имеют линейную сложность в среднем пропорциональную длине самого массива. Это относится как к операциям поиска вида элемент in список, так и к операциям удаления вида del список[i]. Поскольку массив — это непрерывный блок элементов в памяти, для удаления из него i-го элемента на место i-го надо переписать i+1-й, на место i+1-го — i+2-й, и так далее до последнего элемента, который должен встать на место предпоследнего. Эта часть алгоритма очевидно зависит от длины списка и в среднем переставляет половину его элементов.
Может показаться, что для моделирования цепочек эффективнее было бы использовать связные списки, потому что в них операция удаления заранее известного элемента не зависит от длины списка. Однако в нашем случае (как и в подавляющем большинстве других) операции удаления непременно предшествует операция поиска, и поскольку она заведомо линейна, её сложность доминирует над константной сложностью удаления.
Насколько эффективны хеш-таблицы с раздельным хранением цепочек? Чтобы ответить на этот вопрос, надо посчитать и количество обращений к ячейкам таблицы, и количество просмотров пар в цепочке. Забегая вперёд скажем, что быстродействие таблиц с раздельными цепочками практически такое же, что и быстродействие таблиц с открытой адресацией; единственное важное достижение — количество пар в раздельных цепочках может быть актуально бесконечным. Тем не менее, как мы скоро увидим, когда количество хранимых пар N заметно превышает количество ячеек M, производительность значительно падает. Требования к памяти у обоих методов примерно одинаковые, однако в случае с раздельными цепочками структура данных посложнее, и и нужно дополнительно заводить M пустых списков.
Оценка
Итак, у нас есть две различных структуры, которые реализуют абстракцию «хеш-таблица» и позволяют хранить пары (ключ, значение). В варианте с раздельными цепочками мы предусмотрели операцию удаления пары. Удаление также можно было бы реализовать и в варианте с открытой адресацией: найти пару, записать на её место последнюю пару в цепочке, очистить ячейку, в которой раньше лежала последняя пара. Однако такая операция нарушает порядок добавления ключей в цепочку — свойство, которое может понадобится при использовании хеш-таблицы. В любом случае эффективность обеих структур ещё предстоит оценить.
Сначала оценим сложность по памяти: сколько её требуется для хранения N пар (ключ, значение)? Обе структуры изначально содержат массив из M элементов (при открытой адресации это пустые ячейки, при раздельном хранении цепочек — пустые списки ячеек). Для хеш-таблицы с раздельными цепочками N может быть сколь угодно большим, для хеш-таблицы с открытой адресацией N должно быть строго меньше M — значит, это M нужно выбирать исходя из знания о максимально возможном количестве пар. В обеих структурах размер базового массива table прямо пропорционален M.
Когда мы добавим хеш-таблицу все N пар, общий объём памяти при хранении с открытой адресацией не изменится, потому что базовый массив уже хранит ячейки типа Entry. При раздельном хранении цепочек каждая цепочка — это список из ячеек типа Entry, поэтому с каждой добавленной парой объём увеличивается на размер одной ячейки, и под конец размер это дополнительного пространства будет прямо пропорционален N.
Для открытой адресации требуется объём памяти, пропорциональный сразу и M, и N, но поскольку N < M, можно просто заметить, что сложность по памяти в данном случае — O(M).
Раздельное хранение цепочек для базового массива требует память с объёмом, пропорциональным M, а для всех цепочек — память с объёмом, пропорциональным N; так что сложность по памяти стоит оценить как O(M + N).
Теперь возьмёмся за сложность по времени. Основное действие — это обращение к ячейке таблицы, а мы будем подсчитывать количество таких обращений. Наихудший случай наступает, когда все вычисленные хеши ключей оказываются равны. В обеих структурах при этом M - 1 цепочка пуста, а оставшаяся единственная цепочка, которая соответствует этому одному на всех хешу, содержит все N пар. Для наихудшего случая предположим, что искомый элемент всегда оказывается в конце этой цепочки, так что и поиск по списку при раздельном хранении и поиск по самой таблице при открытой адресации прямо пропорционален N. Можно смело сказать, что независимо от реализации сложность худшего случая для операции get() — это O(N).
Казалось бы, к чему тогда все наши усилия? Если хеш-функция исследована математически, она даёт очень хороший разброс хешей на ожидаемом множестве ключей. В действительности с увеличением M вероятность коллизии уменьшается, а значит, уменьшаются и длины цепочек в наших структурах. В таблице 3-3 приведено сравнение обоих подходов: будем помещать 321129 английских слов в хеш-таблицы размера M от N/2 до 2×N. Вдобавок в первой строке приведём данные по хеш-таблицам размера M = 20 × N, а в пяти последних — малых (непригодных для открытой адресации) размеров.
Таблица 3-3 показывает результаты двух измерений для каждого M, N:
Средний размер непустой цепочки в хеш-таблице. Этот параметр годится и для открытой адресации и для раздельнохо хранения.
Максимальный размер цепочки в хеш-таблице. Этот параметр тоже годится для обоих структур и подсказывает ухудшение их быстродействия. Когда таблица с открытой адресацией близится к переполнению или хранимая цепочка становится чересчур длинной, быстродействие падает.
|
Раздельное хранение |
Открытая адресация |
||
M |
Средняя цепочка |
Наибольшая цепочка |
Средняя цепочка |
Наибольшая цепочка |
6 422 580 |
1.0 |
4 |
1.1 |
5 |
642 258 |
1.3 |
7 |
3.0 |
43 |
610 145 |
1.3 |
6 |
3.3 |
46 |
579 637 |
1.3 |
7 |
3.7 |
56 |
550 655 |
1.3 |
7 |
4.1 |
58 |
523 122 |
1.3 |
7 |
4.6 |
69 |
496 965 |
1.4 |
7 |
5.5 |
98 |
472 116 |
1.4 |
7 |
6.4 |
112 |
448 510 |
1.4 |
8 |
7.9 |
107 |
426 084 |
1.4 |
7 |
10.3 |
204 |
404 779 |
1.4 |
9 |
13.6 |
206 |
384 540 |
1.5 |
9 |
21.4 |
319 |
365 313 |
1.5 |
8 |
39.0 |
700 |
347 047 |
1.5 |
8 |
94.4 |
1516 |
329 694 |
1.6 |
8 |
784.5 |
9759 |
313 209 |
1.6 |
9 |
* |
* |
187 925 |
2.1 |
10 |
* |
* |
112 755 |
3.0 |
13 |
* |
* |
67 653 |
4.8 |
15 |
* |
* |
40 591 |
7.9 |
21 |
* |
* |
24 354 |
13.2 |
30 |
* |
* |
Таблица 3-3. Усреднённые показатели при добавлении 321129 ключей в хеш-таблицу размера M, по мере уменьшения M
Все приведённые значения растут с уменьшением M8. Причина в том, что чем меньше хеш-таблица, тем больше случается коллизий при добавлении, и цепочки ключей становятся длиннее. Хорошо видно, что в варианте с открытой адресацией этот рост существенно выше, особенно под конец, когда в одну многотысячную цепочку начинают сливаться последовательности ключей с разными хешами. Что ещё хуже, стоит M оказаться меньше, чем N, и открытая адресация вообще становится неприменима (в таблице отмечено «звёздочками»). А вот хранение цепочек в списках себя оправдало: в разумных пределах оно нечувтствительно к этой проблеме. Когда размер таблицы M достаточно велик — скажем, в два раза больше количества хранимых элементов N — средний размер цепочки вообще близок к 1, да и длина наибольшей цепочки сравнительно невелика. Тем не менее M нужно выбирать заранее, и в случае открытой адресации у нас закончится место, как только N станет равно M - 1.
С раздельным хранением не всё так страшно. Таблица 3-3 показывает, что даже когда N превышает размер таблицы M более, чем десятикратно, списки растут себе и растут, и даже производительность не падает так ужасающе, как при открытой адресации c N всего лишь примерно равным M. Это можно понять по размеру максимальной цепочки в списке, который приведён в той же таблице.
Изучив эти показатели, мы можем изобрести для хеш-таблицы размера M простую методику оценки эффективности. Производительность хеш-таблицы зависит от того, насколько она «заполнена», от есть от отношения N к M. В экономической науке есть похожее понятие — альфа-индекс9, но мы будем пользоваться более очевидным термином — загруженность хеш-таблицы.
При раздельном хранении цепочек показатель загруженности — это средняя длина цепочки (в отличие от данных в таблице 3-3, где приведена средняя длина только среди непустых цепочек). Этот показатель может превышать 1 и зависит только от объёма доступной памяти.
При открытой адресации показатель загруженности — это доля занятых ячеек таблицы. Наибольшее его значение — (M - 1)/M, так что он не может превышать 1.
Многолетние исследования показали, что хеш-таблицы начинают стремительно терять производительность при показателе загруженности больше 0.75 — например, когда таблица с открытой адресацией заполнена на 3/410. При раздельном хранении цепочек хеш-таблица не бывает «совсем заполнена», но принцип остаётся тот же11
На иллюстрации 3-3 приведены средние и наибольшие длины цепочек после добавления N = 321129 слов в хеш-таблицу размера M. М меняется по оси ординат, длины отложены по оси абсцисс. Средние длины помечены ромбами, их шкала расположена слева от диаграммы, наибольшие — квадратами, их шкала — справа. Задавшись целью обеспечить определённый средний или наибольший размер цепочки для заранее известного количества ключей N, можно, по аналогии с диаграммой на иллюстрации, подобрать подходящее M.
![]() |
| Иллюстрация 3-3. При вставке заданного числа элементов N в хеш-таблицы разных размеров можно уверенно предсказать, какой длины будут средняя и наибольшая цепочки |
|
Длина раздельной цепочки в списке (средняя и наибольшая) |
|
Средняя длина |
|
Наибольшая длина |
|
-<>- Средняя -[]- Наибольшая |
|
Если бы мы могли расширять хеш-таблицу, то есть увеличивать M по необходимости, можно было бы удерживать оптимальный показатель загруженности! Как мы сейчас увидим, при известном старании такую таблицу сделать вполне реально.
Расширяемые хеш-таблицы
В класс DynamicHashtable из примера 3-7 заведено поле load_factor, равное 0.75. Это показатель загруженности, на основании которого вычисляется пороговая загруженность threshold.
Пример 3-7. Определение показателя и порога загруженности при создании динамической хеш-таблицы
- Для M ⩽ 3 порог не должен превышать M - 1.
Что делать, когда хеш-таблица будет заполнена свыше порогового значения? Распространённый подход геометрического масштабирования советует сразу увеличивать таблицу вдвое. Точнее, увеличивать вдвое и ещё прибавлять к размеру единицу12. Как только количество занятых ячеек таблицы table превысит порог (threshold), для эффективной работы понадобится таблицу увеличить. В примере 3-8 приведён доработанный метод put() для раздельно хранимых цепочек.
Пример 3-8. Доработанный метод put(), который вызывает resize()
Добавим новую ячейку в коней цепочки table[hc].
Проверим, не превысило ли N порог.
Увеличим размер массива ячеек вдвое (плюс ещё одна ячейка).
Процедура удвоения создаёт новый массив (при открытой адресации — ячеек, при раздельном хранении — цепочек) и заново добавляет в эту новую таблицу все пары (key, value). Поскольку загруженность таблицы стала равна пороговой, но не превысила её, мы ожидаем в среднем константное время добавления одной ячейки, и следовательно, в среднем линейную сложность функции resize() по отношению к количеству ячеек, то есть, предположительно O(M). Прежде, чем продвинуться дальше, попытаемся (некорректно!) упростить процедуру удвоения. Во многих языках программирования есть операции копирования части или целого массива в другой массив. Эта операция уж точно имеет сложность O(M), ибо копировать надо ровно M ячеек старого массива. Почему бы нам просто не скопировать старый массив в новый? А в случае Python — так и вовсе просто добавить M + 1 ячейку прямо в конец старого (тип данных list позволяет сделать это очень эффективно)? Не получится ли такое решение в несколько раз быстрее повторного добавления всех пар в таблицу?
Так-то оно так, вот только работать как надо эта операция не будет! После «механического» расширения хеш-таблицы с открытой адресацией некоторые её элементы нельзя будет найти, а раздельно ещё и хранимые цепочки окажутся некорректными! На иллюстрации 3-4 показано, к чему приводит «механическое» удвоение размера таблицы для обоих подходов. Попробуйте, к примеру, проследить, что произойдёт с ключами 19 и 26.
![]() |
| Иллюстрация 3-4. Некоторые ячейки «теряются», если таблицу просто увеличить, не меняя содержимого |
Начнём искать ключ 19. Хеш этого ключа — 19 % 15 = 4, но соответствующая ячейка будет пуста в обеих структурах, то есть его там как бы нет. При старом-то размере таблицы с открытой адресацией M = 7 последовательный просмотр цепочки (с перезапуском от начала таблицы) приводил пару с ключом 19 в ячейку 2. А теперь и хеш другой, и размер, так что возврат к началу таблицы происходит после 15 ячейки, то есть алгоритм поиска не соответствует действительности.
По чистой случайности в таблице с открытой адресацией некоторые ключи всё же можно найти, но алгоритм их поиска не будет адекватен исходному. Например, ключ 20 будет найден, но не сразу (в table[5]), а последовательным просмотром, в table[6]. Точно так же и ключ 15 найдётся не в table[0], а в table[1]. И только ключ 5 остаётся на своём месте — потому что его хеш не изменился.
Дело, конечно же, в том, что при увеличении таблицы меняется хеш-функция, так что элементы скорее всего окажутся не на своих местах. Придётся вернуться к идее повторного хеширования: создадим пустую хеш-таблицу размером 2M + 1, и заново добавим в неё все пары из исходной таблицы. В терминах примера 3-8: каждую пару (key, value) исходного массива self.table добавим в новую таблицу temp с помощью temp.put(key, value) — тогда все пары точно можно будет найти. Воспользуемся динамической природой объектов Python и подменим старый массив новым (никакого копирования ячеек при этом не происходит). В результате у нового массива окажется два имени — self.table и temp.table, а у старого — ни одного, и вскорости он будет удалён исполняющей системой Python. При выходе из функции resize() прекращает существование переменная temp, и все объекты, у которых не осталось имён также будут удалены — но новый массив никуда не денется: у него останется ещё одно имя. Этот способ подходит обеим схемам хранения цепочек.
Пример 3-8. Динамическое масштабирование таблицы с открытой адресацией путём повторного хеширования
- Создаём временную хеш-таблицу нужного размера
- Добавляем в неё все пары из старой таблицы
Обновляем массив ячеек, значение M и порог threshold
При раздельном хранении цепочек процедура практически не меняется, надо только вспомнить, что элементы массива table — не сами ячейки, а их списки.
Пример 3-7. Динамическое масштабирование таблицы с раздельным хранением цепочек путём повторного хеширования
- Внешний цикл — по спискам ячеек
- Внутренний цикл — по ячейкам в списке
Итак, мы определили механизм масштабирования хеш-таблицы. На иллюстрации 3-5 показано, как в действительности должны выглядеть таблица с открытой адресацией (с иллюстрации 3-2) и таблица с раздельными цепочками (с иллюстрации 3-3) после увеличения размера с 7 до 15.
![]() |
| Иллюстрация 3-5. Структура хеш-таблиц после масштабирования с повторным хешированием |
Насколько лучше стало с применением масштабирования? Проведём эксперимент: для каждого начального размера таблицы M предпримем 25 тестов, в которых будем измерять:
- Время заполнения
Время, которое потребуется для того, чтобы добавить N = 321129 ключей в хеш-таблицу с начальным размером M и удвоением размера при превышении порога загруженности.
- Время доступа
Время, за которое мы найдем в получившейся таблице все N ключей.
В таблице 3-4 приведены результаты измерения времени заполнения и времени доступа для хеш-таблиц с открытой адресацией и с раздельными цепочками, причём начальный размер таблицы, M, меняется от 625 до 6400000. В последней строке приведены аналогичные данные для хеш-таблицы фиксированного размера на 428172 ячейки — этот размер соответствует пороговому заполнению таблицы 321129 элементами, потому что 428172 × 0.75 = 321129, и сравнение вполне оправдано.
|
Раздельные цепочки |
Открытая адресация |
||
M |
Время заполнения |
Время доступа |
Время заполнения |
Время доступа |
625 |
0.783 |
0.173 |
1.050 |
0.197 |
1250 |
0.783 |
0.176 |
1.057 |
0.198 |
2500 |
0.787 |
0.177 |
1.053 |
0.198 |
5000 |
0.780 |
0.175 |
1.059 |
0.200 |
10000 |
0.798 |
0.180 |
1.069 |
0.200 |
20000 |
0.782 |
0.176 |
1.062 |
0.201 |
40000 |
0.775 |
0.177 |
1.037 |
0.204 |
80000 |
0.763 |
0.176 |
1.015 |
0.203 |
160000 |
0.722 |
0.177 |
0.932 |
0.209 |
320000 |
0.627 |
0.182 |
0.747 |
0.208 |
640000 |
0.409 |
0.164 |
0.327 |
0.178 |
… |
… |
… |
… |
… |
Fixed |
0.378 |
0.172 |
0.434 |
0.285 |
Таблица 3-4. Сравнение масштабируемых хеш-таблиц с таблицами фиксированного размера (время в миллисекундах)
В последнем ряду приведён «наихудший идеальный» случай: идеальный — потому что размер таблицы заранее подобран так, чтобы показатель загруженности не превысил 0.75 (стало быть, resize() не вызывается), а наихудший — потому что под конец заполнения таблицы он подходит к пороговому значению вплотную. Разумно ожидать, что в этом случае и время заполнения, и время доступа в таблице с открытой адресацией будут несколько больше аналогичных параметров таблице с раздельными цепочками: в первом случае коллизии одного хеша в конце концов начинают неизбежно удлиннять несколько разных цепочек, а во втором — только одну, которая этому шешу соответствует. А вот для «слишком идеального» случая в предпоследней строке (с таблицей, заполненной примерно наполовину) может выиграть открытая адресация — за счёт более простой структуры, которая требует линейно меньше операций. Что более важно: время доступа к N = 321129 элементам динамической хеш-таблицы не зависит от её начального размера M, так что отпадает необходимость магически предсказывать этот размер заранее.
Оценка производительности динамических хеш-таблиц
Мы уже говорили, что чисто теоретически хеши всех ключей могут совпасть и угодить в одну цепочку (как при открытой адресации, так и для цепочек в списках). Как следствие, время выполнения операций put() и get() будет прямо пропорционально количеству элементов в хеш-таблице N, а значит, в наихудшем случае сложность этих операций следует оценить как O(N).
Довольно частое требование к хеш-функции — равномерное распределение хешей на всём множестве значений. Если это свойство обеспечить, любой случайный ключ будет иметь равную вероятность попасть в любую цепочку. В идеале средняя длина отдельной цепочки должна быть равна N/M, это доказывается математически. Остаётся только надеяться на то, что специалист, который разработал для Python функцию hash(), предусмотрел и равномерное распределение в ней13
Раз уж у нас таблица динамически растёт, причём N гарантированно меньше, чем M, можно уверенно утверждать, что среднее значение N/M — это константа, то есть O(1), а стало быть, оно не зависит от N.
![]() |
Если ключ есть в таблице, поиск завершается «попаданием», а если нет — «промахом». Предположим, хеш-функция равномерно распределяет значения, и оценим среднее количество обращений к ячейкам таблицы с открытой адресацией и пороговым значением alpha для обоих результатов. В случае попадания придётся просмотреть в среднем (1 + 1 / (1 - alpha))/2 ячеек, для alpha = 0.75 получается 2.5. В случае промаха результат равен 1 + 1 / (1 - alpha)²/2, то есть 8.5 при тех же условиях. |
Стоит понять, во что нам обходится динамическое изменение размера таблицы — допустим, с порогом загруженности 0.75 и геометрическим масштабированием (удвоением). Возьмём для начала M = 1023, а N — гораздо большим, чем M, к примеру, используем всё тот же словарь из 321129 английских слов. Попробуем посчитать, сколько раз мы добавляем пару в таблицу — в том числе в новую, внутри функции resize().
Первый раз resize() вызовется при добавлении 768-го ключа (потому что 768 ⩾ 767.25 = 0.75 × 1023), и размер таблицы M станет равен 1023 × 2 + 1, то есть 2047. В процессе масштабирования нам придётся заново добавить все 768 ключей (заново вычисляя их хеши). Заметим, что в результате показатель загруженнсти упадёт до 768 / 2047, т. е примерно 0.375.
После добавления ещё 768 ключей их количество опять подойдёт к пороговому — 1536, и все пары будут перенесены (с повторным хешированием) в новую таблицу размера M = 2047 × 2 + 1, то есть 4095. В третий раз масштабирование произойдёт после добавления ещё 1536 ключей, и придётся добавлять уже 3072 пары в таблицу размера 8191.
Что эти числа нам говорят? Посмотрим на таблицу 3-5: там показано количество слов N, которые надо добавить в таблицу, чтобы произошло масштабирование, а ещё — каково будет после этого общее число операций добавления ключа в таблицу. После каждого перехеширования будет увеличиваться, а затем снова падать среднее количество операций добавления на один хранимый элемент; эта характеристика — отношение общего числа добавлений к N — представлена в последнем столбце. В нашем случае она никогда не превышает 3 в силу геометрической природы масштабирования — и это несмотря на то, что при каждой смене размера нужно заново добавлять все ключи из старой таблицы в новую.
Слово |
M |
N |
Всего |
В среднем |
absinths |
1,023 |
768 |
1,536 |
2.00 |
accumulatively |
2,047 |
1,536 |
3,840 |
2.50 |
addressful |
4,095 |
3,072 |
8,448 |
2.75 |
aladinist |
8,191 |
6,144 |
17,664 |
2.88 |
anthoid |
16,383 |
12,288 |
36,096 |
2.94 |
basirhinal |
32,767 |
24,576 |
72,960 |
2.97 |
cincinnatian |
65,535 |
49,152 |
146,688 |
2.98 |
flabella |
131,071 |
98,304 |
294,144 |
2.99 |
peps |
262,143 |
196,608 |
589,056 |
3.00 |
… |
… |
… |
… |
… |
zyzzyvas |
524,287 |
321,129 |
713,577 |
2.22 |
Таблица 3-5. На добавлении какого слова происходит изменение размера таблицы, сколько всего для этого требуется добавлений, и сколько раз в среднем добавлялось одно слово
Главное, что понятно из этих данных: с увеличением размера таблицы геометрическое масштабирование происходит значительно реже. В последней строке показано, как с добавлением заключительного слова среднее количество операция становится равным 2.22, и при этом масштабирование не понадобится ещё для 72087 добавлений. Если первый интервал между операциями масштабирования составлял 768 добавлений, то девятый (196608) уже в 2⁸ = 256 больше.
Если исследовать алгоритм геометрического масштабирования аналитически, картина не меняется. Для начала заметим, что ни размер таблицы, ни пороговый коэффициент загруженности на подсчёт среднего количества добавлений не влияют. В самом деле: перед очередной операцией масштабирования в таблицу размера M уже добавлено N = M·3/4 ячеек в соответствии с пороговым значением. При масштабировании размер таблицы становится 2M (забудем на время про одну дополнительную ячейку), а новое пороговое значение — 2M·3/4 = 2N. При этом на операцию масштабирования тратится ещё N добавлений, N ячеек в новой таблице оказываются заняты, и ещё 2N - N = N ячеек туда можно добавить до следующего масштабирования. В следующий раз нам потребуется уже 2N добавлений, чтобы скопировать старую таблицу, и 2N добавлений останется в запасе.
Итак, сколько добавлений проделано между этими двумя операциями масштабирования? N — для новых элементов и 2N — при повторном хешировании. Вот и весь секрет «магического» числа 3, к которому приближается среднее количество операций на элемент в хеш-таблице! После первого же масштабирования на каждое добавление нового элемента приходится дополнительно ещё две операции при копировании. Если бы начинали сразу с полупустой таблицы, это отношение было бы равно строго 3. Но поскольку мы начинаем с пустой таблицы размера M, и следовательно, имеем M/2 «неучтённых» свободных ячеек, да ещё после удвоения прибавляем дополнительную ячейку, отношение общего количества добавлений к количеству хранимых ключей в хеш-таблице с геометрическим удвоением всегда меньше 3.
В частности, добавление всех N=321129 слов в нашу динамически расширяемую хеш-таблицу медленнее добавления в неизменяемую таблицу подходящего для N размера не более, чем втрое. Как мы уже договорились в главе 2, это всего лишь мультипликативная постоянная, и она не влияет на оценку сложности операции put() в среднем: даже несмотря на дополнительные затраты на масштабирование сложность остаётся O(1).
Динамические массивы
Мы уже несколько раз указывали на то, что тип list в Python реализован как массив; строго говоря — как таблица, то есть массив ссылок на объекты. Объекты Python могут быть разного размера, а вот ссылки — всегда одинакового. Наиболее распространён интерпретатор Python, написанный на Си, т. н. CPython; в нём, как в Си, ссылки — это просто адреса соответствующих структур в оперативной памяти). Доступ к этим ссылкам программист почти не имеет: в том же CPython функция id(объект) вернёт число, соответствующее адресу объекта в памяти, но смысла в этом немного, ибо сделать с ним ничего нельзя, да сам этот id не обязан быть уникальным. Например, если создать произвольный объект (скажем, строку), затем удалить его, затем создать объект другого типа (скажем, кортеж), есть шанс, что оба эти объекта получат одинаковый id, потому что будут, каждый в своё время, расположены по одному и тому же адресу оперативной памяти.
О свойствах массива мы рассказывали в отдельном подразделе, заметим только, что созданием и удалением самих объектов, ссылки на которые содержит объект типа list, занимается исполняющая система Python и, в конечном итоге, операционная система. Сейчас нас будет интересовать другое очень полезное свойство типа list, которое отличает его от простого массива: его размер можно менять.
Последовательная организация оперативной памяти предполагает, что «классические» массивы могут иметь только заранее заданный размер. Скажем, в восьми идущих подряд ячейках памяти находится массив из восьми целых чисел A, а в следующих восьми — массив из четырёх вещественных чисел двойной точности B. Добавить больше восьми элементов в массив A невозможно: дальше начинается стартовый элемент массива B, и если записать в A[8] целочисленное значение, то в B[0] окажется что-то совсем невообразимое. Тем не менее в списки Python можно добавлять сколько угодно элементов, и мы указывали на то, что операция эта сравнительно дешёвая — в среднем O(1).
Ничего не напоминает? Конечно же, ситуацию с переполнением хеш-таблицы, только в упрощённой форме! Допустим, объект A типа list занимает восемь ячеек памяти и все они уже заняты: len(A) равно 8. Тогда операция A.append(объект) начнётся с масштабирования массива, причём тоже геометрического и тоже с коэффициентом 2. Опуская подробности: будет выделен фрагмент памяти на 16 ячеек, туда скопировано содержимое восьми ячеек из старого фрагмента, добавлено значение девятой ячейки (ссылка на объект), имя A начнёт указывать на это новый фрагмент, а старый — освобождён. При этом len(A) будет возвращать 9, и в A останется ещё 7 свободных мест для добавления новых объектов за константное время. Как и в случае динамической хеш-таблицы, операция добавления в динамический массив, достигший предела загруженности (здесь он просто равен 1) имеет линейную сложность. Мы уже знаем, что геометрическое масштабирование гарантирует нам O(1) в среднем, с мультипликативной постоянной 3.
Вот и весь секрет! Прежде, чем двинуться дальше, остаётся сделать два важных замечания. Во-первых, мы не рассматривали операцию удаления ключа из хеш-таблицы — потому что она сравнительно редко требуется. Однако удаление элемента из динамического массива требуется сплошь и рядом, оно имеет в среднем линейную сложность (из-за «сдвига» элементов) и в среднем константную при удалении последнего элемента, например, для A.pop(). Логично предположить, что при достижении некоторого нижнего порога загруженности (скажем, в четверть прежнего размера), нам захочется уменьшить размер массива. Это делается ровно тем же способом, геометрически масштабированием, только в меньшую сторону, и ровно по этой причине также не влияет на константность операции .pop().
Во-вторых, здесь кажется уместным ответить (или скорее не ответить) на вопрос «Как Python справляется с фрагментацией памяти?». Суть вопроса в следующем. Допустим, мы создали сто динамических массивов, и они расположились в оперативной памяти, как и полагается, подряд. Затем в каждый второй из этих массивов мы добавили столько элементов, что произошло их масштабирование. При этом, как мы договорились, для этих пятидесяти была выделена новая память, а старая «освобождена». В результате чего фрагмент памяти, в котором изначально лежали все сто массивов, приобрёл вид «массив, свободное место, массив, свободное место, массив…». Итак, вопрос: не захламляют ли такие структуры оперативную память, и кто в случае Python заведует всем этим хозяйством? Ответ: разумеется, захламляют, и заведует этим исполняющая система Python, пользуясь программным интерфейсом операционной системы.
Эффективное управление памятью — серьёзная, активно развивающаяся дисциплина IT, не имеющая ни идеального, ни даже просто устраивающего всех решения. Ей посвящены многие научные и практические труды. В нашей книге мы не станем посягать на эту тему. Чтобы оценить масштаб проблемы, которую мы не станем решать, задумаемся ещё вот над чем. Бог с ними, с массивами. Допустим, мы создали сто обычных питоновских объектов, а потом просто удалили каждый второй. Не произойдёт ли и тут фрагментации памяти, и если да, то кто и что с ней будет делать? Ведь такое происходит, считай, постоянно? Изучение этого вопроса столь же занимательно, сколь и обширно, оно может окончательно увести в сторону от главной темы. Лучше вернёмся к ней.
Идеальный хеш
Если заранее знать, чему будут равны все наши N ключей, можно подобрать то, что называется идеальной хеш-функцией, в которой каждому ключу взаимно однозначно соответствует уникальный хеш, и стало быть, и попадание, и промах в хеш-таблице потребуют ровно одного обращения. Неожиданный, но довольно распространённый приём — по набору ключей автоматически изготовить фрагмент программы на произвольном языке программирования, в которой будет определена соответствующая хеш-функция.
В примере 3-8 приведён вариант такой функции perfect_hash(), сгенерированный сторонним модулем perfect-hash из файла, в котором в каждой строке расположен один ключ — слово из бессмертного пушкинского «Читатель ждёт уж рифмы розы; На, вот возьми её скорей!» (без знаков препинания и прописных букв, зато с буквами «ё»)14. Несущественную отладочную информацию и комментарии мы удалили.
Пример 3-8. Идеальный хеш для десяти пушкинских слов
![]() |
Мы уже пользовались встроенной в Python функцией enumerate(), которая возвращает последовательность пар (индекс, элемент) для любой итерируемой последовательности. Строго говоря, исходная последовательность даже не обязана сама быть индексируемой, то есть конструкция последовательность[i] может быть синтаксически недопустимой. Но если существует возможность пройтись по ней циклом вида for элемент in последовательность, то всегда возможна конструкция вида for индекс, элемент in enumerate(последовательность). Переменная индекс при этом будет расти от 0 с шагом 1. Если при проходе циклом нужны и элементы, и их индексы, рекомендуется в любом случае использовать enumerate(), как в примере ниже. |
Вспомним, как на иллюстрации 3-1 мы задали массив day_array и в дополнение к нему — функцию base32(), которая вычисляла хеш каждого из 12 месяцев. Тогда размер этого массива зависел от найденного нами числа — 35, остатки деления хешей каждого месяца на которое все оказываются различными. Тем самым мы задали идеальную для месяцев года хеш-функцию, формула которой — day_array[base32(месяц)%35]. Алгоритм из perfect-hash достигает того же эффекта, но существенно более остроумным способом. Не пытаясь описать методику создания полученных структур данных и функций15, попробуем убедиться хотя бы в том, что это именно хеш, и он идеален для наших ключей.
Итак, у нас есть массив G, размер которого (11) превосходит общее количество ключей N=10, два вспомогательных массива S1 и S2 меньшей длины (ниже мы убедимся, что эта длина растёт очень незначительно по сравнению с ростом N, вспомогательная хеш-функция hash_f() и собственно perfect_hash(), которая, поверим авторам на слово, будет выдавать идеальный хеш для заданного множества ключей. Чтобы вычислить хеш ключа «уж», необходимо сначала получить два промежуточных значения:
hash_f('уж', S1) = (S1[0] * ord('у') + S1[1] * ord('ж')) % 11, то есть (4 * 1091 + 10 * 1078) % 11 = 15144 % 11 = 8 (1091 и 1078 — порядковый номер букв «у» и «ж» в таблице Unicode).
hash_f('уж', S2) = (S2[0] * ord('у') + S2[1] * ord('ж')) % 11, то есть (7 * 1091 + 1 * 1078) % 11 = 8715 % 11 = 3
Функция perfect_hash('уж') вычисляет (G[8] + G[3]) % 11, то есть (3 + 10) % 11 = 2. Таким образом, хеш ключа «уж» равен двум. Повторим упражнение на ключе «вот»:
hash_f('вот', S1) = (4 * 1074 + 10 * 1086 + 4 * 1090) % 11 = 19516 % 11 = 2.
hash_f('вот', S2) = (7 * 1074 + 1 * 1086 + 6 * 1090) % 11 = 15144 % 11 = 8.
perfect_hash('вот') = (G[2] + G[8]) % 11 = (3 + 3) % 11 = 6.
Продолжая в том же духе, получим, что хеш «уж» — это 2, хеш «вот» — 6, и вообще хеш каждого слова из «читатель ждёт уж рифмы розы на вот возьми её скорей» уникален. Математика16 временами творит настоящие чудеса!
В примере 3-9 приведена функция perfect_hash(), которую сгенерировал этот модуль для нашего тренировочного словаря из 321129 слов. На самом первом английском слове — «a» — функция возвращает 0, а на последнем, «zyzzyvas» — 321128. Сам массив G, содержащий 667596 промежуточных ключей, мы здесь, понятное дело, не приводим, а вот два массива S1 и S2 для вычисления идеального хеша всё ещё невелики.
Читатель может самостоятельно изготовить полный пример: установить python-модуль perfect-hash и выполнить команду python3 -m perfect_hash --hft=2 words.english.txt, используя файл words.english.txt из репозитория с программами к данной книге. Поскольку при наполнении служебных массивов модуль использует случайный выбор, числа в получившемся примере будут другие, и длина G наверняка будет слегка отличаться.
Промежуточная функция hash_f() генерирует довольно большое число — сумму значений из S1 или S2, — далее это число обрезается с помощью остатка от деления на 667596, что даёт индекс каждой из половин хеша в большом массиве G. После того, как половины складываются и снова обрезаются до 667596, получается уникальный ключ для любого допустимого английского слова из нашего словаря в диапазоне от 0 до 667595. В действительности всё ещё интереснее: алгоритм рассчитан так, что perfect_hash(слово) возвращает индекс этого слова во входном списке, то есть число от 0 до 321128, и это значение (в отличие от содержимого S1, S2 и G) не меняется при повторной генерации текста функции.
Если попробовать внезапно посчитать хеш чего-то, что не является словом из исходного словаря, может возникнуть коллизия. В примере 3-9 слово «watered» и не-слово «not-a-word» получали одинаковый хеш; в только что сгенерированном примере на «not-a-word» может не быть коллизии, но она (в силу того, что всевозможных не-слов гораздо больше, чем 667595 штук) обязательно появится на каких-то других парах «слово - не-слово». Интересная задача — отыскание таких пар для конкретного варианта perfect_hash(). В этом нет никакой ошибки: за то, что нашей функции будут предъявляться только слова из исходного словаря, отвечает программист.
Проход таблицы циклом
Основная задача хеш-таблицы — эффективные операции get(k) и put(k, v). НО ещё было бы неплохо иметь доступ ко всем ячейкам таблицы, независимо от того, какой цепочке они принадлежат, и какой подход — открытая адресация или раздельное хранение — используется для их хранения.
![]() |
Генератор, или вычислимая последовательность, — один из самых удобных инструментов Python. В современном языке программирования для последовательного просмотра некоторого набора данных не нужно задействовать отдельную память со списком этих данных. В главе 2 мы уже видели, что вычислимые последовательности range(0, 1000) и range(0, 100000) занимают одинаковый объём памяти, порождая соответствующие последовательности целых чисел. Генератор-функция — общая форма задания вычислимой последовательности в Python. |
Вот такая генератор-функция изготовит и вернёт вычислимую последовательность целых неотрицательных чисел от 0 до n, в десятичном представлении которой нет заданной цифры:
Пример 3-10. Задание генератор-функции
Анализируя синтаксис описания функции, Python обнаружит в ней оператор yield, и это будет значить, что данная это — генератор-функция, которая при вызове возвращает собственно генератор. Генератор-функцию надо вызвать один раз, получить генератор, а по генератору — пройтись циклом. Когда мы первый раз в цикле обращаемся к генератору, выполняются строки от начала функции до первого встреченного yield. В примере — от строки 2 до строки 5, при этом в цикл возвращается выражение, указанное параметром yield (в примере — i). В следующий раз выполнение продолжается непосредственно после выполненного yield и до следующего yield. В примере yield — последняя строка блока цикла, так что выполнение продолжается со строки 3 до строки 5, затем снова возвращается i, и так до тех пор, пока вычисляется цикл. Выход за пределы функции (в примере — когда i сравняется с n) означает останов цикла использующего генератор.
Посмотрим, как работает наша avoid_digit() в командной строке Python:
Пример 3-11. Работа генератор-функции
Создадим генератор и назовём его gen
Ещё раз убедимся в том, что avoid_digit() возвращает генератор, а не, скажем, целое число
Поскольку генератор — это последовательность, его элементы можно пройти циклом for; цикл закончится, когда закончится сама последовательность
Если попробовать ещё раз пройтись циклом по тому же самому генератору, выяснится, что он уже закончился, и ни одной итерации не произойдёт17.
Если в классе Python задать специальный метод .__iter__(), сконструированный из этого класса объект станет итерируемым — его можно будет использовать в конструкции вида for элемент in объект. В примере 3-12 приведена реализация прохода циклом хеш-таблицы с открытой адресацией, а в примере 3-13 — реализация прохода циклом хеш-таблицы с раздельным хранением цепочек в списках.
Пример 3-12. Метод-итератор по всем элементам хеш-таблицы с открытой адресацией, реализованный как генератор-функция
Пропустим все пустые (содержащие None) ячейки
Вернём в yield кортеж (ключ, значение)
Пример 3-13. Метод-итератор по всем элементам хеш-таблицы с раздельным хранением цепочек
- Цикл по цепочкам
- Цикл по ячейкам в цепочке
Пара (ключ, значение) в качестве возвращаемого значения
Рассмотрим последовательности пар, порождаемые итераторами в трёх случаях: проходом по хеш-таблице размера M = 13 с открытой адресацией, по таблице размера 13 с раздельным хранением цепочек и по идеальной хеш-таблице на основе слов английского языка. После добавления в них слов из строки «a rose by any other name would smell as sweet» порядок выдачи пар при проходе циклом будет различен. В таблице 3-6 показан результат для всех трёх случаев.
Open addressing |
Separate chaining |
Perfect hash |
a |
sweet |
a |
by |
any |
any |
any |
a |
as |
name |
would |
by |
other |
smell |
name |
would |
other |
other |
smell |
as |
rose |
as |
name |
smell |
sweet |
by |
sweet |
rose |
rose |
would |
Таблица 3-6. Порядок слов, возвращаемых итераторами хеш-таблиц
Порядок слов в двух наших реализациях выглядит случайным, а для идеальной хеш-функции — словарным. Конечно, никакого случайного выбора из множества ключей мы не писали: дело в алгоритме вычисления хеша, который использует Python-овскую функцию hash() с её равномерным распределением. Более того, если запустить соответствующий пример из репозитория самостоятельно, последовательность слов в этих двух случаях изменится, ибо современный hash() в Python случайно непредсказуем.
Слова в выдаче третьего итератора идут в словарном порядке, потому что при формировании идеальной хеш-функции perfect_hash(ключ) исходный список слов был словарно упорядочен. Напомним одно из полезных свойств модуля perfect-hash: порождаемый этой функцией хеш — это номер слова во входном словаре. Так что если словарь был упорядочен, и ячейки в таблицу записываются в порядке значения хеша, итератор вернёт пары тоже в словарном порядке. Ещё одно важное следствие этого свойства — хеш-таблица на основе идеального хеша имеет размер, строго равный количеству элементов в исходном словаре, и не нуждается в разрешении коллизий.
В последней, восьмой, главе мы поближе познакомимся с типом данных dict — Python-овскими ассоциативными массивами (или словарями). Всякий раз, когда в программе нужна хеш-таблица, надо использовать dict, а не наши самодельные классы, потому что dict и намного быстрее, и больше умеет.
Заключение
В этой главе мы рассмотрели несколько важных понятий:
Примитивным хеш-таблицам, которые хранят пары «ключ-значение» в массиве ячеек размера M, не хватает алгоритма разрешения коллизий — ситуации, когда хеш двух и более ключей приводит к одной и той же ячейке.
Открытая адресация ключа позволяет рассредоточить хранимые пары по всему массиву, но тогда для действительно эффективной работы нужно, чтобы размер этого массива M был как минимум вдвое больше максимального количества элементов N. Удаление элемента из такой хеш-таблицы — это целое упражнение (в тренировочных заданиях таких упражнений два).
- Цепочки можно хранить раздельно, в списках. В этом случае совпадение хешей для двух и более ключей приводит к добавлению их в один и тот же список, а основной массив таблицы оказывается массивом этих списков. Удалить элемент из такой таблицы проще.
Самому придумать хеш-функцию непросто, но в Python есть встроенная функция hash(), её и стоит использовать.
- Геометрическое масштабирование ассоциативного массива (хеш-таблицы) позволяет сохранить среднее быстродействие операции добавления за счёт того, что «тяжёлая» процедура увеличения размера с перехешированием выполняется всё реже с ростом размера.
Python-овский тип данных list, который мы используем для списков, по сути — массив с геометрическим масштабированием при добавлении, то есть динамический массив. Это задаёт константную, O(1), сложность операциям добавления в конец списка.
Идеальное хеширование — функция, возвращающая уникальный хеш для каждого элемента из заданного множества — возможна при условии, что это множество заранее определено. Более того, если это множество как-либо упорядочить, есть алгоритм, в котором хеш элемента совпадает с его порядковым номером. Вычислительная сложность и сложность по памяти идеальной хеш-функции чаще всего выше стандартной Python-овской hash().
Тренировочные задания
Улучшается ли работа открытой адресации, если вместо последовательного просмотра с шагом 1 и возвратом к началу избрать другую тактику разрешения коллизий? Сделайте размер массива равным степени двойки, а шаг просмотра — переменным, равным n-му элементу из последовательности т. н. треугольных чисел. После просмотра соседней ячейки (расстояние +1) исследуются ячейки на расстоянии 3, 6, 10, 15, 21 и т. д. (с учётом возврата к началу при выходе заграницы). Треугольное число № n вычисляется по формуле n · (n + 1) / 2.
- Будет ли производительность такой таблицы лучше, чем при последовательном просмотре?
Имеет ли смысл сортировать ключи в раздельно хранимых цепочках хеш-таблицы? Напишите вариант хеш-таблицы в раздельным хранением, в которой ячейка в цепочку добавляется так, чтобы (ключ, значение) оставались упорядоченными по возрастанию ключа.
Проведите эксперимент. Создайте хеш-таблицу на 524287 элементов (524287 — простое число) и добавьте в неё первую половину словаря — 160564 английских слов (загруженность будет примерно на треть). Если добавлять слова в порядке возрастания, это сделает линейной сложность поиска нужного места для вставки пары, но константной — сложность вставки (потому что она будет фактически соответствовать добавлению в конец). Если добавлять слова в порядке убывания — наоборот. Какой из этих вариантов окажется наихудшим случаем? Сравните его производительность с производительностью хеш-таблиц того же размера с открытой адресацией и несортированными раздельными цепочками.
Теперь измерьте время поиска первых 160564 английский слов в этой таблице. Можно ожидать, что это будет наилучший случай, потому что все эти слова уже есть в таблице. Сравните производительность с производительностью аналогичных операций на хеш-таблицах с открытой адресацией и несортированными раздельными цепочками. Затем поищите оставшиеся 160565 слов из второй половины словаря. Это должен оказаться наихудший случай, потому что поиск несуществующих слов в списке требует его полного просмотра. Снова сравните производительность с производительностью двух других типов таблиц. Насколько результаты сравнения связаны с индексом загруженности хеш-таблицы? Например, что изменится, если задать размер таблицы 214129 (загруженность 75%) или 999983 (загруженность 16%)?
Чтобы убедиться в опасности предсказуемой хеш-функции, посмотрите на пример 3-14. Класс BadString в примере порождает объекты, схожие во всем с Python-овским типом str. Но работа встроенной функции hash() для них перегружена, потому что определён специальный метод .__hash__(). В результате hash() для таких объектов может быть равен только 0, 1, 2 или 3.
Пример 3-14. Ужасная реализация хеш-функции
Создайте хеш-таблицу с раздельно хранимыми цепочками на 50000 элементов и вызовите .put(BadString(w), 1) для первых 20000 английских слов из нашего словаря. Сделайте ещё одну такую же хеш-таблицу и добавьте туда те же слова непосредственно, без преобразования в BadString. Посчитайте:
- Среднюю длину цепочки
- Наибольшую длину цепочки
Число ячеек в хеш-таблице, M, на практике выгодно делать простым. Если взять для примера числовой ключ и остаток от деления на M в качестве индекса в таблице, окажется, что ключ, имеющий общий делитель с M, попадает только в ячейку, индекс которой имеет тот же общий делитель с M. Скажем, пускай M = 632 = 8 · 79, а добавляем мы пару с ключом 2133 = 27 · 79 ; хеш этого ключа будет равен 2133 % 632 = 237 = 3 · 79. Видите 79? Неприятность в том, что вместо равномерного распределения по всем ячейкам, от которого зависит быстродействие хеш-таблицы, получается, что некоторые ключи будут попадать строго в ограниченный набор ячеек.
Исследуйте влияние M на результат. По аналогии с base32() напишите функцию base26(), которая каждому слову английского словаря поставит в соответствие числовой ключ. Создайте 101 хеш-таблицу размера M, где M будет меняться от 428880 до 428890 включительно, добавьте в каждую такую таблицу все 321129 английских слова по их числовому ключу base26(), и сравните средние и наибольшие длины цепочек. Проделайте этот опыт на хеш-таблицах с открытой адресацией и на хеш-таблицах с раздельными цепочками. Нет ли среди результатов некоторой общей закономерности? Сформулируйте гипотезу и попробуйте воспользоваться ею для поиска наихудшего M среди последующих 10000 размеров (до 438890). Для этого M длина наибольшей цепочки должна быть почти вдесятеро больше.
Из хеш-таблицы с открытой адресацией нельзя просто так удалить произвольную пару — если пометить ячейку как пустую, проходившая через неё цепочка прервётся, и последовательный просмотр не доберётся до продолжения этой цепочки. Допустим, у нас есть хеш-таблица на пять элементов, в которую мы добавили ключи 0, 5 и 10. В результате ключи в таблице разместятся так: [0, 5, 10, ×, ×], потому что с каждой коллизией просмотр будет добираться до следующей ячейки. Если сейчас спроста удалить 5, получится таблица с ключами [0, ×, 10, ×, ×], в которой внезапно образовались две цепочки, причём ключ 10 найти в ней нельзя, потому что цепочка для хеша 0 теперь состоит из всего одного элемента.
Один из путей решения проблемы — ввести специальное логическое поле в классе Entry, в котором будет отмечено, что эта ячейка удалена, но является частью цепочки. Напишите метод .remove(ключ), удаляющий элемент по ключу. Что изменится в работе методов .get() и .put()? Не забудьте также поправить процедуру масштабирования и метод .__iter__() — они должны пропускать удалённые ячейки.
- При таком подходе понадобится также обратное масштабирование — уменьшение размера массива ячеек в случае, когда более половины из них помечены как удалённые. Сравните быстродействие хеш-таблицы с открытой адресацией и удалением и хеш-таблицы с раздельными цепочками (в которых удаление уже реализовано).
Масштабирование хеш-таблицы весьма серьёзно понижает производительность: требуется повторно хешировать все N ключей и заполнить N ячеек новой таблицы. В поэтапном масштабировании хранятся обе таблицы — старая, размера M, и новая, размера 2M + 1. Вызов get(ключ) сначала ищет ключ в новой таблице .а если его там нет — в старой. Вызов put(ключ, значение) всегда добавляет пару в новую таблицу, после чего D раз берёт пару из старой таблицы, повторно хеширует ключ, добавляет её в новую, а из старой удаляет. Когда старая таблица опустеет, её можно удалить18.
Реализуйте поэтапное масштабирование для хеш-таблиц с раздельным хранением цепочек и исследуйте различные значения D. В частности, каково минимальное D, при котором к следующему масштабированию старая таблица гарантированно опустеет? Постоянна ли величина D, или она зависит от M, или даже от N?
Такой подход должен снизить максимальную сложность операции .put(). Проведите эксперимент, составив таблицу, аналогичную таблице 3-5, в которой на этот раз сравните производительность самых неэффективных операций.
Обратное масштабирование можно реализовать и в обычной хеш-таблице с удалением. При вызове метода .remove() надо проверить, что таблица загружена не более, чем на ¼ M, и если да — запустить масштабирование к половинному размеру. Это позволит освободить неиспользуемую память. Доработайте любой из вариантов хеш-таблицы с удалением элемента, добавив в него обратное масштабирование, и проведите несколько испытаний на предмет того, стоит ли результат затраченных усилий.
- Используйте хеш-таблицу, чтобы найти элемент списка, который встречается в нём не реже любого другого. Если таких несколько, можно возвращать любой из наиболее повторяющихся элементов.
Например most_duplicated([1, 2, 3, 4]) может вернуть 1, 2, 3 или 4, а вот most_duplicated([1, 2, 1, 3]) обязан вернуть 1.
Ещё один способ удалить пару из хеш-таблицы с открытой адресацией — это повторно хешировать все пары (ключ, значение), которые следуют в цепочке за удаляемой парой. Добавьте в хеш-таблицу с открытой адресацией такой способ удаления и проведите несколько экспериментов, сравнивая производительность хеш-таблицы с раздельным хранением цепочек c производительностью хеш-таблицы с открытой адресацией и удалением цепочки описанным способом19.
В этой главе запись «пара (ключ, значение)» обозначает единый элемент хранения, и поэтому мы для простоты нередко рассматриваем только ключ. (1)
В авторском тексте использовались английские буквы, и речь шла про кодировку ASCII. Поскольку ord() в действительности работает не с ASCII, а именно с Unicode, мы решили, что перевод примеров на русский не только приблизит их к читателю, но и сгладит неоднозначность. Разумеется, строчные буквы в Unicode отличаются от прописных! Например, ord('я') == 1103, а ord('Я') == 1071. (2)
А вот «ё» не повезло: её порядковый номер на 2 меньше, чем у «а» — поэтому мы её не используем в примере. (3)
Сама base32() хеш-функцией, согласно этому определению, не является, потому что область её значений не ограничена. (4)
Например, в языке программирования Java хеш 32-битный, и он действительно совпадает у некоторых разных строк; скажем, и у "misused", и у "horsemints" он равен 1 069 518 484 — прим. автора. (5)
Напомним, что минимум одна ячейка всегда должна быть не заполнена, так что размер таблицы table в этом случае N+1. (6)
Худший случай при этом не отменял никто, но среди случайных наборов он встречается довольно редко. (7)
Можно заметить, что длина наибольшей раздельной цепочки растёт не слишком строго, в силу «случайности» функции hash(). Всегда есть вероятность, что для двух близких значений M в одном случае пары разложатся по цепочкам оптимальнее, чем в другом. (8)
Строго говоря, альфа-индекс — это такое число M/N, при котором наблюдается субъективно наиболее эффективная работа экономической модели. (9)
В Python-овской структуре данных dict пороговое значение другое — 2/3 (прим. автора). (10)
Оставив «многолетние исследования» на совести автора, ещё раз заметим, что показатель загруженности — понятие субъективное: оно задаёт порог, после которого структурой становится неудобно пользоваться. Например, согласно таблице 3-3, при открытой адресации порог 2/3 соответствует средней длине цепочки примерно 5, а максимальной — примерно 100; а порог 3/4 — 10 и 200 соответственно. (11)
Давно замечено, что наилучшие результаты дают хеш-таблицы, размер которых равен простому числу (у нас даже есть такое задание в упражнениях к этой главе); ну а тут пускай будет хотя бы нечётный размер, это тоже полезно. (12)
Важно понимать, что худший случай так никуда и не делся! Уже говорилось, что до введения рандомизации в функцию hash() было возможно практически подобрать последовательность ключей с одинаковым хешом. Это, наверное, удастся даже сейчас, но при условии, что известна «соль» — случайное значение, которое подмешивается во все хеши конкретного экземпляра python-интерпретатора, и есть весьма серьёзный вычислительный ресурс для подбора этой последовательности. Случайно же получить худший случай уж совсем невероятно. (13)
Модуль можно установить из командной строки с помощью pip install perfect-hash. Текст в примере был сгенерирован тоже из командной строки, примерно так: perfect-hash --trials=1000 --hft=2 файл_со_словами_в_столбик.txt. (14)
Ссылка на серьёзную математическую статью и упрощённое описание алгоритма подбора идеальной хеш-функции есть в документации к модулю. (15)
В данном случае — теория графов и теория чисел. (16)
По этой причине генераторы можно считать «одноразовыми», и не хранить их в переменных, а сразу писать что-то вроде for element in avoid_digit(15, "3"). (17)
Скорее всего, она удалится сама при следующем масштабировании. (18)
Не забудьте сначала убедиться, что реализованное таким способом удаление сохраняет работоспособность нашей структуры. (19)







