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

Да, мы привыкли, что перемножение двух байт, или двух LONG это операция, которая происходит за константное время и не требует какого то особого алгоритма. Даже в школе мы учили наизусть таблицу умножения, что позволяло нам за константное время получить любой результат умножения двух чисел размером от 1 до 10.

Но, что если нам надо перемножить два числа любой длины? Не LONG, не байт, не число от 1 до 10, а любые два числа, которое, имеют в общем случае длину n бит, а результат умножения может иметь длину 2n бит.

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

Давайте применим его к нашей задаче.

В двоичной системе все выглядит проще, чем то, чему нас учили в школе для десятичных чисел. Берем два числа x и y в двоичном представлении. Чтобы получить произведение, нам надо сложить несколько раз числа x сдвинутые влево на позиции всех ненулевых битов y.

Есть сложность с последним сложением, но какова эта сложность? Для компьютера, получить эти слагаемые это семечки. Он очень хорошо умеет сдвигать двоичные числа. А вот сложить эти слагаемые…

В общем случае слагаемое может быть сдвинуто максимум на n позиций влево, значит каждое слагаемое имеет длину максимум в 2n бит. Чтобы сложить два числа длиной 2n бит, мы также можем применить сложение “столбиком”. Сложность этого алгоритма очевидна, мы проходим все разряды чисел от младшего к старшему. Для каждого разряда мы получаем сумму двух разрядов, добавляем перенос (если есть), учитываем перенос для следующего разряда. Все эти операции для одного разряда выполняются за константное время O(1). Очевидно, чтобы сложить два числа длиной 2n потребуется время O(2n) или просто O(n), опускаем, как это принято, постоянные множители.

Таким образом, чтобы получить произведение xy “столбиком” нам надо сложить, максимум n слагаемых длиной 2n. Каждое сложение имеет сложность O(n), значит сложность умножения “столбиком” это O(n2)

И тут и возникает вопрос: а можно ли перемножить два числа длины n бит более эффективным алгоритмом, чем O(n2).

Далее мы попробуем поискать такие алгоритмы, и (спойлеры) Да! Такие более эффективные способы умножения двух чисел существуют!

Ленивый Гаусс

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

Гаусс быстро заметил, что складывать числа гораздо проще, чем умножать их. Мы и сами это увидели в прошлом абзаце. Умножение “столбиком” двух чисел длины n бит имеет сложность O(n2) в то время, как сложение таких же чисел имеет сложность O(n)

Гаусс посмотрел на известную формулу умножения комплексных чисел

(a+ib)(c+id)=ac-bd+i(bc+ad)

И заметил, что в этой формуле можно делать не 4 операции умножения [ac, bd, bc, ad]

А всего три операции умножения: [ac, bd, (a+b)(c+d)]

Ведь: bc+ad=(a+b)(c+d)-ac-bd

Красиво, на 25% работы стало меньше. но в современных реалиях, когда мы оцениваем сложность алгоритма в нотациях O(...) не имеет никакого значения, имеет ли алгоритм эффективность O(4n2) или O(3n2), постоянный множитель мы в этой нотации всегда опускаем.

Но сама идея сокращения количества операций умножения не так уже бесполезна. Рассмотрим пример.

Как и в прошлом абзаце у нас есть два числа x и y длиной n бит.

“Разрежем” эти числа пополам на старшую (левую) часть длиной n/2 бита и младшую (правую) часть длиной n/2 бита. Обозначим новые числа следующим способом:

x_L,x_R,y_L,y_Rx=[x_Lx_R]=2^{n/2}x_L+x_Ry=[y_Ly_R]=2^{n/2}y_L+y_R

Например,

x=10110110_2; x_L=1011_2; x_R=0110_2 \quadи\quad x=2^4\cdot1011_2 +0110_2

Тогда произведение xy можно записать, как

xy=(2^{n/2}x_L+x_R)(2^{n/2}y_L+y_R)=2^nx_Ly_L+2^{n/2}(x_Ly_R+x_Ry_L)+x_Ry_R \quad(1)

