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

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

Структуры под операции: что спрашивают чаще всего

Коротко

Структуру данных выбирают не по объекту, а по операциям над ним и их частоте. Для каждого модуля игры выписывают, что с данными делают каждый ход, сколько раз и при каком размере, - и структура находится по таблице курса. Очередь событий «стройка закончится на ходу 17» - это извлечение минимума и вставка: на отсортированном списке 52 860 сдвигов, на куче 2530 обменов. «Кто из врагов рядом» - поиск по пространству: корзины по квадратам карты дают 173 проверки вместо 4950. Но если все отряды сбились у одной крепости, корзины проверяют те же 4950 пар: структура выбирается и под распределение данных.

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

Как думаешь, где хранить события

В игре всё происходит «через сколько-то ходов»: стройка закончится на 17-м, караван придёт на 12-м, отряд вылечится на 9-м. Каждый ход сервер добавляет новые события и выполняет все, чей ход настал, по порядку. Событий в очереди - сотни. Список, отсортированный по ходу, или что-то другое?

Ответь себе до чтения. Отсортированный список хорош для чтения: первое событие всегда в начале. Но каждая вставка в середину сдвигает хвост, а снятие первого элемента pop(0) сдвигает весь список. Операции здесь ровно две - «добавить» и «взять самое раннее», - и обе часты. Это описание очереди с приоритетом из раздела 9, а она живёт в куче: обе операции за log n.

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

Кухня ресторана. Повар не раскладывает заказы по столу в идеальном порядке - ему важно одно: какое блюдо готовить следующим. Заказы приходят всё время, а уходят по одному, самый срочный. Держать идеальный порядок всех заказов - лишняя работа; достаточно всегда знать самый срочный, и куча делает ровно это.

Механизм: таблица операций игры

Для каждого модуля выписываются операции, частота за ход и размер - и по ним структура.

модуль операции каждый ход размер структура раздел
события добавить, взять самое раннее сотни двоичная куча 9
кто на клетке найти по координатам, переставить 200 отрядов словарь по клетке 7
кто рядом все пары ближе R 200 равномерная сетка: корзины по квадратам карты 23
острова связаны? объединить, проверить 16 384 клетки непересекающиеся множества 9
место в рейтинге изменить очки, «сколько выше» 10 000 игроков дерево Фенвика по очкам 8
отмена хода вернуть прошлое состояние 16 384 клетки журнал изменений, а не копия мира 25
склад игрока есть ли товар, сколько десятки словарь 7

Строка таблицы читается так: если операции не те - структура не та. Словарь не умеет «самый ранний», куча не умеет «найти по ключу», список умеет всё, но дорого. Двух одинаковых строк в таблице нет, и это нормально: одна игра - много разных структур.

Правило

Выбирай структуру по операциям и их частоте, а не по названию объекта. Выпиши для модуля, что с данными делают каждый ход и сколько раз, найди в курсе структуру, где частые операции дешёвые, - и проверь, на каких данных её гарантия ломается.

Разбор: очередь событий - список против кучи

Четыреста событий, очередь держится около ста: как только в ней больше ста, самое раннее выполняется. Это модель нагрузки, а не ход сервера: настоящий сервер берёт из очереди события, чей ход настал, - но операции те же, «добавить» и «взять самое раннее», и их цену модель меряет честно. Ключ события - одно число ход · 10000 + номер: «сначала по ходу, при равенстве по номеру» тогда сравнивается одним <. В обычном Python для этого берут пару (кортеж) - здесь одно число, и это тот же порядок, пока номеров меньше десяти тысяч.

import random

random.seed(3)
# событие - одно число: ход * 10000 + номер. Так порядок «сначала по
# ходу, при равенстве по номеру» сравнивается одним «<», без пары чисел
sobytiya = []
for nomer in range(400):
    hod = random.randint(1, 40) + nomer // 30     # события копятся
    sobytiya.append(hod * 10000 + nomer)
rabota = [0, 0]                    # [сдвигов в списке, обменов в куче]

def v_spisok(spisok, x):
    # вставка в отсортированный список: всё, что больше x, сдвигается
    spisok.append(x)
    i = len(spisok) - 1
    while i > 0 and spisok[i - 1] > x:
        spisok[i] = spisok[i - 1]
        rabota[0] = rabota[0] + 1
        i = i - 1
    spisok[i] = x

