Сложность алгоритмов: O-нотация и классы скорости роста
Почему программа на маленьких данных летает, а на больших умирает: определение O-большого, таблица классов роста, подсчёт операций и честные примеры.
Программа, которая мгновенно обрабатывает сто строк, на миллионе виснет намертво. Погони за железом здесь обычно нет: умирает не машина, а алгоритм, который делает слишком много работы. Умение заранее оценить, сколько шагов сделает алгоритм на входе размера , отделяет инженера от человека, копирующего код со Stack Overflow. Оценка эта записывается специальным языком — O-нотацией, и после этого урока вы будете читать записи вроде как открытую книгу.
Время работы и размер входа#
Секунды — плохая мера работы алгоритма: они зависят от процессора, языка и настроения компилятора. Вместо этого считают базовые операции — сравнения, присваивания, обращения к памяти — и смотрят, как их число растёт вместе с размером входа : длиной массива, числом вершин графа, количеством строк. Обычно интересуются худшим случаем: гарантия «не дольше, чем столько-то» полезнее средних обещаний, когда на кону отзывчивость системы. Функцию — число операций в худшем случае — и описывает O-нотация.
Читаем определение по-человечески: растёт не быстрее, чем , если константный множитель и участок малых не считать. Запись верна: при сумма не превышает , константа найдена. Рядом живут два соседа: — оценка снизу («растёт не медленнее»), — точная зажимающая оценка с обеих сторон. На практике чаще всего пишут , имея в виду разумную верхнюю границу порядка роста.
Правила работы с O-нотацией#
Четыре правила снимают почти все вопросы упрощения. Первое — константы не важны: , потому что множитель зажат в из определения. Второе — младшие слагаемые отбрасываются: при больших член высшего порядка съедает все остальные, что и показал график выше. Третье — сумма порядков равна максимуму: , медленная часть прячется за быстрой. Четвёртое — основание логарифма не важно: , а изменение основания — тоже константный множитель, поэтому пишут просто . Произведения же остаются как есть: — вложенные циклы умножают работы.
| Класс | $n = 10$ | $n = 100$ | $n = 1000$ | Типичный источник |
|---|---|---|---|---|
| 1 | 1 | 1 | доступ к элементу массива по индексу | |
| ≈3,3 | ≈6,6 | ≈10 | бинарный поиск | |
| 10 | 100 | 1000 | один проход по массиву | |
| ≈33 | ≈664 | ≈10 000 | сортировка слиянием | |
| 100 | 10 000 | 10⁶ | вложенные циклы | |
| 1024 | ≈1,3·10³⁰ | — | перебор всех подмножеств | |
| 3 628 800 | ≈9,3·10¹⁵⁷ | — | перебор маршрутов коммивояжёра в лоб |
Таблица стоит любого десятка определений. При квадратичный алгоритм делает миллион операций — компьютеру смешно. При это уже операций: на машине со операций в секунду — семнадцать минут против долей секунды у линейного прохода и около операций у . Рост порядка — это не «чуть медленнее», это смена категории: от мгновения к минутам, от минут к годам.
Оговорка о честности: сложность бывает не только худшей. Быстрая сортировка в среднем делает операций, но на неудачных опорных элементах деградирует до — поэтому в библиотеках её страхуют переключением на другой алгоритм или случайным выбором опоры. Когда в задаче не сказано, о каком случае речь, спрашивайте сами: худший даёт гарантию, средний — ожидание, и это разные обещания.
Учимся считать: разбор типовых алгоритмов#
Оценка сложности — это подсчёт операций, а не гадание. Бинарный поиск в отсортированном массиве: на каждом шаге половина выбрасывается, размер задачи идёт — вопрос «за сколько шагов останется один элемент?» равен вопросу «сколько раз делится на 2», ответ . Для миллиона элементов — около шагов: . Логарифмическая сложность — фирменный знак «делим задачу пополам»; тот же счёт ведёт к глубине сбалансированных деревьев в структурах данных.
Вложенные циклы умножают. Внешний цикл по от 1 до , внутренний — по от 1 до : на -м витке внутренний делает операций, всего . Половинка — константа, а сумма старших членов — снова : ответ . Сортировка слиянием делит массив пополам и сливает: рекуррента разворачивается в уровней по операций, итого — развёртка показана в уроке о рекуррентных соотношениях. А перебор всех маршрутов коммивояжёра делает вариантов — факториал, против которого бессильна любая техника; прогресс здесь возможен только сменой постановки задачи.
- Внешний цикл выполняется раз; на витке с номером внутренний цикл делает операций.
- Суммируем: .
- Арифметическая прогрессия: .
- Упрощаем по правилам O-нотации: — константа, слагаемое — младший порядок. Остаётся .
- Ответ: . Контрольная проверка: при получаем операций — между и , ближе к квадрату.
P, NP и честная граница мечты#
Классы сложности делят задачи не по алгоритмам, а по самим задачам. P — задачи, решаемые за полиномиальное время, вроде сортировки () или кратчайших путей у Дейкстры из соответствующего урока. NP — задачи, чей ответ можно быстро проверить: показать готовый маршрут коммивояжёра длиной меньше K — минутное дело, а найти такой — неизвестно как. Вопрос «совпадают ли P и NP» — открытая проблема с миллионным призом; практический итог знают все: для NP-трудных задач заменяют точные алгоритмы приближенными и эвристиками.
Куда двигаться дальше? Сложность неразрывна с рекуррентами: любое время рекурсии — рекуррентное соотношение, и уметь его разворачивать — половина дела. Другая половина — практика: считайте операции в собственном коде, найдите двойной цикл и спросите себя, не обойтись одним проходом. Заодно посмотрите потоки в сетях — там алгоритм Форда-Фалкерсона оценивается числом итераций, и это живой пример того, как анализ сложности выбирает между вариантами реализации.
Финальная проверка наблюдательности: в таблице выше для записан как — посчитано через формулу Стирлинга, а само число шагов алгоритма коммивояжёра, , для уже превысит сорок миллиардов. Числа такого масштаба — лучшая мотивация выучить факториал не как значок калькулятора, а как реальную категорию медлительности. Ну а считать перестановки и сочетания быстро — отдельный навык, его качает тренажёр по комбинаторике.
Упростите: 3n² + 100n + 7. Чему это равно в O-нотации?
Сколько сравнений в худшем случае сделает бинарный поиск по отсортированному массиву из 1 000 000 элементов?
Что растёт быстрее при неограниченном росте n?
Частые вопросы
Чем O-большое отличается от тета Θ?
O — верхняя граница: 3n² + n = O(n²) и одновременно O(n³). Θ — точная оценка порядка с двух сторон: 3n² + n = Θ(n²). В разговорной практике O часто используют там, где строго было бы Θ, — это общепринятая вольность, но на экзамене различие могут проверять.
Почему константы вообще можно отбрасывать?
Потому что O-нотация описывает масштабирование, а не абсолютное время. Константа не зависит от n, и при сравнении алгоритмов разных порядков рано или поздно старший порядок побеждает. Внутри одного порядка константы, наоборот, важны — но это уже уровень оптимизации кода, а не выбора алгоритма.
Что такое сложность по памяти и зачем она отдельно?
Это объём дополнительной памяти как функция от n, измеренный той же O-нотацией. Сортировка слиянием тратит O(n) на буфер, быстрая сортировка — O(log n) на стек рекурсии, а хеш-таблица меняет время поиска на O(1) ценой памяти. Время и память часто обмениваются друг на друга, и обе оценки смотрят вместе.
Как оценивать сложность своего кода на практике?
Идите по циклам: подряд — складывайте, вложенные — умножайте. Каждое деление задачи пополам приносит логарифм, каждый вызов рекурсии — рекурренту. Найдите самый внутренний участок и спросите, сколько раз он выполняется; это и есть доминанта. Достаточно уметь оценивать порядок — точный подсчёт не нужен.
Готовитесь к контрольной?
Чеклист тем по «Дискретка»: что вы уже умеете, что повторить и в каком порядке.
Открыть чеклист предмета →
Проверьте себя в бою
Босс-экзамен по «Дискретка»: квизы всех уроков плюс бесконечный поток сгенерированных задач. Каждая попытка — новый расклад.
Начать босс-экзамен →