В 1960-м году Андрей Колмогоров вместе с другими пионерами советской информатики собрались на научном семинаре и выдвинули «гипотезу »: невозможно перемножить два -значных числа, быстрее, чем за . Это подразумевает, что умножение «в столбик», придуманное шумерами как минимум четыре тысячи лет назад и никем на тот момент не побитое, является асимптотически оптимальным алгоритмом умножения двух чисел.
Через неделю 23-летний аспирант Анатолий Карацуба предложил метод умножения с оценкой времени работы и тем самым опроверг гипотезу. В этой статье мы и рассмотрим этот метод.
#Алгоритм
Основная идея алгоритма очень простая: в нём умножение двух чисел длины небольшим алгебраическим трюком сводится к трём умножениям чисел длины . Число элементов на каждом уровне рекурсии будет расти, но на самом нижнем будет суммарно всего элементов, чем и объясняется такая странная асимптотика.
В нашей версии алгоритма мы будем перемножать не числа, а многочлены. Это эквивалентная задача — если вместо подставить основание системы счисления, то в качестве коэффициентов можно взять последовательность цифр числа:
Итак, пусть у нас есть два многочлена и равной длины и мы хотим их перемножить. Разделим их коэффициенты на две равные части и представим как Теперь рекурсивно вычислим многочлены-произведения и : А также многочлен : Результат умножения исходных многочленов (многочлен размера ) теперь можно посчитать по следующей формуле — внимание, алгебра:Корректность формулы можно проверить, просто выполнив нужные подстановки.
#Анализ
Если посчитать необходимые операции, то выясняется, что для перемножения двух многочленов размера нам нужно посчитать три произведения — , и — размера и выполнить константное количество сложений, вычитаний и сдвигов (домножений на ), которые суммарно можно выполнить за .
Мастер-теорема утверждает, что в данном случае асимптотика всего алгоритма будет : наша задача разбивается на части в раз меньшего размера, а объединение происходит за .
#Реализация
Для простоты будем предполагать, что это степень двойки. Если это не так, то в зависимости от обстоятельств это можно исправить одним из двух костылей:
- Можно дополнить коэффициенты многочлена нулями до ближайшей степени двойки — в худшем случае это будет работать в раза дольше.
- Можно «отщепить» последний коэффициент от многочленов и свести задачу размера к задаче размера и константному количество сложений.
Мы будем использовать первый метод, так как он проще в реализации.
Основные соображения по поводу эффективной реализации:
- Нужно выделять как можно меньше лишней памяти, для чего нужно переиспользовать имеющиеся массивы.
- Все арифметические операции нужно реализовать как простые линейные проходы по массивам, чтобы компилятор смог их векторизовать.
- Вместо использования базы вида
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;
}
}
}
После трёх рекурсивных вызовов массив — это конкатенация и .
После этого, для подсчета самого многочлена проще всего мысленно разделить его на четыре равные части, а многочлен — на две половины и , а затем посмотреть на формулу и подумать, как изменится каждая часть:
- : не меняется — это первая половина .
- : выражается как , то есть изменяется на «первую» половину .
- : выражается как , то есть изменяется на «вторую» половину .
- : не меняется — это вторая половина .
Из-за векторизации важно использовать максимально «лёгкий» тип данных и при возможности компилировать с AVX:
#pragma GCC optimize("O3")
#pragma GCC target("avx2")
Реализация достаточно эффективна: она может перемножить два многочлена размера за секунду.
#Обобщение идеи
Похожий метод можно применить к матричному умножению — это называется алгоритмом Штрассена. В нём две матрицы разбиваются на частей, перемножаются блочно, и сложной алгеброй от одного из 8 умножений получается избавиться, что даёт асимптотику .
И в алгоритме Штрассена, и в алгоритме Карацубы можно достичь и лучшей асимптотики, если разбивать объекты на большее число частей. Однако, в реальности это не применяется, потому что в асимптотиках подобных алгоритмов скрыта непрактично большая константа.