К обычному разбору
Тренировка по собеседованиюТехническое собеседованиеHeadHunter2026-02-20

HeadHunter Technical: Python, ретраи и надежность

Идите сверху вниз: сначала попробуйте сами, затем откройте разбор. Если шаг с кодом, пишите решение прямо здесь и запускайте проверки на странице.

Шагов
7
Вопросов
4
Задач
3
1ЗадачаMedium

Top K частых символов

Условие

Реализуйте функцию find_top_k(s, k), которая возвращает k самых часто встречающихся символов в строке.

Нельзя использовать collections.Counter — частоты нужно посчитать обычным Python-кодом.

Если у символов одинаковая частота, верните их в лексикографическом порядке. Это правило добавлено для детерминированных автотестов.

Сигнатура

def find_top_k(s: str, k: int) -> list[str]:

Пример

find_top_k("abracadabra", 3) -> ["a", "b", "r"]

Решение прямо на странице

Напишите код, запустите проверки и только потом открывайте разбор.

Проверка решения

Нажмите «Запустить проверки» или Ctrl+Enter.

Показать разбор

Подсказки

  • Сначала частоты

    Используйте dict: freq[ch] = freq.get(ch, 0) + 1.

  • Сортировка

    Сортируйте пары (символ, частота) по ключу (-частота, символ).

Идея решения

Подход: посчитать частоты в словаре, затем отсортировать символы по двум ключам: частота по убыванию и сам символ по возрастанию.

Почему не Counter? На собеседовании просили написать подсчет вручную, чтобы проверить работу со словарем и оценку сложности.

Оптимизация: если уникальных символов много, а k маленькое, можно использовать heap и получить O(n + m log k).

Эталонный код

def find_top_k(s: str, k: int) -> list[str]:
    freq: dict[str, int] = {}
    for ch in s:
        freq[ch] = freq.get(ch, 0) + 1

    ordered = sorted(freq.items(), key=lambda item: (-item[1], item[0]))
    return [ch for ch, _ in ordered[:k]]
Сложность
Время: O(n + m log m). Память: O(m).
n — длина строки, m — число уникальных символов. Сначала считаем частоты, затем сортируем уникальные символы.
2ЗадачаMedium

Retry-декоратор

Условие

Реализуйте decorator retry, который повторяет вызов функции при указанном типе исключения.

Требования:

  • вернуть результат исходной функции при успехе;
  • повторять только исключения типа on;
  • остальные исключения пробрасывать сразу;
  • после исчерпания попыток пробросить последнее подходящее исключение;
  • сохранить metadata функции через functools.wraps.

Решение прямо на странице

Напишите код, запустите проверки и только потом открывайте разбор.

Проверка решения

Нажмите «Запустить проверки» или Ctrl+Enter.

Показать разбор

Подсказки

  • Три уровня функций

    retry(on, num_attempts) возвращает decorator, decorator(func) возвращает wrapper.

  • Последняя попытка

    Если это последняя попытка, внутри except нужно снова поднять исключение через raise.

  • Метаданные

    Используйте @wraps(func), иначе имя функции станет wrapper.

Идея решения

retry — это фабрика декораторов: внешний уровень принимает настройки, средний получает функцию, внутренний wrapper вызывает функцию в цикле.

Ловим только except on, чтобы не скрывать неожиданные ошибки. На последней попытке делаем raise, чтобы наружу ушло исходное исключение с нормальным traceback. functools.wraps сохраняет имя и метаданные исходной функции.

Эталонный код

from functools import wraps


def retry(on: type[Exception], num_attempts: int = 1):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(num_attempts):
                try:
                    return func(*args, **kwargs)
                except on:
                    if attempt == num_attempts - 1:
                        raise
        return wrapper
    return decorator


def run_retry_case(outcomes: list[str], retry_on: str, num_attempts: int) -> dict:
    exception_types = {
        'KeyError': KeyError,
        'ValueError': ValueError,
        'RuntimeError': RuntimeError,
    }
    state = {'calls': 0, 'index': 0}

    def flaky():
        state['calls'] += 1
        outcome = outcomes[state['index']]
        state['index'] += 1
        if outcome.startswith('ok:'):
            return outcome[3:]
        raise exception_types[outcome](outcome)

    wrapped = retry(exception_types[retry_on], num_attempts)(flaky)
    try:
        value = wrapped()
        return {'status': 'ok', 'value': value, 'calls': state['calls'], 'name': getattr(wrapped, '__name__', '')}
    except Exception as exc:
        return {'status': 'error', 'error': type(exc).__name__, 'calls': state['calls'], 'name': getattr(wrapped, '__name__', '')}
Сложность
Время: O(a * T). Память: O(1).
a — число попыток, T — время одного вызова исходной функции. Декоратор хранит только счетчик попыток.
3ЗадачаMedium

Moving average за последние 5 минут

Условие

Реализуйте класс MovingAvg, который хранит значения метрики и возвращает среднее за последние 5 минут.

Интерфейс:

class MovingAvg: 
    def record(self, value: int) -> None: ...
    def mean(self) -> float: ...
    def now(self) -> int: ...
now() уже реализован внешней системой и возвращает текущее время в секундах. В проверках время подменяется, а ваш класс вызывается через helper.

Решение прямо на странице

Напишите код, запустите проверки и только потом открывайте разбор.

Проверка решения

Нажмите «Запустить проверки» или Ctrl+Enter.

Показать разбор

Подсказки

  • Очередь событий

    Нужна структура, из которой удобно удалять самые старые записи.

  • Не пересчитывайте сумму

    Поддерживайте текущую сумму и вычитайте значение, когда удаляете старую запись.

  • Граница окна

    Событие с timestamp ровно now() - 300 еще находится в последних 5 минутах.

