Всем привет! Я расскажу, что такое сложность алгоритмов и откуда она берётся, разберу типичные заблуждения и самые частые ошибки новичков. Материал рассчитан в первую очередь на начинающих Python-разработчиков, а также на тех, у кого Python — первый язык программирования.

Что такое сложность алгоритмов?

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

  • Log(N) — бинарный поиск в уже отсортированном массиве;

  • N — работа кода в одном цикле;

  • N*Log(N) — сортировка;

  • N**2 — цикл, вложенный в другой цикл.

Примечание: ассемблер настолько близок к машинному коду, что фактически одному оператору ассемблера соответствует одна машинная инструкция (1:1). Таким образом, можно довольно точно оценить фактическую сложность алгоритма.

Очевидно, что чем сложнее алгоритм, тем быстрее возрастает функция. Но что это значит, если копнуть поглубже? Рассмотрим на примере C++ какой-нибудь алгоритм со сложностью O(N), например, создание массива, и посмотрим, как эта операция будет выглядеть в дизассемблере.

Подробнее почитать о том, сколько стоит каждая операция можно в статье «Сложность алгоритмов и операций на примере Python».

Примечание: C/C++ намного ближе к железу в отличие от высокоуровневого Python, поэтому его гораздо легче дизассемблировать. Ясно, что список в Python работает отлично от того, как работает массив в C++, но принципы их работы абсолютно одинаковы.

Чтобы создать массив из 7 элементов в C++:

int arr[] = {1, 2, 3, 4, 5, 6, 7};

нужно будет сделать 7 операций в ассемблере:

mov     DWORD PTR [rbp-32], 1
mov     DWORD PTR [rbp-28], 2
mov     DWORD PTR [rbp-24], 3
mov     DWORD PTR [rbp-20], 4
mov     DWORD PTR [rbp-16], 5
mov     DWORD PTR [rbp-12], 6
mov     DWORD PTR [rbp-8], 7

Именно это и показывает функция сложности алгоритма O(N) — сколько элементов нужно обработать, столько операций и будет проделано. Для O(N*N) — то же самое, но количество операций уже будет в квадрате. В Python для создания списка из 7 элементов также потребуется не менее семи операций:

l = [1, 2, 3, 4, 5, 6, 7]

Другой пример: чтобы записать один элемент массива в C++:

arr[0] = 111;

Требуется всего одна операция в ассемблере:

mov     DWORD PTR [rbp-32], 111

Именно поэтому эта операция имеет сложность O(1). То же самое и для Python:

l[0] = 111

Эта операция также имеет сложность O(1) по той же логике.

Теперь, когда стало ясно, откуда берётся сложность алгоритмов, можно перейти к типичным ошибкам.

Пример 1, работа с типом string

Рассмотрим простейший показательный пример, который вытекает из непонимания определения сложности алгоритма. Допустим, нам необходимо собрать строку в цикле, для этого можно написать такой код:

line = ""
for i in range(10_000):
    line += "i"

Казалось бы, простой и работающий код, который даёт верный результат — в данном случае строку из 10 тысяч символов «i». Время исполнения этого кода 5 мс. 

На первый взгляд это алгоритм со сложностью O(N), но если посмотреть на последнюю строчку кода, то видно, что тип string на самом деле не добавляет символ к текущему значению, а пересоздаёт значение с новым символом, так как string является неизменяемым (immutable) типом. Другими словами, только одна эта операция будет иметь сложность O(N), ведь чтобы скопировать значение в новую строку, нужно скопировать каждый элемент старой строки плюс новый символ. Кроме того, эта операция выполняется ещё и в цикле, то есть итоговая сложность этого алгоритма будет равна O(N*N). 

Примечание: учитывая, что переменная line растёт последовательно от 0 до N, правильно будет сказать, что сложность равна O(N*M), где M < N. Но для простоты будем считать, что сложность этого алгоритма O(N*N). В общем-то, это не будет большой ошибкой.

Этот алгоритм можно улучшить, если использовать операцию, сложность которой равна O(1), например append:

word = []
for i in range(10_000):
    word.append("i")
line = "".join(word)

Теперь алгоритм отработал за 2 мс, что в 2,5 раза быстрее чем предыдущий вариант, к тому же теперь его сложность равняется O(N).

Стоит обратить внимание, что операция append в Python выполняется на самом деле за амортизированный O(1), также как и в других языках высокого уровня, таких как Java или C#. Другими словами, крайне редко, но эта операция может выполниться за время O(N). Под капотом у списка лежит массив, в который и сохраняются элементы. Если же размера списка не хватит, то перед сохранением нового элемента он будет увеличен в два раза, именно в этом случае и будет O(N).

