O-нотация (Big O)
O-нотация (Big O) описывает худший сценарий (или верхнюю границу) того, как будет расти время выполнения алгоритма при увеличении объема входных данных ($n$).
1. O(1) — Константное время (Мгновенно)
Время выполнения не зависит от количества элементов. Алгоритм выполняет фиксированное количество операций, будь то для 10 элементов или для 10 миллионов.
Примеры в Go:
- Определение того, четное ли число страниц в книге.
- Доступ к элементу слайса по индексу (
arr[5]). - Чтение или запись в
map(в среднем случае). - Получение длины слайса
len(arr).
func getFirstElement(arr[]int) int {
if len(arr) == 0 {
return 0
}
// O(1): Процессор сразу вычисляет адрес памяти первого элемента
return arr[0]
}
2. O(log n) — Логарифмическое время (Очень быстро)
На каждом шаге алгоритм отсекает половину оставшихся элементов. Время растет очень медленно. Например, для 1 000 000 элементов потребуется всего около 20 шагов.
Примеры в Go:
- Бинарный поиск (Binary Search) по отсортированному слайсу.
- Поиск в бинарном дереве поиска (BST).
- Поиск слова в бумажном словаре (Как ты ищешь слово "Кот"? Ты открываешь словарь примерно посередине. Видишь букву "П". Понимаешь, что "К" левее, и отбрасываешь половину словаря. Снова открываешь середину левой части и так далее. Даже в словаре из 100 000 слов ты найдешь нужное всего за ~17 шагов.)
// O(log n): Бинарный поиск
func binarySearch(arr[]int, target int) bool {
low, high := 0, len(arr)-1
for low <= high {
mid := low + (high-low)/2
if arr[mid] == target {
return true
} else if arr[mid] < target {
low = mid + 1 // Отсекаем левую половину
} else {
high = mid - 1 // Отсекаем правую половину
}
}
return false
}
3. O(n) — Линейное время (Нормально)
Время выполнения растет прямо пропорционально количеству элементов. Если данных в 10 раз больше, алгоритм будет работать в 10 раз дольше. Нам нужно "потрогать" каждый элемент.
Примеры в Go:
- Простой перебор (цикл
forпо всему слайсу). - Поиск минимума/максимума в неотсортированном массиве.
- Подсчет суммы всех элементов.
- Чтение книги от корки до корки или ручной пересчет стопки купюр (Тебе нужно прочитать каждую страницу. Если книга в 2 раза толще, ты потратишь на чтение ровно в 2 раза больше времени. Зависимость прямая)
// O(n): Линейный поиск
func findMax(arr []int) int {
max := arr[0]
for _, val := range arr { // Проходим по каждому элементу ровно один раз
if val > max {
max = val
}
}
return max
}
4. O(n log n) — Линейно-логарифмическое время (Хорошо для сортировки)
Это комбинация $O(n)$ и $O(\log n)$. Обычно означает, что алгоритм делит задачу на части (как логарифм), а затем выполняет линейную работу для объединения этих частей. Это лучшее достижимое время для алгоритмов сортировки на основе сравнений.
Примеры в Go:
- Встроенная сортировка слайсов (
slices.Sort(arr)илиsort.Ints(arr)). - Алгоритмы сортировки: Merge Sort, Quick Sort (в среднем случае), Heap Sort.
- Сортировка колоды карт (или стопки контрольных работ) по мастям и возрастанию (Ты не можешь сделать это за один проход (O(n)) Обычно ты сначала раскладываешь карты на 4 кучки по мастям (делишь задачу, это логарифмическая часть), а затем быстро сортируешь каждую кучку (линейная работа). Это самый оптимальный способ для человека и для компьютера.)
import "slices"
func sortData(arr[]int) {
// Внутри Go использует алгоритм pdqsort (Pattern-Defeating Quicksort)
// Его сложность в худшем/среднем случае — O(n log n)
slices.Sort(arr)
}
5. O(n²) — Квадратичное время (Медленно)
Время выполнения растет в квадрате от количества элементов. Если данных стало в 10 раз больше, время работы увеличится в 100 раз. Обычно возникает при использовании вложенных циклов. Подходит только для небольших объемов данных.
Примеры в Go:
- Два вложенных цикла по одному и тому же массиву.
- Наивные алгоритмы сортировки (Сортировка пузырьком, Сортировка выбором).
- Рукопожатия всех со всеми в комнате
// O(n^2): Сортировка пузырьком
func bubbleSort(arr[]int) {
n := len(arr)
// Внешний цикл (n раз)
for i := 0; i < n; i++ {
// Внутренний цикл (n раз)
for j := 0; j < n-i-1; j++ {
if arr[j] > arr[j+1] {
arr[j], arr[j+1] = arr[j+1], arr[j]
}
}
}
}
6. O(2ⁿ) — Экспоненциальное время (Почти нереально для больших данных)
С каждым новым добавленным элементом время выполнения удваивается. Алгоритм "взрывается" даже на небольших данных ($n = 30$ уже может считаться вечность). Обычно это полный перебор всех возможных комбинаций.
Примеры в Go:
- Вычисление чисел Фибоначчи через наивную рекурсию.
- Перебор всех подмножеств множества.
- Взлом кодового замка на чемодане (подбор пароля)
// O(2^n): Наивная рекурсия Фибоначчи
func fibonacci(n int) int {
if n <= 1 {
return n
}
// Каждый вызов порождает ДВА новых вызова (дерево разрастается)
return fibonacci(n-1) + fibonacci(n-2)
}
Как чинить: Такие задачи обычно решаются с помощью Мемоизации (кэширования) или Динамического программирования, что позволяет свести сложность к $O(n)$.
График роста
Представь, что $n = 1000$:
- $O(1)$ = 1 операция.
- $O(\log n)$ ≈ 10 операций.
- $O(n)$ = 1 000 операций.
- $O(n \log n)$ ≈ 10 000 операций.
- $O(n^2)$ = 1 000 000 операций (миллион).
- $O(2^n)$ = Число с 300 нулями (больше атомов во Вселенной).