Бинарный алгоритм
Быстрый рекурсивный алгоритм можно превратить в быстрый итеративный, просматривая двоичные цифры числа [latex] n [/latex]. Просматривать их можно слева направо, а можно справа налево. Соответственно получаем две версии алгоритма. Иногда их называют бинарным алгоритмом возведения в степень слева направо (или справа налево). Кормен и соавторы используют название «метод многократного возведения в квадрат».
В англоязычных источниках описанные методы встречаются под названиями «exponentiation by squaring», «exponentiation by repeated squaring», «square-and-multiply exponentiation», «binary exponentiation». Обычно рассматривается один из методов, а второй предлагается в качестве упражнения. Но мы рассмотрим оба.
Схема «слева направо»
Пусть количество цифр в числе [latex] n [/latex] есть [latex] l(n) = t [/latex]: [latex] \left( n_{t-1} \dots n_{0} \right)_{2} [/latex]. Для каждого [latex] k \in \left[ 0 \dots t-1 \right] [/latex] обозначим [latex] m_{k} = \left( n_{t-1} \dots n_{k} \right)_{2} [/latex].
Если [latex] k = 0 [/latex], то [latex] m_k = n[/latex] и поэтому [latex] a ^{m_k} = a^{n} [/latex].
Если [latex] 0 < k \leq t-1 [/latex], то
[latex] m_{k-1} = \left( n_{t-1} \dots n_{k} n_{k-1} \right)_{2} = [/latex] [latex] 2\left( n_{t-1} \dots n_{k} \right)_{2} + n_{k-1}= 2 m_{k} + n_{k-1}[/latex]
и поэтому
[latex] a^{m_{k-1}} = \left( a^{m_{k}} \right) ^ {2} a^{n_{k-1}} = [/latex][latex]\begin{cases}\left(a^{m_{k}}\right)^{2} & \text{} n_{k-1} = 0 \\a * \left(a^{m_{k}}\right)^{2} & \text{} n_{k-1} = 1 \end{cases} [/latex]
Получаем следующий алгоритм:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
|
def pow(a,n): # Обработка тривиального случая if (n == 0): return e() # Стадия препроцессинга D = [] while (n > 0): D.append(n % 2) n = n // 2 t = len(D) # Стадия вычислений r = a for k in range(t-2,-1,-1): r *= r if (D[k] == 1): r *= a return r |
Отмечу, что процесс «накопления» показателя степени в данном алгоритме по сути идентичен процессу вычисления значения многочлена [latex]\sum_{k=0}^{t}n_{k}x^{k} [/latex] в точке [latex] x = 2 [/latex] по схеме Горнера.
Главным преимуществом данного алгоритм перед алгоритмом по схеме «справа налево» является тот факт, что на стадии домножения (если текущая цифра — единица), умножение всегда происходит на один и тот же элемент. Иногда это позволяет существенно сэкономить время, затрачиваемое на умножение. Например, в тестах простоты встречается ситуация степени с малым основанием и большим показателем. В этом случае умножение на основание является операцией существенно более быстрой, чем умножение на произвольное число, которое может возникнуть в схеме «справа налево».
Оценка времени. Деление на [latex] 2 [/latex] можно считать быстрой операцией. Поэтому, игнорируя детали работы со списками, время на препроцессинг можно оценить как линейно зависящее от [latex] l(n) [/latex]. Оценим количество возведений в квадрат / умножений. В случаях [latex] n = 0 [/latex] и [latex] n = 1 [/latex] не производится ни возведений в квадрат, ни умножений. Если же [latex] n > 1 [/latex], то цикл for в стадии вычислений отработает [latex] l(n)-1 [/latex] шаг и на каждом из них будет произведено возведение в квадрат. При этом «на каждой единице», кроме старшей, будет произведено умножение на [latex] a [/latex]. В итоге получаем, что при [latex] n > 1 [/latex] алгоритму требуется [latex] l(n) — 1 [/latex] возведений в квадрат и [latex] s(n) — 1 [/latex] обычных умножений, где [latex] l(n) [/latex] — количество цифр в двоичной записи числа [latex] n [/latex], [latex] s(n) [/latex] — количество единиц в двоичной записи [latex] n [/latex].
Схема «справа налево»
Пусть количество цифр в числе [latex] n [/latex] есть [latex] l(n) = t [/latex]: [latex] \left( n_{t-1} \dots n_{0} \right)_{2} [/latex]. Для каждого [latex] k \in \left[ 0 \dots t-1 \right] [/latex] обозначим [latex] m_{k} = \left( n_{k} \dots n_{0} \right)_{2} [/latex].
Если [latex] k = t-1 [/latex], то [latex] m_k = n[/latex] и поэтому [latex] a ^{m_k} = a^{n} [/latex].
Если [latex] 0 \leq k < t-1 [/latex], то
[latex] m_{k+1} = \left( n_{k+1} n_{k} \dots n_{0} \right)_{2} = [/latex] [latex] 2^{k+1} n_{k+1} + \left( n_{k} \dots n_{0} \right)_{2}= 2^{k+1} n_{k+1} + m_{k}[/latex]
и поэтому
[latex] a^{m_{k+1}} = \left( a^{ 2^{k+1} } \right)^{n_{k+1}} a^{m_{k}} =[/latex] [latex]\begin{cases}a^{m_{k}} & \text{} n_{k+1} = 0 \\ a^{2^{k+1}} a^{m_{k}} & \text{} n_{k+1} = 1 \end{cases} [/latex]
Мы могли бы получить алгоритм, похожий на тот, что приведён в схеме «слева направо», но цифры числа можно просматривать «динамически», без предвычисления.
|
def pow(a,n): r = e() while (n != 0): if (n % 2 == 1): r = r * a n = n // 2 if (n != 0) a = a * a return r |
На примере этого алгоритма рассмотрим, как проводятся доказательства корректности методом инварианта.
Докажем следующую теорему: для любых [latex] a \in M [/latex] и [latex] n \in \mathbb{N}_{0}[/latex], поданных на вход алгоритма pow, этот алгоритм завершается и возвращает [latex] a ^ {n} [/latex].
Доказательство будем проводить методом инварианта: покажем, что перед циклом и после каждого шага цикла справедлив следующий инвариант: [latex] r a^{n} = A^{N} [/latex], где [latex] A [/latex] и [latex] N [/latex] — первоначальные значения переменных [latex] a [/latex] и [latex] n [/latex].
Предусловие. Перед началом цикла [latex] r a^{n} = e A^{N} = A ^ {N} [/latex].
Сохранение. Предположим, что инвариант справедлив перед некоторым шагом цикла и пусть [latex] \rho, \nu, \alpha [/latex] — текущие значения переменных [latex] r, n, a[/latex]. Если [latex] \nu [/latex] чётное, то после завершения текущего шага будем иметь: [latex] r = \rho [/latex], [latex] a = \alpha ^ {2} [/latex], [latex] n = \frac{\nu}{2} [/latex], а если нечётное, то [latex] r = \rho a [/latex], [latex] a = \alpha ^ {2} [/latex], [latex] n = \frac{\nu — 1}{2} [/latex]. Непосредственно проверяется, что в обоих случаях справедливо равенство [latex] r a ^ {n} = \rho \alpha ^ {\nu}[/latex]. По предположению об инварианте, правая часть равна [latex] A ^ {N} [/latex]. Значит, инвариант после завершения текущего шага сохраняется.
Завершение доказательства.
Если [latex] N = 0 [/latex], то цикл ни разу не запустится и алгоритм завершится, возвратив [latex] e = A^{0} [/latex], т.е. завершится корректно.
Если [latex] N = 1 [/latex], то цикл отработает единственный шаг и алгоритм завершится, возвратив [latex] A = A^{1} [/latex], т.е. завершится корректно.
Если [latex] N > 0 [/latex], то, поскольку на каждому шаге мы целочисленно делим положительное число [latex] N [/latex] на [latex] 2 [/latex], за конечное число шагов цикл завершится. С помощью индукции нетрудно показать, что точное число шагов в этом случае составит [latex] l(N) [/latex] — количество цифр в двоичной записи числа [latex] N [/latex]. После завершения цикла переменная [latex] n [/latex] содержит значение [latex] 0 [/latex] и после последнего шага, по выше доказанному, инвариант сохранялся. Значит, [latex] A ^ { N} = r \cdot a ^ {n} = r \cdot a ^ {0} = r \cdot e = r [/latex], т.е. алгоритм к.
Оценка времени. При [latex] n > 1 [/latex] цикл while, как было сказано выше отработает [latex] l(n) [/latex] шагов, на каждом из которых, кроме последнего, производится возведение в квадрат. При этом на каждом шаге с единицей мы производим умножение. Однако первое умножение — это фактически умножение на [latex] e [/latex], а такие умножения принято исключать из подсчёта. В итоге получаем, что при [latex] n > 1 [/latex] алгоритму требуется [latex] l(n) — 1 [/latex] возведений в квадрат и [latex] s(n) — 1 [/latex] обычных умножений, где [latex] l(n) [/latex] — количество цифр в двоичной записи числа [latex] n [/latex], [latex] s(n) [/latex] — количество единиц в двоичной записи [latex] n [/latex].