Модуль 8 · Два указателя · ОП.04

Алгоритмы на массивах · модуль 8 · два указателя

Глава 8.10

8.10

Замер четырёх способов
найти пару

В этой главе оценки сложности из предыдущих глав проверяются замером. Четыре способа найти пару с заданной суммой — перебор, указатели, словарь и сортировка с указателями — запущены на списках от тысячи до миллиона элементов. Разобрано, как устроен замер, что показывают числа и как по ним выбирать способ. Скрипт замера входит в стартовый набор.

Категория
Эксперимент
Уровень
Начальный
Опирается на
Занятие 4: замеры времени; главы 8.2 и 8.4
Зачем это знать
Подтверждать оценку O(…) числами и выбирать способ под ограничения

§1Четыре способа

Два способа уже разобраны в главе 8.4. Ещё два пригодятся, когда список не отсортирован: словарь уже просмотренных цен и сортировка копии с последующими указателями. Все четыре отвечают на один вопрос — есть ли пара с суммой target, — но требуют разного от входа и тратят разную память.

СпособВремяДоп. памятьНужен порядок?Что возвращает
Перебор всех парO(n²)O(1)нетиндексы
Указатели навстречуO(n)O(1)даиндексы
Словарь просмотренныхO(n) в среднемO(n)нетиндексы
Сортировка копии + указателиO(n log n)O(n)нетзначения: индексы копии другие
Напоминание из занятия 3: что такое O(n log n)

log₂ n — сколько раз n можно поделить пополам, пока не останется 1. Он растёт очень медленно: для тысячи это около 10, для миллиона — около 20. Поэтому O(n log n) лишь немного хуже O(n) и намного лучше O(n²): так стоят хорошие сортировки, в том числе sorted в Python.

>>> import math
>>> round(math.log2(1000)), round(math.log2(1_000_000))
(10, 20)
bench.pyдва способа для неотсортированного входа
def pair_sum_set(prices, target):
    seen = {}                               # цена → индекс первого вхождения
    for j, p in enumerate(prices):
        if target - p in seen:              # нужная пара уже встречалась
            return (seen[target - p], j)
        seen.setdefault(p, j)
    return None


def pair_sum_sorted_copy(prices, target):
    ordered = sorted(prices)                # новый список, O(n log n)
    answer = pair_sum(ordered, target)
    if answer is None:
        return None
    i, j = answer
    return (ordered[i], ordered[j])
Python в этих строках: enumerate, словарь и setdefault

enumerate отдаёт в цикле пары «индекс, элемент». Словарь хранит пары «ключ → значение»; ключ in словарь проверяет, есть ли такой ключ, в среднем за O(1). setdefault(ключ, значение) записывает значение, только если ключа ещё нет, — так сохраняется индекс первого вхождения цены.

>>> for j, p in enumerate([300, 450]):
...     print(j, p)
...
0 300
1 450
>>> seen = {}
>>> seen.setdefault(300, 0)
0
>>> seen.setdefault(300, 5)
0
>>> seen
{300: 0}
>>> 1600 - 1300 in seen
True
Python в этой строке: sorted(a) и a.sort()

sorted(a) возвращает новый отсортированный список, исходный не меняется. a.sort() сортирует сам список на месте и возвращает None. Обе сортировки стоят O(n log n).

>>> a = [3, 1, 2]
>>> b = sorted(a)
>>> a, b
([3, 1, 2], [1, 2, 3])
>>> a.sort()
>>> a
[1, 2, 3]
>>> print(a.sort())
None

Словарь устроен так: для каждой цены p проверяется, встречалась ли раньше парная ей цена target − p. Если встречалась, пара найдена. Если нет, цена p записывается в словарь вместе со своим индексом, чтобы её могли найти следующие цены. Проверка in у словаря в среднем стоит O(1), подробно это разобрано в занятии 17. Сам словарь занимает память под каждую просмотренную цену.