Рассмотрим также пример с операцией присвоения, которая уже действительно имеет сложность O(1):

N = 10_000
word = [None] * N
for i in range(N):
    word[i] = "i"
line = "".join(word)

Время исполнения сократилось до 1,35 мс, что немного быстрее, чем с append. Оба эти алгоритма будут исполняться за примерно одно и то же время, вариант с присвоением на практике будет отрабатывать чаще совсем на чуть-чуть быстрее.

Пример 2, преобразования массивов

Возьмём очень большой список и будем в нём искать элементы:

arr = list(range(10_000_000))
matcher = [500_000, 100_000, 1000_000_000] * 10

В нашем случае массив будет состоять из 10 млн элементов, а искать мы в нём будем 30 чисел. В самом простом виде алгоритм будет выглядеть так: в цикле перебираем каждый из 30 элементов из matcher и ищем его в arr

for i in matcher:
    if i in arr:
        pass

Этот алгоритм имеет сложность O(N*N): перебор в цикле это O(N), и поиск в списке тоже O(N). Длительность его работы 1,2 секунды.

Этот код можно улучшить, ведь мы знаем, что поиск по множеству имеет сложность O(1). Как же может переписать алгоритм начинающий программист? Например, так:

for i in matcher:
    if i in set(arr):
        pass

Да, имея чистые помыслы, начинающий или не знающий сложности алгоритмов программист может так написать. Этот пример тоже имеет сложность O(N*N), поиск по множеству — O(1), но преобразование списка в множество — O(N). Несмотря на сложность O(N*N), как и в предыдущем примере, алгоритм выполняется теперь за 15,2 секунды. Причина в том, что поиск по списку в худшем случае даёт O(N), а вот преобразование будет всегда O(N).

Правильно было бы написать так:

arr_s = set(arr)
for i in matcher:
    if i in arr_s:
        pass

Теперь код исполняется 0,5 секунды. Эта ошибка встречается на удивление часто, она незаметна, но может замедлить работу алгоритма и увеличить потребление памяти. Важно понимать, что ненужное преобразование массива элементов потребляет лишние ресурсы.

Типичные примеры с ошибками преобразования:

  • set(list(map(str, arr))) — нужно сразу приводить к множеству: set(map(str, arr))

  • list(sorted(arr))sorted и так возвращает список

И другие ошибки, вариаций может быть множество.

Пример 3, ошибки при объявлении переменных

Недаром говорят, что объявлять переменные желательно в начале метода. Это, конечно, рекомендация, но для начинающих она полезна. Часто неопытный программист может совершить подобную ошибку: допустим, у нас есть какой-то метод, который производит операцию деления:

def divider(a: int, b: int):
    res = a / b
    return res

Предположим, что QA-инженер (он же тестировщик) в команде нашёл в этом коде ошибку: заметил, что нельзя делить на ноль, выставил баг, и начинающий программист исправил код:

def divider(a: int, b: int):
    try:
        res = a / b
    except ZeroDivisionError:
        print("don't divide on zero!")
    return res

Да, это довольно частая ситуация, когда баг маскируется другой ошибкой. В данном случае деление на ноль обработается корректно, но тут же возникнет ошибка: UnboundLocalError: local variable 'res' referenced before assignment, то есть обращение к несуществующей переменной, так как она не будет инициализирована в блоке try/except из-за ошибки деления.

Самый простой способ этого избежать — объявлять переменные заранее:

def divider(a: int, b: int):
    res = None
    try:
        res = a / b
    except ZeroDivisionError:
        print("don't divide on zero!")
    return res

Часто переменные объявляют в сегменте if, подобный код не является хорошим тоном:

def divider(a: int, b: int, flag: bool):
    if flag:
        res = a / b
    return res

Объявить так переменную возможно в Python, а в Java или C# подобное не прокатит. Такой код в этих языках программирования вызовет ошибку на этапе компиляции.

Пример 4, входные параметры

Есть известная рекомендация: не менять входные параметры. Почему так говорят, давайте посмотрим.

def func(l: list):
    l[0] = 10
l = [1, 2, 3]
func(l)

Казалось бы, простой код, но что же будет лежать в списке после его выполнения? А вот что:

print(l)

[10, 2, 3]

Как же так получилось? Ведь метод ничего не возвращает, список мы в коде никак не переопределяем, только внутри метода. Рассмотрим другой пример:

def func2(a: int):
    a = 10
a = 0
func2(a)

Чему же будет равна переменная:

print(a)

0