Что если мы будем считать произведение xy, используя формулу в правой части? Умножение на 2n это всего лишь побитовый сдвиг, это даже за умножение не считаем, константная операция. Три сложения выполняются за линейное время O(n). Сложность представляют только четыре операции умножения: 

x_Ly_L, x_Ly_R,x_Ry_L,x_Ry_R

Но, заметим, что это операции теперь не с числами длиною n бит, а с числами длиною n/2 бит. Правда, с другой стороны, вместо одной операции умножения их стало четыре. 

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

Если нам надо перемножить два числа длиной n=2 бит, то мы можем эту операцию разбить на умножение четырех чисел, каждое длиною 1 бит. 

Если надо перемножить два числа длиною n=4 бит, то мы его можем эту операцию разбить на умножение четырех чисел, каждое длиною 2 бит, в свою очередь, это заменяется на операцию умножения 16 чисел длиною 1 бит. 

Напрашивается какой то рекурсивный алгоритм

Если количество элементарных операций которые нам надо проделать для перемножения двух чисел длиною n бит этим рекурсивным алгоритмом обозначить, как T(n), то для вычисления такого произведения, мы вычисляем рекурсивно четыре произведения T(n/2) и добавляем операции сложения и сдвига линейной сложности O(n).

T(n)=4T(n/2)+O(n)

Не будем заниматься подробно вычислением этого уравнения, так как этот алгоритм не стоит того, ответ будет T(n)=O(n2)

То есть мы получили новый алгоритм умножения двух чисел, и он дает такую же эффективность, как и перемножение “столбиком”. Обидно!

И тут то нам и приходит на помощь Гаусс. Что если в формуле (1) можно делать не четыре, а всего три операции умножения. Конечно, ведь

x_Ly_R+x_Ry_L=(x_L+x_R)(y_L+y_R)-x_Ry_R-x_Ly_L

И нам надо вычислить всего три произведения n/2-битных чисел

(x_L+x_R)(y_L+y_R),\quad x_Ry_R,\quad x_Ly_L

А все остальное - это опять сложения и сдвиги, которые выполняются за линейное время.

Сложность такого алгоритма, построенного рекурсивным способом:

T(n)=3T(n/2)+O(n)

Решим это уравнение, построив рекурсивное дерево. На вершине этого дерева, произведение двух чисел xy длины n бит.

Уровнем рекурсии ниже дерево расходится на три произведения для чисел длины n/2

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

На k-ом уровне мы имеем 3k операций умножения, каждое из которых имеет длину n/2k бит. На k-ом уровне алгоритм проводит еще некоторое время, выполняя линейные операции сложения и сдвига для чисел длиною n/2k бит, т.е. операцию сложностью O(n/2k)

Операции умножения чисел длиною n/2k мы не учитываем, так как эта операция выполняется на k+1 ом уровне рекурсии и на наш уровень уже передан ответ. Мы выполняем на k-ом уровне только 3k линейных операций сложения и сдвига, каждое из которых O(n/2k) и передаем ответы уровню выше.

Т.е. количество операций на k-ом уровне рекурсии это

3^kO(n/2^k)=(3/2)^kO(n)

Получается, что количество операций по уровням дерева, это геометрическая прогрессия с множителем 3/2

На самом верхнем уровне k=0 по этой формуле получается просто O(n)

На самом нижнем уровне k=log2(n) нам нужно провести операций 

(3/2)^{log_2n}O(n)=\frac{3^{log_2n}}{2^{log_2n}}O(n)=\frac{2^{log_23\cdot log_2n}}{n}O(n)=n^{log_23}O(1)=O(n^{log_23})

А между верхним и нижним уровнем мы имеем нечто промежуточное полученное последовательным умножением на множитель 3/2 сколько то раз.

А значит, общая сложность алгоритма, это сумма сложностей на каждом уровне дерева, или, как мы видим, это просто сумма геометрической прогрессии с множителем 3/2 и первым членом O(n)

