O(n²) - Квадратичная Сложность

Медленная

O(n²) - это когда объём работы растёт как квадрат размера входных данных. Вдвое больше данных - не вдвое, а вчетверо больше работы. Это класс, в котором живёт каждый код с циклом внутри цикла, где оба цикла зависят от одного и того же n - самый частый источник неожиданных тормозов, потому что на маленьких данных он выглядит совершенно безобидно.

Проблема

Задачи вроде «сравнить каждый элемент с каждым другим» (найти дубликаты, отсортировать без лишней памяти, посчитать расстояние между всеми парами точек) естественным образом наводят на код с двумя вложенными циклами. На 10 элементах это 45 сравнений - мгновенно. На 10 000 - уже под 50 000 000, и разница между «работает» и «зависает» здесь не постепенная, а обвальная. Нужно уметь заранее увидеть эту опасность в коде, а не после того, как проект столкнётся с реальными объёмами данных.

Решение

Алгоритм - O(n²), если в нём есть два вложенных цикла, и оба зависят от размера входа n: внешний проходит по n элементам, а внутри каждой его итерации - ещё один проход, тоже по n (или почти по n) элементам. Итоговое число операций - произведение n · n = n². Узнать такой код просто: если убрать один из циклов, второй сам по себе был бы обычным O(n) - именно вложенность, а не сами циклы по отдельности, и даёт квадрат.

Как это работает

Формально O(n²) означает: работа не превышает c · n² для некоторой константы c, начиная с какого-то размера входа. У bubbleSort точное число сравнений - n(n - 1) / 2, то есть c = 1/2. Это тот же порядок роста, что и у без множителя - Big O описывает форму кривой, а не точный коэффициент перед ней.

Числа растут пугающе быстро. При n = 1 000 - около 499 500 сравнений, меньше полумиллиона, ещё терпимо. При n = 1 000 000 - уже 499 999 500 000, то есть почти полтриллиона. Данные выросли в 1000 раз, а работа - примерно в 1 000 000 раз, а не в 1000: рост здесь квадратичный по отношению к росту самих данных, при k-кратном увеличении n.

Структурно причина всегда одна: два цикла, вложенных друг в друга, и оба зависящие от n. У bubbleSort внешний цикл проходит по i от 0 до почти n, а внутри каждой его итерации - ещё цикл по j, тоже до почти n. Если убрать внешний цикл и оставить только внутренний, получился бы обычный O(n) проход - именно комбинация двух таких проходов и даёт квадрат.

O(n²) не обязательно означает «ровно n²»: n(n - 1) / 2 (треугольное число, как у bubbleSort), 3n², n² / 4 - всё это один и тот же класс O(n²), потому что все они растут пропорционально квадрату n с точностью до постоянного множителя. Отличать их по названию бессмысленно - важна форма кривой, а не число перед .

Сортировка не обязана быть O(n²) - Merge Sort и другие алгоритмы разделяй-и-властвуй (уже разобраны в материале про O(n log n)) решают ту же задачу за n log n, заметно быстрее при больших n. Но на маленьких массивах (примерно до пары десятков элементов) простой вложенный цикл Bubble Sort или Insertion Sort часто оказывается быстрее на практике - у рекурсии mergeSort есть собственные накладные расходы, которые перевешивают выигрыш от log n, пока n не станет достаточно большим.

Некоторые задачи, которые выглядят как «нужно сравнить всё со всем», на самом деле не обязаны быть O(n²) - построение выпуклой оболочки (уже упомянуто в материале про O(n log n)) наивно кажется перебором всех пар точек, но алгоритмы вроде Quickhull решают её за n log n, находя структуру в задаче вместо честного перебора. Настоящий O(n²) остаётся только там, где такой структуры нет и сравнить действительно нужно каждую пару - например, точный расчёт попарных расстояний между всеми точками.

Нюансы выбора

Малые и умеренные n - до нескольких тысяч элементов, где абсолютное время выполнения всё ещё измеряется миллисекундами, а не секундами или минутами.

Задачи, генуинно требующие всех пар - попарные расстояния, коллизии, корреляционные матрицы - там, где для конкретной задачи не существует более быстрой структуры.

Против O(n log n) - как только n выходит за пределы «маленького» (обычно уже на паре тысяч элементов), для сортировки и похожих задач почти всегда стоит переходить на алгоритм с более быстрым классом сложности.

Как индикатор скрытой проблемы - если в коде обнаружился неожиданный вложенный цикл (например, линейный поиск внутри другого цикла), это почти всегда сигнал пересмотреть структуру данных, а не мириться с квадратом.

Примеры в коде

Bubble Sort, Insertion Sort и Selection Sort (в разделе сортировок) - три классических примера квадратичной сортировки через вложенный цикл, каждый со своим вариантом того, что именно повторяется на внутреннем проходе.

Наивные физические движки - проверка коллизий каждого объекта с каждым другим (for i { for j { ... } }) без пространственного разбиения даёт O(n²) на числе объектов сцены.

Корреляционные и дистанционные матрицы в анализе данных - строки для n точек данных, где каждая пара сравнивается по отдельности, естественным образом дают таблицу размером n².

Наивное сравнение строк на схожесть (например, посимвольное сравнение всех пар строк в списке без индекса) - количество пар растёт как n², даже если сравнение одной пары само по себе быстрое.

Когда применять

  • Когда n заведомо маленькое или умеренное (примерно до нескольких тысяч) и простота кода важнее выжимания последних миллисекунд.
  • Когда задача по своей природе требует сравнить все пары элементов между собой (расстояние между всеми точками, попарные коллизии) и более быстрой структуры для неё нет.

Примеры из практики

  • Bubble Sort и Insertion Sort (в разделе сортировок) - классические примеры вложенного цикла, дающего O(n²) в среднем и худшем случае.
  • Наивная проверка на дубликаты двумя вложенными циклами (for x of arr { for y of arr { ... } }) - до появления Set или хеш-таблицы это был стандартный подход.

Похожие алгоритмы

O(n log n) - Линеарифмическая Сложность

O(n log n) - это когда алгоритм делит данные пополам снова и снова (получается log n уровней), а на каждом уровне честно обрабатывает все n элементов. Итоговая работа - произведение этих двух чисел, не сумма. Это класс, в котором живёт почти любая быстрая сортировка общего назначения: заметно быстрее, чем перебор всех пар (O(n²)), но чуть дороже, чем один проход по данным (O(n)).

Умеренная

O(n) - Линейная Сложность

O(n) - это когда работы становится ровно во столько же раз больше, во сколько выросли данные. 10 элементов - 10 шагов. 100 элементов - 100 шагов. Никаких сюрпризов: сколько данных, столько и работы. Это самый простой и понятный класс сложности - примерно так люди и представляют себе «обработать список», даже без всякой математики.

Умеренная

O(n³) - Кубическая Сложность

O(n³) - это когда объём работы растёт как куб размера входных данных: три вложенных цикла, и все три зависят от одного и того же n. Вдвое больше данных - не вчетверо, как у O(n²), а в восемь раз больше работы. Это класс, где цена лишнего уровня вложенности особенно заметна: третий цикл добавляет не ещё немного работы, а умножает всё, что было, на n.

Медленная