# теория · шаг 4 из 7
Рейтинг: место игрока деревом Фенвика
Коротко
После каждой партии игрок хочет видеть своё место среди всех. Перебор считает, сколько игроков выше, за
линию: 400 игроков и 100 партий - 40 000 сравнений, а на 10 000 игроков - 10 000 на каждую партию, и цена
растёт вместе с числом игроков. Дерево Фенвика из раздела 8, построенное не по игрокам, а по очкам,
хранит, сколько игроков набрало каждое число очков, и отвечает «сколько не больше x» за log - 1478 шагов
на те же 100 партий, обновления включены. Спуск по тому же дереву находит очки k-го игрока списка тоже за
log. И спецификация решает, что такое место при равных очках: в спортивном счёте двое со вторым
результатом делят второе место, и третьего нет.
Если ты умеешь строить дерево Фенвика по значениям и спускаться по нему к k-му элементу, листай до «Теперь сам».
Как думаешь, по чему строить дерево
Игроков 10 000, очки - от 0 до 1000, после каждой партии очки одного игрока меняются, и он смотрит своё
место. Дерево Фенвика умеет сумму на префиксе и изменение элемента за log. Префикс ЧЕГО нужен для места?
Ответь себе до чтения. Если строить дерево по игрокам в каком-то порядке, префикс ничего не даст: место
зависит от очков, а не от номера игрока. Нужно другое: «сколько игроков набрали не больше x очков» - это
префикс по оси ОЧКОВ. Значит, позиция в дереве - не игрок, а значение очков, и по ней учитывают, сколько
игроков его набрали; сама ячейка дерева Фенвика хранит сумму по отрезку значений. Тогда место = 1 + все
игроки − префикс до своих очков.
На что это похоже
Табло на марафоне. Судье не нужно пересортировывать всех бегунов, когда один финишировал: достаточно знать, сколько человек пробежало быстрее. Если вести счётчик по каждой минуте финиша, ответ - сумма счётчиков до нужной минуты. Дерево Фенвика - тот же счётчик по минутам, только сумма до минуты считается не перебором минут, а за логарифм.
Механизм: дерево по значениям и спуск
Ячейка i дерева Фенвика отвечает за отрезок значений длиной i & −i - младший единичный бит номера,
приём из раздела 19. Добавление игрока с очками x поднимается по ячейкам x + 1, прибавляя младший бит;
сумма «не больше x» спускается, вычитая его. Обе операции проходят log₂ 1001 ≈ 10 ячеек. Смена очков
игрока - два добавления: −1 к старым очкам и +1 к новым.
Обратный вопрос - «чьи очки k-е снизу» - решает спуск по дереву Фенвика. Размер дерева берут степенью
двойки, и ответ собирают прыжками 1024, 512, …, 1: прыжок делают, если в отрезке, который он перепрыгивает,
меньше k игроков, - и вычитают их из k. Каждый прыжок проверяет одну ячейку, всего проверок
log₂ 1024 + 1 = 11. k-й сверху - это (n − k + 1)-й снизу.
Место при равных очках - вопрос спецификации, не алгоритма. Формула «1 + число игроков строго выше» даёт спортивный счёт: 1, 2, 2, 4. Порядковый номер в списке считается тем же деревом. «Плотный» счёт 1, 2, 2, 3 - нет: ему нужно число РАЗНЫХ значений очков выше игрока, и для этого рядом держат второе дерево, где у каждого занятого значения единица. Ставят её, когда число игроков с этими очками стало 1, и снимают, когда оно упало до 0, - это число даёт первое дерево. Это разные ответы, и выбрать надо до кода.
Правило
Место в рейтинге - префиксная сумма по ОСИ ОЧКОВ, а не по игрокам: дерево Фенвика по значениям считает его
и обновляется за log. «Кто в k-й строке таблицы» - спуск по тому же дереву прыжками степеней двойки. Что
такое место при равных очках, решает спецификация.
Разбор: сто партий, четыреста игроков
Игроки со случайными очками, сто партий: после каждой один игрок меняет очки на ±30 и смотрит место. Сравниваем дерево с перебором.
import random
random.seed(17)
MAKS = 1000 # очки от 0 до 1000
igroki = []
for i in range(400):
igroki.append(random.randint(0, MAKS))
fenvik = [0] * (MAKS + 2) # индексы 1..MAKS+1: очки + 1
rabota = [0, 0] # [перебором, деревом Фенвика]
def dobavit(ochki, d):
# в дереве Фенвика (раздел 8) число игроков с такими очками += d
i = ochki + 1
while i <= MAKS + 1:
rabota[1] = rabota[1] + 1
fenvik[i] = fenvik[i] + d
i = i + (i & -i)
def ne_bolshe(ochki):
# сколько игроков набрали очков не больше ochki
i = ochki + 1
s = 0
while i > 0:
rabota[1] = rabota[1] + 1
s = s + fenvik[i]
i = i - (i & -i)
return s
def mesto_derevom(ochki):
# место = 1 + число игроков СТРОГО выше
return 1 + len(igroki) - ne_bolshe(ochki)
def mesto_pereborom(ochki):
vyshe = 0
for x in igroki:
rabota[0] = rabota[0] + 1
if x > ochki:
vyshe = vyshe + 1
return 1 + vyshe
for x in igroki:
dobavit(x, 1)
rabota[1] = 0
sovpalo = True
for partiya in range(100):
# после партии игрок меняет очки и смотрит своё место
kto = random.randint(0, len(igroki) - 1)
novye = min(MAKS, max(0, igroki[kto] + random.randint(-30, 30)))
dobavit(igroki[kto], -1)
dobavit(novye, 1)
igroki[kto] = novye
if mesto_derevom(novye) != mesto_pereborom(novye):
sovpalo = False
print("места совпали:", sovpalo, "| партий: 100, игроков:", len(igroki))
print("перебором:", rabota[0], "сравнений | деревом:", rabota[1],
"шагов")
места совпали: True | партий: 100, игроков: 400
перебором: 40000 сравнений | деревом: 1478 шагов
Места совпали во всех ста партиях. Перебор платит числом игроков за каждый вопрос - 400 на партию. Дерево платит логарифмом от числа ЗНАЧЕНИЙ очков - около 10 ячеек на каждое из двух обновлений и на запрос, около 15 шагов на партию с учётом коротких путей. На 10 000 игроков перебор стоит 10 000 на партию, а дерево - те же полтора десятка: оно вообще не зависит от числа игроков. Граница правила: дерево по значениям требует небольшого диапазона очков. Если очки - дробные или до миллиарда, значения сначала сжимают: сортируют все встречающиеся и заменяют номером по порядку, а дерево строят по номерам. Это работает, когда все значения известны заранее, - например, когда сервер обрабатывает журнал партий целиком. Если новые значения появляются на ходу, вместо дерева по номерам берут сбалансированное дерево поиска с размерами поддеревьев из раздела 8.
Разбор: кто на пятом месте и что такое третье
Десять игроков, у некоторых равные очки. Считаем места спортивным счётом и спуском находим очки игрока на 1-м, 3-м и 5-м месте списка.
MAKS = 1000
RAZMER = 1024 # степень двойки не меньше MAKS + 1
fenvik = [0] * (RAZMER + 1)
ochki = [900, 850, 850, 800, 700, 700, 700, 650, 600, 420]
def dobavit(x, d):
i = x + 1
while i <= RAZMER:
fenvik[i] = fenvik[i] + d
i = i + (i & -i)
def ne_bolshe(x):
i = x + 1
s = 0
while i > 0:
s = s + fenvik[i]
i = i - (i & -i)
return s
def k_ya_snizu(k):
# спуск по дереву: прыжки 512, 256, ..., 1 - каждый, если слева от
# конца прыжка меньше k игроков. Ответ - очки k-го снизу, за log
poz = 0
shag = RAZMER
while shag > 0:
if poz + shag <= RAZMER and fenvik[poz + shag] < k:
poz = poz + shag
k = k - fenvik[poz]
shag = shag // 2
return poz # индекс poz + 1 - это очки poz
for x in ochki:
dobavit(x, 1)
n = len(ochki)
mesta = []
for x in ochki:
mesta.append(1 + n - ne_bolshe(x))
print("очки: ", ochki)
print("места:", mesta)
for k in [1, 3, 5]:
print("на", k, "месте сверху очков:", k_ya_snizu(n - k + 1))
очки: [900, 850, 850, 800, 700, 700, 700, 650, 600, 420]
места: [1, 2, 2, 4, 5, 5, 5, 8, 9, 10]
на 1 месте сверху очков: 900
на 3 месте сверху очков: 850
на 5 месте сверху очков: 700
Спортивный счёт дал 1, 2, 2, 4: двое с 850 делят второе место, третьего места нет. Спуск на вопрос «третий
сверху» ответил 850 - это третий ИГРОК в списке, и он стоит на втором МЕСТЕ. Оба ответа верны, и именно
поэтому спецификация обязана сказать, что показывать: «третье место - никого» или «третья строка таблицы -
игрок с 850». Спуск сделал по одиннадцать проверок на каждый вопрос - без сортировки и без прохода по игрокам.
Граница правила: спуск находит ОЧКИ k-го, а не самого игрока. Чтобы назвать игрока, рядом держат
словарь «очки → игроки с этими очками».
Что ломается без этого
Сортировка после каждой партии. На тестовом сервере с сотней игроков всё мгновенно, на живом с
десятью тысячами - n log n на каждый вопрос, и таблица лидеров отстаёт тем сильнее, чем больше игроков.
Дерево по игрокам. Построили Фенвика по номерам игроков - и он отвечает на «сумма очков игроков с 1 по 100», которая никому не нужна.
Место без договорённости. Два игрока с равными очками видят «2-е место» и «3-е место» - и пишут в поддержку, кто из них настоящий второй.
Зачем это в работе
Таблицы лидеров, перцентили времени ответа («сколько запросов быстрее 200 мс»), медиана в потоке данных при известном диапазоне значений, позиция товара в рейтинге продаж - везде, где нужен ранг при меняющихся данных, дерево по значениям отвечает за логарифм. А сжатие значений делает приём годным и для дробных, и для огромных чисел.
Теперь сам
- Очки: [500, 500, 500, 300]. Какие места у игроков в спортивном счёте? Кто третий в списке?
- Очки - от 0 до 10⁹, игроков 10⁵. Как построить дерево Фенвика, чтобы оно поместилось в память?
- Сколько ячеек проверит спуск из программы урока, если размер дерева 4096?
Ответ. 1. Места 1, 1, 1, 4. Третий в списке - игрок с 500 очками, и его место - первое.
-
Сжать значения: собрать все встречающиеся очки (не больше
10⁵разных плюс изменения), отсортировать, заменить номером по порядку. Дерево - по номерам, размером с число разных значений. -
13: прыжки 4096, 2048, …, 1 - это
log₂ 4096 + 1.
Главное
Рейтинг - префиксная сумма по оси очков: дерево Фенвика по значениям отвечает на место и на «кто k-й» за логарифм, не глядя на число игроков. А что считать местом при равных очках, решает спецификация.
Комментарии
Пока нет комментариев. Будь первым!
Оставить комментарий