T(n)=\frac{(3/2)^{log_2n}O(n)-1}{3/2-1}=2(3/2)^{log_2n}O(n)-2=2O(n^{log_23})-2

Опускаем вычитание константы и умножение на константу, получаем итоговую сложность алгоритма умножения.

T(n)=O(n^{log_23})=O(n^{1.59})

Итак наш алгоритм несомненно более эффективный, чем умножение “столбиком” со сложностью O(n2).

Данный алгоритм называется алгоритмом Карацубы. В конце 1950-ых Адрей Колмогоров выдвинул гипотезу о том, что умножение "в столбик" со сложностью O(n2) является асимптотически оптимальным алгоритмом умножения двух чисел, что означает, что любой другой алгоритм, решающий эту задачу потребует Ω(n2) элементарных операций. В 1960-ом, Колмогоров проводил семинар по математическим проблемам кибернетики в МГУ, где он озвучил эту гипотезу, и ряд других задач по вычислительной сложности алгоритмов. В течение недели, 23-летний студент Анатолий Карацуба нашел данный алгоритм O(nlog(3)) и, таким образом, опроверг гипотезу. Колмогоров был восхищен открытием. Он рассказал об алгоритме на следующий день на семинаре. С алгоритмом Карацубы Колмогоров провел несколько лекций и выступлений на конференциях по всему миру. Алгоритм был опубликован в 1962 году: Ю. П. Офман, А. А. Карацуба, «Умножение многозначных чисел на автоматах» Доклады АН СССР. — 1962 — Т. 145. — С. 293—294.

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

Мы разделили задачу длины n на 3 задачи  длины n/2 и вдруг, обнаружили, что это эффективнее!

А можно ли так сделать и с другими алгоритмами, не только алгоритмом умножения?

Да, можно!

В этом и состоит принцип «Разделяй и властвуй» улучшения работы базовых алгоритмов.

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