def v_kuchu(k, x):
    # двоичная куча (раздел 9): новый элемент всплывает
    k.append(x)
    i = len(k) - 1
    while i > 0 and k[(i - 1) // 2] > k[i]:
        k[i], k[(i - 1) // 2] = k[(i - 1) // 2], k[i]
        rabota[1] = rabota[1] + 1
        i = (i - 1) // 2

def iz_kuchi(k):
    # минимум уходит, последний элемент тонет на его место
    verh = k[0]
    k[0] = k[len(k) - 1]
    k.pop()
    i = 0
    while True:
        m = i
        for d in [2 * i + 1, 2 * i + 2]:
            if d < len(k) and k[d] < k[m]:
                m = d
        if m == i:
            return verh
        k[i], k[m] = k[m], k[i]
        rabota[1] = rabota[1] + 1
        i = m

spisok = []
kucha = []
po_spisku = []
po_kuche = []
for x in sobytiya:
    v_spisok(spisok, x)
    v_kuchu(kucha, x)
    if len(spisok) > 100:          # очередь держится около 100 событий
        po_spisku.append(spisok[0])
        spisok.pop(0)
        # pop(0) сдвигает весь хвост списка
        rabota[0] = rabota[0] + len(spisok)
        po_kuche.append(iz_kuchi(kucha))
print("порядок совпал:", po_spisku == po_kuche, "| событий:",
      len(po_kuche))
print("отсортированный список:", rabota[0], "сдвигов")
print("куча:                  ", rabota[1], "обменов")
порядок совпал: True | событий: 300
отсортированный список: 52860 сдвигов
куча:                   2530 обменов

Порядок выполнения одинаковый - обе структуры верны. Работа расходится в двадцать раз: список платит за каждую вставку длиной хвоста, который больше нового события, и за каждое pop(0) - длиной всего списка. Куча платит высотой дерева, log₂ 100 ≈ 7 обменов на операцию в худшем случае, а в среднем меньше. Граница правила: если событий всегда единицы, список проще и не медленнее. Куча выигрывает с ростом очереди, и на сотнях событий действий у неё уже в двадцать раз меньше.

Разбор: кто рядом - пары против корзин

Сто отрядов на карте 128 × 128; отряд замечает врага не дальше 4 клеток по большей из осей. Сравниваем перебор всех пар с корзинами: карта делится на квадраты 8 × 8, и соседей ищут только в своём квадрате и восьми соседних - дальше 4 клеток от отряда там никого быть не может, раз сторона квадрата не меньше 4.

import random

N = 128                     # карта 128 × 128
R = 4                       # отряд видит врага не дальше 4 клеток
K = 8                       # корзина - квадрат 8 × 8 клеток, K >= R

def ryadom(a, b):
    # расстояние по большей из осей: квадрат обзора
    return max(abs(a[0] - b[0]), abs(a[1] - b[1])) <= R

def vse_pary(otryady):
    proverok = 0
    par = 0
    for i in range(len(otryady)):
        for j in range(i + 1, len(otryady)):
            proverok = proverok + 1
            if ryadom(otryady[i], otryady[j]):
                par = par + 1
    return [par, proverok]

def korziny(otryady):
    # отряд кладётся в корзину своего квадрата; соседей ищем только в
    # своей и восьми соседних корзинах - дальше R не бывает, раз K >= R
    kor = {}
    for i in range(len(otryady)):
        k = str(otryady[i][0] // K) + ":" + str(otryady[i][1] // K)
        if k not in kor:
            kor[k] = []
        kor[k].append(i)
    proverok = 0
    par = 0
    for i in range(len(otryady)):
        r = otryady[i][0] // K
        c = otryady[i][1] // K
        for dr in [-1, 0, 1]:
            for dc in [-1, 0, 1]:
                k = str(r + dr) + ":" + str(c + dc)
                if k in kor:
                    for j in kor[k]:
                        if j > i:
                            proverok = proverok + 1
                            if ryadom(otryady[i], otryady[j]):
                                par = par + 1
    return [par, proverok]

random.seed(8)
po_karte = []
for i in range(100):
    po_karte.append([random.randint(0, N - 1), random.randint(0, N - 1)])
v_kuche = []
for i in range(100):         # все отряды сбились у одной крепости
    v_kuche.append([random.randint(60, 67), random.randint(60, 67)])
for imya, o in [["по всей карте", po_karte],
                ["у одной крепости", v_kuche]]:
    a = vse_pary(o)
    b = korziny(o)
    print(imya + ":", "пар рядом", a[0], b[0], "| проверок",
          a[1], "против", b[1])
по всей карте: пар рядом 23 23 | проверок 4950 против 173
у одной крепости: пар рядом 3403 3403 | проверок 4950 против 4950

Когда отряды раскиданы по карте, корзины проверили 173 пары вместо 4950 и нашли те же 23. Когда все сто отрядов сбились у одной крепости - в четырёх соседних корзинах, по две на каждой оси, - корзины проверили те же 4950 пар. Рядом из них 3403: отряды в квадрате 8 × 8, и далеко друг от друга не бывают. Это граница правила, и она про данные, а не про код. Гарантия корзин держится на том, что в каждой корзине мало отрядов; в осаде крепости это не так, и там пар много по существу - сам ответ квадратичен, как «все пары» в разделе 26.

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

Структура по названию объекта. «События - это список событий» - и на тысяче событий сервер тратит ход на сдвиги, хотя нужен только самый ранний.

Структура без проверки распределения. Корзины прошли тесты на равномерной карте, а в первой же большой битве все отряды в одной корзине - и ход длится секунду.

Копия мира на каждую отмену. Отмену хода сделали копией всей карты - 16 384 клетки на каждый ход, хотя за ход меняется сотня; журнал изменений стоит сотню.

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

Любая система с повторяющимися запросами - очередь задач, кеш, планировщик, индекс в базе - выбирает структуру ровно так: какие запросы, как часто, на каком объёме. Таблица операций на модуль за полчаса заменяет недели споров «а давайте возьмём дерево» и сразу показывает, на каком распределении данных выбранная структура перестанет спасать.

Теперь сам

  1. Модуль «торговый журнал»: сделки только добавляются, раз в ход нужна сумма сделок за любой отрезок ходов [a, b]. Какая структура?
  2. Модуль «кто владеет клеткой»: 16 384 клетки, каждый ход меняются десятки, запрос «чья клетка» - сотни раз. Что выбрать?
  3. В очереди событий всегда не больше трёх событий. Стоит ли писать кучу?

Ответ. 1. Префиксные суммы по ходам (раздел 8 и 26): сумма за [a, b] - разность двух префиксов. Сделки только добавляются в конец, поэтому новый префикс считается за одно сложение.

  1. Список или массив на 16 384 элемента с номером клетки r · 128 + c как индексом: и изменение, и запрос стоят одно обращение.

  2. Нет: на трёх элементах список с поиском минимума проходом не медленнее кучи, а кода в нём меньше.

Главное

Структуру выбирают по операциям, их частоте и распределению данных, а не по названию объекта. Гарантия структуры держится на допущении о данных - и это допущение надо назвать.

Комментарии

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

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

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