Всем привет! Я расскажу, что такое сложность алгоритмов и откуда она берётся, разберу типичные заблуждения и самые частые ошибки новичков. Материал рассчитан в первую очередь на начинающих 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
), их также нельзя делать пустыми в параметрах по умолчанию.
Заключение
Простыми словами и на примерах я разобрал, что такое сложность алгоритма, показал, откуда берётся это понятие, разобрал типовые ошибки, вытекающие из непонимания сложности и самые распространённые среди новичков.
Не стоит бояться делать ошибки, если ты начинающий разработчик. Не ошибается только тот, кто ничего не делает.
Спасибо за внимание.
Комментарии (20)
margothequeen
21.10.2024 06:13мне кажется, что Вы путаете подсчëт числа операций с асимптотическим анализом
artschedrov
21.10.2024 06:13Именно это и показывает функция сложности алгоритма O(N) — сколько элементов нужно обработать, столько операций и будет проделано.
К сожалению нет, O(N) показывает верхнюю границу ограничения функции, иначе говоря сложность алгоритма в наихудшем случае.
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 как значение по умолчанию не навредит ...
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): ...
crushilov
21.10.2024 06:13Добрый вечер. А операция с join не дает дополнительной сложности алгоритму? А заполнение массива [None] x N?
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 раза.
homm
21.10.2024 06:13Примечание: ассемблер настолько близок к машинному коду, что фактически одному оператору ассемблера соответствует одна машинная инструкция (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.
homm
21.10.2024 06:13В приведенном отрывке видимо имеются в виду реально выполненные во времени операции, хотя на мой вкус написано коряво. Вы же утверждаете, что сложность можно оценить буквально сосчитав количество операций на языке ассемблера (то есть в коде программы).
DimaFromMai Автор
21.10.2024 06:13На n входных параметров у алгоритма n ассемблерных операций, у другого алгоритма на n входных параметров n*n ассемблерных операций, т.е. можно увидеть фактическую сложность алгоритма, если считать, что ассемблерная команда - это элементарная операция. Вы правильно написали: "Сложность как раз никак не связана с количеством написанных инструкций", - я же не утверждаю, что сложность как-то зависит от количества написанных строчек кода.
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 раз. Какая это сложность?
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).
DimaFromMai Автор
21.10.2024 06:13Привет!
У меня получились другие порядки времени, может ещё кто-нибудь проверить?
10_000 - 4.09 ms
100_000 - 285 msпруфы
Интересно, от чего это зависит, от процессора и/или версии Python?
CPU: Xeon E5-2650 v2
Python: 3.12.2homm
21.10.2024 06:13Проверил отдельно в python 3.12 и jupyter-notebook, результат тот же что у меня получился раньше.
Survtur
Да просто вторые - иммутабельные, отсюда всё и вытекает.
MamedovAlexandr
Вроде все объекты передаются по ссылке. Это видно по id объектов
Survtur
Я не спорю. Просто нет смысла отдельно помнить, что как передаётся. Достаточно понимать, иммутабельный объект или нет.