Big O
Примечание. Сокращенный перевод, скорее пересказ своими словами.
UPD: как отметили в комментариях, примеры не идеальны. Автор не ищет лучшее решение задачи, его цель объяснить сложность алгоритмов «на пальцах».
Big O нотация нужна для описания сложности алгоритмов. Для этого используется понятие времени. Тема для многих пугающая, программисты избегающие разговоров о «времени порядка N» обычное дело.
Если вы способны оценить код в терминах Big O, скорее всего вас считают «умным парнем». И скорее всего вы пройдете ваше следующее собеседование. Вас не остановит вопрос можно ли уменьшить сложность какого-нибудь куска кода до n log n против n^2.
Структуры данных
Выбор структуры данных зависит от конкретной задачи: от вида данных и алгоритма их обработки. Разнообразные структуры данных (в .NET или Java или Elixir) создавались под определенные типы алгоритмов.
Часто, выбирая ту или иную структуру, мы просто копируем общепринятое решение. В большинстве случаев этого достаточно. Но на самом деле, не разобравшись в сложности алгоритмов, мы не можем сделать осознанный выбор. К теме структур данных можно переходить только после сложности алгоритмов.
Здесь мы будем использовать только массивы чисел (прямо как на собеседовании). Примеры на JavaScript.
Начнем с самого простого: O(1)
Возьмем массив из 5 чисел:
const nums = [1,2,3,4,5];
Допустим надо получить первый элемент. Используем для это индекс:
const nums = [1,2,3,4,5]; const firstNumber = nums[0];
Насколько это сложный алгоритм? Можно сказать: «совсем не сложный — просто берем первый элемент массива». Это верно, но корректнее описывать сложность через количество операций, выполняемых для достижения результата, в зависимости от ввода (операций на ввод).
Другими словами: насколько возрастет кол-во операций при увеличении кол-ва входных параметров.
В нашем примере входных параметров 5, потому что в массиве 5 элементов. Для получения результата нужно выполнить одну операцию (взять элемент по индексу). Сколько операций потребуется если элементов массива будет 100? Или 1000? Или 100 000? Все равно нужна только одна операция.
Т.е.: «одна операция для всех возможных входных данных» — O(1).
O(1) можно прочитать как «сложность порядка 1» (order 1), или «алгоритм выполняется за постоянное/константное время» (constant time).
Вы уже догадались что O(1) алгоритмы самые эффективные.
Итерации и «время порядка n»: O(n)
Теперь давайте найдем сумму элементов массива:
const nums = [1,2,3,4,5]; let sum = 0; for(let num of nums)
Опять зададимся вопросом: сколько операций на ввод нам потребуется? Здесь нужно перебрать все элементы, т.е. операция на каждый элемент. Чем больше массив, тем больше операций.
Используя Big O нотацию: O(n), или «сложность порядка n (order n)». Так же такой тип алгоритмов называют «линейными» или что алгоритм «линейно масштабируется».
Анализ
Можем ли мы сделать суммирование более эффективным? В общем случае нет. А если мы знаем, что массив гарантированно начинается с 1, отсортирован и не имеет пропусков? Тогда можно применить формулу S = n(n+1)/2 (где n последний элемент массива):
const sumContiguousArray = function(ary) < //get the last item const lastItem = ary[ary.length - 1]; //Gauss's trick return lastItem * (lastItem + 1) / 2; >const nums = [1,2,3,4,5]; const sumOfArray = sumContiguousArray(nums);
Такой алгоритм гораздо эффективнее O(n), более того он выполняется за «постоянное/константное время», т.е. это O(1).
Фактически операций не одна: нужно получить длину массива, получить последний элемент, выполнить умножение и деление. Разве это не O(3) или что-нибудь такое? В Big O нотации фактическое кол-во шагов не важно, важно что алгоритм выполняется за константное время.
Алгоритмы с константным временем это всегда O(1). Тоже и с линейными алгоритмами, фактически операций может быть O(n+5), в Big O нотации это O(n).
Не самые лучшие решения: O(n^2)
Давайте напишем функцию которая проверяет массив на наличие дублей. Решение с вложенным циклом:
const hasDuplicates = function (num) < //loop the list, our O(n) op for (let i = 0; i < nums.length; i++) < const thisNum = nums[i]; //loop the list again, the O(n^2) op for (let j = 0; j < nums.length; j++) < //make sure we're not checking same number if (j !== i) < const otherNum = nums[j]; //if there's an equal value, return if (otherNum === thisNum) return true; >> > //if we're here, no dups return false; > const nums = [1, 2, 3, 4, 5, 5]; hasDuplicates(nums);//true
Мы уже знаем что итерирование массива это O(n). У нас есть вложенный цикл, для каждого элемента мы еще раз итерируем — т.е. O(n^2) или «сложность порядка n квадрат».
Алгоритмы с вложенными циклами по той же коллекции всегда O(n^2).
«Сложность порядка log n»: O(log n)
В примере выше, вложенный цикл, сам по себе (если не учитывать что он вложенный) имеет сложность O(n), т.к. это перебор элементов массива. Этот цикл заканчивается как только будет найден нужный элемент, т.е. фактически не обязательно будут перебраны все элементы. Но в Big O нотации всегда рассматривается худший вариант — искомый элемент может быть самым последним.
Здесь вложенный цикл используется для поиска заданного элемента в массиве. Поиск элемента в массиве, при определенных условиях, можно оптимизировать — сделать лучше чем линейная O(n).
Пускай массив будет отсортирован. Тогда мы сможем использовать алгоритм «бинарный поиск»: делим массив на две половины, отбрасываем не нужную, оставшуюся опять делим на две части и так пока не найдем нужное значение. Такой тип алгоритмов называется «разделяй и влавствуй» Divide and Conquer.