Прогон ниже — на неотсортированном каталоге из главы 8.5, где указатели пару потеряли. Список читается один раз слева направо, указатель j под ячейками — текущая цена, содержимое словаря видно в таблице переменных.

Пара нашлась на пятой цене: для 900 парная цена 700 уже лежит в словаре с индексом 3. Порядок цен для словаря не важен, поэтому ответ (3, 4) совпадает с ответом перебора.

§2Как устроен замер

Замер повторяет правила занятия 4: измеряется худший случай, каждое число — медиана нескольких запусков, копирование и подготовка данных вне замера.

  1. Худший случай — пары нет. Цены — чётные числа 0, 2, 4, …, цель — 1. Сумма двух чётных чисел чётная, поэтому пары нет, и каждый способ просматривает вход до конца.
  2. Одинаковые данные. Перебор и указатели получают отсортированный список, словарь и сортировка — тот же список, перемешанный с фиксированным зерном random.Random(8).
  3. Медиана пяти запусков. Время одного запуска меряется time.perf_counter(), из пяти берётся медиана: она не реагирует на единичный скачок, когда процессор ненадолго занят другой программой.
  4. Перебор до 16 000. На списке из 100 000 элементов перебор идёт больше минуты, поэтому его не запускали. Для миллиона время дано оценкой.
bench.pyфункция замера
def measure(func, prices, target, repeat=5):
    times = []
    for _ in range(repeat):
        start = time.perf_counter()
        func(prices, target)
        times.append(time.perf_counter() - start)
    return statistics.median(times) * 1000    # миллисекунды

§3Результаты

Настоящий вывод python bench.py на ноутбуке автора: Apple M4, Python 3.12.12, macOS, 28.09.2026. На другой машине числа будут другими, отношения между ними — близкими.

Терминалpython bench.py
Python 3.12.12, Darwin arm64
n        | перебор   | указатели | словарь   | сортировка + указатели
------------------------------------------------------------------------
1000     |     12.97 |     0.033 |     0.059 |     0.082
2000     |     51.73 |     0.063 |     0.110 |     0.135
4000     |    170.09 |     0.131 |     0.225 |     0.341
8000     |    751.50 |     0.282 |     0.472 |     0.782
16000    |   2785.62 |     0.548 |     0.886 |     1.765
100000   | не мерили |     3.042 |     6.925 |    12.720
1000000  | не мерили |    33.625 |   177.025 |   206.950
Время в миллисекундах. Перебор на n > 16 000 не запускается: он идёт минуты.

На графике обе оси логарифмические: каждая метка в 10 раз больше предыдущей. На такой шкале зависимость вида nk выглядит прямой линией, и её наклон равен k. Линия перебора идёт круче остальных вдвое: у неё k = 2, у остальных около 1. Наведите на точку, чтобы увидеть число.

§4Что показывают числа

Рост при удвоении n

Удобнее сравнивать, во сколько раз растёт время, когда список становится вдвое длиннее. Для O(n) ожидаем рост примерно в 2 раза, для O(n²) — примерно в 4.

n: было → сталоПереборУказатели
1 000 → 2 000в 4,0 разав 1,9 раза
2 000 → 4 000в 3,3 разав 2,1 раза
4 000 → 8 000в 4,4 разав 2,2 раза
8 000 → 16 000в 3,7 разав 1,9 раза

Отношения колеблются вокруг 4 и 2 — оценки O(n²) и O(n) подтверждаются. Точно 4 и 2 не получается: на время влияют кэш процессора и работа интерпретатора, которые от n зависят не строго.

Сколько шёл бы перебор на миллионе

На 16 000 элементах перебор проверил 127 992 000 пар за 2,79 с, то есть около 21,8 нс на пару. На миллионе элементов пар 499 999 500 000, и при той же скорости перебор шёл бы около 10 900 с — примерно три часа. Указатели справились за 34 мс.

