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

И уж конечно, никогда при написании a * b мы не задумываемся о том, как реализовано умножение чисел a и b в нашем языке. Какие вообще есть алгоритмы умножения? Это какая‑то нетривиальная задача?

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

Оглавление

  1. Его величество столбик
    Про О-нотацию

  2. Разделяй и властвуй: алгоритм Карацубы

  3. Многочлены vs преобразование Фурье: алгоритм Шенхаге-Штрассена
    Про быстрое преобразование Фурье

  4. Модульная арифметика и второй алгоритм Шенхаге-Штрассена
    Про модульную арифметику чисел
    Про модульную арифметику многочленов

Зачем быстро умножать числа?

Программы постоянно перемножают числа. Перемножения 32-битных, 64-битных, а иногда и более длинных чисел встроены напрямую в арифметико‑логические устройства микропроцессоров; генерация оптимальных цепей для кремния — отдельная инженерная наука. Но не всегда встроенных возможностей хватает.

Например, для криптографии. Криптографические алгоритмы (вроде повсеместно используемого RSA) оперируют числами длиной в тысячи бит. Если мы сводим операции над 4096-битными числами к операциям над 64-битными словами, разница в количестве операций между алгоритмами за N^2 и N\log N уже составляет десять раз!

Деление тоже сводится к умножению; в некоторых процессорах даже нет инструкции для целочисленного деления.

Но сразу признаюсь: не все существующие алгоритмы быстрого умножения достаточно практичны для широкого применения — по крайней мере на сегодняшний день. Помимо практического здесь силён академический интерес. Как вообще математически устроена операция умножения? Насколько быстро её можно делать? Можем ли мы найти оптимальный алгоритм и чему научимся в процессе поиска?

Его величество столбик

Когда-то очень давно мне попалось на глаза видео с кричащим названием "How to Multiply", описывающее так называемый японский метод умножения. Что меня удивило — так это то, что метод этот подаётся как более простой и интуитивно понятный. Оказывается, если умножение в столбик делать не в столбик, а занимая половину листа бумаги, получается проще и понятнее!

Да-да, принципиально это тот же самый алгоритм умножения столбиком. Можем посмотреть на запись в столбик и сравнить её с картинкой:

92 — это две больших группы красных точек на нижне-правой диагонали. 23 — две маленьких группы на верхне-левой диагонали. Точно так же, как и в столбике, мы вынуждены перемножить попарно все разряды, потом сложить, и потом сделать перенос. Умножение в столбик — не что иное, как компактный на бумаге и относительно удобный для человеческого сознания способ проделать алгоритм, объединяющий в себе практически все придуманные до XX века способы умножения, за редким исключением вроде умножения египетских дробей.

Асимптотическая сложность умножения в столбик не зависит от того, в какой системе счисления мы производим умножение, и составляет O(M\cdot N) операций, где M и N — количество разрядов в множителях. Каждый из M разрядов одного множителя нужно умножить на каждый из N разрядов второго множителя, после чего получившиеся MN маленьких чисел (в каждом не больше двух разрядов) сложить.

Почему асимптотика не зависит от системы счисления?

В разных системах счисления у чисел разное количество разрядов, это верно. Количество десятичных разрядов в числе x равно \lceil\log_{10}x\rceilв двоичной — \lceil\log_{2}x\rceil (скобки-уголки означают округление вверх). Но мы знаем, что логарифмы по разному основанию связаны друг с другом константным множителем:

\log_{10}x=\log_{10}2\cdot\log_{2}x

А константные множители не имеют значения при анализе асимптотики.

Обычно при анализе сложности под M и N подразумевается количество двоичных разрядов, а числа предполагаются примерно одинаковой длины; тогда формула сложности упрощается до O(N^2).

Что означает запись O(N²)?

Многих программистов О-нотации учит улица — они сталкиваются с ней сразу в оценке сложности каких-то алгоритмов. Но О-формализм происходит из математического анализа, и у него есть строго определённый смысл, причём не очень хорошо согласующийся с остальными привычными для математики обозначениями.

Если у нас есть функция f(N) — в наших примерах это, как правило, будет количество операций, нужных для обработки алгоритмом входных данных в размере N бит — то запись f(N) = O(N^2) означает, что существует некоторая константа C такая, что начиная с достаточно больших N

f(N) \le C\cdot N^2

То есть функция растёт не быстрее, чем N^2.

При этом сама запись O(N^2) означает класс функций, для которых выполнено это условие. В привычной теоретико-множественной нотации это было бы правильно записать как f(N)\in O(N^2) — функция входит во множество функций, растущих не быстрее чего-то там.

При этом ничего не сказано о том, может ли функция расти медленнее, чем N^2. Например, в случае алгоритма умножения в столбик мы могли бы без зазрения совести записать f(N) = O(N^3) — и формально всё ещё были бы правы. Действительно, операций нужно меньше, чем N^3. Полезная ли это информация? Не очень.

Соответственно, одна и та же функция может быть «равна» разным O(\ldots); при этом между друг другом эти O(\ldots) не становятся равны:

f(N) = O(N^2),\ f(N) = O(N^3)\ \ \not{\!\!\!\Longrightarrow}\ \ O(N^2) = O(N^3)

Так что обращаться с O(\ldots)в выражениях нужно осторожно.

Помимо O(\ldots)— наиболее, пожалуй, распространённого среди программистов — есть и другие классы функций с аналогичной записью. f(N) = o(N^2) означает, что функция растёт медленее, чем N^2; да не просто медленнее, а настолько, что их частное становится с ростом N всё ближе и ближе к нулю:

\frac{f(N)}{N^2} \rightarrow 0 \mbox{ при } N\rightarrow\infty

Например, в матанализе постоянно используют запись o(1), чтобы обозначить незначительную величину, стремящееся к нулю. Единица в скобках при этом не имеет какого-то существенного значения — там могла бы быть любая константа; можете написать o(69) и формально это будет верно. Иногда бывает важно указать, с какой именно скоростью величина стремится к нулю. Тогда пишут o(1/\sqrt{N}), o(1/N) и так далее.

Другая встречающаяся нотация f(N) = \Theta(N^2) означает, что функция растёт точно со скоростью N^2, то есть существуют константы C_1 и C_2 такие, что начиная с достаточно больших N

C_1\cdot N^2 \le f(N) \le C_2\cdot N^2

Помимо простого использования со знаком равенства, как в примерах выше, классы функций можно использовать в арифметических операциях. Например, запись

f(N) = N^2 + o(1)

означает, что функция равна N^2 плюс нечто маленькое, стремящееся к нулю при росте N. В отличие от f(N) = O(N^2) или f(N) = \Theta(N^2), эта запись не допускает произвольных множителей при N^2 — ровно N^2 и всё тут.

Работает (при некоторых ограничениях) и привычная арифметика в равенствах. Так, если f(N) = O(N^3), обе части равенства можно поделить, например, на константу:

\frac{f(N)}{2} = \frac{O(N^3)}{2} = O(N^3)

O(\ldots) съедает любые константы, поэтому справа ничего не изменилось. А можно поделить на N:

\frac{f(N)}{N} = \frac{O(N^3)}{N} = O(N^2)

Не-константу нельзя просто выкинуть, поэтому она ушла внутрь O(\ldots) и сократилась с тем, что там было.

Разделяй и властвуй: алгоритм Карацубы

Первый шаг на пути ускорения умножения совершил в 1960-м году советский математик Анатолий Карацуба. Он заметил, что если длинные числа поделить на две части:

{\color{Red}{235739}}\,{\color{DarkBlue}{098113}}\ \times\ {\color{DarkOrange}{187129}}\,{\color{Magenta}{102983}} \\[4mm] \left({\color{Red}{a_1}}\cdot 10^6 + {\color{DarkBlue}{a_0}}\right)\ \times\ \left({\color{DarkOrange}{b_1}}\cdot 10^6 + {\color{Magenta}{b_0}}\right) \\[4mm] {\color{Red}{a_1}}\cdot{\color{DarkOrange}{b_1}}\cdot 10^{12} + ({\color{Red}{a_1}}\cdot {\color{Magenta}{b_0}} + {\color{DarkBlue}{a_0}}\cdot {\color{DarkOrange}{b_1}})\cdot 10^6 + {\color{DarkBlue}{a_0}}\cdot {\color{Magenta}{b_0}}

То можно обойтись тремя умножениями этих более коротких частей друг на друга, а не четырьмя, как можно было бы подумать. Вместо прямого подсчёта {\color{Red}{a_1}}\cdot {\color{Magenta}{b_0}} + {\color{DarkBlue}{a_0}}\cdot{\color{DarkOrange}{b_1}}, требующего двух умножений, достаточно посчитать \left({\color{Red}{a_1}} + {\color{DarkBlue}{a_0}}\right) \times \left({\color{DarkOrange}{b_1}} + {\color{Magenta}{b_0}}\right) и вычесть из результата числа {\color{Red}{a_1}}\cdot{\color{DarkOrange}{b_1}} и {\color{DarkBlue}{a_0}}\cdot {\color{Magenta}{b_0}}, нужные нам в любом случае для получения младших и старших разрядов.

После этих расчётов остаётся лишь пробежаться по разрядом справа налево и провести суммирование:

\begin{array}{c@{\quad=\quad}rrrr} {\color{DarkBlue}{a_0}}\cdot {\color{Magenta}{b_0}} & &&\!\!\!\!\!\!10103&\!\!\!\!\!\!9710\overset{\displaystyle\leftarrow}{79} \\ {\color{Red}{a_1}}\cdot {\color{Magenta}{b_0}} + {\color{DarkBlue}{a_0}}\cdot {\color{DarkOrange}{b_1}} & &\!\!\!\!\!\!42636&\!\!\!\!\!\!897014& \\  {\color{Red}{a_1}}\cdot{\color{DarkOrange}{b_1}} & 44113&\!\!\!\!\!\!603331&& \\ \hline a \cdot b & 44113&\!\!\!\!\!\!645967&\!\!\!\!\!\!907117&\!\!\!\!\!\!971079 \end{array}

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