Этот алгоритм основан на логарифме.
Быстрый обзор логарифмов
Рассмотрим пример, чему будет равен x?
Нужно взять кубический корень от 8 — это будет 2. Теперь посложнее
С использованием логарифма задачу можно записать так
«логарифм по основанию 2 от 512 равен x». Обратите внимание «основание 2», т.е. мы мыслим двойками — сколько раз нужно перемножить 2 что бы получить 512.
В алгоритме «бинарный поиск» на каждом шаге мы делим массив на две части.
Мое дополнение. Т.е. в худшем случае делаем столько операций, сколько раз можем разделить массив на две части. Например, сколько раз мы можем разделить на две части массив из 4 элементов? 2 раза. А массив из 8 элементов? 3 раза. Т.е. кол-во делений/операций = log2(n) (где n кол-во элементов массива).
Получается, что зависимость кол-ва операций от кол-ва элементов ввода описывается как log2(n)
Таким образом, используя нотацию Big O, алгоритм «бинарный поиск» имеет сложность O(log n).
Улучшим O(n^2) до O(n log n)
Вернемся к задачке проверки массива на дубли. Мы перебирали все элементы массива и для каждого элемента еще раз делали перебор. Делали O(n) внутри O(n), т.е. O(n*n) или O(n^2).
Мы можем заменить вложенный цикл на бинарный поиск*. Т.е. у нас остается перебор всех элементов O(n), внутри делаем O(log n). Получается O(n * log n), или O(n log n).
const nums = [1, 2, 3, 4, 5]; const searchFor = function (items, num) < //use binary search! //if found, return the number. Otherwise. //return null. We'll do this in a later chapter. >const hasDuplicates = function (nums) < for (let num of nums) < //let's go through the list again and have a look //at all the other numbers so we can compare if (searchFor(nums, num)) < return true; >> //only arrive here if there are no dups return false; >
* ВНИМАНИЕ, во избежание Импринтинга. Использовать бинарный поиск для проверки массива на дубли — плохое решение. Здесь лишь показывается как в терминах Big O оценить сложность алгоритма показанного в листинге кода выше. Хороший алгоритм или плохой — для данной заметки не важно, важна наглядность.
Мышление в терминах Big O
- Получение элемента коллекции это O(1). Будь то получение по индексу в массиве, или по ключу в словаре в нотации Big O это будет O(1)
- Перебор коллекции это O(n)
- Вложенные циклы по той же коллекции это O(n^2)
- Разделяй и властвуй (Divide and Conquer) всегда O(log n)
- Итерации которые используют Divide and Conquer это O(n log n)
- big-o notation
- алгоритмы
- javascript
- структуры данных
- сложность алгоритма
- сложность
- бинарный поиск
- JavaScript
- Программирование
- Алгоритмы
Что такое «O» большое в программировании?
Замечали ли вы, что одни программы выполняются дольше, чем другие? Причиной задержки может быть, например, используемый компьютер. Но предположим, что у вас хороший компьютер с мощным процессором. Тогда в чем причина?
Дело в том, что время выполнения написанной программы зависит от переданных входных данных.
Но как выяснить, эффективна ли программа? Есть ли способ это определить? Как проверить, при передаче каких входных данных программа работает лучше всего?
Прежде чем перейти к ответам, разберемся с тем, как вообще работает эффективная программа. И в этом поможет одна забавная история.
Однажды в компании, которой надоел медленный интернет, было решено провести ряд тестирований. Передачу данных из одного места в другое, находящееся на расстоянии 50 км, проводили одновременно двумя методами.
Один метод заключался в использовании почтового голубя, к лапке которого привязали USB-флешку.
Вторым методом было использование интернета в тех же целях, что и голубя.
Самое интересное заключалось в том, что голубь обогнал интернет. Но как?
Если голубю потребовалось время, чтобы добраться до места, независимо от размера данных, то время достижения интернет-сигнала меняется в зависимости от размера данных.
Перейдем к вычислительным сложностям
Вот две сложности, связанные с программированием.
- Временная: время, необходимое для обработки входных данных.
- Пространственная: количество места, требуемое для обработки входных данных.
Что такое нотация “О” большое?
Этот термин используется в программировании для описания вычислительной сложности алгоритма. В частности, он позволяет оценить, сколько времени требуется для запуска программы.
Проще говоря, термином “О” большое определяется, как время выполнения растет по мере увеличения входных данных.
- Увеличение времени выполнения. Поскольку время, необходимое для выполнения программы, зависит от процессора компьютера, используется “О” большое, чтобы показать, как меняется время выполнения.
- Ввод данных. Поскольку проверяется не только время, которое требуется для выполнения программы, но и ввод, в нотации “О” большое есть “n”, которое определяет количество элементов обрабатываемых входных данных. Поскольку время выполнения растет с увеличением размера входных данных, эту величину можно представить в виде O(n).
- По мере увеличения входных данных. По мере увеличения входных данных программам иногда требуется больше времени для выполнения, что приводит к проблемам с производительностью. Поэтому разрабатываемую программу нужно проверять по мере увеличения входных данных.
Визуализированные примеры
O(1) не увеличивается с изменением размера входных данных. Таким образом, время обработки O(1) — величина постоянная независимо от того, какие входные данные были переданы.
Пример:
let names = ['Atit', 'mahesh', 'ramesh', 'kamlesh'];
let data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];function checkLength(data) return data.length;
>console.log(checkLength(names)); // 4
console.log(checkLength(data)); // 10
В данном случае, независимо от того, какие входные данные были переданы, сложность останется равной O(1).
Линейная временная сложность означает, что время пропорционально входным данным. Время будет меняться в зависимости от входных данных.
Пример:
function print(array) for (var i = 0; i < array.length; i++) console.log(array[i]);
>
>print(names) //4 times
print(data) //10 times
Показывает производительность пропорционально размеру входных данных. O(n²) представляет наихудшую производительность.
Пример:
function testdata(data) data.forEach(function(items) console.log('values ', data);
items.forEach(function(number) console.log('Marks', number); //>);
>);
>const test = [
['maths', 52],
['science', 65],
['english', 72]
]testdata(test)
Логарифмическая | O(log n)
Логарифмическая сложность представляет время, необходимое для выполнения алгоритма, пропорциональное логарифму количества элементов (n) входных данных.
Пример:
function log(n) for (let i = 1; i < n; i = i * 2) const result = i;
console.log(result);
>
>log(4); //2
Для данной программы с любыми итерациями значение i = i*2. Поэтому на n-й итерации значение i= i*n и i всегда меньше размера самого цикла (N).
Следовательно, можно получить:
2^n < N
log(2^n) < log(N)
n < log(N)
Таким образом, наихудшая временная сложность такого алгоритма будет равна O(log(n)).
Отбросим константы
Существует вероятность того, что O(N) код быстрее, чем O(1) код для определенных входных данных. “О” большое просто описывает скорость увеличения. По этой причине мы отбрасываем константу, что означает, что O(3N) на самом деле O(N):
Можно отбросить не только константы, но и неглавные члены:
- O(N3+N) → O(N3)
- O(N+logN) → O(N)
- O(2∗2N + 1000N100) → O(2N)
Как вычислить сложность?
Очевидно, что сложность различается в зависимости от структуры кода, используемого в алгоритме. Рассмотрим несколько примеров кода и их сложность.
Циклы (Loops)
- Внутри цикла операторы повторяются n раз. Если для выполнения кода требуется сложность O(m), то внутри n раз повторенного цикла она будет равна n∗O(m) или O(n∗m).
for (let i in arr1) print(i);
>
- Количество циклов будет равно 4, а выполнение кода внутри цикла равно O(1), поэтому суммарное выполнение будет равно O(4).
Вложенные циклы ( Nestedloops )
- Если один цикл находится внутри другого цикла, сложность будет расти экспоненциально. Другими словами, если сложность простого цикла равна O(n), добавление еще одного цикла внутри этого цикла сделает сложность O(n²).
for (let i in arr1) print(i);
for (let j in i) <>
>
- Здесь сложность первого цикла равна O(5), поскольку количество массивов равно 5, поэтому вложенный цикл также выполняется с той же сложностью 5 раз, а значит, оба цикла будут O(5²).
- Важные аспекты математики в науке о данных — «что» и «почему»
- Решение алгоритмических проблем: Поиск повторяющихся элементов в массиве
- 8 показателей эффективности классификации
О-большое
«O» большое и «o» малое (
, если существует константа C > 0 , что для всех x из некоторой окрестности точки x0 имеет место неравенство
;
, если для любого
найдется такая проколотая окрестность 

Иначе говоря, в первом случае отношение | f | / | g | в окрестности точки x0 ограничено сверху, а во втором оно стремится к нулю при .
Обозначение
Обычно выражение «f является „O“ большим („о“ малым) от g» записывается с помощью равенства f(x) = O(g(x)) (соответственно, f(x) = o(g(x))).
Это обозначение очень удобно, но требует некоторой осторожности при использовании (а потому в наиболее элементарных учебниках его могут избегать). Дело в том, что это не равенство в обычном смысле, а несимметричное отношение.
В частности, можно писать
f(x) = O(g(x)) (или f(x) = o(g(x))),
O(g(x)) = f(x) (или o(g(x)) = f(x))
Другой пример: при x → 0 верно, что
O(x²) = o(x),
o(x) = O(x²).
Вместо знака равенства методологически правильнее было бы употреблять знаки принадлежности и включения, понимая O( ) и o( ) как обозначения для множеств функций, то есть используя запись в форме
x² + x³ ∈ O(x²)

x² + x³ = O(x²)

Однако на практике такая запись встречается крайне редко, в основном в простейших случаях.
При использовании данных обозначений должно быть явно оговорено (или очевидно из контекста), о каких окрестностях (одно- или двусторонних; содержащих целые, вещественные или комплексные числа и т. п.) и о каких допустимых множествах функций идет речь (поскольку такие же обозначения употребляются и применительно к функциям многих переменных, к функциям комплексной переменной, к матрицам и др.).
Другие подобные обозначения
Для функций f(n) и g(n) при n → n0 используются следующие обозначения:
| Обозначение | Интуитивное объяснение | Определение |
|---|---|---|
![]() |
f ограничена сверху функцией g (с точностью до постоянного множителя) асимптотически | ![]() |
![]() |
f ограничена снизу функцией g (с точностью до постоянного множителя) асимптотически | ![]() |
![]() |
f ограничена снизу и сверху функцией g асимптотически | ![]() |
![]() |
g доминирует над f асимптотически | ![]() |
![]() |
f доминирует над g асимптотически | ![]() |
![]() |
f эквивалентна g асимптотически |
![]() ![]() СимметричностьПерестановочная симметрия
Здесь U — окрестность бесконечности, то есть луч чисел n > N для некоторого N . Что же написано в определении о-малого?
Видите в чём разница? В первом кванторе. Для О-большого стоит квантор существования, а для о-малого квантор всеобщности. Конечно же первый квантор доказывать гораздо проще: нашел константу C , доказал, что для всех достаточно больших n неравенство выполняется, и свободен! С квантором всеобщности дело гораздо веселее. Нужно доказывать, что |f(n)/g(n)| ⟶ 0 , то есть показать что там, на бесконечности, никаких выбросов быть не может. В большинстве случаев это шибко сложно, мало кто хочет с такой фигнёй связываться.
Возьмём для примера теорему о распределении простых чисел Чебышов сравнительно элементарными средствами доказал в 1850-м году, что π(x) «болтается вокруг» x/ln(x) и нашел константы, которые ограничивают отклонения. Но тот факт, что эти константы равны единице, и асимптотически π(x) сливается с x/ln(x) доказали только спустя 50 лет, причём это потребовало введения Риманом такой ниипической пушки, как дзета-функция, и аггрессивного развития теории функции комплексной переменной. ИМХО, причина использования О-большого заключается именно в сложности доказательства теорем для о-малого. |
















