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

# теория · шаг 2 из 6

Модель вычислителя - за что мы платим

«Этот алгоритм делает n шагов» - фраза бессмысленная, пока не сказано, что считается шагом. Сложить два числа - шаг. А отсортировать список одной строкой a.sort() - тоже шаг? Если да, то любая задача решается за константу, надо только назвать нужную операцию элементарной.

Как думаешь, сколько шагов делает эта строка

if x in spisok:
    print("есть")

Выглядит как одно действие. На самом деле in идёт по списку слева направо и останавливается на первом совпадении. На списке из миллиона элементов, где x отсутствует, это миллион сравнений, спрятанных за двумя буквами.

Та же строка со словарём стоит один шаг: словарь считает от ключа номер ячейки и заглядывает в неё. Одна и та же запись, разница в тысячу раз, и в тексте программы её не видно.

Модель стоимости

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

Правило, которое из этого следует, простое: операция, объём работы которой растёт вместе с данными, единицей быть не может. Поиск по списку стоит столько элементов, сколько просмотрел. Склейка двух списков стоит их суммарную длину. Сортировка стоит n·log n сравнений. Машина курса считает шаги именно так, поэтому её счётчик не расходится с рассуждением на бумаге.

Логарифм в цене сортировки - тот самый счётчик делений пополам из главы «Математика и логика для курса»: log₂ n это сколько раз n делится на 2 до единицы. Стоит в оценке именно он, потому что быстрые алгоритмы на каждом шаге отбрасывают половину данных. Ещё раз он понадобится в главе про остановку цикла, на числе кругов алгоритма Евклида.

Одна строка - не один шаг

Читая чужой код, ищи операции, спрятанные за коротким синтаксисом: in по списку, срез a[1:] (копирует хвост), del a[0] (сдвигает хвост), конкатенация строк в цикле. Именно там обычно и живёт лишний порядок роста.

Считаем вместе: два способа посчитать уникальные

Задача: сколько различных чисел в списке из n элементов. Первый способ:

unikalnye = []
for x in a:
    if x not in unikalnye:
        unikalnye.append(x)
print(len(unikalnye))

Внешний цикл делает n кругов. На каждом круге not in проходит по списку unikalnye, а он растёт. Если все числа разные, суммарно получится 0 + 1 + 2 + ... + (n-1), то есть примерно n²/2 сравнений. При n = 10 000 это пятьдесят миллионов.

Второй способ:

vidennye = set()
for x in a:
    vidennye.add(x)
print(len(vidennye))

Проверка принадлежности множеству стоит один шаг независимо от размера множества. Получается n шагов вместо n²/2 - при тех же десяти тысячах элементов десять тысяч операций против пятидесяти миллионов. Разница не в аккуратности записи, а в том, за что платит каждая строка.

Считаем вместе: цена снятия из начала

ochered = [1, 2, 3, 4, 5]
pervyj = ochered.pop(0)

pop(0) возвращает первый элемент, а потом сдвигает все остальные на одну ячейку влево - иначе нарушится главное свойство массива, из-за которого обращение по номеру стоит единицу. Значит снятие из начала стоит n, а снятие с конца (pop()) - единицу, там двигать нечего.

Отсюда следует практический вывод, который пригодится в разделе про очереди: очередь (кто раньше встал, тот раньше обслужен) на списке с pop(0) обрабатывает n элементов за n²/2 операций. Очередь, которая помнит номер головы и не двигает хвост, справляется за n.

Чего модель не видит

RAM-модель считает все обращения к памяти одинаковыми. Настоящая машина - нет: данные, лежащие подряд, читаются в разы быстрее разбросанных, потому что попадают в кеш процессора. Поэтому обход массива обгоняет обход связного списка при одинаковом порядке роста. Модель врёт здесь сознательно: она ловит порядок роста, а не константу, и для выбора между n² и n·log n этого достаточно. Разговор про константы и кеш будет в разделе про структуры на практике.

Теперь сам

Сколько шагов сделает этот фрагмент на списке из n элементов?

itog = ""
for slovo in slova:
    itog = itog + slovo

Ответ. Строки в Python неизменяемы, поэтому itog + slovo не дописывает, а создаёт новую строку и копирует в неё обе. На круге номер i копируется около i символов, суммарно получается порядка n². Правильный способ - собрать куски в список и склеить один раз через "".join(slova): тогда копирование происходит однажды и обходится в n.

Оговорка про CPython: он умеет дописывать строку на месте, если на неё больше никто не ссылается, и срабатывает это только для локальной переменной функции. Замер на Python 3.12: тот же цикл на 80 000 слов внутри функции занял 9 мс и рос линейно, а на уровне модуля - 3,8 с, и удвоение входа умножало время в 2,4-5 раз. Язык эту оптимизацию не обещает, поэтому считают по худшему случаю, а склеивают через join.

Главное

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

Комментарии

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

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

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