Для построения эффективного алгоритма осталось превратить это наблюдение в рекурсивную процедуру. Половинки чисел тоже будем делить на половинки и так далее, пока не дойдём до достаточно коротких чисел, которые можно перемножить в столбик или, допустим, через lookup table.

Поскольку мы каждый раз делим задачу на три задачи с вдвое меньшими (по количеству бит) числами, для перемножения двух чисел длины 2^k нам потребуется рекурсия глубины k и суммарно 3^k умножений чисел наименьшей длины; отсюда получаем оценку сложности в O(N^{\log_2 3}) \approx O(N^{1.58}).

Занятный факт — аналогичные фокусы с экономией за счёт одновременных умножений используются ещё в ряде мест. Так, два комплексных числа a+bi и c+di также можно перемножить за три вещественных умножения вместо четырёх, вычислив ac, bd и (a+b)\cdot(c+d). А алгоритм Пана для быстрого перемножения матриц основывается на том, что можно одновременно вычислить произведения двух пар матриц XY и UV за меньшее число умножений, чем по отдельности [1, 2]. Не говоря уж о том, что само по себе перемножение матриц быстрее, чем за O(N^3) — это быстрое одновременное умножение матрицы на N векторов.

Числа vs многочлены: алгоритмы Тоома-Кука

При виде магического сокращения вычислительной сложности при разбиении множителей на две части как-то сам собой возникает вопрос: а можно разбить множители на бóльшее число частей и получить бóльшую экономию?

Ответ — да; и подход, позволяющий это сделать, включает в себя алгоритм Карацубы как частный случай. Но для его формулировки нам придётся проделать некий фокус.

Давайте попробуем разбить те же самые числа на три части:

{\color{Red}{2357}}\,{\color{DarkBlue}{3909}}\,{\color{DarkGreen}{8113}}\ \times\ {\color{DarkOrange}{1871}}\,{\color{Magenta}{2910}}\,{\color{Golden}{2983}} \\[5mm] \left({\color{Red}{a_2}}\cdot 10^8 + {\color{DarkBlue}{a_1}}\cdot 10^4 + {\color{DarkGreen}{a_0}}\right)\ \times\ \left({\color{DarkOrange}{b_2}}\cdot 10^8 + {\color{Magenta}{b_1}}\cdot 10^4 + {\color{Golden}{b_0}}\right)

Если мы внимательно посмотрим на коэффициенты при степенях десятки, которые возникают при перемножении этих скобок:

\begin{array}{r} {\color{Teal}{a_0\cdot b_0}} \\  {\color{Violet}{a_1\cdot b_0 + a_0\cdot b_1}} \quad\quad \\ {\color{Orchid}{a_2\cdot b_0 + a_1\cdot b_1 + a_0\cdot b_2}} \quad\quad\quad\quad \\ {\color{Gray}{a_2\cdot b_1 + a_1\cdot b_2}}\quad\quad\quad\quad\quad\quad \\ {\color{DarkRed}{a_2\cdot b_2}}\quad\quad\quad\quad\quad\quad\quad\quad \\ \hline a\cdot b \end{array}

То (при наличии опыта в алгебре) заметим, что где-то мы такое уже видели. При перемножении многочленов!

Если взять части наших чисел a и b и объявить их коэффициентами многочленов f(x) и g(x) при соответствующих степенях, числа в таблице выше будут не чем иным, как коэффициентами многочлена f(x) \cdot g(x):

\begin{array}{l}  f(x) \cdot g(x) = \\[2mm] \quad\quad = (a_0 + a_1x + \ldots)\ \times\ (b_0 + b_1x + \ldots) = \quad\quad\quad\quad \\[2mm] \qquad\qquad\begin{array}{r@{\ }l}  = & ({\color{Teal}{a_0\cdot b_0}})  \\  + & ({\color{Violet}{a_1\cdot b_0 + a_0\cdot b_1}})\cdot x \\  + & ({\color{Orchid}{a_2\cdot b_0 + a_1\cdot b_1 + a_0\cdot b_2}})\cdot x^2 \\  + & ({\color{Gray}{a_3\cdot b_0 + a_2\cdot b_1 + a_1\cdot b_2 + a_0\cdot b_3}})\cdot x^3 \\   + & \ldots \end{array} \end{array}

Сами же числа получаются из многочленов путём вычисления значения в некоторой точке. Какой именно — зависит от системы счисления и размера частей:

a = {\color{Red}{a_2}}\cdot 10^8 + {\color{DarkBlue}{a_1}}\cdot 10^4 + {\color{DarkGreen}{a_0}} = f(10^4)

Хорошо, свели одну задачу к другой. В чём преимущество многочленов? Помимо того, что это некоторые формальные конструкции с параметрами, это также функции; чтобы вычислить f(x) \cdot g(x) в произвольной точке, не нужно перемножать многочлены — достаточно вычислить оба значения в этой точке и перемножить их. Если бы я писал продакшн-код, в котором по какой-то причине нужно перемножать многочлены, я бы и вовсе сделал перемножение ленивым.

Но нам не нужно вычислять значение в точке. Нам нужны коэффициенты. А коэффициенты многочлена можно восстановить по значениям в точках, решив систему линейных уравнений:

\left\{\begin{matrix} c0 + c_1\cdot x_1 + \ldots  = f(x_1)\cdot g(x_1) \\  c0 + c_1\cdot x_2 + \ldots  = f(x_2)\cdot g(x_2) \\  \vdots \end{matrix}\right.

Здесь x_i — это точки, в которых нам известны значения многочлена, в правой части — сами эти значения, а c_i — неизвестные нам коэффициенты многочлена. Количество неизвестных соответствует степени многочлена + 1; чтобы решение было единственным, нужно, соответственно, знать значения в таком же количестве точек. В нашем примере это пять точек, потому что у многочлена-произведения степень 4:

f(x) \cdot g(x) = {\color{DarkRed}{a_2\cdot b_2}}\,\cdot x^4 + \ldots

Если переписать эту систему уравнений в матричном виде, получим

\begin{bmatrix} 1 & x_1 & x_1^2 & x_1^3 & \cdots \\[1mm] 1 & x_2 & x_2^2 & x_2^3 & \cdots \\[1mm] 1 & x_3 & x_3^2 & x_3^3 & \cdots \\ \vdots & \vdots & \vdots & \vdots & \ddots \end{bmatrix} \times \begin{bmatrix} c_0 \\[1mm] c_1 \\[1mm]  c_2 \\ \vdots \end{bmatrix} = \begin{bmatrix} f(x_1)\cdot g(x_1) \\[1mm] f(x_2)\cdot g(x_2) \\[1mm] f(x_3)\cdot g(x_3) \\ \vdots \end{bmatrix}

Соответственно, для создания алгоритма быстрого умножения чисел нам достаточно:

  1. выбрать, на сколько частей мы разбиваем числа (можно даже разбить aи bна разное количество частей);

  2. выбрать точки x_i, в которых мы будем вычислять значения многочленов;

  3. построить по ним матрицу Вандермонда;

  4. вычислить её обратную матрицу.

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

Давайте посмотрим на матрицу для точек 0, 1, -1, 2, -2:

from sympy import Rational
from sympy.matrices import Matrix

# Точки, в которых будем вычислять значения многочленов:
x = [0, 1, -1, 2, -2]

# Матрица Вандермонда, построенная по этим точкам:
v = Matrix([
    [
        Rational(x_i ** j )
        for j, _ in enumerate(x)
    ] for x_i in x
])
\left[\begin{matrix}1 & 0 & 0 & {\color{Gray}{0}} & {\color{Gray}{0}}\\1 & 1 & 1 & {\color{Gray}{1}} & {\color{Gray}{1}}\\1 & -1 & 1 & {\color{Gray}{-1}} & {\color{Gray}{1}}\\1 & 2 & 4 & {\color{Gray}{8}} & {\color{Gray}{16}}\\1 & -2 & 4 & {\color{Gray}{-8}} & {\color{Gray}{16}}\end{matrix}\right]

(Серым я отметил числа, не имеющие значения, поскольку соответствующие коэффициенты в многочленах-множителях заведомо равны нулю.)

И на её обратную:

\frac{1}{24}\times\left[\begin{matrix}24 & 0 & 0 & 0 & 0\\0 & 16 & -16 & -2 & 2\\-30 & 16 & 16 & -1 & -1\\0 & -4 & 4 & 2 & -2\\6 & -4 & -4 & 1 & 1\end{matrix}\right]

За исключением некоторой проблемы с делением на 3, подавляющая часть вычислений здесь представляется битовыми сдвигами, сложениями и вычитаниями «коротких» чисел, а это операции «простые» — имеющие линейную сложность.

Как проанализировать итоговую сложность получившегося алгоритма? Давайте обозначим количество операций через C(N), где N — количество бит в наших множителях, и выведем рекуррентную формулу:

C(N) = 5\cdot C\left(\frac{N}{3}\right) + \mathrm{const}\cdot \frac{N}{3}

Что здесь написано: умножение двух чисел длиной N бит мы умеем сводить к 5 умножениям чисел длиной N/3 бит и ещё какому-то количеству простых операций над числами длиной N/3 бит. Применяем основную теорему о рекуррентных соотношениях и получаем итоговую сложность

C(N) = O(N^{\log_3 5}) \approx O(N^{1.46})

Таким образом мы ускорили умножение с O(N^{1.58}) до O(N^{1.46}).

Алгоритмы, получаемые таким образом, называются алгоритмами Тоома-Кука. Они активно используются на практике; в одной только библиотеке GMP поддержано 13 разных вариантов разбиения чисел.

Закономерный следующий вопрос: что дальше? Если число бьётся на M частей, сложность получается

C(N) = O\left(N^{{\color{DarkBlue}{\log_M (2M+1)}}}\right)

Показатель степени при большом M можно упростить:

{\color{DarkBlue}{\log_M (2M+1)}} \approx \log_M (2M) = \frac{\log_2{2} + \log_2 M}{\log_2 M} = 1 + \frac{1}{\log_2 M} \rightarrow 1

Получается, мы можем построить алгоритм со сколь угодно близкой к 1 степенью N в асимптотике? Увы, тут есть сложности.

Во-первых, это непрактично. Логарифм возрастает крайне медленно; асимптотика, соответственно, тоже будет падать медленно, а константа при этом будет быстро расти, потому что числа в матрицах будут всё больше и разнообразнее.

Во-вторых, это неинтересно. Да-да! Дальше мы будем обсуждать алгоритмы, сложность которых лучшеO(N^{1+\varepsilon}), каким маленьким бы ни было \varepsilon.

Занятный факт — аналогичной методу Тоома-Кука техникой строятся быстрые алгоритмы перемножения матриц. Однако процедура перемножения матриц по своей математической природе оказалась намного сложнее перемножения многочленов; поэтому до сих пор нет уверенности, что можно перемножать матрицы за O(N^{2+\varepsilon}) для сколь угодно малого \varepsilon. Лучшая асимптотика на сегодняшний день составляет примерно O(N^{2.37}).

Многочлены vs преобразование Фурье: алгоритм Шенхаге-Штрассена

Раз рекуррентное деление на несколько частей нам не интересно, давайте попробуем сделать радикальный шаг. Что, если делить числа не на фиксированное количество частей, а на части фиксированной длины? 

Например, на десятичные разряды. В таком случае нам нужно будет 2N-1 точек для N-разрядного числа; в остальном подход как будто бы тот же самый, что в предыдущем разделе. Что ж, давайте возьмём x_i = i и проведём численный эксперимент, чтобы убедиться, что всё работает!

import math
import numpy as np

# Числа, которые мы изначально собирались перемножить:
a = 235739098113
b = 187129102983
m = len(str(a))

# Коэффициенты соответствующих многочленов в порядке 
# возрастания степени. Поскольку результирующий многочлен 
# будет степени 2*m-1, добиваем нулями до нужной ширины:
a_coefs = [int(a_i) for a_i in str(a)[::-1]] + [0] * (m - 1)
b_coefs = [int(b_i) for b_i in str(b)[::-1]] + [0] * (m - 1)
n = len(a_coefs)

# Точки, в которых будем вычислять значения многочленов:
x = np.arange(n)

# Матрица Вандермонда, построенная по этим точкам:
v = np.vander(x, increasing=True)

# Вычисление значения многочлена в точке — не что иное, как
# умножение этой матрицы на вектор коэффициентов:
a_y = v @ a_coefs
b_y = v @ b_coefs

# Поточечно перемножаем значения, чтобы получить 
# значения многочлена-произведения:
c_y = a_y * b_y

# Восстанавливаем коэффициенты многочлена-произведения:
c_coefs = np.linalg.solve(v, c_y)

# Для сверки считаем коэффициенты "в лоб":
actual_c_coefs = [
    sum(a_coefs[j] * b_coefs[i-j] for j in range(i+1)) 
    for i in range(n)
]

# Считаем длину вектора-разности и делим на длину настоящего,
# чтобы получить относительную погрешность:
print(np.linalg.norm(c_coefs - actual_c_coefs) /
    np.linalg.norm(actual_c_coefs))

Запускаем и…

2.777342817120168e+17

Хо-хо, да это не просто мимо, это фантастически мимо! Но почему? 

Оказывается [3], у матриц Вандермонда есть неприятное свойство — решать системы с ними в подавляющим большинстве случаев очень плохая идея, потому что погрешность результата растёт экспоненциально с размером матрицы. Можно улучшить ситуацию, взяв вместо x_i = i комплексные точки на единичной окружности:

x = np.exp(1j * np.random.uniform(0, math.pi, size=n))

...

0.00015636299542432733

Но лучшим выбором оказывается взять точки, равномерно распределённые на единичной окружности и являющиеся корнями из единицы степени N.

x = np.exp(-1j * np.linspace(0, 2*math.pi, n, endpoint=False))
Что ещё за корни из единицы?

Хотя в привычной нам вещественной арифметике равенство x^n = 1 выполнено только при x = \pm 1, на комплексной плоскости всё гораздо веселее. У каждого многочлена степени n > 0 есть ровно n комплексных корней, и многочлена x^n - 1 это правило (также известное как основная теорема алгебры) тоже касается.

Корнями из единицы степени n являются числа на комплексной плоскости, расположенные в вершинах правильного n-угольника, вписанного в единичную окружность:

Поскольку при умножении комплексных чисел их модули перемножаются (а у всех корней из единицы модуль равен единице — они же на единичной окружности), а фазы складываются, при возведении числа {\omega} в последовательные степени мы получим по очереди все корни из единицы. За это свойство {\omega} называется первообразным корнем из единицы.

Запускаем новый вариант кода и получаем 

1.5964929527133826e-15

Отлично! Мы дошли до предела машинной точности.

Но самое изумительное — при таком выборе иксов матрица представляет собой не что иное, как матрицу дискретного преобразования Фурье! А значит, мы можем умножать векторы на эту матрицу и решать систему уравнений с ней алгоритмом быстрого преобразования Фурье за O(N \log N) операций сложения и умножения — вместо наивного алгоритма за O(N^2).

Что такое дискретное преобразование Фурье?

Преобразование Фурье досталось нам из высшей математики и волновой физики. В физике оно известно как способ разложить сигнал (например, аудио) какой-то произвольной формы

на сумму элементарных сигналов — гармоник с длиной волны, кратной ширине отрезка:

Одна гармоника соответствует постоянному звуку некой фиксированной частоты и громкости; сумма гармоник может дать любую мелодию. Сколь угодно сложный сигнал можно разложить на гармоники, если взять их достаточно много. Совокупность гармоник (частоты + амплитуды) называется спектром сигнала.

Преобразование Фурье — построение в первую очередь теоретическое; Жозеф Фурье открыл его задолго до появления компьютеров. На практике мы не можем оперировать сигналами как функциями; у нас есть только дискретизации, замеры значения сигнала с каким-то шагом по времени.

Согласно теореме Котельникова-Найквиста-Шеннона при такой ограниченной информации мы всё ещё можем восстановить гармоники (а с ними — и весь сигнал), если предположим, что сигнал был достаточно простым (в нём не было каких-то сверхвысоких частот). Этим и занимается дискретное преобразование Фурье — фактически это матрица, которую нужно умножить на вектор со значениями дискретизированного сигнала, чтобы получить вектор с амплитудами, соответствующими разным частотам гармоник.

Так, дискретизация на картинке выше недостаточно частая, чтобы восстановить самую высокую частоту:

А более частая, удовлетворяющая условию теоремы — достаточна:

По роковому стечению обстоятельств матрица дискретного преобразования Фурье является матрицей Вандермонда, построенной по корням из единицы.

В целом анализ Фурье — очень интересный раздел математики, связывающий воедино множество ранее разобщённых концепций. Его подробное описание, конечно, уходит совсем далеко за рамки статьи; можно начать со статей на Хабре «Простыми словами о преобразовании Фурье» и «Преобразование Фурье в действии: точное определение частоты сигнала и выделение нот».

Что такое быстрое преобразование Фурье?

Дискретное преобразование Фурье применяется повсеместно для обработки сигналов (например, шумоподавления), часто реализуется аппаратно и встречается с совершенно неожиданных устройствах (например, МРТ-сканерах [4]). Это создаёт потребность в создании настолько быстрого алгоритма, насколько возможно.

Умножение на матрицу «в лоб» стоит O(N^2) — это считается довольно медленным в практических алгоритмах. Быстрое преобразование Фурье — название семейства алгоритмов, достигающих асимптотики O(N\log N) за счёт сведения задачи к нескольким задачам меньшего размера с линейной стоимостью одного шага рекурсии. Самый простой вариант — алгоритм для N=2^k, сводящий задачу к двум задачам размера 2^{k-1}; рекурсия глубины k приводит к общей сложности O(k\cdot 2^k).

Про него в интернете, конечно, написано много материалов разной степени читабельности. Но давайте я попробую очень быстро на пальцах показать, за счёт чего вычисление можно ускорить до O(N\log N).

Дискретное преобразование Фурье и обратное дискретное преобразование Фурье — восстановление сигнала по спектру — очень похожи математически, и алгоритм почти идентичный; объяснить его будет проще на примере обратного преобразования.

Обратное преобразование заключается в том, что нужно взять N гармоник, у которых мы знаем частоту и амплитуду, посчитать их значения в N точках отрезка [0, 2\pi] и сложить, получив вектор из N значений сигнала-суммы гармоник. На рисунке пример для N=8:

Поделив отрезок пополам, можно заметить, что гармоники делятся на два типа:

В верхней части — кривые, половинки которых одинаковы в первой и второй половине отрезка; в нижней — выглядящие вертикально отражёнными, то есть одна половина получается из другой умножением на -1. А значит, можно сэкономить, вычисляя значения всех гармоник только на половине отрезка!

При этом задача для произвольной гармоники на половине отрезка соответствует задаче для гармоники с вдвое меньшей частотой и N/2 точек:

Получается, что для решения исходной задачи восстановления сигнала в N точках по данным N гармоникам нам достаточно:

  1. просуммировать N/2 гармоник первого типа (взяв вдвое меньшую частоту) в N/2 точках;

  2. просуммировать N/2 гармоник второго типа (также взяв вдвое меньшую частоту) в N/2 точках;

  3. первую сумму — вектор длины N/2 — повторить два раза, получив вектор длины N;

  4. вторую сумму — также вектор длины N/2 — тоже повторить два раза, но второй раз — со знаком минус;

  5. два получившихся вектора сложить.

Поскольку мы свели задачу для N к двум задачам для N/2 и дополнительным шагом обработки стоимостью O(N), итоговая сложность получается равной O(N\log N).

Так мы и получили предельно простой пересказ алгоритма Кули-Тьюки!

Делить числа на части по десятичным разрядам — это, конечно, не самый эффективный способ. Тут даже нет как таковой рекурсии — после первого разделения на части мы сразу приходим к числам от 0 до 9, которые можно перемножить по таблице умножения. Наилучшая асимптотика в таком алгоритме получается, если делить N-битные числа на части длиной примерно \log N бит.

Полностью алгоритм Шенхаге-Штрассена выглядит так. 

  1. Берём на вход два числа, которые хотим перемножить; числа эти длины N бит. На практике это значит, что бóльшее из чисел имеет N бит.

    \overbrace{1001010011000001}^{N} \times \overbrace{1100000001001110}^{N}

  2. Делим двоичную запись чисел на фрагменты, каждый длиной примерно \log N бит:

    \overbrace{\underbrace{\color{Red}{1001}}_{\log N}\underbrace{\color{Red}{0100}}_{\log N}\underbrace{\color{DarkBlue}{1100}}_{\log N}\underbrace{\color{DarkBlue}{0001}}_{\log N}}^{N} \times  \overbrace{\underbrace{\color{DarkOrange}{1100}}_{\log N}\underbrace{\color{DarkOrange}{0000}}_{\log N}\underbrace{\color{Magenta}{1001}}_{\log N}\underbrace{\color{Magenta}{1110}}_{\log N}}^{N}

  3. Эти фрагменты — целые числа с \log N бит — объявляем коэффициентами многочленов, которые нужно перемножить:

    \left({\color{Red}{1001}}\!\cdot\!x^3 + {\color{Red}{0100}}\!\cdot\!x^2 + {\color{DarkBlue}{1100}}\!\cdot\!x + {\color{DarkBlue}{0001}}\right)\times\left({\color{DarkOrange}{1100}}\!\cdot\!x^3 + {\color{DarkOrange}{0000}}\!\cdot\!x^2 + {\color{Magenta}{1001}}\!\cdot\!x + {\color{Magenta}{1110}}\right)

  4. Теперь у нас на руках два вектора коэффициентов многочленов. К этим векторам применяем дискретное преобразование Фурье:

    FFT\times\begin{bmatrix} {\color{DarkBlue}{0001}} \\ {\color{DarkBlue}{1100}} \\  {\color{Red}{0100}} \\  {\color{Red}{1001}} \\  0 \\ 0 \\ 0\end{bmatrix} ,\quad FFT\times\begin{bmatrix} {\color{Magenta}{1110}} \\  {\color{Magenta}{1001}} \\  {\color{DarkOrange}{0000}} \\  {\color{DarkOrange}{1100}} \\  0 \\ 0 \\ 0\end{bmatrix}




    Для этого нам потребуются вычисления с плавающей точкой, гарантирующие достаточно точный результат — на это потребуется порядка O(\log N) бит. Умножения делаем рекурсивно этим же алгоритмом.

  5. Полученные векторы перемножаем поэлементно. Для перемножения элементов также используем рекурсивно этот алгоритм (или простые алгоритмы, если числа уже достаточно маленькие). Рекурсия будет очень небольшой глубины, потому что на каждом шаге мы от входных данных размера O(N) переходим к O(\log N); например, для чисел, занимающих гигабайт (!) каждое, глубина рекурсии будет всего 6.

  6. К получившемуся вектору применяем обратное преобразование Фурье. Умножения делаем рекурсивно этим же алгоритмом.

    IFFT \times \left[\!\!\begin{array}{r}780 \\ -146.17 - 93.35i \\ 22 - 58.43i \\ -217.82+41.36i \\ \vdots\qquad\quad\ \end{array}\!\!\right]


  7. Наконец, из получившегося вектора коэффициентов многочлена-произведения восстанавливаем число-результат умножения, суммируя в столбик справа налево.

    \begin{array}{rrrrrrrr} &&&&&&&\!\!\!\!\!\!11\overset{\displaystyle\leftarrow}{10} \\ &&&&&\!\!\!\!\!\!1100&\!\!\!\!\!\!1100 \\ &&&&\!\!\!\!\!\!110&\!\!\!\!\!\!1000 \\ &&&\!\!\!\!\!\!1001&\!\!\!\!\!\!1010 \\  &&\!\!\!\!\!\!1011&\!\!\!\!\!\!0100 \\ &11&\!\!\!\!\!\!0000 \\ 110&\!\!\!\!\!\!1100 \\ \hline 110&\!\!\!\!\!\!1111&\!\!\!\!\!\!1011&\!\!\!\!\!\!1110&\!\!\!\!\!\!0001&\!\!\!\!\!\!0010&\!\!\!\!\!\!1100&\!\!\!\!\!\!1110 \end{array}

Формула асимптотической сложности получившегося алгоритма довольно страшная, в целом асимптотика получается чуть-чуть хуже O(N \log N \log \log N). Но можно оценить полёт мысли и количество математических конструкций, требующихся для достижения такого результата.

Модульная арифметика и второй алгоритм Шенхаге-Штрассена

Хотя первый алгоритм Шенхаге-Штрассена круто улучшает асимптотику по сравнению с ранее существовавшими методами, у него всё ещё есть зоны роста. Во-первых, на каждом шаге рекурсии происходит экспоненциальное уменьшение размеров чисел — с N до \log N. Из-за этого глубина рекурсии очень мала и воспользоваться ускорением получается не в полном объёме. Во-вторых, необходимость производить расчёты с плавающей точкой разной точности весьма непрактична — мы все привыкли к float и double, а реализация чисел с плавающей точкой настраиваемой ширины весьма нетривиальна. (Здесь читатель может возразить, что многое из вышеописанного было нетривиально. Могу только согласиться.)

Второй алгоритм Шенхаге-Штрассена лишён этих двух недостатков, более быстр и в теории, и в практике и наиболее широко используется — например, в библиотеке GMP.

Для решения первой проблемы давайте попробуем разбить числа на более крупные фрагменты — например, размера \sqrt{N} вместо \log N. Здесь мы незамедлительно столкнёмся с проблемой: для дискретного преобразования Фурье в таком случае потребуется O(\sqrt{N}\log\sqrt{N}) операций умножения чисел из \sqrt{N} бит; каждое такое умножение будет стоить в лучшем случае O(\sqrt{N}\log\sqrt{N}) (напомню, все алгоритмы выше стоили ещё дороже), что даёт нам суммарную сложность не меньше O(\sqrt{N}\log^2 N), что уже хуже предыдущего алгоритма. Что-то здесь нужно ускорить.

Для решения второй проблемы можно вспомнить, что в модульной арифметике тоже можно делать быстрое преобразование Фурье. Тогда мы будем иметь дело только с целыми числами! А если подобрать в качестве элементов матрицы Фурье степени двойки, то на них можно будет умножать быстрее, чем за O(\sqrt{N}\log\sqrt{N}). В идеале вообще за O(\sqrt{N}), потому что умножение на степень двойки — это просто битовый сдвиг.

Что такое модульная арифметика?

Арифметика по модулю — это ровно то, о чём можно подумать из названия. Привычные арифметические операции (сложение, вычитание, умножение) берутся с единственным изменением — после проведения операции надо взять остаток от деления результата на некое фиксированное число p. Так, в арифметике по модулю 7 имеем 2 + 2 = 4, 2 \cdot 3 = 6, но 2 \cdot 4 = 1, 4 + 5 = 2. Принято писать

2\cdot 4\equiv 1 \mod 7

Арифметика по модулю нам, программистам, очень близка. Если вы оперируете 32-битными (или 64-битными) беззнаковыми целыми числами в своём коде, вы фактически делаете все операции в арифметике по модулю 2^{32} (или, соответственно, 2^{64}).

Примечательный факт в том, что при таком определении арифметических операций мы получаем полноценную арифметику, в которой сохраняется большинство свойств, к которым мы привыкли в обычной арифметике. Но, конечно, есть нюансы.

Множество чисел в арифметике по модулю p называется кольцом вычетов по модулю p и обозначается \mathbb{Z}_p;  в Серьёзных Книгах также можно встретить запись \mathbb{Z}/p\mathbb{Z}. В нём содержатся числа

0,\ 1,\ \ldots,\ p-2,\ p-1

Само число p в арифметике по модулю p эквивалентно нулю (таков остаток от деления на само себя); p+1 эквивалентно 1 и так далее. Отрицательные числа работают по схеме, привычной программистам: -1 эквивалентно p-1, -2 эквивалентно p-2 и так далее.

Деление в модульной арифметике — это первый нюанс. В \mathbb{Z}_p нет дробей; как определить частное двух чисел, не делящихся друг на друга? По определению деления a/b — это такое число, что b \cdot (a/b) = a. Возвращаясь к примерам в арифметике по модулю 7, из равенства 2 \cdot 4 = 1 получаем

\begin{array}{l}1/4\equiv 2\mod 7,\\1/2\equiv 4\mod 7.\end{array}

Однако не всегда в модульной арифметике возможно поделить два числа друг на друга. Это второй нюанс — произведение двух ненулевых чисел в модульной арифметике может быть равно нулю! Так, в арифметике по модулю 6 имеем 2\cdot 3\equiv 0\mod 6. Такие числа называются делителями нуля и делить на них не получится. Само существование 1/2 и 1/3 в арифметике по модулю 6 противоречило бы некоторым аксиомам; это всё равно, что делить на ноль. На практике мы можем легко проверить перебором, что подходящих на роль частного чисел не существует:

Z_6 = range(6)
[(x * 2) % 6 for x in Z_6]
# [0, 2, 4, 0, 2, 4] — нет единицы
[(x * 3) % 6 for x in Z_6]
# [0, 3, 0, 3, 0, 3] — нет единицы

Множество чисел, не являющихся делителями нуля, называется мультипликативной группой кольца вычетов по модулю p и обозначается \mathbb{Z}_p^*. Например, в случае p = 6 имеем

\mathbb{Z}_6^*=\{1,5\}

— ведь как мы выяснили только что, 2 и 3 являются делителями нуля; кратная двойке четвёрка — тоже: 4\cdot 3=2\cdot 2\cdot 3 = 2\cdot 0 = 0.

Примечательный факт про мультипликативную группу — любые два числа в ней можно умножать и делить друг на друга, и снова получать элементы из этой группы. Так, 5\cdot 5\equiv 1\mod 6.

Если основание арифметики — простое число, делителей нуля в ней не существует и можно делить друг на друга любые числа. Соответственно, в мультипликативной группе в этом случае содержатся все числа из \mathbb{Z}_p, кроме нуля:

\mathbb{Z}_7^*=\{1,2,3,4,5,6\}

Как находить частное двух чисел на практике, кроме как перебором? Занимательный факт из алгебры: если в мультипликативной группе n элементов, то любой её элемент в степени n равен единице. Давайте проверим на паре примеров:

Z_7_mul = range(1, 7)
[pow(x, 6, 7) for x in Z_7_mul]
# [1, 1, 1, 1, 1, 1]

Z_8_mul = [1, 3, 5, 7]
[pow(x, 4, 8) for x in Z_8_mul]
# [1, 1, 1, 1]

А это значит, что

x \cdot x^{n-1} \equiv x^n \equiv 1\mod p

и, соответственно, 1/x\equiv x^{n-1}\mod p. Возвести число в (n-1)-ю степень можно за O(\log n) умножений алгоритмом быстрого возведения в степень.

Отсюда нюанс третий — в мультипликативной группе все числа являются корнями из единицы степени n! Эти корни при правильном обращении ведут себя аналогично корням из единицы в комплексной арифметике, и для них тоже можно построить алгоритм быстрого преобразования Фурье.

Наблюдение первое: самая удобная арифметика

Для того, чтобы быстро делать умножения во время быстрого преобразования Фурье, нам нужно, чтобы некие числа сочетали два свойства: были степенями двойки — чтобы можно было быстро на них умножать, и были корнями из единицы — чтобы быстрое преобразование Фурье в принципе работало. Значит, привычная всем программистам n-битная арифметика не подходит — потому что это фактически вычисления по модулю 2^n, а значит, любая степень двойки при возведении в достаточно большую степень обращается в ноль и никак не может быть корнем из единицы:

num_bits = 5
[pow(2, i, 2**num_bits) for i in range(2*num_bits)]
# [1, 2, 4, 8, 16, 0, 0, 0, 0, 0]

Если же основание арифметики кратно двум, но не степени двойки, мы не получим ноль, но и никогда не получим единицу:

mod = 6
[pow(2, i, mod) for i in range(2*mod)]
# [1, 2, 4, 2, 4, 2, 4, 2, 4, 2, 4, 2]

Потому что двойка является делителем нуля (в примере выше её можно умножить на три и получить 0 по модулю 6) и не является членом мультипликативной группы.

Гораздо лучше дела обстоят с основаниями, взаимно простыми с двойкой:

mod = 5
[pow(2, i, mod) for i in range(2*mod)]
# [1, 2, 4, 3, 1, 2, 4, 3, 1, 2]

Поскольку в этом случае двойка состоит в мультипликативной группе, всегда есть степень, в которую её можно возвести, чтобы получить 1 — и это справедливо также для любой степени двойки. У нас есть неограниченный запас простых чисел, мы могли бы просчитать их наперёд с запасом и делать вычисления по модулю достаточно большого простого числа… Но умножать числа по произвольному модулю быстро не получится даже на степень двойки, потому что после битового сдвига нужно брать остаток от деления, а это медленно.

Время для первого наблюдения — идеально для наших нужд подходит арифметика по модулю 2^n+1. Это число гарантированно взаимно простое с двойкой (потому что нечётное), и по нему легко брать остаток от деления. Как?

Допустим, мы умножили некое число x на некую степень двойки 2^y, сделав битовый сдвиг. Если результат уместился в первые n бит, всё отлично. Если результат равен 2^n, тоже неплохо. Если больше — то есть случилось переполнение — все биты, начиная с (n+1)-го, надо обнулить. И для этого есть простой алгоритм! 

В вычислениях по модулю 2^n+1 имеем 2^n\equiv-1\mod(2^n+1). Домножая равенство на 2^k, получим 2^{n+k}\equiv-2^k\mod(2^n+1). Соответственно, каждый лишний (n+k)-й бит мы можем превратить в вычитаниеk-го бита. Собираем по битам число, которое надо вычесть, и вычитаем по модулю — эта операция стоит O(n). Et voilà!

\begin{array}{crl} & {\color{Red}{1010}}\overbrace{101001}^{n} \\  & \downarrow\quad \\  & \begin{matrix} -\!\!\!\!\!\! & \begin{array}{r} 101001 \\[-2mm] {\color{Red}{1010}} \end{array} \end{matrix} \\ & \downarrow\quad \\  & 011111 \end{array}

И дополнительный приятный факт: 2^{2n}=-2^n=+1. Двойка является корнем из единицы заведомо известной нам степени 2n.

Что там с асимптотикой?

Окей, мы решили, что сводим задачу умножения длинных чисел к нескольким задачам умножения чисел по модулю 2^n+1 посредством быстрого преобразования Фурье. Более формально, если на входе у нас были числа длиной N бит, мы делим их на m частей по n бит, N = m\cdot n, по уже хорошо знакомой нам процедуре:

\overbrace{\underbrace{\color{Red}{1001}}_{n}\underbrace{\color{Red}{0100}}_{n}\underbrace{\color{DarkBlue}{1100}}_{n}\underbrace{\color{DarkBlue}{0001}}_{n}}^{N = \,m\cdot n\mbox{ бит}} \times  \overbrace{\underbrace{\color{DarkOrange}{1100}}_{n}\underbrace{\color{DarkOrange}{0000}}_{n}\underbrace{\color{Magenta}{0100}}_{n}\underbrace{\color{Magenta}{1110}}_{n}}^{N = \,m\cdot n\mbox{ бит}}

Дальше снова превращаем их в многочлены степени m-1:

\left({\color{Red}{1001}}\!\cdot\!x^3 + {\color{Red}{0100}}\!\cdot\!x^2 + {\color{DarkBlue}{1100}}\!\cdot\!x + {\color{DarkBlue}{0001}}\right)\times\left({\color{DarkOrange}{1100}}\!\cdot\!x^3 + {\color{DarkOrange}{0000}}\!\cdot\!x^2 + {\color{Magenta}{0100}}\!\cdot\!x + {\color{Magenta}{1110}}\right)

Коэффициенты этих многочленов рассматриваем в арифметике по модулю 2^{n'}+1, где n'\approx  2n+\log m — количество бит, способное вместить сумму m произведений двух чисел длины n бит, чтобы не было переполнения. Именно таким может быть самый большой коэффициент в произведении этих двух многочленов:

c_{m-1} = \underbrace{{\color{DarkBlue}{a_0}}{\color{DarkOrange}{b_{m-1}}} + {\color{DarkBlue}{a_1}}{\color{DarkOrange}{b_{m-2}}} + \ldots + {\color{Red}{a_{m-1}}}{\color{Magenta}{b_0}}}_{m\ слагаемых}\le m\cdot 2^{n}\cdot 2^n

Мы уже знаем, что можно свести умножение таких многочленов к умножению 2m-1 чисел в \mathbb{Z}_{2^{n'}+1} посредством быстрого преобразования Фурье; эти умножения поменьше легко можно провести этим же алгоритмом, а взять потом остаток от деления на 2^{n'}+1, как мы уже выяснили, можно легко и быстро. Само сведение — быстрое преобразование Фурье в модульной арифметике с быстрыми корнями из единицы — обойдётся нам в O(m\log m) сложений и умножений стоимости O(n'), то есть суммарно O(m\log m\cdot n') = O(mn\log m) = O(N\log m) битовых операций. Итого сложность C(N) совершения умножения имеет вид

C(N) = (2m-1)\cdot C(2n+\log m) + O(N\log m)

Насколько крутое ускорение мы можем получить? Сейчас я проведу сильно упрощённый анализ «на пальцах»; более строгий можно глянуть, например, в [5].

Если выбрать n\approx m\approx\sqrt{N} и учесть, что \log m очень мало по сравнению с 2n, имеем

C(N) \approx 2\sqrt{N}\cdot C(2\sqrt{N}) + O(N\log N)

Сделав замену C(N)=c(N)\cdot N\log N, получим более простую для анализа формулу:

c(N)\cdot N\log N \approx 2{\color{Magenta}{\sqrt{N}}}\cdot c(2\sqrt{N})\cdot 2{\color{Magenta}{\sqrt{N}}}\log{{\color{DarkGreen}{2}}\color{Red}{\sqrt{{\color{Black}{N}}}}} + O(N\log N)c(N)\cdot N\log N \approx 2{\color{Magenta}{N}}\cdot c(2\sqrt{N})\cdot \not{2}\cdot {\color{Red}{\frac{1}{\not{2}}}}\log N + \underbrace{O(N\log N)}_{+...\,\cdot {\color{DarkGreen} \log 2}}
c(N) \approx 2\cdot c(2\sqrt{N}) + \mathrm{const}

Так, рекуррентное соотношение. От N бит переходим к 2\sqrt N, потом к 2\sqrt 2\sqrt[4]{N}, потом к 2\sqrt 2\sqrt[4] 2\sqrt[8]{N} и так далее. Раскрывая k раз, получаем

c(N) \approx 2^k c(2\sqrt{2}\sqrt[4]{2}\ldots\!\!\!\sqrt[2^{k-1}]{2}\sqrt[2^k]{N}) + \ldots + 4\cdot \mathrm{const} + 2\cdot \mathrm{const} + \mathrm{const}

\sqrt[2^k]{N} убывает очень быстро, и как только мы дойдём до какого-то достаточно малого числа, рекурсия остановится. Какая будет глубина рекурсии? Можем заметить, что на k-м шаге длина чисел, с которыми мы имеем дело, имеет вид

{\color{DarkGreen}{2}} {\color{DarkRed}{\sqrt{2}}} {\color{DarkBlue}{\sqrt[4]{2}}} \ldots\!\!\! {\color{Teal}{\sqrt[2^{k-1}]{2}}}\, \sqrt[2^k]{N} = \underbrace{\,2^{\displaystyle\Bigl(\overbrace{{\color{DarkGreen}{1}} + {\color{DarkRed}{\frac{1}{2}}} + {\color{DarkBlue}{\frac{1}{4}}} + \ldots + {\color{Teal}{\frac{1}{2^{k-1}}}}}^{\displaystyle\overset{2}{\uparrow}}\Bigl)}}_{\displaystyle\underset{4}{\downarrow}}\cdot\,N^{\frac{1}{2^k}}

Соответственно, решая уравнение N^{\frac{1}{2^k}}=\mathrm{const}, получаем глубину рекурсии k\approx\log\log N. Складывая степени двойки от нулевой до \log\log N (в равенстве выше они умножаются на const), получаем c(N) = \log N и суммарную сложность

C(N) = O({\color{Red}{N\cdot\log^2 N}})

И это медленно! В первом алгоритме Шенхаге-Штрассена мы уже добились сложности чуть хуже O(N\log N\log\log N). Нужно ускорить ещё.

Наблюдение второе: самые короткие многочлены

В выражении, оценивающем сложность

c(N) \approx 2^k c(2\sqrt{2}\sqrt[4]{2}\ldots\!\!\!\sqrt[2^{k-1}]{2}\sqrt[2^k]{N}) + \ldots + 4\cdot \mathrm{const} + 2\cdot \mathrm{const} + \mathrm{const}

можно заметить, что основной вклад в слишком большой результат вносят множители-степени двойки. Они там потому, что разбивая число на m частей, мы сводим задачу к \approx 2m умножений. Это нужно, потому что при умножении двух k-битных чисел получается (2k-1)-битное число.

Но нам не нужно (2k-1)-битное число. Как только мы сделали первый шаг рекурсии, все вычисления у нас происходят по модулю 2^k+1. А при вычислениях по модулю длина чисел не может изменяться! Можно ли как-то вместо удвоения длины и взятия остатка от деления обойтись расчётами на k битах?

Если мы хотим при этом сохранить преимущества быстрого преобразования Фурье, от перемножения многочленов мы отказаться не можем. Есть ли в мире многочленов что-то, похожее на арифметику по модулю? Есть!

Арифметика многочленов по модулю???

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

Складывать и умножать многочлены мы уже умеем — там всё очевидно. Менее очевидно, что многочлены также можно друг на друга делить с остатком — причём это можно делать вот прямо в столбик, как обычные целые числа.

Можем быстро проверить в SymPy, что всё правильно:

import sympy 
x = sympy.Symbol("x")
f = 5*x**5 + 4*x**4 + 3*x**3 + 2*x**2 + x
g = x**2 + 2*x + 3
sympy.div(f, g, domain="Q")
# (5*x**3 - 6*x**2 + 20, -39*x - 60)

Как только есть деление с остатком, можно ввести арифметику по модулю; основанием такой модульной арифметики, соответственно, будет уже не число p, а многочлен p(x). Подобно тому, как мы строим модульную арифметику, объявляя, что теперь число p эквивалентно нулю, здесь мы объявляем, что многочлен p(x) эквивалентен нулю. Многочлен p(x) + 1 становится эквивалентен 1, p(x) + x — многочлену f(x) = x и так далее.

Всё это продолжает работать, если мы меняем арифметику коэффициентов многочленов, заменяя привычные нам целые или вещественные числа на арифметику по модулю p. Выстраивается каскад абстракций: многочлен — это сумма степеней икса с коэффициентами из некоторого множества; неважно, какого именно — главное, чтобы мы эти коэффициенты могли складывать, умножать и (иногда) делить. Этого достаточно, чтобы определить сложение, умножение и деление для самих многочленов, в том числе по модулю p(x). Коэффициенты при этом живут своей независимой жизнью.

При обычном перемножении многочленов коэффициент при x^m соответствует битам произведения чисел, начиная с (m\cdot n)-го. Если же мы делаем расчёты по модулю x^m+1, коэффициент при x^m становится эквивалентен нулевому коэффициенту (при x^0) со знаком минус:

x^m + 1\equiv 0 \mod(x^m+1) \\  \Downarrow \\ x^m\equiv-1\mod(x^m+1) \\  \Downarrow \\ ax^m\equiv-a\mod(x^m+1)

А в задаче умножения по модулю 2^{mn}+1(m\cdot n)-й бит… тоже соответствует нулевому биту со знаком минус!

2^{mn}\equiv-1\mod(2^{mn}+1)

Это и есть наше ключевое второе наблюдение: перемножение многочленов по модулю x^m+1 эквивалентно перемножению чисел по модулю 2^{mn}.

Теперь у нашего многочлена-произведения есть только m значащих коэффициентов. Но есть нюанс: как только мы начинаем рассматривать многочлены по модулю, фраза значение многочлена в точке x теряет смысл. Многочлен в арифметике по модулю x^m+1 — это не конкретная функция f(x), а совокупность функций вида

f(x)+(d_0+d_1x+d_2x^2+\ldots)\cdot(x^m+1)

Коэффициенты d_i могут быть произвольными, и могут изменять значения многочлена в разных точках.

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

# Будем делить числа на 4 куска по 2 бита.
m = 4
n_ = 4  # n' с запасом

# Основание арифметики коэффициентов:
mod = 2**n_ + 1  # 17

# Два 8-битных множителя:
a_coefs = [0b01, 0b01, 0b10, 0b01]  # 101
b_coefs = [0b01, 0b11, 0b00, 0b01]  # 77


def construct_vandermonde_matrix_and_inverse(xs: List[int]):
    # Строим матрицу Вандермонда "в лоб",
    # потому что нам нужна модульная арифметика:
    v = np.array([
        [pow(xs[i], j, mod) for j in range(m)]
        for i in range(m)
    ])

    # Дальше хитрый фокус, чтобы обратить матрицу
    # в модульной арифметике. Да, мне было лень писать
    # метод Гаусса или конвертировать матрицу из sympy.
    det = int(round(np.linalg.det(v)))
    det_inv = pow(det, mod - 2, mod)
    v_inv_real = np.linalg.inv(v) * det * det_inv
    v_inv = np.array(np.round(v_inv_real), dtype=int) % mod

    # Проверяем, что действительно получилась 
    # обратная матрица в модульной арифметике:
    assert np.all((v @ v_inv) % mod == np.eye(m, dtype=int))

    return v, v_inv


# Перебираем разные наборы точек:
for xs in [
    [2, 8, 15, 9],
    [2, 4, 8, 16],
    [3, 5, 7, 11],
    [5, 7, 11, 13],
]:
    v, v_inv = construct_vandermonde_matrix_and_inverse(xs)
    a_y = (v @ a_coefs) % mod
    b_y = (v @ b_coefs) % mod
    c_y = (a_y * b_y) % mod
    c_coefs = (v_inv @ c_y) % mod
    print(c_coefs)

# [14  2  4  8]
# [ 2 10  4 16]
# [ 4  4  0  8]
# [ 0  6  4 13]

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

Дело как раз в том, что мы никак не учли здесь, что мы умножаем по модулю x^m+1. Как это учесть?

Посмотрим ещё раз на многочлен по модулю:

f(x)+(d_0+d_1x+d_2x^2+\ldots)\cdot(x^m+1)

На самом деле есть несколько точек, в которых фраза значение многочлена в точке не бессмысленна. Эти точки — корни многочлена x^m+1! В этих точках «произвольная» часть этой обобщённой функции всегда обращается в ноль, благодаря чему значение фиксируется.

Какие это точки в примере выше? 

def xmp1(x):
    return (pow(x, m, mod) + 1) % mod

{i: xmp1(i) for i in range(mod)}

# { 0:  1,
#   1:  2,
#   2:  0,   <--
#   3: 14,
#   4:  2,
#   5: 14,
#   6:  5,
#   7:  5,
#   8:  0,   <--
#   9:  0,   <--
#  10:  5,
#  11:  5,
#  12: 14,
#  13:  2,
#  14: 14,
#  15:  0,   <--
#  16:  2}

А это — нечётные степени двойки, взятые по модулю:

[pow(2, i, mod) for i in range(1, mod, 2)]
# [2, 8, 15, 9, 2, 8, 15, 9]

В более общем случае это нечётные степени числа \omega=2^{n'/m}: \omega, \omega^3, \ldots.

Нечётные степени не очень удобны для быстрого преобразования Фурье. Тут остаётся последний шаг — факторизовать матрицу Вандермонда для чисел \omega, \omega^3, \ldots, превратив в произведение матрицы Фурье и диагональной (которая не мешает, потому что на неё можно быстро умножать):

 \begin{bmatrix} 1 & \omega & \omega^2 & \omega^3 & \ldots \\  1 & \omega^3 & \omega^6 & \omega^9 & \ldots \\  1 & \omega^5 & \omega^{10} & \omega^{15} & \ldots \\  1 & \omega^7 & \omega^{14} & \omega^{21} & \ldots \\ \vdots & \vdots & \vdots & \vdots & \ddots \end{bmatrix}  =  \begin{bmatrix} 1 & 1 & 1 & 1 & \ldots \\  1 & \omega^2 & \omega^4 & \omega^6 & \ldots \\  1 & \omega^4 & \omega^8 & \omega^{12} & \ldots \\  1 & \omega^6 & \omega^{12} & \omega^{18} & \ldots \\ \vdots & \vdots & \vdots & \vdots & \ddots \end{bmatrix} \times  \begin{bmatrix} 1 &  &  &  & \\ & \omega &  &  & \\  &  & \omega^2 &  & \\ &  &  & \omega^3 & \\ &  &  &  & \ddots \end{bmatrix}

Итак, с перемножением многочленов по модулю разобрались — вернёмся к анализу сложности алгоритма. Поскольку мы перешли от многочленов порядка 2m-1 к многочленам порядка m-1, множитель 2 в формуле сложности алгоритма исчезает:

c(N) \approx {\color{Gray}{\not{2}}}\cdot c(2\sqrt{N}) + \mathrm{const}

Глубина рекурсии остаётся порядка \log\log N, но без накапливающегося множителя-двойки c(N) также становится порядка \log\log N:

c(N) \approx \underbrace{{\color{Gray}{\not{2^k}}} \cdot c(2\sqrt{2}\sqrt[4]{2}\ldots\!\!\!\sqrt[2^{k-1}]{2}\sqrt[2^k]{N}) + \ldots + {\color{Gray}{\not{4}}}\cdot \mathrm{const} + {\color{Gray}{\not{2}}}\cdot \mathrm{const} + \mathrm{const}}_{\log\log N\mbox{ слагаемых}}

Суммарная сложность теперь имеет вид

C(N) = O({\color{DarkGreen}{N\cdot\log N\cdot\log\log N}})

Что наконец-то превосходит первый алгоритм Шенхаге-Штрассена!

Собираем алгоритм

Итак! Перейдём к полному описанию второго алгоритма.

  1. Формулируем изначальную задачу как задачу умножения по модулю 2^N+1. Какие бы у нас ни были изначально числа, всегда можно взять N достаточно большим, чтобы результат умножения влез в N бит. Для удобства рекурсии выбираем в качестве N степень двойки.

    \overbrace{1001010011000001}^{N = \,2^k\mbox{ бит}} \times \overbrace{1100000001001110}^{N = \,2^k\mbox{ бит}} \mod (2^N+1)

  2. Выбираем числа m и n такие, что N = m\cdot n и m, n \approx \sqrt{N}. Есть хитрые формулы для наилучшего выбора, я их приводить не буду; можно подсмотреть в [5]. Делим входные числа, которые нужно перемножить, на m частей по n бит.

    \overbrace{\underbrace{\color{Red}{1001}}_{n}\underbrace{\color{Red}{0100}}_{n}\underbrace{\color{DarkBlue}{1100}}_{n}\underbrace{\color{DarkBlue}{0001}}_{n}}^{N = \,m\cdot n\mbox{ бит}} \times  \overbrace{\underbrace{\color{DarkOrange}{1100}}_{n}\underbrace{\color{DarkOrange}{0000}}_{n}\underbrace{\color{Magenta}{0100}}_{n}\underbrace{\color{Magenta}{1110}}_{n}}^{N = \,m\cdot n\mbox{ бит}}\mod (2^N+1)

  3. Превращаем в многочлены с коэффициентами, содержащими n'\approx  2n+\log m бит, то есть добавляем несколько нулевых бит, чтобы промежуточные вычисления помещались без переполнения.

    (\underbrace{00\color{Red}{1001}}_{n'}\cdot\,x^3 + \underbrace{00\color{Red}{0100}}_{n'}\cdot\,x^2 + \ldots) \times (\underbrace{00\color{DarkOrange}{1100}}_{n'}\cdot\,x^3 + \underbrace{00\color{DarkOrange}{0000}}_{n'}\cdot\,x^2 + \ldots)

  4. Теперь у нас на руках два вектора коэффициентов многочленов. Векторы длины m + 1, числа в них длины n' бит. Эти векторы умножаем сперва на диагональную матрицу (в формулах выше) за O(m\cdot n') = O(N), потом делаем быстрое преобразование Фурье за O(m\cdot n'\cdot \log m) = O(N\log N).

    FFT \times \begin{bmatrix} 1 &  &  &  & \\ & \omega &  &  & \\  &  & \omega^2 &  & \\ &  &  & \omega^3 & \\ &  &  &  & \ddots \end{bmatrix} \times \begin{bmatrix} 00{\color{DarkBlue}{0001}} \\  00{\color{DarkBlue}{1100}} \\  00{\color{Red}{0100}} \\  00{\color{Red}{1001}} \\  \vdots \end{bmatrix},\quad FFT \times \begin{bmatrix}\ldots\end{bmatrix} \times \begin{bmatrix} 00{\color{Magenta}{1110}} \\  00{\color{Magenta}{0100}} \\  00{\color{DarkOrange}{0000}} \\  00{\color{DarkOrange}{1100}} \\  \vdots \end{bmatrix}

  5. Полученные векторы перемножаем поэлементно. Для перемножения чисел по модулю 2^{n'}+1 используем рекурсивно этот же самый алгоритм (или простые алгоритмы, если числа уже достаточно маленькие). Здесь будет рекурсия глубины O(\log\log N).

    \begin{array}{c} \underbrace{[ \qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad ]}_{N}\ \times\ \underbrace{[ \qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad ]}_{N} \\ \downarrow \\[5mm] \qquad\qquad\quad\underbrace{[ \qquad\qquad\qquad ]}_{\sqrt{N}}\,\times\,\underbrace{[ \qquad\qquad\qquad ]}_{\sqrt{N}} \qquad\sqrt{N}\mbox{ раз} \\ \downarrow \\[5mm] \qquad\qquad\qquad\qquad\qquad\quad\underbrace{[ \qquad ]}_{\sqrt[4]{N}}\,\times\,\underbrace{[ \qquad ]}_{\sqrt[4]{N}} \qquad\sqrt{N}\cdot\sqrt[4]{N} = N^{3/4}\mbox{ раз} \\ \downarrow \\[5mm] \cdots \end{array}

  6. К получившемуся вектору применяем обратное преобразование Фурье за O(N\log N), потом умножаем на обратную диагональную матрицу за O(N).

    \begin{bmatrix} 1 &  &  &  & \\ & \omega^{-1} &  &  & \\  &  & \omega^{-2} &  & \\ &  &  & \omega^{-3} & \\ &  &  &  & \ddots \end{bmatrix} \times IFFT \times \left[\!\!\begin{array}{r}001001 \\ 110111 \\ 1000000 \\ 011001 \\ \vdots \end{array}\!\!\right]

    (Обратите внимание на слишком длинный элемент 1000000 в этом примере — это ровно 2^{n'}, наибольшее число, разрешённое модульной арифметикой!)

  7. Из получившегося вектора коэффициентов многочлена-произведения восстанавливаем число-результат умножения, суммируя в столбик справа налево за O(N).

    \begin{array}{rrrrrrrr} &&&&&&&\!\!\!\!\!\!11\overset{\displaystyle\leftarrow}{10} \\ &&&&&\!\!\!\!\!\!1100&\!\!\!\!\!\!1100 \\ &&&&\!\!\!\!\!\!110&\!\!\!\!\!\!1000 \\ &&&\!\!\!\!\!\!1001&\!\!\!\!\!\!1010 \\  &&\!\!\!\!\!\!1011&\!\!\!\!\!\!0100 \\ &11&\!\!\!\!\!\!0000 \\ 110&\!\!\!\!\!\!1100 \\ \hline 110&\!\!\!\!\!\!1111&\!\!\!\!\!\!1011&\!\!\!\!\!\!1110&\!\!\!\!\!\!0001&\!\!\!\!\!\!0010&\!\!\!\!\!\!1100&\!\!\!\!\!\!1110 \end{array}

Вот и всё! Все гениальные приёмы второго алгоритма Шенхаге-Штрассена лежат у нас перед глазами. Конечно, для реализации этого алгоритма, эффективной на практике, нужно учесть ещё много нюансов и применить много приёмов; но на уровне идейном мы освоили его целиком.

В совсем не таком уж далёком 1960 году Андрей Колмогоров, один из величайших математиков своего времени, выдвинул гипотезу, что невозможно умножать числа быстрее, чем за O(N^2). И совершенно изумительно видеть, насколько быстрее оказалось возможно умножать числа, чем это изначально предполагалось. В этой статье мы рассмотрели четыре основополагающих алгоритма, перейдя от O(N^2) к O(N\log N\log\log N); для их понимания нам пришлось разобраться в теории Фурье, модульной арифметике и алгебре многочленов.

За рамками статьи остался алгоритм Фюрера и его варианты, а также опубликованный в 2020-м году [6] алгоритм с заявленной сложностью O(N\log N); если эта статья окажется востребована, возможно, когда-нибудь мы разберём и их.

Спасибо за внимание!

P. S.

Эту статью я старался написать максимально популярно, избегая присущей учебникам математической строгости. Некоторые формулы намеренно упрощены, где-то вообще удалось обойти без них (мало где); в их оформлении там, где они остались, я сделал акцент на читаемость с ходу, без разбора с бумажкой и ручкой (выделение цветом, зачёркивание сокращённых величин); также я старался избегать математической терминологии, где это возможно («арифметика по модулю» вместо «в кольце вычетов по модулю»). Судьёй того, насколько моя задумка удалась, предстоит быть тебе, читатель; мне же остаётся лишь надеяться, что кто-то сможет почерпнуть из этого текста свежий взгляд на математику, обычно такую возвышенно-труднодоступную.

Литература

  1. Pan, Victor. "How can we speed up matrix multiplication?." SIAM review 26.3 (1984): 393-415. PDF

  2. Pan, Victor. "Strassen's algorithm is not optimal trilinear technique of aggregating, uniting and canceling for constructing fast algorithms for matrix operations." 19th Annual Symposium on Foundations of Computer Science (sfcs 1978). IEEE, 1978.

  3. Pan, Victor. "How bad are Vandermonde matrices?." SIAM Journal on Matrix Analysis and Applications 37.2 (2016): 676-694. PDF

  4. Pharr, Matt, and Randima Fernando. GPU Gems 2: Programming techniques for high-performance graphics and general-purpose computation (gpu gems). Addison-Wesley Professional, 2005. HTML

  5. Kruppa, Alexander. "A GMP-based implementation of Schonhage-Strassen’s large integer multiplication algorithm." PDF

  6. Harvey, David, and Joris Van Der Hoeven. "Integer multiplication in time O (n log n)." Annals of Mathematics 193.2 (2021): 563-617. PDF

Библиотеки

  1. NumPy использовалась для большинства численных экспериментов в статье.

  2. SymPy использовалась для нескольких символьных вычислений.

  3. GMP неоднократно упоминалась как стандартная реализация длинной арифметики.

Комментарии (28)


  1. sochix
    00.00.0000 00:00
    +19

    Спасибо за прекрасно разобранную тему с крутыми примерами. Давно тут не было такого годного контента


    1. sanders1967
      00.00.0000 00:00
      +10

      Отличная статья... хороший слог и хорошая подача :)))


  1. Mingun
    00.00.0000 00:00
    +12

    Отличное изложение! Обязательно продолжайте в том же духе. Цветовое выделение в формулах — вообще бомба, обычно что-то мало-мальски сложнее дискриминанта квадратного уравнения в таких статьях просто пробегаешь глазами и всё, тут же всё просто и понятно.


    За рамками статьи остался алгоритм Фюрера и его варианты, а также опубликованный в 2020-м году [6] алгоритм с заявленной сложностью O(N\log N); если эта статья окажется востребована, возможно, когда-нибудь мы разберём и их.

    Очень интересно, просим

    image


  1. Sland_fgh
    00.00.0000 00:00
    +1

    Я согласен с другими отлично идёте


  1. MatveiKyzin744
    00.00.0000 00:00
    +3

    Разборчивое объяснение. До этого не мог найти ничего подобного. Везде все поверхностно, а здесь все конкретно изложено. Спасибо за ваш вклад. Побольше бы таких статей.


  1. savostin
    00.00.0000 00:00
    +5

    Я что ли один сюда зашел за способом быстрее и удобнее перемножать числа в уме?


    1. shushu
      00.00.0000 00:00
      +1

      А я знаю как быстро в уме проверить или число на 3 делится :) интересно?


      1. shushu
        00.00.0000 00:00

        Hidden text

        "Суммируем" число, и если результат делится на 3, то само число делится на 3.

        Например,

        Число 111. 1 + 1 + 1 = 3. 3 делится на 3, значит и 111 делится на 3.

        1. 2 + 4 + 3 = 9. 9 делится на 3, значит 243 тоже делится на 3.


        1. Mingun
          00.00.0000 00:00

          Давно известный прием. Интереснее вспомнить его обоснование


          1. saluev Автор
            00.00.0000 00:00
            +11

            Он легко обосновывается через арифметику по модулю! Берем число, скажем, 123. Нам нужно выяснить, равен ли нулю его остаток от деления на три. Раскладываем по десятичным разрядам и пользуемся свойством, что 10 \equiv 1 \mod 3:

            1\cdot 10^2 + 2\cdot 10 + 3 \equiv 1\cdot 1^2+2\cdot 1+3 \equiv 1+2+3\mod 3


    1. fshp
      00.00.0000 00:00
      +6

      Ну для начала потренеруйтесь fft в уме делать.


  1. strvv
    00.00.0000 00:00

    спасибо, не знал о этих методах.

    недавно ребёнку пытался расписать другие методы умножения длинных произвольных чисел на такие же числа, но свалился в логарифмы для быстрой оценки какое будет число и потерялся.

    т.е. если необходимо примерно вычислить быстро - через перевод в примерный логарифм, суммируешь и берёшь обратно переводишь в число.


  1. voted
    00.00.0000 00:00
    -1

    Вот кстати если перемножать большие числа (например 10^100) в тот же столбик например то легко заметить что мы это преобразавываем в сумму произведения каждого разряда на множитель (т.е. 1 множитель разбиваем на количество цифр на листике) и легко доказать что в десятичной системе у нас будет не более 10 вариантов которые надо сложить. Т.е. предположим сложение это 1 операция, сложений у нас N, и до 10 довольно простых операций умножения (все варианты которого можно получить за 10 сложений). Сложность такого умножения выходит 10 (получение слогаемых) + N * (1 операцию выборки и 1 операцию сложения + 1 операцию сдвига на разряды) = 3N + 10.

    Мне почему то кажется что при больших N можно получить сложность даже ниже чем O(N log N)


    1. fshp
      00.00.0000 00:00
      +1

      В двоичной системе счисления ещё проще, там всего 2 варианта умножения, которое делать то и не нужно.

      Но вы забываете, что сложение за константу больших чисел с бумажки, это далеко не константа для железа.


      1. voted
        00.00.0000 00:00

        Да, согласен что в этом то весь и прикол, статья написано вполне отлично, просто там про сложение написано:

        По сравнению с умножениями это быстрая операция, не заслуживающая особого внимания — как и два лишних сложения в скобках

        И вот для 100-значных чисел нам надо 10 сложений для расчёта таблицы и 100 условных сложений с выборкой, итого 110 операций.

        А в двоичной кстати по такой же логике (для 1000 битного числа, 100 значного десятичного) - надо 0 для расчета таблицы, и примерно 500 условных сложений (предположим что единица у нас в половине случаев).

        А дьявол как известно кроется в деталях как раз. На больших числах проблема как раз не умножения, а вообще операции над такими большими числами, и трансформации больших чисел в массивы конечной размерности. Хотя сложение по частям это достаточно тривиальная задача, а использование "таблицы" позволяет вообще уйти от умножения, может и получится в итоге меньше чем O(N^2), тут уже больше как раз зависит сложности реализации длинного сложения, конвертации систем, количества доступной памяти для "таблицы"


    1. saluev Автор
      00.00.0000 00:00

      Не совсем понял. Чтобы получить произведение каждого разряда на множитель, вам уже нужно O(N^2)операций. В любом случае нужно умножить каждый разряд одного числа на каждый разряд другого.


      1. fshp
        00.00.0000 00:00

        Имелось ввиду что вариантов умножения всего 10, их можно запомнить, а затем использовать значение из таблицы, домножая/сдвигая на нужное число разрядов.


        1. Cerberuser
          00.00.0000 00:00
          +1

          Речь о том, что количество этих "использований таблицы" будет квадратичным.


          1. fshp
            00.00.0000 00:00
            +3

            Не согласен.

            123×802

            Строим таблицу 123*0, 123*1, ..., 123*9. При этом при расчетах умножение можно заменить за сложение.

            Результат выглядит тогда так

            (table[8] << 2) + (table[0] << 1) + (table[2] << 0)

            Сдвиг тут десятичный. Таблица была использована logN раз.

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

            Но это не подходит для длинной арифметики, потому что сдвиги и сложения будут не за константу.


  1. kretuk
    00.00.0000 00:00

    Занимательный факт из алгебры: если в мультипликативной группе n элементов, то любой её элемент в степени n равен единице.

    с каких это пор https://ru.wikipedia.org/wiki/Теорема_Эйлера_(теория_чисел) является занимательным фактом?


    1. saluev Автор
      00.00.0000 00:00
      +1

      Вы не согласны с тем, что это факт, или с тем, что он занимательный?)


      1. kretuk
        00.00.0000 00:00

        согласен. действительно - факт, и действительно - занимательный))


      1. kretuk
        00.00.0000 00:00

        статья, кстати говоря, очень хорошая, но - где-то это я недавно слышал...а-а, на канале Савватеева. там какой-то талантливый школьник рассказывал точь-в-точь тоже самое.

        (сам школьник имеет шанс далеко пойти, если попадет к нужным людям, и его не испортят торгаши), но остается вопрос - чья же это оригинальная идея? (или она уже, давно, не оригаинальная?)


        1. saluev Автор
          00.00.0000 00:00
          +1

          Ну, сами исследования все давно опубликованы, моя оригинальная тут только подача. Хотел написать с тех пор, как статья про алгоритм за O(N\log N)вышла.


  1. Papayaved
    00.00.0000 00:00
    +1

    А какой алгоритм используется в блоках умножения процессоров?


  1. Crocodilus
    00.00.0000 00:00

    // Преобразование Фурье — построение в первую очередь теоретическое; Жозеф Фурье открыл его задолго до появления компьютеров. //

    Не может быть. Неужели и Лев Толстой набивал свои опусы не на лэптопе Apple?! А я думал, Ньютон выводил движение планет в Maple.

    П.С. Ряды Фурье известны как минимум с Гиппарха, III век до нашей эры. Фурье (лишь) обосновал их применение к решению ряда краевых задач уравнений матфизики. Об Эйлере и не говорю.


    1. saluev Автор
      00.00.0000 00:00
      +1

      А можно ссылку на Гиппарха? Интересно почитать


      1. Crocodilus
        00.00.0000 00:00

        К сожалению, о Гиппархе можно судить по косвенным ошмёткам и арабским переводам, всё остальное сгорело в Александрийской библиотеке во время тогдашней спецоперации. Но идея раскладывать гелиоцентрические орбиты (именно гелио-) планет в ряды независимых круговых движений (эпициклы) быть может, была даже и до него.