Идея решения

Храним пары (timestamp, value) в очереди и текущую сумму актуальных значений.

Перед расчетом среднего и после записи удаляем из начала очереди все события старше 300 секунд относительно текущего now(). Тогда среднее считается за O(1), а удаление амортизированно O(1), потому что каждая запись удаляется только один раз.

Эталонный код

from collections import deque


class MovingAvg:
    def __init__(self):
        self.data = deque()
        self.cur_sum = 0

    def record(self, value: int) -> None:
        ts = self.now()
        self.data.append((ts, value))
        self.cur_sum += value
        self._clean_old(ts)

    def mean(self) -> float:
        ts = self.now()
        self._clean_old(ts)
        if not self.data:
            return 0.0
        return self.cur_sum / len(self.data)

    def _clean_old(self, ts: int) -> None:
        while self.data and ts - self.data[0][0] > 300:
            _, value = self.data.popleft()
            self.cur_sum -= value

    def now(self) -> int:
        raise NotImplementedError


def moving_average_results(events: list[list]) -> list[float]:
    obj = MovingAvg()
    current_time = 0

    def fake_now():
        return current_time

    obj.now = fake_now
    result = []

    for event in events:
        if event[0] == 'record':
            current_time = event[1]
            obj.record(event[2])
        elif event[0] == 'mean':
            current_time = event[1]
            result.append(obj.mean())

    return result
Сложность
Время: Амортизированно O(1). Память: O(w).
Каждое событие добавляется и удаляется из очереди не более одного раза. w — число событий в актуальном окне.
4Вопрос10 мин

Счетчик попыток в retry-loop

Как считать attempts в retry-декораторе так, чтобы логи и метрики не искажали реальное число вызовов?

Ответьте без подсказки

Сначала проговорите ответ вслух или тезисами.

Запишите черновик

Формулы, план решения, риски и примеры.

Сравните с разбором

Откройте разбор только после своей попытки.

Показать разбор

Короткий ответ

Attempt нужно увеличивать перед каждой реальной попыткой вызова, логировать номер попытки, исключение и итоговый статус отдельно от общего числа retry.

Подробный разбор

В retry-loop полезно различать initial attempt и retries. Счетчик должен соответствовать фактическим вызовам функции: перед call фиксируется attempt number, после exception пишутся тип ошибки, elapsed time, delay до следующей попытки и решение retry/stop.

Метрики лучше разделить: attempts_total, retries_total, failures_after_retries, success_after_retry и latency. Тогда retry не маскирует нестабильность зависимости: успешный итог после трех попыток все равно виден как degraded behavior.

5Вопрос10 мин

Метрики времени и значений ошибки

Что именно считать в метриках: timestamps, длительность операции или сами значения ошибки?

Ответьте без подсказки

Сначала проговорите ответ вслух или тезисами.

Запишите черновик

Формулы, план решения, риски и примеры.

Сравните с разбором

Откройте разбор только после своей попытки.

Показать разбор

Короткий ответ

Timestamps нужны для корреляции событий, duration - для latency/SLO, error labels - для группировки причин. Эти сигналы нельзя смешивать в одну метрику.

Подробный разбор

В observability разные поля отвечают на разные вопросы. Timestamp показывает, когда произошло событие и как оно коррелирует с deploy, нагрузкой или внешним incident. Duration измеряет latency операции и проверяет SLO. Error type/status/reason помогают группировать причины отказов.

Практичный набор: histogram latency, counter ошибок по типу, retry attempts, timeout count, success/failure outcome и trace id. Значение exception не всегда безопасно превращать в label: high cardinality ломает метрики, поэтому подробности часто уходят в logs/traces.

6Вопрос10 мин

Cleanup и fallback при ошибке

Как не заблокировать систему, если fallback или cleanup тоже может упасть?

Ответьте без подсказки

Сначала проговорите ответ вслух или тезисами.

Запишите черновик

Формулы, план решения, риски и примеры.

Сравните с разбором

Откройте разбор только после своей попытки.

Показать разбор

Короткий ответ

Cleanup должен быть best-effort и изолирован от основного failure path. Fallback имеет timeout, bounded retries и понятный degraded result.

Подробный разбор

После ошибки нельзя бесконечно зависать в cleanup или fallback. Cleanup стоит делать идемпотентным и best-effort: закрыть ресурс, записать лог, отправить событие, но не блокировать основной поток без лимита времени.

Fallback должен иметь отдельный timeout, ограниченное число попыток и предсказуемый результат: cached value, default response, queued retry или явная ошибка. В логах важно различать original failure и failure fallback-ветки, иначе отладка станет непрозрачной.

7Вопрос10 мин

Randomized backoff и jitter

Какой backoff выбрать для retry и зачем добавлять jitter?

Ответьте без подсказки

Сначала проговорите ответ вслух или тезисами.

Запишите черновик

Формулы, план решения, риски и примеры.

Сравните с разбором

Откройте разбор только после своей попытки.

Показать разбор

Короткий ответ

Exponential backoff снижает давление на зависимость, jitter рассинхронизирует клиентов, а общий budget ограничивает latency и число попыток.

Подробный разбор

Fixed delay прост, но при массовом сбое все клиенты могут повторять запросы синхронно. Exponential backoff увеличивает паузы после повторных ошибок и снижает нагрузку на деградирующую зависимость. Jitter добавляет случайность, чтобы избежать retry storm.

Нужны верхняя граница delay, максимум attempts и общий deadline. Retry уместен для transient ошибок: timeout, 429, 503. Для validation errors или deterministic failures retry только увеличит нагрузку и задержку.