Процессор, объём кэша, скорость памяти. На другом ноутбуке та же программа отработает за другое время.
Занятие 3 · раздел 1 · формат интервью и оценка эффективности
Временная
сложность
и O-нотация
Мера трудоёмкости алгоритма: модель вычислений, формальное определение асимптотической оценки, каталог классов роста, анализ программ и границы применимости метода.
план занятия
- I–IV мера, T(n) и O-нотация
- V каталог классов роста
- VI–VII анализ программ и память
- VIII границы метода
- IX применение и практика
Точка разрыва между парами — после экрана 50.
Раздел I · постановка вопроса
Два решения одной задачи
Дан упорядоченный список из 64 номеров; требуется определить, содержится ли в нём заданный номер. Оба решения возвращают одинаковый результат. Ниже — число обращений к списку в случае, когда искомого номера нет.
обращений к списку 0
обращений к списку 0
64 против 6; при n = 1024 — 1024 против 10. Различие устойчиво и растёт с размером входа. Задача раздела — построить меру, которая описывает это различие независимо от вычислительной машины.
Раздел I · постановка вопроса
Чем измерить разницу
между двумя решениями?
Первый ответ, который приходит в голову, — секундомер. Следующий экран показывает, почему он не подходит.
Раздел I · первый вариант меры
Секунды описывают запуск, а не алгоритм
Одна и та же программа на одних и тех же данных даёт разное время на разных запусках и на разных машинах.
Фоновые процессы, режим питания, температура. Два запуска подряд дают разные числа.
Версия интерпретатора, библиотеки. Python 3.11 быстрее 3.8 на том же коде.
Ни один из этих факторов не относится к алгоритму. Значит, по секундам нельзя сказать, какое решение лучше, — можно сказать только, какой запуск оказался быстрее.
Раздел I · выбранная мера
Считаем операции, а не секунды
Число выполненных действий зависит только от двух вещей: от алгоритма и от входных данных. Оно одинаково у всех, кто запустит тот же код на том же входе.
Это и есть мера трудоёмкости, принятая в анализе алгоритмов. Дальше разбираемся, что именно считать за одно действие.
Поиск номера в списке из 8 пропусков
на ноутбуке преподавателя → 0,000004 с
на компьютере в аудитории → 0,000011 с
при запущенном браузере → 0,000019 с
сравнений в любом из трёх запусков → 8
Раздел II · что считать за единицу
Элементарная операция
Элементарная операция — действие, время выполнения которого не зависит от размера входных данных. Такое действие принимают за единицу счёта.
присваивание
total = 0арифметическое действие
a + bсравнение двух значений
if a > bобращение по индексу
data[5]вызов и возврат
return xВсе пять действий выполняются за одинаковое время и при десяти элементах, и при миллионе. Поэтому каждое из них стоит 1.
Раздел II · стоимость 1
Что выполняется за одно действие
Список слева содержит миллион номеров. Каждая строка справа выполняется одинаково быстро независимо от этого.
Общий признак: чтобы выполнить такую строку, программе не нужно просматривать элементы. Позиция вычисляется арифметически, длина хранится готовой, хеш считается от самого значения.
| Строка | Что делает | Стоимость |
|---|---|---|
orders[0] | адрес вычисляется по индексу | 1 |
orders[-1] | адрес последнего известен | 1 |
len(orders) | длина хранится рядом со списком | 1 |
orders.append(x) | запись в готовую ячейку в конце | 1 |
orders.pop() · с конца | снимает последний элемент, сдвига нет | 1 |
Раздел II · стоимость 1 · часть 2
Множество и словарь: почему это тоже единица
Проверка «есть ли такой номер» в списке стоит n, а в множестве — 1. Разница в том, как хранятся данные.
В списке номера лежат подряд, и найти нужный можно только перебором. В множестве позиция вычисляется из самого значения: хеш-функция превращает номер в адрес ячейки, и программа сразу смотрит туда.
Плата за это — память: под множество из n номеров выделяется таблица, и она тем больше, чем больше элементов. Размен «время за память» разбирается в разделе о памяти.
| Строка | Что делает | Стоимость |
|---|---|---|
x in seen · множество | адрес вычисляется хеш-функцией | 1 |
seen.add(x) | запись по вычисленному адресу | 1 |
counts[key] = 5 | то же для словаря | 1 |
counts.get(key, 0) | чтение по ключу | 1 |
x == y, x < y | сравнение двух чисел | 1 |
a, b = b, a | обмен значениями переменных | 1 |
total += value | сложение и присваивание | 1 |
Раздел II · стоимость n · часть 1
Строки, которые просматривают весь список
Тот же список из миллиона номеров. Чтобы выполнить любую строку справа, программе приходится дойти до конца — или до первого совпадения.
Цикл при этом может быть записан явно, а может находиться внутри библиотечной функции. Стоимость от этого не меняется: sum(orders) складывает миллион чисел ровно так же, как цикл, написанный руками.
| Строка | Что делает | Стоимость |
|---|---|---|
for x in orders: | перебирает каждый элемент | n |
x in orders · список | сравнивает с элементами подряд | n |
sum(orders) | складывает все элементы | n |
max(orders), min(orders) | сравнивает все элементы | n |
orders.count(x) | считает совпадения до конца списка | n |
orders.index(x) | ищет позицию перебором | n |
Раздел II · стоимость n · часть 2
Строки, которые копируют или сдвигают
Второй источник стоимости n — перемещение данных. Здесь программа ничего не ищет, но переносит каждый элемент на новое место.
Отсюда правило, которое пригодится в практике: вставка и удаление в начале списка стоят n, а в конце — 1. Если алгоритму нужно снимать элементы с начала, список заменяют на deque из модуля collections — двустороннюю очередь, где обе операции стоят 1.
| Строка | Что делает | Стоимость |
|---|---|---|
orders[1:] | копирует элементы в новый список | n |
orders.copy() | копирует каждый элемент | n |
orders.insert(0, x) | сдвигает все элементы вправо | n |
orders.pop(0) | снимает первый и сдвигает остальные | n |
orders.remove(x) | находит элемент и сдвигает остальные | n |
orders.reverse() | переставляет все элементы | n |
set(orders), list(range(n)) | создаёт новую структуру из n элементов | n |
Раздел II · две строки рядом
Одинаковые по виду, разные по стоимости
Обе строки проверяют, встречался ли номер раньше. Написаны они почти одинаково, а работы выполняют разное количество.
seen = set()
...
if badge in seen:
1 операция
позиция вычисляется по значению номера — программа сразу смотрит в нужную ячейку
seen = []
...
if badge in seen:
до n операций
программа сравнивает искомый номер с каждым элементом подряд, пока не найдёт
Разница в одной записи — set() вместо [] — меняет стоимость строки с 1 на n. Это самая частая причина, по которой решение получает верный ответ, но не проходит по времени.
Раздел II · между единицей и n · часть 1
Дешевле полного прохода и дороже его
Список из 1000 номеров. Полный проход по нему стоит 1000 действий. Первая строка справляется за 10, вторая тратит 10 000 — и обе записаны в одну строку кода.
from bisect import bisect_left # стандартный модуль
i = bisect_left(codes, 480) # 10 действий
# то же половинным делением вручную:
while low <= high:
mid = (low + high) // 2
if codes[mid] < 480:
low = mid + 1
else:
high = mid - 1
После каждого сравнения половина списка отпадает: 1000 → 500 → 250 → … → 1. Работает только на упорядоченном списке: без порядка неизвестно, какую половину отбрасывать.
prices.sort() # ≈10 000 действий
sorted(prices)[:3] # столько же
min(prices) # 1000 действий
Сортировка складывает список из отсортированных кусков: сначала по одному элементу, потом по два, по четыре. На каждом уровне просматривается весь список целиком.
уровней ровно столько, сколько раз 1000 делится пополам, — 10. Каждый стоит 1000 просмотров: 10 × 1000 = 10 000
Раздел II · между единицей и n · часть 2
Когда стоимость определяется не длиной списка
Иногда работа определяется не длиной списка, а чем-то другим: размером куска, длиной второй строки или тем, повезло ли на этом шаге. Три таких случая — ниже.
window = prices[10:20]
first_ten = text[:10]
Срез копирует только те элементы, которые в него попали. Сколько цен в списке — тысяча или миллион — неважно: копируется ровно k = 20 − 10 = 10 штук.
взять 10 элементов → 10 действий при любой длине спискаif "отказ" in log_line:
...
big_list + other_list
Программа прикладывает слово к первой позиции строки и сверяет буквы, потом ко второй, и так далее. Позиций — n, букв в слове — m, значит до n · m сверок.
строка 1000 символов, слово из 5 букв → до 5000 сверокorders.append(x)
orders.pop()
Список лежит в памяти куском фиксированного размера. Пока в куске есть место, добавление стоит 1. Когда место кончилось, берётся кусок вдвое больше и всё копируется — вот это добавление стоит n.
1000 добавлений → 2023 действия всего, то есть 2,02 на одноИтого стоимость строки бывает четырёх видов. 1 — не зависит от данных; log n — данные делятся пополам; n и выше — данные просматриваются целиком; k или m — работу задаёт отдельная величина. Все четыре встретятся при разметке фрагментов.
Раздел II · одна операция, разные структуры
Стоимость зависит не от действия, а от структуры данных
Одно и то же действие стоит по-разному в списке, множестве, словаре и очереди. Выбор структуры — основной способ изменить сложность решения.
| Действие | список | множество / словарь | deque |
|---|---|---|---|
| проверить наличие элемента | n | 1 | n |
| добавить в конец | 1 | 1 | 1 |
| добавить в начало | n | — | 1 |
| удалить из начала | n | — | 1 |
| обратиться по номеру | 1 | — | n |
| найти минимум | n | n | n |
Отсюда практическое правило, к которому мы вернёмся в разделе о памяти. Если решение упирается в проверку наличия — берут множество; если в добавление и снятие с обоих концов — deque; если нужен доступ по номеру — список. Замена структуры меняет класс сложности, не меняя логики решения.
Раздел II · на чём держится счёт
Три допущения, которые мы принимаем
Считая обращение к памяти за единицу, мы пользуемся упрощённой моделью машины. В анализе алгоритмов она называется RAM — машина с произвольным доступом к памяти.
Обращение к началу и к концу списка стоит одинаково.
На практике данные в кэше достаются в десятки раз быстрееСложение двух чисел стоит одну операцию.
Для чисел из тысячи разрядов это неверно: сложение стоит O(разрядов)Действия выполняются последовательно.
Параллельные вычисления и векторные инструкции модель не учитываетМодель сознательно огрубляет устройство машины — иначе оценка зависела бы от конкретного процессора. Чем приходится платить за это упрощение, разбирается в разделе VIII.
Раздел II · вторая величина
Размер входа n
Размер входа n — та характеристика данных, при росте которой растёт объём работы. Прежде чем считать операции, нужно назвать, что именно в задаче является n.
элементов в списке
[417, 133, 604, …]символов в строке
"A-118"вершин и рёбер графа
O(V + E)два независимых списка
O(n + m)разрядов числа k
k = 10⁹ → 30 битПоследний случай важен для задач, где на входе одно число: размером входа считается число разрядов, а не само значение. Поэтому перебор до k считается экспоненциальным относительно длины записи числа.
Раздел III · функция трудоёмкости
Как считается T(n)
T(n) — число операций, выполненных алгоритмом на входе размера n. Считается по тексту программы, а не запуском.
- Каждой строке приписать её стоимость: 1 или n.
- Умножить на число выполнений: строка внутри цикла выполняется n раз.
- Сложить всё, что получилось.
Дальше — пять примеров подряд, от самого простого к самому неочевидному. Каждый разбирается построчно.
Что получится в примерах
① обращение по индексу → T(n) = 3
② поиск перебором → T(n) = 2n + 2
③ два цикла подряд → T(n) = 4n + 5
④ вложенные циклы → T(n) = n² + n + 2
⑤ скрытый проход → T(n) = n² + 2n + 2
Пример 1 из 5 · пункт выдачи
Обращение по индексу
Функция возвращает первую и последнюю заявки смены. Циклов нет, каждая строка выполняется один раз.
Число операций не зависит от того, 10 заявок в журнале или 10 миллионов: обращений всё равно два плюс возврат.
T(n) = 3 — в записи нет буквы n. Это первый признак того, что алгоритм не зависит от размера входа.
def first_and_last(orders):
a = orders[0] 1
b = orders[-1] 1
return a, b 1
T(n) = 1 + 1 + 1 = 3
Пример 2 из 5 · стадион
Один цикл по списку
Поиск пропуска перебором. Появляется цикл: строки внутри него выполняются столько раз, сколько элементов в списке.
Считаем худший случай — искомого номера нет, поэтому цикл проходит до конца. Заголовок цикла выполняется n раз, сравнение внутри — тоже n раз.
T(n) = 2n + 2. При n = 8 это 18 операций, при n = 16 — 34. Удвоение входа удвоило работу.
def find_pass(passes, code):
for i in range(len(passes)): n + 1
if passes[i] == code: n
return i
return -1 1
T(n) = (n + 1) + n + 1 = 2n + 2
Пример 3 из 5 · теплица
Два цикла подряд
Среднее значение и число выбросов выше среднего. Два цикла идут друг за другом, а не один внутри другого.
Каждый цикл даёт по 2n операций: заголовок и тело. Вне циклов выполняется пять действий. Складываем: 2n + 2n + 5.
T(n) = 4n + 5. Работы вдвое больше, чем в примере 2, но растёт она так же: удвоили вход — удвоили работу.
def average_and_spikes(temperatures):
total = 0 1
for value in temperatures: n
total += value n
average = total / len(...) 2
spikes = 0 1
for value in temperatures: n
if value > average: n
spikes += 1
return average, spikes 1
T(n) = 2n + 2n + 5 = 4n + 5
Пример 4 из 5 · проходная
Цикл внутри цикла
Проверка, есть ли среди пропусков два одинаковых. Внешний цикл берёт очередной пропуск, внутренний сравнивает его со всеми следующими.
for i in range(len(badges)):
for j in range(i + 1, len(badges)):
if badges[i] == badges[j]:
return True
return False
Ключевое отличие от примера 3: там циклы шли друг за другом и работа складывалась. Здесь внутренний цикл отрабатывает целиком на каждом шаге внешнего — значит, числа перемножаются.
Для пропуска номер i внутренний цикл сравнивает его со всеми, кто стоит правее. Чем дальше i, тем меньше остаётся.
7 + 6 + 5 + 4 + 3 + 2 + 1 = 28 сравнений
это n(n−1)/2 = 8·7/2 = 28 · при n = 16 будет 120, при n = 1000 — 499 500
Пример 4 из 5 · откуда берётся n²
Почему 28 сравнений — это n², а не n
Число сравнений равно n(n−1)/2 — половине квадрата. Проверим по определению класса: посмотрим, как оно меняется, когда вход удваивается.
| n | Сравнений: n(n−1)/2 | Во сколько раз больше предыдущего | Для сравнения: один проход, n |
|---|---|---|---|
| 8 | 28 | — | 8 |
| 16 | 120 | 4,3 | 16 |
| 32 | 496 | 4,1 | 32 |
| 64 | 2 016 | 4,1 | 64 |
| 1 000 | 499 500 | 4,0 | 1 000 |
Вход удваивается — число сравнений растёт вчетверо. Так ведёт себя только квадрат: 2² = 4.
n(n−1)/2 = n²/2 − n/2. Множитель ½ и младшее слагаемое отбрасываются по правилам записи — остаётся n².
Если бы работа росла как n, при удвоении входа множитель был бы 2, а не 4. Один проход по списку в последнем столбце — как раз такой случай.
Пример 5 из 5 · отчёт по заказам
Цикл один, а работы как при вложенных
Удаление повторов с сохранением порядка. Вложенного цикла в тексте нет — но строка row not in result стоит не 1, а n, как мы разобрали на экране про стоимость строк.
Цикл выполняется n раз. На каждом шаге проверка просматривает список result, в котором к концу работы до n элементов. Перемножаем: n · n.
T(n) = n² + 2n + 2. Этот пример — самый частый источник ошибок в разметке: по виду кода решение выглядит линейным.
def unique_keep_order(rows):
result = [] 1
for row in rows: n
if row not in result: n · n
result.append(row) n
return result 1
T(n) = n² + 2n + 2
Раздел III · пять примеров рядом
Как ведут себя пять функций T(n)
| Пример | T(n) | n = 8 | n = 16 | n = 1000 | Во сколько раз выросло |
|---|---|---|---|---|---|
| ① индекс | 3 | 3 | 3 | 3 | 1,0 — не изменилось |
| ② перебор | 2n + 2 | 18 | 34 | 2 002 | 1,9 — почти вдвое |
| ③ два цикла | 4n + 5 | 37 | 69 | 4 005 | 1,9 — почти вдвое |
| ④ вложенные | n² + n + 2 | 74 | 274 | 1 001 002 | 3,7 — почти вчетверо |
| ⑤ скрытый проход | n² + 2n + 2 | 82 | 290 | 1 002 002 | 3,5 — почти вчетверо |
Примеры ② и ③ различаются вдвое по числу операций, но ведут себя одинаково. Примеры ④ и ⑤ различаются всего на 8 операций при n = 8 — и тоже ведут себя одинаково. Функции разбиваются на группы по тому, как они ведут себя при росте n. Эти группы и записывает O-нотация.
Раздел IV · переход к оценке
Зачем упрощать T(n),
если она уже посчитана?
Записи 2n + 2 и 4n + 5 неудобно сравнивать, а на собеседовании их не произносят. Нужна форма, в которой одинаково ведущие себя функции выглядят одинаково.
Раздел IV · правило 1 из 2
Постоянный множитель отбрасывается
Примеры ② и ③ дали 2n + 2 и 4n + 5. Второй делает вдвое больше работы — и всё же при удвоении входа оба удваиваются одинаково.
Множитель зависит от того, что мы посчитали за операцию. Считать ли заголовок цикла отдельным действием? Считать ли присваивание внутри? От ответа множитель меняется, а поведение функции — нет.
Поэтому множитель в оценку не входит: O(2n) = O(4n) = O(n).
| Функция | n = 1000 | n = 2000 | Отношение |
|---|---|---|---|
| 2n + 2 | 2 002 | 4 002 | 2,00 |
| 4n + 5 | 4 005 | 8 005 | 2,00 |
| 7n + 12 | 7 012 | 14 012 | 2,00 |
| 100n | 100 000 | 200 000 | 2,00 |
Все четыре функции различаются по величине в десятки раз, но столбец «отношение» у них один. Оценка сохраняет этот столбец.
Раздел IV · правило 2 из 2
Младшие слагаемые отбрасываются
Пример ④ дал n² + n + 2. С ростом n доля слагаемых n и 2 в общей сумме падает: при n = 1000 они составляют одну десятую процента.
Старшее слагаемое растёт быстрее остальных и со временем определяет всю сумму. Младшие в оценку не входят: O(n² + n + 2) = O(n²).
Правило применяется только к слагаемым одной переменной. Если величины две и растут независимо, ни одна не является младшей: O(n + m) так и остаётся.
| n | n² | n + 2 | Доля младших |
|---|---|---|---|
| 8 | 64 | 10 | 13,5 % |
| 100 | 10 000 | 102 | 1,0 % |
| 1 000 | 10⁶ | 1 002 | 0,1 % |
| 10⁶ | 10¹² | 10⁶ | 0,0001 % |
При n = 8 младшие слагаемые ещё заметны — это одна из причин, по которой оценка не применяется к малым входам.
Раздел IV · результат упрощения
Пять функций — два класса
Применим оба правила к пяти примерам, которые считали построчно. Пять разных формул превращаются в три записи, а из них две совпадают.
| Пример | T(n) | убираем множитель | убираем младшие | оценка |
|---|---|---|---|---|
| ① обращение по индексу | 3 | 1 | 1 | O(1) |
| ② поиск перебором | 2n + 2 | n + 2 | n | O(n) |
| ③ два цикла подряд | 4n + 5 | n + 5 | n | O(n) |
| ④ вложенные циклы | n² + n + 2 | n² + n + 2 | n² | O(n²) |
| ⑤ скрытый проход | n² + 2n + 2 | n² + 2n + 2 | n² | O(n²) |
Примеры ② и ③ получили одну запись. Точность при этом не теряется: они действительно ведут себя одинаково. Примеры ④ и ⑤ тоже совпали, хотя один написан с вложенным циклом, а другой без него.
Раздел IV · строгое определение
Что означает запись O(f(n))
T(n) = O(f(n)) ⟺ ∃ c > 0, n₀ ≥ 0 : T(n) ≤ c · f(n) при всех n ≥ n₀
Найдутся такое число c и такой размер входа n₀, что начиная с n₀ функция T(n) не превосходит c · f(n). Читается «T(n) есть O от f(n)» и означает: T(n) растёт не быстрее f(n).
постоянный множитель. Позволяет не различать 2n и 100n: обе функции укладываются под c · n при подходящем c.
размер входа, начиная с которого неравенство выполняется. На меньших входах оно может нарушаться — определение этого не запрещает.
оценка сверху. O не утверждает, что T(n) растёт именно как f(n), — только что не быстрее.
Обозначение пришло из математического анализа: символ ввёл Пауль Бахман в 1894 году, в анализ алгоритмов его перенёс Дональд Кнут в 1976-м. Отсюда и свойство записи — она описывает поведение функции при больших n.
Раздел IV · применение определения
Проверим: 4n + 5 действительно O(n)
Чтобы доказать оценку, достаточно предъявить одну пару чисел (c, n₀), при которой неравенство выполняется. Возьмём пример ③ и найдём такую пару.
требуется подобрать c и n₀
переносим: 5 ≤ n
при n = 6: 29 ≤ 30, дальше разрыв растёт
пара (5, 5) предъявлена, определение выполнено
| n | 4n + 5 | 5n | Неравенство |
|---|---|---|---|
| 4 | 21 | 20 | нарушено — n < n₀ |
| 5 | 25 | 25 | выполнено |
| 10 | 45 | 50 | выполнено |
| 1 000 | 4 005 | 5 000 | выполнено |
При n = 4 неравенство нарушено: 21 > 20. Определение это допускает — оно требует выполнения только начиная с n₀ = 5.
Оценка описывает поведение при больших n. На четырёх элементах разницы между решениями всё равно нет: любое отработает мгновенно.
Запись O(n) не обещает ничего про маленькие входы. Она говорит одно: начиная с некоторого размера работа растёт не быстрее n. Что делать с маленькими входами, показывают замеры — занятие 4.
Раздел IV · форма ответа
Как записывать оценку
В записи остаётся одно слагаемое — старшее — без множителя. Формы слева автопроверка не принимает и объясняет причину.
| Неверно | Верно | Основание |
|---|---|---|
| O(2n) | O(n) | правило множителя |
| O(n/2) | O(n) | правило множителя |
| O(n² + n) | O(n²) | правило младших слагаемых |
| O(4n + 5) | O(n) | оба правила |
| O(log₂ n) | O(log n) | основание даёт множитель |
| O(n + m) | O(n + m) | верно: величины независимы |
Почему O(n + m) не упрощается
Правило младших слагаемых применимо, когда обе части зависят от одной величины. Если списка два и их размеры не связаны, ни одно слагаемое не является младшим: при n = 10 и m = 10⁶ преобладает m, при обратном соотношении — n.
Запись O(n) здесь означала бы дополнительное предположение о малости второго входа. Условие задачи такого предположения не даёт — значит, в ответе остаются обе величины.
Раздел IV · соседние обозначения
Кроме O существуют Ω и Θ
O ограничивает функцию сверху. Для полноты картины нужны ещё два символа: Ω ограничивает снизу, Θ — с двух сторон сразу.
Разница видна на трёх знакомых алгоритмах: у одних верхняя и нижняя границы совпадают, у других расходятся. Совпали — значит подходит Θ; разошлись — остаётся называть O и Ω по отдельности.
В курсе используется O: и на собеседовании, и в проверяющей системе нужна гарантия сверху. Ω и Θ нужны, чтобы читать чужой анализ и не путать «не хуже» с «ровно столько».
| Алгоритм | лучший | худший | запись |
|---|---|---|---|
| Поиск перебором | 1 сравнение элемент первый | n сравнений элемента нет | O(n), Ω(1) Θ нет: границы разошлись |
| Сумма списка | n сложений | n сложений | Θ(n) границы совпали |
| Быстрая сортировка | n log n деление пополам | n² опорный — минимум | O(n²), Ω(n log n) в среднем n log n |
Раздел IV · правила действий с оценками
Три свойства, которыми пользуются при анализе
O(c · f) = O(f)
O(f) + O(g) = O(max(f, g))
O(f) · O(g) = O(f · g)
Эти три свойства и есть весь аппарат, которым разбирается любая нерекурсивная программа: выписать стоимость участков, сложить последовательные, перемножить вложенные, убрать множители. Для рекурсивных решений добавится подсчёт по дереву вызовов — раздел VI.
Раздел IV · упорядочение оценок
Какая оценка лучше другой
Оценки упорядочены по скорости роста. Порядок устанавливается отношением функций: если отношение стремится к нулю, первая растёт медленнее.
1 ≺ log n ≺ n ≺ n log n ≺ n² ≺ n³ ≺ 2ⁿ ≺ n!
Как проверить, что одна функция растёт медленнее другой. Разделить первую на вторую и посмотреть, что происходит с частным при росте n. Если оно стремится к нулю — первая растёт медленнее; если держится около постоянного числа — функции одного порядка. Такие частные посчитаны в таблице справа.
Основание логарифма на порядок не влияет: log₂n и log₁₀n отличаются множителем 3,32, который отбрасывается по первому правилу. Поэтому основание в записи не указывают.
| Отношение | n = 10 | n = 1 000 | n = 10⁶ | Предел |
|---|---|---|---|---|
| log n / n | 0,332 | 0,010 | 0,00002 | 0 |
| n / n log n | 0,301 | 0,100 | 0,050 | 0 |
| n log n / n² | 0,332 | 0,010 | 0,00002 | 0 |
| n² / 2ⁿ | 0,098 | ≈10⁻²⁹⁶ | ≈0 | 0 |
Раздел V · каталог
Классы сложности в порядке возрастания
работа не зависит от того, сколько данных
1на каждом шаге половина данных отпадает
10 шаговкаждый элемент трогаем по разу
1 024проходим весь список, и так log n раз
10 240сравниваем каждый элемент с каждым
523 776каждый новый элемент удваивает работу
10³⁰⁸В нижней строке карточки — сколько операций получится при n = 1024. Первые четыре класса годятся для больших данных. Квадратичный применим, пока элементов не больше нескольких тысяч. Экспоненциальный — только при n около двадцати: при большем n перебор не укладывается в разумное время. Каждый класс дальше разбирается на двух экранах: определение с прогоном и три примера из разных задач.
Класс 1 из 6 · определение и прогон
Константная сложность O(1)
Алгоритм имеет константную сложность, если число выполняемых операций ограничено сверху постоянной величиной, не зависящей от размера входа.
def first_and_last(orders):
return orders[0], orders[-1]
Пункт выдачи · журнал заявок за смену
1 200 заявок → 2 обращения к списку
12 000 заявок → 2 обращения
операций 0
Адрес ячейки вычисляется по индексу арифметически, поэтому просмотр остальных элементов не требуется.
Класс 1 из 6 · примеры
Где встречается константная сложность
orders[0]
orders[-1]
prices[i]
Адрес элемента вычисляется по индексу за одно арифметическое действие независимо от длины списка.
T(n) = O(1) · S(n) = O(1)if badge in seen:
...
counts[word] = 0
Позиция определяется хеш-функцией от самого значения. При совпадении хешей возможен короткий перебор, поэтому оценка справедлива в среднем; в худшем случае — O(n).
T(n) = O(1) в среднем · память структуры O(n)len(orders)
orders.append(x)
Длина хранится вместе со списком. Добавление в конец записывает элемент в готовую ячейку; изредка требуется расширение — разбор в разделе VI.
T(n) = O(1) · для append — амортизированноПризнак класса: отсутствие цикла по входным данным и отсутствие вызовов, содержащих такой цикл. Второе условие существеннее первого — раздел VI.
Класс 2 из 6 · определение и прогон
Логарифмическая сложность O(log n)
Алгоритм имеет логарифмическую сложность, если на каждом шаге объём необработанных данных уменьшается в постоянное число раз. При делении пополам число шагов равно log₂n.
Архив чеков · номера 1…1024 · ищем 480
512 → влево·256 → вправо·384 → вправо·448 → вправо·480 найден
пять шагов вместо 480 сравнений при переборе
Класс требует упорядоченности данных: без порядка отбросить половину нельзя, поскольку неизвестно, в какой части искать.
шагов деления 0
Осевшие ячейки исключены из рассмотрения целиком: 6 шагов на 64 элемента, 10 на 1024.
Шаги и сравнения: в opcount.py на шаг приходится два сравнения, поэтому при n = 1024 там 20 сравнений, а шагов 10.
Класс 2 из 6 · примеры
Где встречается логарифмическая сложность
while low <= high:
middle = (low + high) // 2
if ids[middle] < target:
low = middle + 1
else:
high = middle - 1
Отрезок поиска сокращается вдвое на каждой итерации. Применим только к упорядоченным данным.
T(n) = O(log n) · S(n) = O(1)result = 1
while power > 0:
if power % 2:
result *= base
base *= base
power //= 2
Показатель делится пополам на каждом шаге: для power = 10⁹ достаточно 30 итераций вместо миллиарда умножений.
T(n) = O(log power) · S(n) = O(1)low, high = 1, 1000
while low <= high:
guess = (low + high) // 2
if guess < secret:
low = guess + 1
else:
high = guess - 1
Та же идея, что в бинарном поиске, но без списка. Каждый вопрос отсекает половину диапазона: числа от 1 до 1000 угадываются за 10 вопросов, от 1 до миллиона — за 20.
T(n) = O(log n) · S(n) = O(1)Признак класса: управляющая величина делится, а не уменьшается на единицу. Сравните: i //= 2 даёт log n итераций, i -= 1 — n итераций.
Класс 3 из 6 · определение и прогон
Линейная сложность O(n)
Алгоритм имеет линейную сложность, если каждый элемент входа обрабатывается фиксированное число раз. Увеличение входа в k раз увеличивает число операций в k раз.
for i in range(len(passes)):
if passes[i] == code:
return i
return -1
Стадион · искомого пропуска в списке нет
8 пропусков → 8 сравнений · 1000 → 1000
два последовательных цикла дают 2n — тот же класс
сравнений 0
Показан худший случай: искомый элемент отсутствует, поэтому просмотр выполняется до конца списка.
Класс 3 из 6 · примеры
Где встречается линейная сложность
total = 0
for value in temperatures:
total += value
Сумма, максимум, минимум, среднее. Библиотечные sum() и max() выполняют тот же проход, записанный внутри библиотеки.
counts = {}
for word in words:
counts[word] = counts.get(word, 0) + 1
Один проход, на каждом шаге операция со словарём за O(1). Дополнительная память растёт с числом различных значений.
T(n) = O(n) · S(n) = O(n)i = j = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]: i += 1
else: j += 1
Каждый указатель проходит свой список один раз и не возвращается назад. Слияние, поиск пары с заданной суммой — тема занятия 8.
T(n) = O(n + m) · S(n) = O(1) без учёта результатаПризнак класса: один проход по входу, тело которого выполняется за постоянное время. Несколько последовательных проходов класс не меняют: n + n + n = 3n = O(n).
Класс 4 из 6 · определение и прогон
Линейно-логарифмическая сложность O(n log n)
Класс возникает, когда проход по всем n элементам повторяется log n раз. Типичный источник — схема «разделяй и властвуй»: данные делятся пополам, результаты объединяются за линейное время.
Склад · сортировка слиянием восьми цен
[90][15][240][60][75][15][300][45]·[15,90][60,240][15,75][45,300]·[15,60,90,240][15,45,75,300]·[15,15,45,60,75,90,240,300]
три уровня слияния по 8 элементов → 24 просмотра
просмотров 0
Полоса пробегается заново на каждом уровне слияния: пять уровней по 32 элемента — 160 просмотров.
Класс 4 из 6 · примеры
Где встречается линейно-логарифмическая сложность
prices.sort()
sorted(prices)[:3]
В CPython применяется Timsort — сортировка слиянием, которая сначала находит уже упорядоченные куски и склеивает их. На почти отсортированных данных приближается к O(n).
T(n) = O(n log n) · S(n) = O(n)orders.sort()
for i in range(1, len(orders)):
if orders[i] == orders[i - 1]:
return True
Поиск дубликата без дополнительной памяти: O(n log n) на сортировку плюс O(n) на проход. По свойству суммы остаётся старшее слагаемое.
T(n) = O(n log n) · S(n) = O(1) при сортировке на местеdef kth_largest(prices, k):
return sorted(prices, reverse=True)[k - 1]
Третья по величине цена, десятый по времени заказ. Весь список упорядочивается ради одного элемента — отсюда n log n. Способ решить это за O(n) разбирается в разделе про кучу.
T(n) = O(n log n) · S(n) = O(n)Признак класса: сортировка в решении либо рекурсивное деление задачи пополам с линейным объединением. Наличие sorted() само по себе задаёт нижнюю границу класса — быстрее решение уже не будет.
Класс 5 из 6 · определение и прогон
Квадратичная сложность O(n²)
Алгоритм имеет квадратичную сложность, если обрабатывается каждая пара элементов либо цикл по входу вложен в другой цикл по тому же входу. Увеличение входа вдвое увеличивает работу вчетверо.
Проходная · число пар при переборе
5 пропусков → 10 пар · 10 пропусков → 45 пар
вход вырос вдвое, работа — в 4,5 раза
1000 пропусков → 499 500 пар
сравнений 0
Клетка — пара «элемент i против элемента j». Заполняется треугольник: пар ровно n(n−1)/2 = 276.
Класс 5 из 6 · примеры
Где встречается квадратичная сложность
for i in range(len(points)):
for j in range(i + 1, len(points)):
distance(points[i], points[j])
Ближайшая пара точек, поиск дубликатов, сравнение каждого с каждым. Множитель 1/2 от условия j > i на класс не влияет.
for i in range(1, len(data)):
j = i
while j > 0 and data[j-1] > data[j]:
data[j-1], data[j] = data[j], data[j-1]
j -= 1
В худшем случае — обратный порядок — каждый элемент проходит весь пройденный участок. На почти упорядоченных данных вырождается в O(n).
T(n) = O(n²) в худшем, O(n) в лучшемfor row in rows:
if row not in result:
result.append(row)
Цикл записан один, но not in для списка выполняет собственный проход. Скрытый случай квадратичной сложности — раздел VI.
Признак класса: вложенные циклы по одному входу либо операция стоимостью O(n) внутри цикла по входу. Если внутренний цикл идёт по другой величине m, оценка равна O(n·m).
Класс 5 из 6 · продолжение
Полиномиальные классы выше второго
Квадратичный класс — частный случай полиномиального O(n^k). Третья степень возникает при трёх вложенных циклах и встречается в задачах на матрицы и тройки элементов.
for i in range(n):
for j in range(n):
for k in range(n):
c[i][j] += a[i][k] * b[k][j]
Три вложенных цикла по n: для каждой клетки результата складываются n произведений. При n = 500 это 1,25·10⁸ операций — предел того, что успевает уложиться в секунду.
T(n) = O(n³) · S(n) = O(n²)for i in range(n):
for j in range(i+1, n):
for k in range(j+1, n):
if a[i] + a[j] + a[k] == 0:
...
Полный перебор троек. Существует решение за O(n²) — сортировка плюс два указателя; типичная задача собеседования на переход от перебора к оптимизации.
T(n) = O(n³) перебором, O(n²) после сортировкиДопустимый размер входа
Полиномиальные алгоритмы считаются практически применимыми, но лишь при малых степенях. Разница между O(n²) и O(n³) при n = 1000 составляет три порядка: 10⁶ против 10⁹ операций.
Класс 6 из 6 · определение и границы применимости
Экспоненциальная и факториальная сложность
Алгоритм имеет экспоненциальную сложность, если добавление одного элемента умножает объём работы на постоянную величину. Прогон не показан: при n = 24 потребовалось бы 16,7 миллиона клеток.
for mask in range(2 ** len(items)):
...
Каждый предмет либо входит в набор, либо нет: 2ⁿ вариантов. Задача о рюкзаке в лоб, поиск подмножества с заданной суммой.
T(n) = O(2ⁿ) · применимо при n ≤ 25# permutations перебирает все порядки
# обхода городов: 3 города → 6 маршрутов,
# 4 города → 24, 10 городов → 3 628 800
for route in permutations(cities):
length = total_distance(route)
Задача коммивояжёра полным перебором: проверяется каждый возможный порядок обхода. При 20 городах это 2,4·10¹⁸ маршрутов — перебор невозможен ни при каком быстродействии.
T(n) = O(n!) · применимо при n ≤ 11Рост числа вариантов
Экспоненциальные решения не всегда означают ошибку: при малом n они допустимы и просты. Ограничение n ≤ 20 в условии задачи, как правило, указывает именно на полный перебор. Способы сократить перебор — раздел 7 курса.
Раздел V · сравнение на одной задаче
Поиск дубликата: линейное решение против квадратичного
Определить, есть ли в списке два одинаковых номера. Слева — проход с множеством просмотренных значений, справа — перебор всех пар.
проверок 0
сравнений 0
24 проверки против 276 при n = 24. На 1024 элементах — в 511 раз.
Логика та же. Изменилась структура: проверка «видели ли раньше» стоит 1, а не n.
Смена структуры меняет класс сложности, не меняя ни задачи, ни ответа. На собеседовании решение ускоряют выбором структуры, в которой нужная операция дешевле, а не переписыванием того же перебора.
Раздел V · кривые роста
Пять классов на одной координатной сетке
По горизонтальной оси — размер входа, по вертикальной — число операций. Кривые построены по функциям из соответствующих оценок.
- O(1) горизонтальная прямая: значение не зависит от n
- O(log n) возрастает всё медленнее: удвоение n добавляет одну операцию
- O(n) прямая: во сколько раз вырос вход, во столько же выросла работа
- O(n log n) растёт быстрее линейной, оставаясь сопоставимой с ней
- O(n²) покидает область графика на втором десятке
Верхняя граница шкалы выбрана по кривой n log n: при шкале по n² три нижних кривых были бы неразличимы. Точки пересечения в левой части — область малых n, где асимптотическая оценка не определяет выбор.
Раздел V · числовые значения
Порядок роста в числах операций
| Класс | n = 10 | n = 1 000 | n = 1 000 000 | Оценка времени при 10⁷ операций в секунду |
|---|---|---|---|---|
| O(1) | 1 | 1 | 1 | не зависит от n |
| O(log n) | 3 | 10 | 20 | практически не зависит от n |
| O(n) | 10 | 1 000 | 10⁶ | 0,1 секунды |
| O(n log n) | 33 | 10⁴ | 2·10⁷ | около 2 секунд |
| O(n²) | 100 | 10⁶ | 10¹² | более суток |
| O(2ⁿ) | 1 024 | 10³⁰¹ | — | неприменимо |
Столбец n = 10 показывает, почему оценка не применяется к малым входам: различие между 33 и 100 операциями несущественно. Столбец n = 10⁶ показывает обратное: при том же алгоритме различие между O(n log n) и O(n²) достигает пяти порядков. На этом экране заканчивается первая пара.
Раздел VI · стоимость библиотечных операций
Операции, содержащие скрытый проход
Отсутствие цикла в тексте не означает постоянной стоимости: часть операций языка выполняет полный просмотр внутри себя. Ниже фрагмент 10 из fragments.py: цикл записан один, сложность квадратичная.
def unique_keep_order(rows):
result = []
for row in rows: # n итераций
if row not in result: # до n сравнений
result.append(row)
return result
Число операций: n итераций × до n сравнений = O(n²). На отчёте из 10 000 строк без повторов это до 50 миллионов сравнений. Замена списка result на множество снижает оценку до O(n) ценой O(n) памяти.
| Операция | Стоимость |
|---|---|
x in data · список | O(n) |
x in seen · множество | O(1) |
sorted(data) | O(n log n) |
data[1:] · копирование среза | O(n) |
data.insert(0, x) · сдвиг | O(n) |
data.pop(0) · сдвиг | O(n) |
sum(data), max(data) | O(n) |
data.append(x) | O(1)* |
len(data) | O(1) |
Раздел VI · зависимость от данных
Лучший, средний и худший случай
На входах одного размера алгоритм может выполнять разное число операций. Линейный поиск прекращает работу при первом совпадении, поэтому T(n) — не одна функция, а три.
[902, 417, 133, 604, 288, 715, 950, 366]
1 сравнение
Искомый элемент находится в первой позиции.
[417, 133, 604, 902, 288, 715, 950, 366]
4 сравнения
Математическое ожидание по равновероятным позициям равно (n+1)/2 — тот же порядок роста.
[417, 133, 604, 288, 715, 950, 366, 481]
8 сравнений
Элемент отсутствует либо находится в последней позиции.
В ответе указывают худший случай: он даёт гарантию, не зависящую от распределения данных, и именно его проверяет тестирующая система, подавая предельный вход. Средний случай называют дополнительно, когда он отличается по порядку. У быстрой сортировки это O(n log n) в среднем и O(n²) в худшем, у поиска по хеш-таблице — O(1) и O(n).
Раздел VI · рекурсивные алгоритмы
Дерево вызовов и подсчёт по уровням
Зачем отдельный приём. Всё, что было раньше, работало с циклами: посчитал итерации — получил оценку. У рекурсии циклов нет, а функция вызывает саму себя, и сколько раз — по коду сразу не видно.
Поэтому считают по дереву вызовов: сколько работы на одном уровне и сколько уровней всего.
Сортировка слиянием: вызов на массиве длины n порождает два вызова на половинах и выполняет слияние за O(n). На каждом уровне суммарная длина обрабатываемых участков равна n, а уровней — log₂n. Произведение даёт O(n log n).
Как считать любую рекурсию: ① выписать, что делает один вызов кроме рекурсивных; ② определить, на сколько частей задача делится и какого они размера; ③ посчитать число уровней; ④ умножить работу уровня на число уровней.
Здесь: один вызов делает слияние за n, деление пополам даёт log₂n уровней, значит n × log n.
уровней log₂8 = 3 слияния · работа уровня n = 8 · итого 24 просмотра
Раздел VI · амортизированный анализ
Стоимость, усреднённая по последовательности операций
Мы записали append в таблицу со стоимостью 1. Изредка это неправда: одно добавление стоит n. Вопрос в том, можно ли всё равно считать его единицей.
Список хранится в непрерывном участке памяти фиксированной ёмкости. Когда ёмкость исчерпана, выделяется участок примерно вдвое больше и элементы копируются: такое append стоит O(n), все остальные — O(1).
Подсчёт для 1000 добавлений: расширений 10, скопировано 1023 элемента, всего около 2023 операций — 2,02 операции на одно добавление. Величина не растёт с n, поэтому append называют операцией амортизированной стоимости O(1).
| Добавление № | Ёмкость до | Расширение | Скопировано |
|---|---|---|---|
| 1 | 0 | да | 0 |
| 2 | 1 | да | 1 |
| 3 | 2 | да | 2 |
| 5 | 4 | да | 4 |
| 9 | 8 | да | 8 |
| 10…16 | 16 | нет | 0 |
| 513…1000 | 1024 | нет | 0 |
Дорогие расширения случаются всё реже: промежуток между ними удваивается — поэтому O(1) для append законно. А insert(0, x) дорог каждый раз, и там O(n) остаётся.
Не путать со средним случаем: средний случай усредняет по входным данным, амортизированная стоимость — по последовательности операций, без предположений о данных.
За клавиатуру · 6 минут
Запустить счётчик операций и прочитать вывод
1 · что сделать
cd lesson_02
python opcount.py \
--sizes 8 16 32 64 128 1024
Файл лежит в course-materials/complexity/ — скопируйте папку к себе. Скрипт ничего не спрашивает и не требует установки: он считает сравнения и печатает две таблицы.
2 · что появится на экране
n O(1) O(log n) O(n) O(n log n) O(n²)
8 1 6 8 12 28
16 1 8 16 32 120
32 1 10 32 80 496
64 1 12 64 192 2016
128 1 14 128 448 8128
1024 1 20 1024 5120 523776
ВО СКОЛЬКО РАЗ БОЛЬШЕ, КОГДА n УДВАИВАЕТСЯ
16 1.00x 1.33x 2.00x 2.67x 4.29x
128 1.00x 1.17x 2.00x 2.33x 4.03x
3 · на что смотреть
- Столбец O(1) — единица в каждой строке. Сколько бы ни было данных, работа не меняется.
- Столбец O(n) — числа совпадают с n. Один проход по списку.
- Столбец O(n²) — при n = 1024 полмиллиона. В 511 раз больше, чем у соседнего столбца O(n).
- Вторая таблица — во сколько раз выросло при удвоении n. Это главное: 2.00x у линейного, 4.03x у квадратичного, 1.17x у логарифмического.
Проверка: добавьте 2048 в список размеров. Множитель у O(n²) останется около 4, у O(log n) станет ближе к 1,1. Выпишите оба числа в notes.md.
Раздел VII · память
Память считают по созданным структурам, а не по строкам кода
S(n) — сколько новых ячеек заводит решение сверх того, что ему передали. Список, пришедший в аргументах, не считается: он существовал до вызова.
Переменных столько же при любом n: счётчик, границы, накопитель. Сколько бы раз цикл ни повторился, новых ячеек не прибавляется.
Создана структура, длина которой повторяет вход: множество, словарь, копия, срез, новый список из sorted.
Рекурсия: каждый незавершённый вызов держит свой кадр в стеке. Глубина рекурсии и есть оценка памяти.
Отсюда рабочий вопрос к коду: что здесь создаётся и растёт ли оно вместе с n. Цикл сам по себе память не занимает; занимает то, что внутри него накапливается. А требование «дополнительной памяти O(1)» в условии сразу отсекает решения с множеством или копией — даже если по времени они лучше.
count = 0 — одна переменная, при n = 10⁶ тоже однаlow, high, mid — три переменные, от n не зависятseen = set(badges) — множество из всех номеровcopy = badges[:] — срез копирует целикомsorted(badges) — новый упорядоченный списокВерхние три строки не меняются с ростом n → S(n) = O(1). Нижние три повторяют длину входа → S(n) = O(n). Больше памяти брать неоткуда: всё, что не переменная и не входной список, — это созданная структура.
Раздел VII · память · примеры
Сколько памяти у знакомых решений
Четыре функции, которые уже разбирались. Время у них разное, а память определяется одним: завели новую структуру или обошлись переменными.
def count_late(orders):
late = 0
for order in orders:
if order.delay > 0:
late += 1
return late
Одна переменная late. Список из десяти заказов и из миллиона обрабатываются с одной и той же дополнительной памятью.
def has_pair(nums, target):
for i in range(len(nums)):
for j in range(i + 1, len(nums)):
if nums[i] + nums[j] == target:
return True
return False
Самое медленное решение из четырёх, O(n²) по времени. Но памяти не занимает: только счётчики i и j.
def has_duplicate(badges):
seen = set()
for badge in badges:
if badge in seen:
return True
seen.add(badge)
return False
Самое быстрое, O(n) по времени. Плата — множество, которое в худшем случае вырастет до размера входа.
def top_three(prices):
return sorted(prices)[:3]
Циклов нет, но sorted() возвращает новый список из всех цен. Срез из трёх элементов берётся уже от него.
Правило для разметки: завели множество, словарь, копию или результат sorted() — это O(n) памяти; обошлись счётчиками и индексами — O(1). Промежуточных случаев на этом занятии не встретится.
Раздел VIII · границы метода
Два случая, когда оценка не отвечает на вопрос
Оценка говорит, что будет при большом n. Есть две ситуации, где этого недостаточно, и решение выбирают не по ней.
Квадратичное решение с простым телом цикла обгоняет «правильное» n log n, пока элементов немного: у второго больше действий на каждом шаге.
| n | n² | 100·n log n | быстрее |
|---|---|---|---|
| 16 | 256 | 6 400 | n² |
| 256 | 65 536 | 204 800 | n² |
| 1 000 | 10⁶ | 997 000 | поровну |
| 10 000 | 10⁸ | 1,3·10⁷ | n log n |
Множитель 100 — цена одного шага: рекурсия, вызовы, работа с памятью. У двойного цикла такой цены нет.
Оценка берётся по худшему случаю. Если реальные данные до него не доходят, программа работает быстрее, чем обещает запись.
- Почти упорядоченный список. Сортировка вставками в худшем случае O(n²), а на таких данных — O(n). Встроенная сортировка Python на этом и построена.
- Элемент обычно в начале. Поиск перебором — O(n) в худшем случае, но если искомое почти всегда первое, реально это одно-два сравнения.
- Словарь и множество. O(1) в среднем и O(n) в худшем — при массовых совпадениях хешей. На практике исходят из среднего.
Оценку называют всегда: она отсекает заведомо неподходящие решения до написания кода. Но если размер данных известен и невелик, выбирают замером. Оценка отвечает на вопрос «что будет, когда данных станет много», замер — «что быстрее вот на этих данных». Замерами занимаемся на занятии 4.
Раздел IX · применение
Ограничение входа определяет допустимый класс
Обратная задача анализа: по ограничению из условия определить, какие решения имеют шанс уложиться в лимит времени. Ориентир для Python — 10⁷ элементарных операций в секунду; величина приблизительная и зависит от машины и содержимого цикла.
| Ограничение | Допустимый класс | Типичное решение |
|---|---|---|
| n ≤ 12 | O(n!) | перебор перестановок |
| n ≤ 25 | O(2ⁿ) | перебор подмножеств |
| n ≤ 500 | O(n³) | тройной вложенный цикл, матрицы |
| n ≤ 5 000 | O(n²) | перебор всех пар |
| n ≤ 10⁵ | O(n log n) | сортировка, бинарный поиск по ответу |
| n ≤ 10⁶ | O(n) | проходы, множество, словарь |
| n ≤ 10⁹ | O(log n) или O(1) | формула, половинное деление |
Последняя строка: при n ≤ 10⁹ один проход по входу требует около двух минут — значит, исключён самим ограничением. Если при этом на входе одно число, а не список, ответ получают формулой либо делением пополам.
Раздел IX · формулировка оценки
Как оценка сообщается на собеседовании
Ответ состоит из четырёх элементов: класс по времени с указанием подсчитанных операций, класс по памяти с указанием хранимого, худший случай и рассмотренные альтернативы.
Образец полного ответа
Время — O(n): один проход, на каждом элементе проверка по множеству за O(1).
Память — O(n): храню множество просмотренных номеров.
Худший случай — все номера различны: ранний выход не срабатывает, множество заполняется целиком.
Альтернатива — перебор пар: O(n²) времени и O(1) памяти; выбрал первый вариант, поскольку ограничения по памяти в условии нет.
Что оценивает интервьюер
- названы операции, а не только запись класса
- память оценена отдельно от времени
- указан вход, на котором достигается худший случай
- назван отвергнутый вариант и причина отказа
- оценка произнесена до написания кода, а не после
Последний пункт существеннее прочих: обсуждение сложности до кодирования показывает, что решение выбрано осознанно. Эти же четыре элемента составляют заметку о сложности в портфолио.
Раздел IX · рабочий порядок
Последовательность анализа незнакомого кода
Что именно называется n: элементы списка, символы строки, вершины графа. При нескольких входах — все величины.
Сравнение, сложение, просмотр ребра — то, что выполняется чаще прочего и определяет порядок роста.
Для каждого цикла — число итераций; для каждого вызова — его собственная стоимость, включая библиотечные операции.
Последовательные участки складываются, вложенные перемножаются; для рекурсии — работа уровня умножается на число уровней.
Отбросить постоянные множители и младшие слагаемые, оставить старшее.
Посчитать операции при n = 8 и n = 16: рост вдвое подтверждает O(n), вчетверо — O(n²), на постоянную величину — O(log n).
Тот же порядок применяется к оценке памяти: вместо числа операций выписывается объём созданных структур, включая кадры стека для рекурсивных решений.
Задание 1 домашней работы · разбираем постановку · 6 минут
Определить класс сложности чужого кода
В файле fragments.py двенадцать работающих функций из разных предметных областей: стадион, пункт выдачи, проходная, склад, архив чеков, теплица, курьеры, служба поддержки, отчёт, упаковка. Сейчас разбираем первый фрагмент вместе, остальные одиннадцать — дома.
- Откройте
answers.py: три словаря —TIME,SPACE,WORST_CASE. - Укажите T(n) и S(n) для всех двенадцати фрагментов, применяя порядок разбора по порядку из раздела IX.
- Опишите худший случай для фрагментов 01 и 04 с указанием входных данных.
- Доведите
pytestдо полного прохождения — это и есть критерий приёмки задания.
Критерии
- Запись без множителей и младших слагаемых
- S(n) — только дополнительная память
- Худший случай назван через входные данные
Если оценка не сходится
Подставьте n = 8 и n = 16, посчитайте операции и сравните числа: рост вдвое, вчетверо или на постоянную величину прямо указывает на класс.
Задание 2 домашней работы · разбираем образец · 5 минут
Подобрать решение под ограничение
1 · что сделать
В файле limits.md шесть условий. По каждому пишутся ровно три строки — те же три, что в образце справа. Код писать не нужно.
- Проходит: какой класс укладывается в лимит. Считается прикидкой: сколько операций даст перебор — n², n·q или 2ⁿ — и сравнивается с ориентиром 10⁷ операций в секунду.
- Решение: какая структура снимает лишнюю работу, и какие у неё T(n) и S(n).
- Что мешало проще: почему перебор не годится, с числом операций.
Дома: бланк lesson_02/limits.md, шесть условий. Это единственное задание, где ИИ разрешён прямо — порядок работы с ним разобран на экране домашнего задания.
2 · образец: так выглядит ответ на одно условие
Условие 1. Номера заказов за сутки, n ≤ 200 000. Есть ли среди них два одинаковых?
проходит
O(n) или O(n log n). Это 2·10⁵ и 3,5·10⁶ операций — доли секунды при ориентире 10⁷.
решение
Множество просмотренных номеров: один проход, проверка принадлежности за O(1).
T(n) = O(n) · S(n) = O(n)
что мешало проще
Перебор пар пишется в четыре строки и памяти не требует, но это n²/2 = 2·10¹⁰ сравнений — около получаса.
Больше трёх строк не нужно. Если по условию ограничена ещё и память, четвёртой строкой называется запасной вариант: здесь это «отсортировать и пройти соседей» — O(n log n) времени и O(1) памяти.
3 · что проверяется
- Названо число. Не «много операций», а 2·10¹⁰ — прикидка по формуле.
- Названа структура. Не «сделать быстрее», а множество, словарь, сортировка, бинарный поиск.
- Названа цена. Что теряем: память, порядок исходных данных, простоту кода.
- Две величины там, где их две. В условиях 3 и 6 в ответе должны стоять обе:
O(n + q), а неO(n).
Задание 3 домашней работы · смотрим код · 4 минуты
Оценить незнакомый алгоритм
Дана функция, определяющая по журналу приёмов час с наибольшим числом визитов и количество различных пород в этот час. Код работающий, изменять его не требуется.
- T(n) с указанием подсчитанных операций.
- S(n): объём дополнительной памяти и что в нём хранится.
- Худший случай: журнал, при котором выполняется наибольшее число операций.
- Строка со скрытой стоимостью: операция, содержащая внутренний проход.
Дома: бланк lesson_02/acceptance.md. Оценка засчитывается при наличии обоснования: запись класса без указания подсчитанных операций не показывает хода рассуждения.
Условия задания
Без ИИ и поисковых систем: это контроль. Запуск файла и добавление счётчиков разрешены — так и проверяют себя, когда оценка не сходится.
Сейчас смотрим код и называем, где в нём прячется скрытая стоимость. Четыре пункта пишутся дома.
Карточки · набор А · 6 минут
Определите оценки для шести фрагментов
Для каждой карточки запишите четыре величины: T(n) — число операций формулой, O — класс по времени, S(n) — память, худший случай — вход, на котором работы больше всего.
def first_order(orders):
if not orders:
return None
return orders[0]def count_late(orders):
late = 0
for order in orders:
if order.delay > 0:
late += 1
return latedef avg_and_max(temps):
total = 0
for t in temps:
total += t
best = temps[0]
for t in temps:
if t > best:
best = t
return total / len(temps), bestdef has_pair(nums, target):
for i in range(len(nums)):
for j in range(i + 1, len(nums)):
if nums[i] + nums[j] == target:
return True
return Falsedef unique_codes(codes):
result = []
for code in codes:
if code not in result:
result.append(code)
return resultdef steps_to_one(number):
steps = 0
while number > 1:
number //= 2
steps += 1
return stepsКарточки · набор А · разбор
Ответы с подсчётом
3 операции: проверка, обращение по индексу, возврат
удвоили вход → ×1, ничего не изменилось
T(n) = 3 · S(n) = O(1) · худший случай: любой вход
заголовок n, сравнение n, late += 1 в худшем случае тоже n, вне цикла 2
удвоили вход → ×2
T(n) = 3n + 2 · S(n) = O(1) · худший случай: опоздали все заказы
два цикла подряд, стоимости складываются: 2n + 3n + 6. Во втором цикле best = t в худшем случае выполняется каждый раз
удвоили вход → ×2
T(n) = 5n + 6 · S(n) = O(1) · худший случай: температуры возрастают
сравнений n(n−1)/2, плюс заголовки обоих циклов. Это пример ④ из раздела III
удвоили вход → ×4
T(n) = n² + n + 2 · S(n) = O(1) · худший случай: подходящей пары нет
цикл один, но not in по списку стоит до n: n × n
удвоили вход → ×4
T(n) = n² + 2n + 2 · S(n) = O(n) · худший случай: все коды различны
k = log₂number делений, на каждом три действия: проверка, деление, счётчик. Для 10⁹ — 30 итераций
удвоили вход → +1 операция
T(n) = 3·log₂n + 3 · S(n) = O(1) · размер входа — число разрядов
Как здесь считаются операции
Считаются действия, а не строки: total / len(temps) — два действия. Заголовок цикла for x in seq — по одному действию на итерацию, всего n; for i in range(len(x)) — плюс одно разовое. Строка внутри if считается по худшему случаю: столько раз, сколько условие может оказаться истинным.
Последнее правило и отличает А3 от примера ③ раздела III. Там условие value > average истинно не для всех значений, а здесь строго возрастающие температуры переписывают best на каждом шаге. Отсюда 5n вместо 4n. Константа зависит от того, насколько мелко дробить действия, поэтому сверяется коэффициент при n и старшее слагаемое.
Разбор А3 и А5 рядом показывает главное различие: в А3 циклы идут подряд и стоимости складываются, в А5 проход спрятан внутри строки и стоимости перемножаются.
Карточки · набор Б · 6 минут
Шесть фрагментов с другими характеристиками
Во втором наборе встречаются оценки с двумя величинами, память рекурсии и класс, которого не было в первом наборе. Записывайте те же четыре величины.
def top_three(prices):
return sorted(prices)[:3]def merge_reports(day, night):
result = []
for row in day:
result.append(row)
for row in night:
result.append(row)
return resultdef count_words(words):
counts = {}
for word in words:
counts[word] = counts.get(word, 0) + 1
return countsdef fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)def assign(orders, couriers):
for order in orders:
for courier in couriers:
if fits(order, courier):
mark(order, courier)def find_code(codes, target):
low, high = 0, len(codes) - 1
while low <= high:
mid = (low + high) // 2
if codes[mid] == target:
return mid
if codes[mid] < target:
low = mid + 1
else:
high = mid - 1
return -1Карточки · набор Б · разбор
Ответы с подсчётом
циклов нет, но sorted() сортирует весь список; срез из трёх элементов стоит O(1)
удвоили вход → ×2,2
T(n) ≈ n log n + 4 · S(n) = O(n) — sorted создаёт новый список
два цикла по разным спискам, в каждом заголовок и append: 2n + 2m + 2. Одной буквой не записывается
удвоили оба входа → ×2
T = 2n + 2m + 2 · S = O(n + m) — результат хранит все строки
n итераций, в строке три действия: get, сложение и запись по ключу. Каждое стоит 1
удвоили вход → ×2
T(n) = 4n + 2 · S(n) = O(n) — словарь растёт с числом различных слов
каждый вызов порождает два новых: 2ⁿ вызовов. fib(40) — около 10⁹
увеличили n на 1 → ×2
T(n) = 2ⁿ · S(n) = O(n) — глубина стека рекурсии
вложенные циклы по разным величинам: n заказов × m курьеров. Внутри проверка и, в худшем случае, назначение
удвоили оба входа → ×4
T = 3n·m + n · S = O(1) · при n = m это O(n²) · худший случай: подходят все пары
log₂n шагов, на каждом около шести действий: проверка цикла, середина, два сравнения, сдвиг границы. Требует упорядоченного списка
удвоили вход → +1 шаг
T(n) ≈ 6·log₂n + 4 · S(n) = O(1) · худший случай: элемента нет
Три ответа из шести содержат то, о чём чаще всего забывают: Б1 — память под результат sorted(), Б2 и Б5 — вторую величину в оценке, Б4 — память под стек рекурсии. Именно эти три пункта проверяются на приёмке.
Справка · материал занятия в сжатом виде
Сводка
T(n) = O(f(n))
⟺ ∃ c, n₀: T(n) ≤ c·f(n)
при всех n ≥ n₀
1 ≺ log n ≺ n ≺ n log n
≺ n² ≺ n³ ≺ 2ⁿ ≺ n!
O(c·f) = O(f)
O(f) + O(g) = O(max)
O(f) · O(g) = O(f·g)
подряд — складываем
вложено — перемножаем
деление — логарифм
вызов в цикле — умножаем
x in список — O(n)sorted() — O(n log n)data[1:] — O(n)insert(0, x) — O(n)
S(n) — созданное сверх входа
счётчики, индексы — O(1)
set, копия, sorted() — O(n)
рекурсия — O(глубины)
Форма ответа: «Время O(?), поскольку подсчитанные операции. Память O(?): хранится что. Худший случай — входные данные. Альтернатива — вариант и причина отказа».
Справка · распространённые ошибки
Шесть неверных утверждений
Последовательные участки складываются: n + n = 2n, класс O(n). Перемножаются только вложенные.
n(n−1)/2 отличается от n² постоянным множителем, который отбрасывается по свойству оценок.
sorted(), in для списка и срез выполняют проход по всем элементам без явного цикла.
При постоянном множителе 100 у второго квадратичное решение выигрывает до n ≈ 1000. Оценка описывает поведение при росте n.
Число операций определяется алгоритмом. От реализации и машины зависит время выполнения — занятие 4.
Каждый незавершённый вызов занимает кадр стека: глубина d даёт O(d) дополнительной памяти.
Вопрос на выход
Алгоритм выполняет 2n + 5 сравнений.
Укажите класс сложности и обоснуйте.
Полный ответ содержит три элемента: запись класса, применённые свойства оценок и пару (c, n₀), при которой выполняется определение.
Домашнее задание № 2 · к следующему занятию
Максим Николаевич не хочет задавать домашнее задание. Но очень хочет
Три задания — это те три практики, которые мы разобрали в конце занятия. Задачи вы уже видели, порядок разбора знаете; дома остаётся довести до конца.
- ①Разметка ·
answers.pyT(n) и S(n) для всех двенадцати фрагментов, худший случай для 01 и 04. Довести до полного прохождения
pytest. Без ИИ: оценку называете сами. - ②Ограничения ·
limits.mdШесть условий по три строки. С ИИ — и к каждому ответу модели своя строка «почему это верно», с числом.
- ③Приёмка ·
acceptance.mdОценить незнакомый алгоритм из
acceptance_vetclinic.pyпо четырём пунктам. Без ИИ: это контроль.
Домашнее задание № 2 · задание 2 · как именно работать с моделью
Модель отвечает, разбираетесь вы
Второе задание — единственное, где ИИ разрешён прямо и намеренно. Задача — научиться проверять чужую оценку: то же самое вы делаете на review и на секции, когда решение предлагает кто-то другой.
Дать модели условие из limits.md целиком и попросить три строки: какой класс проходит, какое решение, что мешало проще.
Пересчитать её прикидку самому: сколько операций даёт перебор и как это соотносится с ориентиром 10⁷ в секунду. Число должно сойтись.
К каждому из шести ответов — своя строка с обоснованием. Строка без числа не засчитывается.
Обязательный седьмой пункт файла
Одно место, где модель ошиблась или ответила расплывчато, и как вы это поймали. Такое место найдётся: модели уверенно пишут O(n) там, где внутри стоит in по списку, и путают O(n + q) с O(n), когда величин на входе две.
Если после проверки ошибок не нашлось — так и напишите, но приложите расчёт по двум условиям, которым вы её проверяли. Пункт «ошибок нет» без расчёта не принимается.
Домашнее задание № 2 · порядок сдачи
Куда положить и как сдать
lesson_02/ в личном репозитории: answers.py, limits.md, acceptance.md. Бланки и чек-лист уже лежат в starter-pack.
Ветка hw-02, коммит с содержательным сообщением, Pull Request в собственную main.
До начала занятия 4: с проверки этих оценок замером занятие и начнётся.
Зелёный pytest, шесть обоснований с числами, седьмой пункт про ошибку модели, четыре пункта приёмки.
Про ИИ в первом и третьем задании: там он закрыт, потому что на занятии 4 мы измеряем ровно те решения, которые вы разметили, и расхождение сразу видно. Во втором задании ИИ разрешён, я сам так делаю всегда. Разница между «спросил» и «списал» — та самая строка «почему это верно».