Оптимизация кода

Временная сложность алгоритма может многое сказать о его эффективности — это правда, но важны и детали реализации. Вот, к примеру, два фрагмента кода, в которых проверяется, есть ли в массиве a элемент x:

ok = False
for i in range(n):
    if a[i] == x:
        ok = True

ok = False
for i in range(n):
    if a[i] == x:
        ok = True
        break

Оба фрагмента работают за время \(O(n)\), однако на практике второй может оказаться значительно эффективнее, потому что прекращает работу, как только элемент x найден. Это полезная оптимизация, потому что она действительно повышает производительность кода и к тому же легко реализуется.

 

Особенности процессора

Исполняя код, процессоры стараются делать это как можно быстрее. Существуют кеши, ускоряющие доступ к памяти, а иногда несколько команд можно выполнять параллельно. Современные процессоры очень сложны, и мало кто понимает, как они в действительности работают.

Кеши

Поскольку доступ к основной памяти сравнительно медленный, в процессорах имеются кеши — участки небольшого объема памяти с более быстрым доступом. Кеши автоматически используются, когда читается или записывается содержимое расположенных рядом ячеек памяти. В частности, просмотр элементов массива слева направо производится быстро, а в случайном порядке — медленно.

 

В качестве примера рассмотрим два таких фрагмента:

for i in range(n):
    for j in range(n):
        s += a[i][j]

for i in range(n):
    for j in range(n):
        s += a[j][i]

В обоих случаях вычисляется сумма элементов двумерного массива, но первый вариант работает намного эффективнее, потому что дружелюбен к кешу. Элементы массива хранятся в памяти в таком порядке:

 

\(x[0][0], \; x[0][1], \; \ldots , \; x[0][n-1], \;x[1][0], \; x[1][1], \; \ldots\)

 

Поэтому лучше, когда во внешнем цикле изменяется первый индекс, а во внутреннем — второй.

Распараллеливание

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

f = 1
for i in range(1, n + 1):
    f = (f * i) % M

Здесь в цикле вычисляется факториал n по модулю M. Можно попробовать сделать этот код эффективнее, поступив следующим образом (в предположении, что n чётно):

f1 = 1
f2 = 1
for i in range(1, n + 1, 2):
    f1 = (f1 * i) % M
    f2 = (f2 * (i + 1)) % M
f = f1 * f2 % M

Идея в том, чтобы использовать две независимые переменные: f1 будет содержать произведение \(1 \cdot 3 \cdot 5 \cdot \ldots \cdot (n – 1)\), а f2 — произведение \(2 \cdot 4 \cdot 6 \cdot \ldots \cdot n\). По завершении цикла оба результата объединяются. Этот код обычно работает в два раза быстрее первоначальной версии, потому что процессор может параллельно выполнять команды, изменяющие переменные f1 и f2 внутри цикла. Можно даже завести больше переменных (скажем, четыре или восемь) и тем самым еще увеличить скорость работы.