# теория · шаг 2 из 7
Бюджет хода: сколько шагов есть у сервера
Коротко
У сервера «Архипелага» 100 мс на ход. Python на этой машине делает около 1,5 · 10⁷ простых операций в
секунду (замер раздела 26), значит, на ход - около 1,5 · 10⁶, и эти полтора миллиона делят все модули
сразу. Бюджет проверяют до кода: 200 отрядов, каждый ищет путь сам, - это миллионы проверок соседей на
карте 128 × 128, не проходит. Один обход от общей цели даёт поле расстояний сразу для всех: на карте
24 × 24 с сорока отрядами - 1838 проверок вместо 21 937. Бюджет решает и спор о толковании: фильтр чата
целыми словами стоит 160 проверок, подстроками - 12 301.
Если ты привык делить бюджет времени между модулями до того, как выбрать алгоритм, листай до «Теперь сам».
Как думаешь, сколько поисков пути
Восемь игроков, у каждого 25 отрядов, и все 200 отрядов идут штурмовать одну крепость в центре карты. Каждый отряд должен знать, куда шагнуть в этот ход. Сколько поисков пути нужно сделать серверу?
Ответь себе до чтения. Первое, что приходит в голову, - 200: у каждого отряда свой старт, значит, свой
поиск. Но цель у всех одна. Если пройти обходом в ширину не от отряда к крепости, а от крепости по всей
карте, то у каждой клетки окажется расстояние до крепости, и каждый отряд просто шагает в соседнюю клетку с
меньшим неотрицательным числом - горы и недостижимые клетки с −1 в поле не годятся. Один поиск вместо
двухсот - и это не оптимизация кода, а другая модель задачи. Обход в ширину считает ходы; если цена хода
зависит от местности, то же поле от цели строит Дейкстра. Идёт она по рёбрам в обратную сторону, и цена
обратного шага - цена той клетки, в которую в прямом направлении входят, то есть клетки, ИЗ которой обратный
шаг выходит.
На что это похоже
Указатели в аэропорту. Аэропорт не провожает каждого пассажира к выходу отдельно - он один раз развешивает стрелки «к выходу 12» на каждом перекрёстке, и любой пассажир, где бы он ни стоял, идёт по стрелке. Стрелки - это поле расстояний: посчитано один раз от цели, работает для всех.
Механизм: бюджет, оценка, распределение
Бюджет хода считается так же, как бюджет задачи в разделе 26: время на ответ, умноженное на скорость. 100
мс на Python - 0,1 · 1,5 · 10⁷ = 1,5 · 10⁶ простых операций. Это на ВЕСЬ ход, а модулей десяток, поэтому
каждому достаётся доля, и считать её надо до кода.
| модуль | что растёт | оценка по замеру | против 1,5 · 10⁶ |
|---|---|---|---|
| пути, каждый отряд сам | 200 отрядов × треть карты × 4 соседа | 200 · 5500 · 4 ≈ 4,4 · 10⁶ |
втрое больше |
| пути, поле от цели | одна карта × 4 соседа на каждую цель | 16 384 · 4 ≈ 6,6 · 10⁴ |
4% |
| чат подстроками | 5 сообщений × 2000 слов × ~25 сравнений | 2,5 · 10⁵ сравнений, каждое - несколько операций |
почти весь |
| чат автоматом (раздел 17) | 5 сообщений × 100 символов | 500 |
ничтожно |
Оценки «треть карты» и «25 сравнений» взяты из замеров ниже: отряд, ищущий путь сам, обходит в среднем около
трети клеток, пока не встретит цель, а наивный поиск слова в сообщении сравнивает около 25 символов. Это
типичный случай, а не худший: если цель за дальним краем карты, каждый поиск обойдёт её почти целиком, и
двести поисков дадут около 200 · 16 384 · 4 ≈ 1,3 · 10⁷ проверок. Стоимость «проверки» больше одной
операции - внутри и сравнение, и обращение к списку, - поэтому всё, что в таблице вышло дороже бюджета, не
пройдёт заведомо, а всё, что вышло в процентах, пройдёт с запасом, даже если проверка стоит пять-десять
операций.
Правило
Бюджет хода делится между всеми модулями. Подставь в оценку каждого модуля наибольший размер - отряды, клетки, сообщения - и сравни с долей бюджета. Не проходит - ищи не быстрый код, а другую модель: общий расчёт для многих запросов, другое толкование, предподготовку.
Разбор: поле расстояний вместо двухсот поисков
Карта 24 × 24, на ней горы, сорок отрядов и крепость в центре. Клетка хранится одним числом r · N + c -
так соседей легко посчитать, а словарь не нужен. Сравниваем два способа и считаем проверки соседей.
import random
random.seed(27)
N = 24 # карта N × N
gora = set()
for i in range(110): # горы непроходимы
gora.add(random.randint(0, N * N - 1))
krepost = N * N // 2 + N // 2 # цель всех отрядов - центр
gora.discard(krepost)
otryady = []
while len(otryady) < 40:
k = random.randint(0, N * N - 1)
if k not in gora and k != krepost:
otryady.append(k)
proverok = [0]
def sosedi(k):
# клетка - одно число r * N + c; соседи по четырём сторонам
out = []
r = k // N
c = k % N
if r > 0:
out.append(k - N)
if r < N - 1:
out.append(k + N)
if c > 0:
out.append(k - 1)
if c < N - 1:
out.append(k + 1)
return out
def bfs(start, stop):
# обход в ширину; stop = -1 - обойти всю карту
dist = [-1] * (N * N)
dist[start] = 0
och = [start]
golova = 0
while golova < len(och):
v = och[golova]
golova = golova + 1
if v == stop:
break
for s in sosedi(v):
proverok[0] = proverok[0] + 1
if dist[s] == -1 and s not in gora:
dist[s] = dist[v] + 1
och.append(s)
return dist
# способ 1: каждый отряд ищет путь к крепости сам
proverok[0] = 0
dliny1 = []
for o in otryady:
dliny1.append(bfs(o, krepost)[krepost])
p1 = proverok[0]
# способ 2: один обход ОТ крепости - поле расстояний для всех сразу
proverok[0] = 0
pole = bfs(krepost, -1)
dliny2 = []
for o in otryady:
dliny2.append(pole[o])
p2 = proverok[0]
print("пути совпали:", dliny1 == dliny2, "| самый длинный:", max(dliny1))
print("каждый сам: ", p1, "проверок соседей")
print("поле от цели:", p2, "проверок соседей")
пути совпали: True | самый длинный: 20
каждый сам: 21937 проверок соседей
поле от цели: 1838 проверок соседей
Длины путей совпали - на карте, где из любой клетки в соседнюю идут в обе стороны, путь от крепости до отряда той же длины, что от отряда до крепости. Сорок поисков сделали 21 937 проверок, в среднем 548 на отряд: каждый обошёл около трети карты, прежде чем наткнулся на крепость. Поле обошло карту один раз - 1838 проверок. На настоящей карте 128 × 128 с двумястами отрядами разница вырастает до десятков раз, и именно она отделяет «не проходит» от «4% бюджета».
Граница правила: поле работает, когда у многих отрядов одна цель. Если у каждого своя, поле на цель одно на отряд, и выигрыша нет - тогда спасают направленный поиск A* из раздела 11, который обходит клетки в сторону цели, и кеш путей между частыми точками. А если по карте можно ходить только в одну сторону (течение реки), поле от цели считается по обратным рёбрам.
Разбор: бюджет решает спор о толковании
Модератор просит: «не пропускать в чат запрещённые слова». Двадцать сообщений по двенадцать слов, 25 запрещённых слов. Два толкования - и две цены.
import random
random.seed(5)
slogi = ["ka", "ro", "mi", "lu", "ten", "vor", "pa", "zu", "dri", "os"]
def slovo(slogov):
w = ""
for i in range(slogov):
w = w + slogi[random.randint(0, 9)]
return w
zapret = set()
while len(zapret) < 25: # запрещено 25 слов из двух слогов
zapret.add(slovo(2))
soobshcheniya = []
for i in range(20): # 20 сообщений по 12 слов
s = []
for j in range(12):
s.append(slovo(random.randint(2, 4)))
soobshcheniya.append(" ".join(s))
sravnenij = [0]
def est_podstroka(tekst, obrazec):
# наивный поиск: каждое начало, сравнение до первого различия
for i in range(len(tekst) - len(obrazec) + 1):
j = 0
while j < len(obrazec):
sravnenij[0] = sravnenij[0] + 1
if tekst[i + j] != obrazec[j]:
break
j = j + 1
if j == len(obrazec):
return True
return False
# толкование 1: запрещённое слово - любая ПОДСТРОКА сообщения
plohih1 = 0
for s in soobshcheniya:
for z in sorted(zapret):
if est_podstroka(s, z):
plohih1 = plohih1 + 1
break
p1 = sravnenij[0]
# толкование 2: запрещено только ЦЕЛОЕ слово - хватит множества
sravnenij[0] = 0
plohih2 = 0
for s in soobshcheniya:
for w in s.split(" "):
sravnenij[0] = sravnenij[0] + 1
if w in zapret:
plohih2 = plohih2 + 1
break
p2 = sravnenij[0]
print("подстрокой: ", plohih1, "сообщений из 20 |", p1, "сравнений")
print("целым словом:", plohih2, "сообщений из 20 |", p2, "проверок")
подстрокой: 20 сообщений из 20 | 12301 сравнений
целым словом: 11 сообщений из 20 | 160 проверок
Подстроками отклонены все двадцать сообщений: запрещённое двухслоговое слово прячется внутри длинных
невинных слов - та же беда, что у фильтров, запрещающих названия городов за «плохой» кусок внутри. Целым
словом - одиннадцать, и проверка стоит один поиск в множестве на слово. Цена различается в 77 раз. В замере
на пару «сообщение, запрещённое слово» приходится около 25 сравнений (12 301 / (20 · 25)) - оценка снизу:
поиск останавливался на первой находке, и часть пар не проверялась вовсе; при 2000 запрещённых словах и пяти
сообщениях за ход это 2,5 · 10⁵ сравнений, а каждое с циклом вокруг - несколько операций: наивный поиск
подстрок съедает почти весь бюджет хода. Бюджет не выбирает толкование, но ставит вопрос заказчику остро:
если нужны подстроки, наивный поиск не годится - нужен автомат Ахо-Корасик из раздела 17, который читает
сообщение один раз при любом числе слов.
Что ломается без этого
Ход длиннее 100 мс. Каждый модуль по отдельности «быстрый», а вместе они не помещаются, и игроки видят рывки. Бюджет не делили - его съел тот, кто считал первым.
Сервер падает в пик. На тестовой карте 32 × 32 с десятком отрядов всё летает. На 128 × 128 с двумястами - в шестнадцать раз больше клеток и в двадцать раз больше поисков, и квадратичный по сути способ ломается.
Оптимизируют не то. Неделю ускоряют внутренний цикл поиска пути вдвое, хотя замена двухсот поисков одним полем дала бы в десятки раз.
Зачем это в работе
Любой сервис с периодическим пересчётом - игровой ход, обновление ленты, пересчёт цен раз в минуту - живёт в фиксированном окне времени. Таблица «модуль, что растёт, оценка, доля бюджета» на одной странице показывает, где упрёмся при росте в десять раз, и делается за полчаса до первой строки кода.
Теперь сам
- Карта 128 × 128, 8 крепостей, к каждой идёт по 25 отрядов. Сколько проверок соседей потребуют поля
расстояний? Пройдёт ли бюджет
1,5 · 10⁶? - Ход сервера сократили до 50 мс. Сколько простых операций теперь на ход?
- Почему поле расстояний на карте с односторонними переходами нельзя считать обычным обходом от цели?
Ответ. 1. Восемь полей по 16 384 · 4 ≈ 6,6 · 10⁴ - около 5,2 · 10⁵ проверок. Это треть
бюджета только если проверка стоит одну операцию; при трёх-четырёх операциях на проверку восемь полей
съедают весь ход. Поэтому поля пересчитывают не каждый ход, а когда меняется цель или карта.
-
0,05 · 1,5 · 10⁷ = 7,5 · 10⁵. -
Обход от цели идёт по рёбрам в обратную сторону: клетка, из которой можно прийти в соседнюю, не обязательно достижима из соседней. Поле считают по перевёрнутым рёбрам - из каждой клетки в те, ИЗ которых в неё можно попасть.
Главное
Бюджет хода общий на все модули, и считать его надо до кода. Когда оценка не проходит, выход почти всегда в другой модели - общий расчёт для многих запросов, - а не в быстром цикле.
Комментарии
Пока нет комментариев. Будь первым!
Оставить комментарий