Сложность алгоритмов и Big O

О лекции

Big O описывает, как растут время и память с размером входа. На бэкенде это про индексы, N+1, аллокации и P99, а не про олимпиадную формулу с доски.

Алгоритмы и структуры данных cs собеседование

Сложность — это не «сколько миллисекунд на моём ноутбуке».

Это ответ на другой вопрос: что будет, если вход станет в десять раз больше? Время вырастет в десять раз? В сто? Почти не изменится?

Буква O в Big O — верхняя оценка. Говорим: рост не быстрее, чем функция f. Константы и мелкие слагаемые отбрасывают. O(2n) и O(n) — один класс: линейный.

Пример. Список из 10 заказов: один проход и два прохода оба мгновенны. Список из миллиона: один проход ещё терпим, вложенный цикл по каждой паре — уже минуты.

На бэкенде «вход n» — это часто число строк в таблице, длина слайса, число RPC. N+1 запросов к базе — это O(n) походов по сети, даже если каждый SQL сам по себе дешёвый. Полный проход таблицы — O(n) чтений с диска. Лишний индекс ускоряет чтение и добавляет работу на каждую запись.

Что означает O(f)

Фраза «поиск в отсортированном массиве — O(log n)» говорит о числе шагов, не о наносекундах. Нужна модель: сколько раз посмотрели элемент, сколько раз сходили в базу, сколько раз скопировали буфер.

Без модели «это O(1)» звучит пусто. Поиск в хэш-таблице в среднем константный. Тот же lookup в холодный Redis через сеть — это round-trip, цена сети, не цена хэша.

Пример. Телефонная книга на миллион имён. Идти с первой страницы — около миллиона шагов, O(n). Открыть середину, отбросить половину, снова середину — около 20 шагов. Это O(log n). Так работает бинарный поиск. Так же устроена высота B-tree.

Есть и нижняя граница. Если сортировка только сравнивает элементы, в худшем случае она не быстрее O(n log n). Дерево решений не может быть ниже логарифма от n!. Counting sort и radix sort на целых с узким диапазоном — другая модель: они не «ломают» эту границу, они не сравнивают.

func maxPairSum(a []int) int { // O(n)
	if len(a) == 0 {
		return 0
	}
	m1, m2 := a[0], a[0]
	for _, v := range a {
		if v > m1 {
			m2, m1 = m1, v
		} else if v > m2 {
			m2 = v
		}
	}
	return m1 + m2
}

func naivePairs(a []int) int { // O(n²)
	best := 0
	for i := range a {
		for j := range a {
			if i != j && a[i]+a[j] > best {
				best = a[i] + a[j]
			}
		}
	}
	return best
}

Один проход по слайсу — линейный. Вложенный цикл по парам — квадрат. На маленьком n разницы не видно.

Средний и худший случай

Quicksort в среднем O(n log n). В худшем — O(n²): пивот всегда крайний, данные почти отсортированы. Хэш-таблица в среднем O(1) на поиск. В худшем — O(n): все ключи свалились в одну корзину.

Амортизация — когда редкая дорогая операция размазывается по многим дешёвым. append у слайса в типичном случае O(1) амортизированно. Пока хватает ёмкости, это запись в массив. Когда не хватает, рантайм выделяет новый массив побольше и копирует элементы. Редкое копирование O(n) делится на много append. Это не значит «каждый append всегда O(1) без копий».

Пример. Слайс с cap 8. Семь append подряд — дешёвые. Восьмой не влез: выделили массив на 16, скопировали 8 элементов. Дальше снова дёшево. Средняя цена одного append близка к константе, хотя один из них был линейным.

Связь с бэкендом

Константы и железо важны не меньше буквы O. Два линейных прохода по массиву в кэше процессора часто быстрее «асимптотически лучшего» алгоритма, который прыгает по памяти и ловит cache miss.

Планировщик Postgres выбирает Index Scan, когда нужных строк мало. Seq Scan — когда прочитать таблицу целиком дешевле случайных прыжков по индексу. Оба плана линейны по числу прочитанных страниц. Константа random I/O другая.

  • O(1) амортизированный append против редкого O(n) копирования при росте массива.
  • Поиск в хэш-таблице: средний O(1), худший O(n) при коллизиях.
  • Сортировка сравнениями не лучше O(n log n) в худшем случае.
  • Бинарный поиск O(log n) только на отсортированных данных. Тот же логарифм у высоты B-tree.
Типичная ошибка

Путать «асимптотически лучше» с «быстрее на проде». На малых n и на данных вне кэша константа бьёт класс сложности. И наоборот: O(n) SQL в цикле (N+1) убивает сервис задолго до того, как CPU станет узким местом.

На собеседовании

Назовите класс сложности и где он ломается: коллизии хэша, полный scan, N+1 по сети. Отделите худший случай от среднего. Амортизированный O(1) — не «всегда константа».