Алгоритм Карацубы - Алгоритмика
Алгоритм Карацубы

Алгоритм Карацубы

автор Сергей Слотин
опубликовано 2019
пререквизиты Многочлены Мастер-теорема

В 1960-м году Андрей Колмогоров вместе с другими пионерами советской информатики собрались на научном семинаре и выдвинули «гипотезу n2n^2»: невозможно перемножить два nn-значных числа, быстрее, чем за O(n2)O(n^2). Это подразумевает, что умножение «в столбик», придуманное шумерами как минимум четыре тысячи лет назад и никем на тот момент не побитое, является асимптотически оптимальным алгоритмом умножения двух чисел.

Через неделю 23-летний аспирант Анатолий Карацуба предложил метод умножения с оценкой времени работы O(nlog23)O(n^{\log_2 3}) и тем самым опроверг гипотезу. В этой статье мы и рассмотрим этот метод.

#Алгоритм

Основная идея алгоритма очень простая: в нём умножение двух чисел длины nn небольшим алгебраическим трюком сводится к трём умножениям чисел длины n2\frac{n}{2}. Число элементов на каждом уровне рекурсии будет расти, но на самом нижнем будет суммарно всего O(nlog23)O(n^{\log_2 3}) элементов, чем и объясняется такая странная асимптотика.

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

A(x)=a0+a1x+a2x2++anxn=a0+a110+a2102++an10n \begin{aligned} A(x) &= a_0 + a_1\cdot x + a_2 \cdot x^2 + \dots + a_n \cdot x^n \\ &= a_0 + a_1\cdot 10 + a_2 \cdot 10^2 + \dots + a_n \cdot 10^n \end{aligned} Итак, пусть у нас есть два многочлена a(x)a(x) и b(x)b(x) равной длины n=2kn = 2k и мы хотим их перемножить. Разделим их коэффициенты на две равные части и представим как a(x)=a1(x)+xka2(x)b(x)=b1(x)+xkb2(x) a(x) = a_1 (x) + x^k a_2(x) \\b(x) = b_1 (x) + x^k b_2(x) Теперь рекурсивно вычислим многочлены-произведения p1p_1 и p2p_2: p1(x)=a1(x)b1(x)p2(x)=a2(x)b2(x) p_1(x) = a_1(x) \cdot b_1(x) \\ p_2(x) = a_2(x) \cdot b_2(x) А также многочлен tt: t(x)=(a1(x)+a2(x))(b1(x)+b2(x)) t(x) = ( a_1(x) + a_2(x) ) \cdot (b_1(x) + b_2(x)) Результат умножения исходных многочленов (многочлен размера 2n2n) теперь можно посчитать по следующей формуле — внимание, алгебра: c(x)=a(x)b(x)=p1(x)+xk(t(x)p1(x)p2(x))+x2kp2(x) c(x) = a(x) \cdot b(x) = p_1(x) + x^k \cdot (t(x) - p_1(x) - p_2(x)) + x^{2k} \cdot p_2(x)

Корректность формулы можно проверить, просто выполнив нужные подстановки.

#Анализ

Если посчитать необходимые операции, то выясняется, что для перемножения двух многочленов размера nn нам нужно посчитать три произведения — p1p_1, p2p_2 и tt — размера n2\frac{n}{2} и выполнить константное количество сложений, вычитаний и сдвигов (домножений на xkx^k), которые суммарно можно выполнить за O(n)O(n).

Мастер-теорема утверждает, что в данном случае асимптотика всего алгоритма будет Θ(nlog23)Θ(n1.58)\Theta (n^{\log_2 3}) \approx \Theta (n^{1.58}): наша задача разбивается на a=3a = 3 части в b=2b = 2 раз меньшего размера, а объединение происходит за O(n)O(n).

#Реализация

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

  1. Можно дополнить коэффициенты многочлена нулями до ближайшей степени двойки — в худшем случае это будет работать в 21.5832^{1.58} \approx 3 раза дольше.
  2. Можно «отщепить» последний коэффициент от многочленов и свести задачу размера (2k+1)(2k + 1) к задаче размера 2k2k и константному количество сложений.

Мы будем использовать первый метод, так как он проще в реализации.

Основные соображения по поводу эффективной реализации:

  • Нужно выделять как можно меньше лишней памяти, для чего нужно переиспользовать имеющиеся массивы.
  • Все арифметические операции нужно реализовать как простые линейные проходы по массивам, чтобы компилятор смог их векторизовать.
  • Вместо использования базы вида if (n == 1) c[0] = a[0] * b[0], имеет смысл, начиная с какого-то размера задачи, использовать более эффективное наивное умножение за квадрат.

Сначала приведем код основной рекурсивной процедуры, а потом объясним, почему он работает:

void karatsuba(int *a, int *b, int *c, int n) {
    if (n <= 64) {
        for (int i = 0; i < n; i++)
            for (int j = 0; j < n; j++)
                c[i + j] += a[i] * b[j];
    } else {
        int k = n / 2;
        int l[k], r[k], t[n] = {0};
        for (int i = 0; i < k; i++) {
            l[i] = a[i] + a[k + i];
            r[i] = b[i] + b[k + i];
        }
        karatsuba(l, r, t, k); // считает t
        karatsuba(a, b, c, k); // считает p1
        karatsuba(a + k, b + k, c + n, k); // считает p2
        int *t1 = t, *t2 = t + k;
        int *s1 = c, *s2 = c + k, *s3 = c + 2 * k, *s4 = c + 3 * k;
        for (int i = 0; i < k; i++) {
            int c1 = s2[i] + t1[i] - s1[i] - s3[i];
            int c2 = s3[i] + t2[i] - s2[i] - s4[i];
            c[k + i] = c1;
            c[n + i] = c2;
        }
    }
}

После трёх рекурсивных вызовов массив cc — это конкатенация p1p_1 и p2p_2.

После этого, для подсчета самого многочлена cc проще всего мысленно разделить его на четыре равные части, а многочлен tt — на две половины t1t_1 и t2t_2, а затем посмотреть на формулу и подумать, как изменится каждая часть:

  • s1s_1: не меняется — это первая половина p1p_1.
  • s2s_2: выражается как s2+t1s1s3s_2 + t_1 - s_1 - s_3, то есть изменяется на «первую» половину tp1p2t - p_1 - p_2.
  • s3s_3: выражается как s3+t2s2s4s_3 + t_2 - s_2 - s_4, то есть изменяется на «вторую» половину tp1p2t - p_1 - p_2.
  • s4s_4: не меняется — это вторая половина p2p_2.

Из-за векторизации важно использовать максимально «лёгкий» тип данных и при возможности компилировать с AVX:

#pragma GCC optimize("O3")
#pragma GCC target("avx2")

Реализация достаточно эффективна: она может перемножить два многочлена размера 41054 \cdot 10^5 за секунду.

#Обобщение идеи

Похожий метод можно применить к матричному умножению — это называется алгоритмом Штрассена. В нём две матрицы разбиваются на 8=4+48 = 4 + 4 частей, перемножаются блочно, и сложной алгеброй от одного из 8 умножений получается избавиться, что даёт асимптотику O(nlog27)O(n2.81)O(n^{\log_2 7}) \approx O(n^{2.81}).

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