08. Алгоритмическая сложность. Сортировки.
О-символика
Сегодня мы поговорим о том, как сравнивать скорость различных алгоритмов. Конечно, скорость выполнения программы зависит от многих факторов - процессор компьютера, его загруженность, архитектура, устройство памяти, сам язык программирования - поэтому хочется уметь сравнивать алгоритмы вне зависимости от них. Определить, какой алгоритм работает в общем случае быстрее другого, можно, например, с помощью О-символики. Начнем с определения.
Определение
Пусть $f,g$ - функции. Говорим, что $f$ растет не быстрее $g$ и пишем $f(n) = O(g(n))$ или $f \preceq g$, если существует такая константа $c>0$, что $f(n) \leq c \cdot g(n)$ для любого $n \in \mathbb N$.
Связанные понятия:
$f(n) = \Omega(g(n)),\ f \succeq g \iff \exists c>0: f(n) \geq c\cdot g(n)$ (не медленнее) $f(n) = \Theta(g(n)),\ f \asymp g \iff f(n) = O(g(n)) = \Omega(g(n))$ (не быстрее и не медленее, т.е. одинаково)
Пример
$$3n^2 + 5n + 2 = O(n^2)$$Общие правила
Часто используемые функции
$$\log n \prec \sqrt{n} \prec n \prec n\log n \prec n^2 \prec 2^n$$
Такой способ позволяет оценить скорость алгоритма в первом приближении, опуская детали, но иногда эти "детали" могут быть очень значимыми, а О-символика их игнорирует. Однако, в программировнии постоянно используют этот способ, часто можно увидеть выражения "алгоритм за квадрат" - $O(n^2)$, "алгоритм за линию" - $O(n)$, "константа" - $O(1)$. Везде за $n$ берется размер входных данных (число строк, число символов, длина массива, количество запросов и т.д.).
Циклы и список
Пример 1
Пусть дан список $A$ длины $N$, тогда какова алгоритмическая сложность следующего кода?
for i in range(len(A)):
print(i)
Цикл делает столько итераций, сколько составляет длина списка, и выполняет лишь одну простую (константную) операцию.
Поэтому сложность: $\Theta(N) \cdot \Theta(1) = O(N)$
Пример 2
s = 0
for i in range(5):
for x in A:
s += A
Задание переменной это простая операция. Далее цикл фиксированной длины, в котором есть внутренний цикл зависящий от длины списка. Внутренний цикл выполняет простую операцию обновления переменной.
Итого: $\Theta(1) + \Theta(1) \cdot \Theta(N) \cdot \Theta(1) = O(N)$
Пример 3
s = 0
for i in range(len(A)):
for j in range(len(A)):
print(A[i] + A[j])
Сначала простая операция задания переменной. Далее двойной цикл по массиву. Внутри операция доступа к элементу массива и простая операция печати.
Итого: $\Theta(1) + \Theta(N) \cdot \Theta(N) \cdot (\Theta(1) + \Theta(1)) = O(N^2)$
Пример 4
for i in range(len(A)):
A[i] = A[i] * i
Цикл по всему массиву, внутри цикла проста операция доступа к элементу списка, простая арифметическая операция, простая операция замены (задания) значения элемента списка.
Итого: $\Theta(N) \cdot (\Theta(1) + \Theta(1) + \Theta(1)) = O(N)$
Пример 5
B = []
for x in A:
B.append(x ** 2)
Сначала простая операция задания пременной пустым массивом. Далее цикл по элементам массива А. Внутри цикла простая арифметическая операция и добавление элемента в конец массива*.
Итого: $\Theta(1) + \Theta(N) \cdot (\Theta(1) + \Theta(N)^*) = O(N^2)$
* - вообще говоря, в Питоне эта операция в лучшем случае выполняется за константное время, и вот почему.
Получается, что само добавление элемента это константная операция, т.к. выделение памяти занимает $O(N)$, куда мы добавим максимум $N$ элементов, тогда в среднем получаем $O(N)/N = O(1)$. Также это называют амортизированной сложностью.
Сложность операций списка (источник)
Operation | Example | Complexity Class | Notes |
---|---|---|---|
Append | l.append(5) |
O(1) | O(N) in worst case |
Containment | x in/not in l |
O(N) | linearly searches list |
Insert/Delete | O(N) | ||
Index | l[i] |
O(1) | |
Store | l[i] = 0 |
O(1) | |
Length | len(l) |
O(1) | |
Pop | l.pop() |
O(1) | |
Clear | l.clear() |
O(1) | similar to l = [] |
Slice | l[a:b] |
O(N) | O(b-a): l[0:-1]:O(N) |
Construction | list(...) |
O(len(...)) | depends on length of ... iterable |
Extend | l.extend(...) |
O(len(...)) | depends only on len of extension |
check ==, != | l1 == l2 |
O(N) | |
Insert | l[a:b] = ... |
O(N) | |
Delete | del l[i] |
O(N) | depends on i; O(N) in worst case |
Copy | l.copy() |
O(N) | Same as l[:] which is O(N) |
Remove | l.remove(...) |
O(N) | |
Pop | l.pop(i) |
O(N) | O(N-i): l.pop(0):O(N) (see above) |
Extreme value | min(l)/max(l) |
O(N) | linearly searches list for value |
Reverse | l.reverse() |
O(N) | |
Iteration | for v in l: |
O(N) | |
Find | l.index(x) |
O(N) | linearly searches list for value |
def copy(A):
B = []
for i in range(len(A)):
B.append(A[i])
return B
def copy2(A):
# B = A.copy()
# return B
return A.copy()
N = 1000
A = [i for i in range(N)]
%timeit copy(A)
%timeit copy2(A)
import random
def sort(iterable):
l = list(iterable)
s = sorted(l)
return s
def sort2(iterable):
return sorted(iterable)
N = 10000
A = [i for i in range(N)]
random.shuffle(A)
iterableA = map(str, A)
print(type(sort(iterableA)), type(sort2(iterableA)))
%timeit sort(iterableA)
%timeit sort2(iterableA)
def scale(A):
# X_std = (X - X.min(axis=0)) / (X.max(axis=0) - X.min(axis=0))
B = A.copy()
for i in range(len(A)):
B[i] = (A[i] - min(A)) / (max(A) - min(A))
return B
def scale2(A):
B = A.copy()
amin, amax = min(A), max(A)
for i in range(len(A)):
B[i] = (A[i] - amin) / (amax - amin)
return B
N = 1000
A = [i for i in range(N)]
%timeit scale(A)
%timeit scale2(A)
def find_all(A, x):
i = 0
idx = []
while x in A[i:]:
i += A[i:].index(x) + 1 # +1 для того чтобы потом искать с места после найденного х
idx.append(i - 1) # -1 чтобы корректно добавить нужный индекс
return idx
def find_all2(A, x):
idx = []
for i in range(len(A)):
if x == A[i]:
idx.append(i)
return idx
N = 1000
n = 3 # try 100
A = [i % n for i in range(N)]
print(find_all(A, 2) == find_all2(A, 2))
%timeit find_all(A, 2)
%timeit find_all2(A, 2)
Хэш-таблицы
На предыдущем занятии мы изучили множество и словарь. Обе структуры данных используют хэш-таблицы для того, чтобы очень быстро добавлять, удалять и искать ключи/значения.
Начнем с того, что такое хэш-таблица.
Хеш-таблица содержит некоторый массив $H$ (buckets), элементы которого есть пары индекс-значение.
Выполнение операции в хеш-таблице начинается с вычисления хеш-функции от ключа. Получающееся хеш-значение $hash(key)$ играет роль индекса $i$ в массиве значений $H$. Затем выполняемая операция (добавление, удаление или поиск) перенаправляется объекту, который хранится в соответствующей ячейке массива $H[i]$.
Более наглядно:
Казалось бы, как все прекрасно. Но есть проблема в том, что хэш-функция может отобразить различные ключи в одно и то же хэш-значение. Такая ситуация называется коллизией.
Коллизии не так уж и редки — например, при вставке в хеш-таблицу размером 365 ячеек всего лишь 23 элементов вероятность коллизии уже превысит 50 % (если каждый элемент может равновероятно попасть в любую ячейку) — см. парадокс дней рождения.
Поэтому механизм разрешения коллизий - важная составляющая любой хеш-таблицы. Есть два механизма разрешения коллизий - хеш-таблица с открытой адресацией и хеш-таблица с цепочками.
$\qquad\qquad$
Если (в лучшем случае) все $N$ ключей попадают в разные ячейки $H$, то операции (добавление, удаление, поиск) производятся за константу. Но если (в худшем случае) все ключи попадут в одно и то же хэш-значение, то эти операции производятся за линейное время, т.к. необходимо пройти весь список значений.
Сложность операций словарей
Operation | Example | Complexity Class | Notes |
---|---|---|---|
Add (store) | d[k] = v |
O(1) | O(N) worst case |
Containment | x in/not in d |
O(1) | |
get/setdefault | d.get(k) |
O(1) | |
Delete | del d[k], d.pop(k) |
O(1) | |
View | d.keys() |
O(1) | same for d.values() |
Сложность операций множеств
Для множеств тоже используется хэш-таблица, где ключи это элементы множества, а значения пустые.
Operation | Example | Complexity Class | Notes | |
---|---|---|---|---|
Add | s.add(5) |
O(1) | O(N) worst case | |
Containment | x in/not in s |
O(1) | ||
Delete | s.remove(..); s.discard(..) |
O(1) | ||
check ==, != | s != t |
O(len(s)) | False in O(1) if the lengths are different | |
<= or < , >= or > | s <= t; s >= t |
O(len(s)), O(len(t)) | ||
Sets operations | s | t; s & t; s - t; s ^ t |
O(len(s)+len(t)) |
Сортировки