Ссылка на вторую часть

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


  1. Lex20
    17.06.2023 12:12
    +3

    Составлю свой список:

    1. BSP дерево, октодерево (позволяют альтернативную реальность рисовать)

    2. Очередь с приоритетом и основанная на ней пирамидальная сортировка (не требует дополнительной памяти, всегда быстрая, но не устойчивая)

    3. Сортировка слиянием (работает на больших объёмах данных)

    4. Префиксное и суффиксное деревья (структуирование информации)

    5. Бинарный поиск (удобно искать место перебитого кабеля, например)


    1. Alexandroppolus
      17.06.2023 12:12

      Бинарный поиск (удобно искать место перебитого кабеля, например)

      Или в поиске бага в большом куске кода, что в принципе то же самое


  1. dronperminov
    17.06.2023 12:12

    Значит ли это, что Карацуба не "заметил интересную особенность" для своего алгоритма перемножения, а лишь использовал наработки Гаусса?


    1. t-jet
      17.06.2023 12:12
      +1

      Про товарища Гаусса ничего не скажу, помню, что бы Карацуба, потом Том-Кук, потом Штрссен и потом Шенгахе-Ширассена, и ещё был Фюрера в 2007. Куча статей по этому подходу даже на Хабре есть. Было бы полезно, добавить несколько ссылок на предшественников :-)


      1. java_prog Автор
        17.06.2023 12:12

        Спасибо, добавил абзац об истории алгоритма


  1. Spaceoddity
    17.06.2023 12:12

    Подушню:

    а любые два числа, которое, имеют в общем случае длину n бит, а результат умножения может иметь длину 2n бит.

    Так и напрашивается продолжение, в виде "где n - натуральное число", потому что у вас это n на протяжении всей статьи постоянно всплывает. Но этого продолжения нет...


    1. qw1
      17.06.2023 12:12

      В этом дополнении нет необходимости, потому что мы договорились, что n — длина строки-записи числа. Отсюда можно вывести, что n не может быть нецелым или отрицательным.


      1. Spaceoddity
        17.06.2023 12:12

        Нет, вы причину со следствием путаете. Из этого можно много чего вывести. И что n может быть равно 0 (а на самом деле нет, поскольку для хранения нуля, нам все равно нужен, минимум, один бит). И что 0 можно представить в двоичном формате в виде строки нулей, длиной 1 экзабайт... Но корректно оперировать значениями n, мы можем в случае когда в условии прописаны все эти допустимые значения.


        1. qw1
          17.06.2023 12:12

          Натуральные числа используются для подсчёта количества неделимых предметов, таких, как цифры в записи числа. Понятно, что если n — длина записи числа, то n — натуральное.


          Нет, вы причину со следствием путаете. Из этого можно много чего вывести. И что n может быть равно 0 (а на самом деле нет

          Можно вывести (то, что n — не натуральное число) или нет? Если можно, покажите, как.


          И что 0 можно представить в двоичном формате в виде строки нулей, длиной 1 экзабайт

          И что с того? 2^60 тоже натуральное число.


          1. Spaceoddity
            17.06.2023 12:12

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

            Нет. Число цифр в записи числа - не неделимая сущность. Например - средняя длина строки может быть, условно, 5.2 байта. Я же понятия не имею, в каком контексте вы собираетесь это n дальше использовать, поэтому и жду соответствующих уточнений.

            Понятно, что если n — длина записи числа, то n — натуральное.

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

            Можно вывести (то, что n — не натуральное число) или нет? Если можно, покажите, как.

            Я понятия не имею - я же как раз об этом. Я не понимаю в каком контексте тут "n" и "длина записи". Вот стразу всплывает вопрос с нулём. Может ли n = 0 - это уже какой-то философский вопрос получается. Длина записи равна нулю -> значит записывать по сути нечего, и числа, как такового, попросту не существует. Но у нас же статья в контексте вычислительных алгоритмов. И здесь даже для хранения null необходимо выделить ячейку памяти. Поэтому мне сразу хочется понимать этот контекст уточнением "где n..."

            И что с того? 2^60 тоже натуральное число.

            Да даже число Грэма - натуральное)) Но вот у меня тот же вопрос - и что с того?

            К чему это уточнение про "а результат умножения может иметь длину 2n бит."? А может и не иметь)) Длина результата вообще довольно слабо коррелирует с длиной множителей.


            1. qw1
              17.06.2023 12:12

              "Длина строки" и "средняя длина строки" — разные понятия, измеряются в разных единицах, путать их — большая ошибка.


              Вы вот уверены, скажем, что длина числа Пи

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


              К чему это уточнение про "а результат умножения может иметь длину 2n бит."

              Надо читать как "не более 2n" (в предположении, что не записываем ведущие нули).


              Длина результата вообще довольно слабо коррелирует с длиной множителей

              Сильно коррелирует, если договориться о записи без ведущих нулей.


  1. 18741878
    17.06.2023 12:12

    А кто это - Иоганн Гаусс? Знаю, что был Карл Фридрих Гаусс, но об Иоганне никогда не слышал


    1. Glen5
      17.06.2023 12:12
      +2

      Это одно лицо. Иоганн Карл Фридрих Гаусс. Прям как Слава Кпсс:)


      1. kompilainenn2
        17.06.2023 12:12

        Что, Карл Маркс и Фридрих Энгельс - это не муж и жена?!


        1. chervital
          17.06.2023 12:12

          Нет, это четыре разных человека :)


  1. Alexandroppolus
    17.06.2023 12:12

    Кстати, определять асимптотику для рекурсии вроде T(n) = 3T(n/2) + O(n) можно через мастер-теорему


  1. mark_ablov
    17.06.2023 12:12

    Обычно этот алгоритм называют именем Карацубы - https://en.wikipedia.org/wiki/Karatsuba_algorithm.

    Ну и есть еще более быстрые алоритмы для больших n.