Словарь и сортировка

Словарь тоже растёт линейно, но на каждом шаге вычисляет хеш и обращается к памяти. Поэтому на миллионе элементов он в 5 раз медленнее указателей и занимает память под миллион записей. Сортировка копии — самый медленный из быстрых способов: к линейному проходу добавляется O(n log n) на сортировку и память под копию. Зато оба работают на неотсортированном входе, где указатели неприменимы.

На полях: почему на малых n разница мала

При n = 1 000 указатели и словарь различаются на 0,03 мс. На малых данных выбор способа по скорости не важен, важны ограничения задачи. Разница становится заметной, когда данных много.

§5Как выбрать способ

Способ выбирают по ответам на вопросы из главы 8.3, а замер показывает цену каждого выбора.

Что известно о задачеСпособПочему
Вход отсортирован, память O(1)УказателиСамый быстрый на замере и без доп. памяти
Вход не отсортирован, нужны исходные индексыСловарьЛинейное время, индексы сохраняются
Вход не отсортирован, достаточно значений, память O(1) не требуетсяСловарь или сортировка с указателямиСловарь быстрее; сортировка удобна, если отсортированный список нужен и дальше
n до нескольких сотен, нужно просто и быстро написатьПереборПри n = 1 000 он идёт 13 мс, код проще всех

Перебор в последней строке выбран намеренно. Если условие гарантирует маленький вход, простой код, в котором легко убедиться, бывает лучше быстрого. На собеседовании это оговаривают: «при таких ограничениях хватит перебора, но если n вырастет — вот решение за O(n)».

§6Замер у себя

Скрипт bench.py лежит в стартовом наборе lesson_08. Он импортирует ваши pair_sum_brute и pair_sum из solution.py, поэтому запускать его нужно после того, как обе функции написаны и тесты проходят.

  1. Перейдите в папку lesson_08 и выполните python bench.py. На машине автора замер идёт около 20 секунд, почти всё время занимает перебор на 16 000 элементах.
  2. Сохраните вывод в файл bench_results.txt: python bench.py > bench_results.txt.
  3. Посчитайте рост времени при удвоении n для перебора и указателей, как в таблице §4, и запишите в complexity_note.md.
  4. Если отношения далеки от 4 и 2, проверьте, не открыты ли тяжёлые программы, и запустите замер ещё раз.

§7Проверьте себя

Вопрос 1Почему в замере цены чётные, а цель равна 1?

  • Скорость сложения от чётности не зависит. Чётность нужна, чтобы гарантировать отсутствие пары.

  • Верно. Сумма двух чётных чётная и не равна 1. Это худший случай: ни один способ не выйдет из цикла раньше.

  • range(0, 2 * n, 2) действительно отсортирован, но для этого чётность не нужна: так же был бы отсортирован range(n).

Вопрос 2Список вырос с 8 000 до 16 000 элементов. Во сколько раз примерно вырастет время перебора?

  • Так растёт время линейного алгоритма. У перебора пар примерно n²/2, при удвоении n их становится вчетверо больше.

  • Два вложенных цикла по n дают n².

  • Верно. На замере — в 3,7 раза: с 751,5 до 2785,6 мс.

Вопрос 3Температуры за месяц записаны по дням. Проверьте, есть ли два разных дня, сумма температур в которых равна 40. Номера дней нужно вернуть, список менять нельзя. Какой способ из этой главы подходит лучше всего?

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

  • Верно. Список не отсортирован и нужны исходные индексы. Словарь находит пару за один проход, O(n) по времени и памяти.

  • Чтение и запись нужны, когда список изменяется на месте. Здесь его менять нельзя, а ответ — пара индексов.

  • После сортировки номера дней станут другими, а вернуть нужно исходные. К тому же сортировка стоит O(n log n).

Модуль 8 · Два указателя · ОП.04 · 3 курс Автор: Макаров Максим Николаевич