1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7

# теория · шаг 1 из 7

Прямая адресация: когда ключ сам и есть номер ячейки

Коротко

Если ключи - целые числа из небольшого диапазона 0..U−1, словарь строится без всяких хитростей: массив из U ячеек, и значение с ключом k лежит в ячейке k. Это прямая адресация: вставка, поиск и удаление за O(1) в ХУДШЕМ случае, без коллизий и без вероятностей. Для множества хватает одного бита на ключ - битовая шкала. Цена - память Θ(U) независимо от того, сколько ключей реально лежит: десять тысяч пропусков из миллиона возможных номеров стоят мегабайт ссылок или 125 КБ бит. Когда U огромен (строки, 64-битные идентификаторы), таблицу не завести вовсе - и тогда номер ячейки приходится вычислять из ключа, о чём следующий урок.

Если ты знаешь, когда прямая адресация выгоднее словаря, сколько памяти стоит битовая шкала и как обойти инициализацию огромного массива, листай до «Теперь сам».

Как думаешь, что быстрее массива

Самая быстрая структура для поиска по ключу, которую ты знаешь, - массив: a[k] за один шаг, без сравнений. Всё, что будет дальше в этом разделе, - попытки получить такую же скорость для ключей, которые индексами не являются.

Прежде чем читать дальше, ответь себе: в офисе выдано десять тысяч пропусков, номер пропуска - от 0 до 999999. Что займёт меньше памяти - массив на миллион флажков «выдан или нет» или множество set из десяти тысяч номеров? Интуиция говорит «множество, ведь 99% массива пусто». Проверим.

На что это похоже

Почтовые ящики в подъезде. У каждой квартиры свой ящик с её номером, и почтальону не нужно ничего искать: письмо в квартиру 57 идёт в ящик 57. Ящик пустой у квартир, где никто не живёт, но он всё равно висит - стена ящиков рассчитана на ВСЕ квартиры дома, а не на нынешних жильцов. Для дома на сотню квартир это удобно. Для «дома» на 2⁶⁴ квартир, где живёт тысяча человек, такую стену не построить - и тут нужен другой способ получить номер ящика.

Механизм: таблица, шкала и ленивая инициализация

ПРЯМАЯ АДРЕСАЦИЯ, ключи 0..U−1
    положить(k, v):  T[k] = v          O(1) в худшем случае
    взять(k):        вернуть T[k]      (пусто - «нет такого ключа»)
    удалить(k):      T[k] = пусто
    память:          U ячеек, сколько бы ключей ни было

БИТОВАЯ ШКАЛА - то же для множества
    бит k хранится в байте k // 8, позиция k % 8
    есть ли k:   (B[k >> 3] >> (k & 7)) & 1
    память:      U / 8 байт

k >> 3 - сдвиг вправо на три разряда, то же, что деление на 8 нацело; k & 7 - три младших бита, то же, что остаток от деления на 8. Это те же операции, просто записанные так, как их делает процессор.

Скрытая цена прямой адресации - заведение массива: обнулить U ячеек стоит Θ(U) времени, даже если потом понадобятся три ключа. Есть приём, который избавляет и от этого, - ленивая инициализация:

два массива без обнуления (там мусор) и счётчик top = 0
T[k]   - номер записи для ключа k (мусор, если ключа нет)
S[i]   - какой ключ записан под номером i, i < top

ключ k есть  ⇔  T[k] < top  и  S[T[k]] == k
положить k:     S[top] = k;  T[k] = top;  top += 1

Мусор в T[k] не обманет проверку: даже если он случайно меньше top, в S под этим номером записан другой ключ. Каждая операция - O(1), а обнуления нет совсем. В Python массивы всегда заполнены значениями, поэтому приём там не нужен; он живёт в C и в ядрах ОС, где выделенная память приходит с чем попало.

Правило

Если ключи - целые из диапазона 0..U−1 и U не намного больше числа ключей, словарь - это массив из U ячеек с ключом в роли индекса: все операции O(1) в худшем случае, коллизий нет. Для множества хватает битовой шкалы в U/8 байт. Память - Θ(U) вне зависимости от числа ключей, поэтому прямая адресация годится, пока U - миллионы, а не 2⁶⁴. Обнуление массива стоит Θ(U); его обходит ленивая инициализация двумя массивами со взаимной проверкой.

Разбор: миллион номеров, десять тысяч пропусков

pryamaya.py
import random
import sys

U = 1_000_000                       # номера пропусков 0..999999
rng = random.Random(3)
vydany = rng.sample(range(U), 10_000)

tablica = [None] * U                # прямая адресация: ячейка = ключ
for k in vydany:
    tablica[k] = f"сотрудник {k}"