Во втором случае переменная не изменилась, отчего же так происходит? Дело в том, что объекты в Python делятся на те, что передаются по ссылке — list, set, dict, и те, что передаются по значению — int, float, str, tuple.

Рассмотрим ещё один пример:

l1 = [1, 2]
l2 = l1
l1.append(3)

В этом случае оба списка будут равны, несмотря на то, что мы меняли только один из них:

print(l1, l2)

([1, 2, 3], [1, 2, 3])

При передаче аргументов в метод, или если нужно сохранить исходное состояние списка, чтобы не было ошибок ссылочные типы нужно копировать явно, например:

l2 = l1[:]
l2 = list(l1)
l2 = l1.copy()

В этом случае ссылки будут на разные области памяти, что и решит проблему.

Пример 5, значение по умолчанию

Это, вероятно, одна из самых популярных ошибок начинающего программиста. Рассмотрим пример:

def func3(val: int, l: list = []):
    l.append(val)
    print(l)

Казалось бы, обычный метод, но давайте вызовем его несколько раз подряд и посмотрим, что получится:

func3(1)

[1]

func3(2)

[1, 2]

Да, в этом и подвох: новый пустой список по умолчанию не создаётся. Именно поэтому при последующих вызовах, в которых программист ожидает пустой массив, как было заложено логикой алгоритма, в массиве остаются элементы от прошлых вызовов метода. Так уж работает Python: он всегда берёт одну и ту же ссылку на список в подобном случае.

Если нужен пустой список по умолчанию, следует писать так:

def func3(val: int, l: Optional[list] = None):
    if not l:
        l = []
    l.append(val)
    print(l)

То же правило касается множества (set) и словаря (dict), их также нельзя делать пустыми в параметрах по умолчанию.

Заключение

Простыми словами и на примерах я разобрал, что такое сложность алгоритма, показал, откуда берётся это понятие, разобрал типовые ошибки, вытекающие из непонимания сложности и самые распространённые среди новичков. 

Не стоит бояться делать ошибки, если ты начинающий разработчик. Не ошибается только тот, кто ничего не делает.

Спасибо за внимание.

Репозиторий с исходным кодом — на GitHub.

