Big O Notation
Язык, на котором индустрия обсуждает скорость и память алгоритмов
Big O Нотация - это способ описать, как растёт время работы или память алгоритма при увеличении размера входных данных n. Она не говорит, сколько миллисекунд займёт запуск на конкретном компьютере - она описывает форму роста: линейный, квадратичный, логарифмический и так далее. Одна и та же нотация одинаково точна и для ноутбука, и для суперкомпьютера, потому что она про количество шагов, а не про секунды.
Зачем это нужно: без Big O сравнение двух алгоритмов сводится к «запустил и засёк время» - результат зависит от железа, языка программирования, загрузки процессора в момент теста. Big O даёт общий язык: сказать «O(n²)» значит одно и то же для любого инженера, на любом языке программирования, в любой команде.
Разница видна на числах. При n = 1 000 линейный алгоритм O(n) сделает 1 000 шагов, а квадратичный O(n²) - 1 000 000 шагов. При n = 1 000 000 разрыв станет триллион против миллиона - вот что на самом деле стоит за буквой «O».
У большинства классов сложности есть узнаваемый признак прямо в структуре кода - не нужно ничего пересчитывать, чтобы прикинуть класс на глаз.
- Один цикл без вложенности по всем n элементам - O(n).
- Цикл внутри цикла, оба по n элементам - O(n²).
- Размер входа делится пополам на каждом шаге (бинарный поиск, разбиение в Quick Sort) - O(log n).
- Внутри одного цикла вызывается ещё одна O(n)-операция (например
.includes()внутриfor) - спрятанный O(n²), частая ошибка. - Постоянное число действий, не зависящее от n (доступ по индексу массива) - O(1).
Постоянные множители не учитываются: цикл, который делает 5 операций на каждый элемент, всё равно O(n), а не O(5n) - Big O описывает форму роста, а не точное число шагов.
У одного и того же алгоритма может быть несколько разных чисел Big O - в зависимости от того, какие данные ему достались. Линейный поиск находит элемент за 1 шаг, если он первый (лучший случай, O(1)), в среднем проверяет половину массива на случайных данных (средний случай, O(n)), и проверяет весь массив, если элемента нет вообще (худший случай, O(n)).
Когда об алгоритме говорят просто «O(n)» без уточнений - почти всегда имеют в виду худший случай: гарантию, что хуже точно не будет, независимо от того, какие данные ему подсунут.
Быстрые
- O(1) - константная. Доступ к элементу массива по индексу.
- O(log n) - логарифмическая. Бинарный поиск в отсортированном массиве.
Умеренные
- O(n) - линейная. Один проход по всем элементам: поиск, сумма, копирование.
- O(n log n) - линейно-логарифмическая. Эффективные сортировки сравнением: Merge Sort, Heap Sort.
Медленные
- O(n²) - квадратичная. Вложенный цикл: Bubble Sort, Insertion Sort.
- O(n³) - кубическая. Тройной вложенный цикл, например перебор всех троек элементов.
Катастрофические
- O(2ⁿ) - экспоненциальная. Перебор всех подмножеств множества из n элементов.
- O(n!) - факториальная. Перебор всех перестановок n элементов - самый редкий и самый тяжёлый случай.
Дальше в этом разделе - подробный разбор каждого класса: график роста, разбор кода и примеры из уже реализованных сортировок.
Восемь классов выше покрывают почти весь код, который реально пишут в индустрии, но существуют и более редкие классы. Они не получили в этом разделе отдельных страниц - каждый либо частный случай уже разобранных восьми, либо встречается настолько редко, что для него не нужна отдельная карточка.
- O(√n) - корневая, растёт быстрее логарифма, но заметно медленнее линейной. Пример: проверка числа на простоту перебором делителей только до квадратного корня из него - для миллиона это тысяча проверок вместо миллиона.
- O(log log n) - ещё медленнее логарифма. Пример: дерево ван Эмде Боаса для операций «предшественник» и «преемник», интерполяционный поиск в среднем случае на равномерно распределённых отсортированных данных.
- Смешанные классы вроде O(n log² n) - возникают, когда одна структура данных с логарифмической стоимостью операции вложена в цикл на n элементов. Пример: персистентное дерево отрезков, где каждый из n запросов сам стоит O(log² n).
- O(nᵏ) при k больше трёх - обобщённая полиномиальная сложность, о которой уже говорилось в материале про O(n³). Пример: перебор всех четвёрок элементов массива, чтобы проверить, образуют ли какие-то из них прямоугольник, - O(n⁴).
- O(nⁿ) - гипотетический класс хуже факториала: растёт даже быстрее n!, потому что на каждом из n шагов выбор идёт заново из всех n вариантов, а не из уменьшающегося набора, как при переборе перестановок. Почти не встречается в реальных алгоритмах - обычно это признак кода, который стоит переписать.
Если в разделе появится алгоритм, для которого один из этих классов - не побочная деталь, а суть, для него заведётся отдельный материал по тому же стандарту, что и у восьми основных.
O(1) - Константная Сложность
O(1) - это когда объём работы вообще не зависит от того, сколько данных на входе. Массив из 10 элементов или из 10 000 000 - разницы никакой, операция займёт одно и то же время. Это самый быстрый класс сложности из всех и одновременно теоретический потолок скорости: быстрее просто не бывает.
O(log n) - Логарифмическая Сложность
O(log n) - это когда каждый шаг алгоритма отбрасывает половину оставшихся данных, вместо того чтобы проверять их по одной. Массив из 1 000 000 элементов такой алгоритм разберёт всего за 20 шагов, а не за миллион. Это самый заметный переход от «медленно» к «быстро»: данных стало в тысячи раз больше, а шагов - всего на несколько штук.
O(n) - Линейная Сложность
O(n) - это когда работы становится ровно во столько же раз больше, во сколько выросли данные. 10 элементов - 10 шагов. 100 элементов - 100 шагов. Никаких сюрпризов: сколько данных, столько и работы. Это самый простой и понятный класс сложности - примерно так люди и представляют себе «обработать список», даже без всякой математики.
O(n log n) - Линеарифмическая Сложность
O(n log n) - это когда алгоритм делит данные пополам снова и снова (получается log n уровней), а на каждом уровне честно обрабатывает все n элементов. Итоговая работа - произведение этих двух чисел, не сумма. Это класс, в котором живёт почти любая быстрая сортировка общего назначения: заметно быстрее, чем перебор всех пар (O(n²)), но чуть дороже, чем один проход по данным (O(n)).
O(n²) - Квадратичная Сложность
O(n²) - это когда объём работы растёт как квадрат размера входных данных. Вдвое больше данных - не вдвое, а вчетверо больше работы. Это класс, в котором живёт каждый код с циклом внутри цикла, где оба цикла зависят от одного и того же n - самый частый источник неожиданных тормозов, потому что на маленьких данных он выглядит совершенно безобидно.
O(n³) - Кубическая Сложность
O(n³) - это когда объём работы растёт как куб размера входных данных: три вложенных цикла, и все три зависят от одного и того же n. Вдвое больше данных - не вчетверо, как у O(n²), а в восемь раз больше работы. Это класс, где цена лишнего уровня вложенности особенно заметна: третий цикл добавляет не ещё немного работы, а умножает всё, что было, на n.
O(2ⁿ) - Экспоненциальная Сложность
O(2ⁿ) - это когда каждый новый элемент на входе удваивает объём работы. Один дополнительный элемент - вдвое больше шагов, два дополнительных - вчетверо больше. Это класс сложности, за которым обычно стоит наивный перебор: код, который на каждом шаге ветвится на два варианта и исследует оба, вместо того чтобы переиспользовать уже посчитанное.
O(n!) - Факториальная Сложность
O(n!) - это самый быстрорастущий класс сложности из тех, что реально встречаются в коде. За ним стоит перебор всех возможных порядков расположения n элементов: первый элемент можно выбрать n способами, второй - оставшимися n - 1, третий - n - 2, и так далее, пока не переберётся каждая перестановка. Растёт быстрее любой экспоненты и быстрее любого полинома, и уже на скромных n превращает алгоритм в неработоспособный.
Best, Average, and Worst Case
Лучший, средний и худший случай - это не отдельный класс сложности, а способ описать, что один и тот же алгоритм может работать по-разному в зависимости от того, какие именно данные ему достались. Три отдельных значения Big O для одного алгоритма - не прихоть, а необходимость: одно число часто просто врёт о том, чего ждать на практике.