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)$$

Общие правила

  1. Постоянные множители можно опускать: $$ 7n^3 = \Theta(n^3), \quad \frac{n^2}{3} = \Theta(n^2)$$
  2. Многочлен более высокой степени растёт быстрее: $$ n^a \prec n^b \iff a < b, \quad n = O(n^2)$$
  3. Экспонента растёт быстрее многочлена: $$n^a \prec b^n\ (\forall a>0, b>1), \quad n^2 = O(3^n), \quad n^{1000} = O(1.1^n)$$
  4. Многочлен растет быстрее логарифма: $$(\log n)^a \prec n^b\ (\forall a, b > 0), \quad (\log n)^{10} = O(\sqrt{n}), \quad n\cdot\log n = O(n^2)$$
  5. Медленно растущие слагаемые можно опускать: $$f + g = O(\max(f,g)), \quad n^2 + n = 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)$

* - вообще говоря, в Питоне эта операция в лучшем случае выполняется за константное время, и вот почему.

  • Во время создания списка, под него выделяется какое-то количество памяти, например, $N$.
  • Потом мы в свободные "ячейки" памяти кладем за константное время новые элементы в конец списка, пока память не заполнится.
  • Когда память заполнилась, мы выделяем $2\cdot N$ памяти, копируем $N$ элементов и далее можем добавить еще $N$ элементов в конец списка.

Получается, что само добавление элемента это константная операция, т.к. выделение памяти занимает $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
In [ ]:
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)
In [ ]:
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)
In [ ]:
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)
In [ ]:
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))