bity = bytearray(U // 8)            # только «есть / нет»: бит на ключ
for k in vydany:
    bity[k >> 3] |= 1 << (k & 7)

mnozh = set(vydany)

print("ячеек занято:", sum(x is not None for x in tablica), "из", U)
print("таблица, байт:", sys.getsizeof(tablica))
print("биты, байт:   ", sys.getsizeof(bity))
print("set, байт:    ", sys.getsizeof(mnozh))
k = vydany[0]
print(tablica[k], bool(bity[k >> 3] >> (k & 7) & 1), k in mnozh)
print("ключ 2**64 в таблицу - ячеек нужно", 2**64)
↳  выводpython3 pryamaya.py
ячеек занято: 10000 из 1000000
таблица, байт: 8000056
биты, байт:    125057
set, байт:     524504
сотрудник 249523 True True
ключ 2**64 в таблицу - ячеек нужно 18446744073709551616

Три структуры отвечают одинаково, а платят по-разному. Таблица ссылок - восемь мегабайт: миллион ячеек по восемь байт, из них занят один процент. Битовая шкала - 125 КБ, в четыре раза МЕНЬШЕ множества, хотя в ней 99% нулей: один бит на возможный ключ дешевле, чем десятки байт на реальный (и getsizeof ещё не считает сами числа внутри set - это примерно по 28 байт на каждое). Интуиция «пустой массив расточителен» верна для массива ССЫЛОК и неверна для битов.

Где граница? Множество тратит порядка 80 байт на элемент вместе с числом, шкала - U/8 байт на всё. Шкала выгоднее, пока n · 80 > U / 8, то есть пока ключей больше, чем примерно U/640. На миллионе номеров это полторы тысячи ключей - у нас их десять тысяч.

Разбор: ленивая инициализация по шагам

U = 10, T и S полны мусора, top = 0
T = [7, 3, 0, 9, 2, 5, 1, 8, 4, 6]      (мусор)

есть ли 4?   T[4] = 2,  2 < top = 0?  нет  → ключа нет
положить 4:  S[0] = 4, T[4] = 0, top = 1
положить 7:  S[1] = 7, T[7] = 1, top = 2

есть ли 4?   T[4] = 0 < 2,  S[0] = 4  → есть
есть ли 2?   T[2] = 0 < 2,  но S[0] = 4 ≠ 2  → нет (мусор в T[2] случайно
             оказался «правдоподобным», но S его разоблачил)

Ключ 2 - ровно тот случай, ради которого нужна вторая проверка: мусорное значение T[2] = 0 указывает на настоящую запись, но запись принадлежит другому ключу.

Что ломается без этого

Первая ошибка - заводить словарь там, где хватило бы массива: идентификаторы от 1 до 10 000, а вместо списка на 10 001 ячейку - dict, который вычисляет хеш на каждой операции, медленнее и занимает больше памяти. Вторая - наоборот, массив под «маленький» диапазон, который на деле не маленький: номер телефона, отметка времени в миллисекундах, 32-битный адрес. [None] * 2**32 - это 32 ГБ ссылок, программа падает с MemoryError на первой строке. Третья - забыть, что прямую адресацию нельзя применить к ключам, которые не являются небольшими целыми: строку нельзя превратить в индекс без отображения, а отображение - это уже хеширование со всеми его коллизиями.

Зачем это в работе

Битовые шкалы повсюду, где ключи плотные: свободные блоки в файловой системе (бит на блок диска), свободные страницы памяти в ядре, занятые порты, «эти пользователи видели уведомление» в базах данных (сжатые битовые карты по идентификаторам). Таблица прямой адресации - это таблица переходов по коду символа в лексерах и автоматах, массив count в сортировке подсчётом из раздела про сортировки и таблица дескрипторов открытых файлов в ОС: дескриптор - это просто индекс. Каждый раз, когда ключ - небольшое целое, сначала спроси, нужен ли тут словарь вообще.

Теперь сам

Ключи - номера дней года (0..365), значения - температура. Сколько ячеек нужно таблице прямой адресации? А если ключи - даты за последние сто лет, записанные как число ГГГГММДД (например, 20261028)? Что сделать, чтобы прямая адресация снова стала выгодной?

Ответ. Для дней года - 366 ячеек, почти все заняты, прямая адресация идеальна. Для ГГГГММДД диапазон - от 19260101 до 20261231, это больше миллиона ячеек под 36 525 дат: в таблице ссылок 96% пустоты и 8 МБ памяти. Причина - неплотная кодировка: месяцы и дни занимают «десятичные разряды» с дырами (дня 20261299 не бывает). Лечится переводом даты в номер дня от начала эпохи (date.toordinal()): за сто лет это 36 525 подряд идущих номеров, и таблица снова плотная.

Главное

Если ключ - небольшое целое, номер ячейки вычислять не нужно: ключ и есть номер. Прямая адресация даёт O(1) в худшем случае без коллизий, битовая шкала хранит множество в U/8 байт - на миллионе номеров это 125 КБ против полумегабайта у set из десяти тысяч. Цена - память Θ(U) независимо от числа ключей, поэтому важен не размер данных, а размер ДИАПАЗОНА ключей и плотность кодировки.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.