Комментарии (20)


  1. Survtur
    21.10.2024 06:13

    Дело в том, что объекты в Python делятся на те, что передаются по ссылке — list, set, dict, и те, что передаются по значению — int, float, str, tuple.

    Да просто вторые - иммутабельные, отсюда всё и вытекает.


    1. MamedovAlexandr
      21.10.2024 06:13

      Вроде все объекты передаются по ссылке. Это видно по id объектов


      1. Survtur
        21.10.2024 06:13

        Я не спорю. Просто нет смысла отдельно помнить, что как передаётся. Достаточно понимать, иммутабельный объект или нет.


  1. margothequeen
    21.10.2024 06:13

    мне кажется, что Вы путаете подсчëт числа операций с асимптотическим анализом


  1. artschedrov
    21.10.2024 06:13

    Именно это и показывает функция сложности алгоритма O(N) — сколько элементов нужно обработать, столько операций и будет проделано.

    К сожалению нет, O(N) показывает верхнюю границу ограничения функции, иначе говоря сложность алгоритма в наихудшем случае.


    1. DimaFromMai Автор
      21.10.2024 06:13

      Хорошее уточнение, спасибо.


  1. ValeryIvanov
    21.10.2024 06:13

    Если функция не собирается менять коллекцию которую ей передают на вход, то имеет смысл использовать соответствующую аннотацию типа(Sequence вместо list/tuple, Mapping вместо dict, AbstractSet вместо set)

    def process_sequence(items: Sequence[...] = ()): # сюда можно передать как list/tuple, так и любую кастомную реализацию Sequence
        ...
    
    def process_mapping(items: Mapping[..., ...] = {}): # так как мы не планируем менять входные данные, то изменяемый dict как значение по умолчанию не навредит
        ...
    


  1. vitalik_sk
    21.10.2024 06:13

    Если нужен пустой список по умолчанию, следует писать так:

    def func3(val: int, l: Optional[list] = None):    if not l:        l = []    l.append(val)    print(l)

    В Python действительно часто наиболее типичным значением по умолчанию является None - объект с неопределенным значением. Но, иногда None является допустимым значением, а иногда требуется определить случай “значение по умолчанию не задано”, либо, как в Вашем примере, когда аргумент l должны иметь значение в виде пустого списка или объявлять пустой список значением по умолчанию. В таком случае использование объекта None не может быть абсолютно допустимым значением. C этой точки зрения, типизации кода приведенного выше, представляется не очень хорошей практикой.

    IMHO: Примером хорошей практики типизации случая, когда аргумент должен иметь значение в виде пустого списка или объявлять пустой список значением по умолчанию, может служить использование паттерна Sentinel Value, как в коде приведенном ниже.

    from typing import Any
    
    sentinel: Any = object()
    
    def func3(val: int, l: list[int] = sentinel): 
    	...

    Либо, вот так:

    from unittest.mock import sentinel
    
    NotSet = sentinel.NotSet
    
    def func3(val: int, l: list[int] = NotSet): 
    	...


  1. Foxonn
    21.10.2024 06:13

    кажется самый быстрый вариант

    line = "".join(["i"] * 10_000)


  1. crushilov
    21.10.2024 06:13

    Добрый вечер. А операция с join не дает дополнительной сложности алгоритму? А заполнение массива [None] x N?


  1. PavelBorsch
    21.10.2024 06:13

    А как Вы измеряете время?
    У меня между.

    line = ""
    for i in range(10_000):
        line += "i"
    

    и

    word = []
    for i in range(10_000):
        word.append("i")
    line = "".join(word)
    

    разница 10%, а не в 2 раза.


  1. homm
    21.10.2024 06:13

    Примечаниеассемблер настолько близок к машинному коду, что фактически одному оператору ассемблера соответствует одна машинная инструкция (1:1). Таким образом, можно довольно точно оценить фактическую сложность алгоритма.

    Это каким таким образом? Сложность как раз никак не связана с количеством написанных инструкций.


    1. DimaFromMai Автор
      21.10.2024 06:13

      А с чем же связана?
      Computational complexity:

      In computer science, the computational complexity or simply complexity of an algorithm is the amount of resources required to run it. Particular focus is given to computation time (generally measured by the number of needed elementary operations) and memory storage requirements.


      1. homm
        21.10.2024 06:13

        В приведенном отрывке видимо имеются в виду реально выполненные во времени операции, хотя на мой вкус написано коряво. Вы же утверждаете, что сложность можно оценить буквально сосчитав количество операций на языке ассемблера (то есть в коде программы).


        1. DimaFromMai Автор
          21.10.2024 06:13

          На n входных параметров у алгоритма n ассемблерных операций, у другого алгоритма на n входных параметров n*n ассемблерных операций, т.е. можно увидеть фактическую сложность алгоритма, если считать, что ассемблерная команда - это элементарная операция. Вы правильно написали: "Сложность как раз никак не связана с количеством написанных инструкций", - я же не утверждаю, что сложность как-то зависит от количества написанных строчек кода.


  1. homm
    21.10.2024 06:13

    Кроме того, эта операция выполняется ещё и в цикле, то есть итоговая сложность этого алгоритма будет равна O(N*N). 

    Ох. Вы хоть проверяли?

    In [1]: %%time
       ...: line = ""
       ...: for i in range(10_000):
       ...:     line += "i"
    Wall time: 1.68 ms
    
    In [2]: %%time
       ...: line = ""
       ...: for i in range(100_000):
       ...:     line += "i"
    Wall time: 17.1 ms

    Количество увеличилось в 10 раз, время увеличилось в 10 раз. Какая это сложность?


    1. homm
      21.10.2024 06:13

      Если кому-то интересно, в CPython есть оптимизация, что если у строки счетчик ссылок равен 1, то строка считается безопасной для изменения на месте и копирование не происходит на каждой итерации (для этого для строки изначально аллоцируется немного больше памяти, чтобы было куда её приращивать). Чтобы сломать этот механизм нужно сделать вторую ссылку на изменяемую строку:

      In [3]: %%time
         ...: line = ""
         ...: for i in range(10_000):
         ...:     line1 = line
         ...:     line += "i"
      Wall time: 3.49 ms
      
      In [4]: %%time
         ...: line = ""
         ...: for i in range(100_000):
         ...:     line1 = line
         ...:     line += "i"
      Wall time: 177 ms

      Теперь время увеличивается в 50 раз, что ближе к O(n*n).


    1. DimaFromMai Автор
      21.10.2024 06:13

      Привет!
      У меня получились другие порядки времени, может ещё кто-нибудь проверить?
      10_000 - 4.09 ms
      100_000 - 285 ms

      пруфы

      Интересно, от чего это зависит, от процессора и/или версии Python?
      CPU: Xeon E5-2650 v2
      Python: 3.12.2


      1. homm
        21.10.2024 06:13

        Проверил отдельно в python 3.12 и jupyter-notebook, результат тот же что у меня получился раньше.


  1. grigorym
    21.10.2024 06:13

    Понимаю, что зря, но текст вызывает сочувствие по отношению к Сберу...