Что такое «О-нотация»? Простым языком для разработчика

Если вы изучаете программирование, то рано или поздно столкнетесь с термином «О-нотация» или «Big O». Часто его подают как что-то сложное и математическое, но на самом деле это интуитивный инструмент, который помогает ответить на простой вопрос: «Насколько медленным или быстрым будет код при увеличении или уменьшении объема данных?»

Давайте разберемся без сложных формул.

Аналогия из жизни: Доставка посылок

Представьте, что вы — курьер, и вам нужно развести посылки.

Задача 1: Доставить одну посылку в один дом. Сколько бы домов ни было в городе, вам нужно доставить только одну. Это займет постоянное время. В терминах О-нотации это О(1) — «константная сложность». Время выполнения не зависит от количества данных (домов).

Задача 2: Доставить по одной посылке в каждый из n домов на одной улице. Если домов станет в 2 раза больше, ваша работа займет в 2 раза больше времени. Это O(n) — «линейная сложность». Время выполнения растет прямо пропорционально объему данных.

Задача 3: Доставить по одной посылке в каждый дом, но обойти все улицы и переулки, проверяя каждый дом в городе. Если в городе n домов, вам в худшем случае придется проверить все n домов для каждой из n посылок. Это уже n * n операций. Если домов станет в 2 раза больше, работы станет в 4 раза больше. Это O(n²) — «квадратичная сложность». Время выполнения растет как квадрат от объема данных.

Суть О-нотации — описать, как в худшем случае будет вести себя алгоритм при изменении входных данных (обозначаемых как n).

Примеры на Go

Давайте посмотрим на конкретные примеры алгоритмов с разной сложностью.

O(1) — Константная сложность

Время выполнения не зависит от размера входных данных. Классический пример — доступ к элементу массива (слайса) по индексу.

package main

import "fmt"

func getFirstElement(slice []int) int {
    return slice[0] // неважно, массив из 10 или 10 000 элементов
}

func main() {
    someSlice := []int{15, 32, 48, 11, 79}
    // всегда выполняется за константное время
    fmt.Println(getFirstElement(someSlice))
}

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

Время выполнения прямо пропорционально размеру данных. Пример — поиск элемента в неотсортированном массиве (в худшем случае).


package main

import "fmt"

func findValue(slice []int, target int) bool {
    // цикл по всем n элементам
    for _, value := range slice {
        if value == target {
            return true
        }
    }

    return false
}

func main() {
    someSlice := []int{15, 32, 48, 11, 79}

    // в худшем случае пройдет все 5 элементов
    fmt.Println(findValue(someSlice, 79))
    // здесь тоже пройдет все 5 элементов, в итоге ничего не найдет
    fmt.Println(findValue(someSlice, 18))
    // здесь не все 5, но акцент на все равно на худшем случае
    fmt.Println(findValue(someSlice, 48))
}

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

Время выполнения пропорционально квадрату размера данных. Пример — поиск дубликатов в неотсортированном массиве (в худшем случае).

package main

import "fmt"

func findDuplicate(slice []int) bool {
    n := len(slice)
    for i := 0; i < n; i++ {
        for j := i + 1; j < n; j++ {
            if slice[i] == slice[j] {
                return true
            }
        }
    }

    return false
}

func main() {
    someSlice := []int{15, 32, 48, 11, 79}
    fmt.Println(findDuplicate(someSlice))
}

O(2ⁿ) — Экспоненциальная сложность

Характерна для задач, где на каждом шаге количество подзадач удваивается. Пример — нахождения числа Фибоначчи.


package main

import "fmt"

func fibonacci(n int) int {
    if n <= 1 {
        return n
    }
    // классический, но неэффективный подход
    return fibonacci(n-1) + fibonacci(n-2)
}

func main() {
    number := 6
    fmt.Println(fibonacci(number)) // 8
}

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

Время выполняется растет очень медленно. Пример — бинарный поиск.

package main

import "fmt"

