Перейти к основному содержимому

Делим задачу пополам

O(log n) означает логарифмическое время: каждый шаг сокращает оставшуюся работу вдвое. Поскольку деление пополам уменьшает числа очень быстро, удвоение входных данных добавляет всего один дополнительный шаг.

Для 16 элементов нужно 4 деления пополам, для 32 элементов — 5, для миллиона — около 20, для миллиарда — около 30. Именно поэтому логарифмические алгоритмы кажутся мгновенными даже на огромных данных.

Классический пример — бинарный поиск, но он работает только на отсортированных данных. Чтобы делить пополам, нужен порядок, иначе не понять, какую половину отбросить.


Цикл с делением пополам

Проще всего увидеть O(log n) на цикле, который делит n на 2, пока не дойдёт до 1. Количество повторений — это логарифм n: при n = 16 цикл выполняется 4 раза, потому что 2 · 2 · 2 · 2 = 16.

Делим пополам, пока не останется единица

16 превращается в 8, 4, 2, 1 — четыре шага. Удвоение n до 32 добавило бы всего один шаг.

Prompt
Напишите программу на TypeScript, которая начинает с n = 16 и в цикле делит n на 2, пока не дойдёт до 1, выводя n после каждого деления и подсчитывая шаги. В конце выведите, сколько шагов потребовалось.
Вывод: программа на TypeScript и захваченный вывод
Halving until one remains

Бинарный поиск

Бинарный поиск находит значение в отсортированном массиве, всегда проверяя средний элемент. Если середина слишком мала, отбрасывается вся левая половина; если слишком велика — правая. Каждая проверка уменьшает диапазон поиска вдвое.

Два маркера, lo и hi, задают текущий диапазон. Цикл заканчивается, когда значение найдено или диапазон пуст.

В TypeScript нет собственного бинарного поиска. Встроенные способы поиска — indexOf() и includes() — проходят массив с начала, поэтому остаются O(n) даже тогда, когда данные уже отсортированы. На отсортированных данных написанный вручную цикл работает быстрее.

Бинарный поиск шаг за шагом

В отсортированном массиве из 16 чисел поиск значения 13 требует всего 4 проверок: диапазон сокращается 16 → 8 → 4 → 2 и приводит к индексу 6. Линейному поиску здесь понадобилось бы 7 шагов — а могло бы и все 16.

Prompt
Напишите программу на TypeScript, которая бинарным поиском ищет значение 13 в отсортированном массиве 1, 3, 5, 7, ... 31 (нечётные числа, 16 элементов), используя маркеры lo и hi и подсчитывая проверки. Когда значение найдено, выведите его, его индекс и количество проверок.
Вывод: программа на TypeScript и захваченный вывод
Binary search step by step

Встроенный поиск всё равно линейный

Оба способа поиска согласны: 9 находится по индексу 4. Но indexOf() добирается туда, проверив 5 элементов, а бинарному поиску нужно 3 проверки. Для отсутствующего значения 10 indexOf() проверяет все 8 элементов и возвращает -1; бинарный поиск останавливается после 3 проверок и даже может сообщить индекс 5 — место, куда следовало бы вставить 10, чтобы массив остался отсортированным.

На 8 элементах разница невелика. На миллионе это примерно 1 000 000 шагов против 20.

Prompt
Напишите программу на TypeScript, которая сравнивает встроенный поиск с написанным вручную бинарным поиском на отсортированном массиве 1, 3, 5, 7, 9, 11, 13, 15. Для искомого значения 9 и отсутствующего значения 10 выведите, что возвращают indexOf и includes, а также сколько элементов затронул проход по массиву, и выведите результат бинарного поиска с количеством проверок и, если значение отсутствует, индексом, куда его следовало бы вставить.
Вывод: программа на TypeScript и захваченный вывод
The built-in lookup is still linear

O(n log n): сортировка

O(n log n) находится между линейной и квадратичной сложностью и является сложностью хороших алгоритмов сортировки: данные снова и снова делятся пополам (log n уровней), и на каждом уровне выполняется линейный объём работы (n).

Сортировку редко пишут самостоятельно — у каждого массива уже есть sort(), и это O(n log n). Отсортировать один раз, а затем много раз применять бинарный поиск — распространённый и быстрый приём.

Сортировка массива

nums.sort((a, b) => a - b) упорядочивает 8 чисел примерно за 8 · log 8 = 24 шагов — намного меньше, чем 64 шага квадратичной сортировки, и разрыв быстро растёт с увеличением массива.

Компаратор здесь не необязателен. Без него sort() сравнивает значения как текст: "13" начинается с 1, поэтому оказывается перед "5". Программа выводит оба порядка.

Prompt
Напишите программу на TypeScript, которая дважды сортирует копию массива 31, 5, 17, 1, 23, 9, 27, 13: один раз обычным вызовом sort() и один раз с компаратором (a, b) => a - b. Выведите оба результата, чтобы был виден строковый порядок обычного вызова.
Вывод: программа на TypeScript и захваченный вывод
Sort an array