func binarySearch(slice []int, target int) bool {
    low, high := 0, len(slice)-1
    for low <= high {
        mid := (low + high) / 2
        if slice[mid] == target {
            return true
        } else if slice[mid] < target {
            low = mid + 1 // поиск в правой половине
        } else {
            high = mid - 1 // поиск в левой половине
        }
    }
    
    return false
}

func main() {
    sortedSlice := []int{8, 12, 15, 33, 49, 52, 67}
    fmt.Println(binarySearch(sortedSlice, 52)) // true
    fmt.Println(binarySearch(sortedSlice, 27)) // false
}

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

Часто встречается в эффективных алгоритмах сортировки. Пример — быстрая сортировка.

package main

import "fmt"

func quickSort(slice []int) []int {
    if len(slice) < 2 {
        return slice
    }

    pivot := slice[0]
    var less, greater []int
    for _, number := range slice[1:] {
        if number <= pivot {
            less = append(less, number)
        } else {
            greater = append(greater, number)
        }
    }

    return append(append(quickSort(less), pivot), quickSort(greater)...)
}

func main() {
    unsortedSlice := []int{12, 3, 17, 10, 53, 24, 1}
    fmt.Println(quickSort(unsortedSlice)) // [1 3 10 12 17 24 53]
}

O(n!) — Факториальная сложность

Время выполнения растет как факториал от n. Пример — генерация всех перестановок.

package main

import "fmt"

func generatePermutations(slice []int) [][]int {
    if len(slice) == 0 {
        return [][]int{{}}
    }

    var result [][]int
    for i := 0; i < len(slice); i++ {
        rest := make([]int, 0, len(slice)-1)
        rest = append(rest, slice[:i]...)
        rest = append(rest, slice[i+1:]...)

        for _, p := range generatePermutations(rest) {
            permutation := append([]int{slice[i]}, p...)
            result = append(result, permutation)
        }
    }

    return result
}

func main() {
    numbers := []int{1, 2, 3}
    fmt.Println(generatePermutations(numbers))
    // 3! = 1 * 2 * 3 = 6
    // [[1 2 3] [1 3 2] [2 1 3] [2 3 1] [3 1 2] [3 2 1]]
}

Почему это важно?

Представьте, что у вас есть 1 000 000 записей в базе данных. Вам нужно выполнить конкретную задачу по обработке такого количества записей, соответственно:

  • O(log n) — сделает всего около 20 операций
  • O(n) — сделает ~ 1 000 000 операций
  • O(n²) — сделает ~ 1 000 000 000 000 операций
  • O(2ⁿ) — сделает ~ 2¹⁰⁰⁰⁰⁰⁰ операций (невычислимо)
  • O(n!) — сделает ~ 1000000! операций (астрономическое число)

Понимание O-нотации помогает выбирать эффективные алгоритмы и структуры данных до написания кода, экономя время и ресурсы.

Самый главный вывод

  1. O-нотация — это мера скорости роста времени выполнения или потребления памяти алгоритма, а не точное время в секундах.
  2. Она описывает поведение в худшем случае.
  3. Константы и менее значимые слагаемые отбрасываются. Для больших n важно только то, что растет быстрее всего.
  4. Шкала эффективности (от лучшей к худшей):
  • O(1) — идеально (константная)
  • O(log n) — отлично (логарифмическая)
  • O(n) — хорошо (линейная)
  • O(n log n) — нормально (линейно-логарифмическая)
  • O(n²) — плохо (квадратичная)
  • O(2ⁿ) — очень плохо (экспоненциальная)
  • O(n!) — очень очень плохо (факториальная)

Когда вы начнете анализировать свои циклы и вложенности, то совсем скоро вы автоматически будете чувствовать, насколько эффективен ваш код. Это ключевой навык для любого серьезного разработчика.

Напишите в комментариях, приходилось ли вам сталкиваться с задачами, где сложность алгоритма играла ключевую роль?

Понравилась статья? Поделиться с друзьями:
Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!: