* Бързо степенуване
Публикувано на 03 юли 2023 в раздел Информатика.
Степенуването е основна математическа операция и е очаквано да се използва често. Интуитивната реализация на алгоритъм за изчисление на [mathi]x^{n}[/mathi] би била „умножаваме [mathi]x[/mathi] по себе си [mathi]n[/mathi] на брой пъти“. Тоест нещо подобно на следното:
static BigInteger naivePow(BigInteger x, long n){
BigInteger result = BigInteger.ONE;
for (long i = 0; i < n; i++) {
result = result.multiply(x);
}
return result;
}
С това решение при достигане до прекалено големи числа ще започне да се отнема сериозно време. Например на компютъра, на който пиша в момента тази статия (Intel Core-i5 9400) изчислението на [mathi]569890561^{121391}[/mathi] отнема около 8 секунди. Не може ли да се случи по-добре?
Основният проблем с бързината идва от там, че извършваме [mathi]n[/mathi] на брой умножения. Вместо това бихме могли да се възползваме от следното наблюдение:
- [mathi]x^{n}=x^{n/2}.x^{n/2}[/mathi] ако [mathi]n[/mathi] е четно;
- [mathi]x^{n}=x^{n/2}.x^{n/2}.x[/mathi] ако [mathi]n[/mathi] е нечетно.
Ето един бърз пример как това може да получи приложение. Представете си, че трябва да изчислим [mathi]5^8[/mathi]. С представената интуитивна реализация ще пресметнем [mathi]5.5.5.5.5.5.5.5=5^{8}[/mathi], т.е. 8 умножения. Вместо това можем да подходим със следната последователност:
- Изчисляваме [mathi]5.5=5^{2}[/mathi];
- Изчисляваме [mathi]5^{2}.5^{2}=5^{4}[/mathi] (като [mathi]5^{2}[/mathi] е вече пресметнато в предишната стъпка число);
- Изчисляваме [mathi]5^{4}.5^{4}=5^{8}[/mathi]
Оказва се, че го направихме с едва 3 умножения. Остава да алгоритмизираме този процес. Най-популярната реализация се нарича „степенуване чрез повдигане на квадрат и умножение“ (square and multiply). При него се възползваме от спецификите на двоичната бройна система и най-вече, че операцията „повдигане на квадрат“ е изключително лека - просто се добавя 0 в края на числото. Ще го демонстрирам директно с пример. Нека да речем, че трябва да изчислим [mathi]5^{45}[/mathi]. Представяме числото 45 в бинарен вид:
[math]101101_{2}[/math]
Нека номерираме битовете с индекси отляво надясно с 1, 2, 3, 4, 5 и 6. Тогава за всеки от тях ще имаме следното:
- [mathi]5^{1_{2}}=5^{1}[/mathi];
- [mathi]5^{1_{2}}.5^{1_{2}}=5^{10_{2}}=5^{2}[/mathi];
- [mathi]5^{10_{2}}.5^{10_{2}}.5^{1_{2}}=5^{101_{2}}=5^{5}[/mathi];
- [mathi]5^{101_{2}}.5^{101_{2}}.5^{1_{2}}=5^{1011_{2}}=5^{11}[/mathi];
- [mathi]5^{1011_{2}}.5^{1011_{2}}=5^{10110_{2}}=5^{22}[/mathi];
- [mathi]5^{10110_{2}}.5^{10110_{2}}.5^{1_{2}}=5^{101101_{2}}=5^{45}[/mathi];
Вижда се как на всяка стъпка след първата се извършва повдигане на квадрат (когато конкретния бит е 0) или повдигане на квадрат и умножение по числото на 1-ва степен (когато конкретния бит е 1). Ето и как се реализира алгоритъма рекурсивно:
static BigInteger fastPowRecursive(BigInteger x, long n){
if(n==0){
return BigInteger.ONE;
}
BigInteger half = fastPowRecursive(x, n/2);
BigInteger squared = half.multiply(half);
if((n % 2)==0){
return squared;
} else {
return squared.multiply(x);
}
}
Ще забележите веднага, че тази реализация ще даде резултат изключително по-бързо, отколкото предишното решение. Итеративната реализация освен това ще използва и по-оптимално паметта:
static BigInteger fastPowIterative(BigInteger x, long n){
BigInteger result = BigInteger.ONE;
while(n > 0){
if((n % 2) == 1){
result = result.multiply(x);
}
x = x.multiply(x);
n = n / 2;
}
return result;
}
Бихте могли допълнително да подобрите бързодействието ако използвате директно побитови операции.
П.П. Този алгоритъм освен това получава изключително често приложение в криптографията, където често се налагат изчисления от типа [mathi]x^{n} mod(m)[/mathi] (например при алгоритъма RSA). Стандартно може да се помисли първо да се изчисли степенуването [mathi]x^{n}=A[/mathi], а след да се намери остатъка от деление [mathi]A mod(m)[/mathi]. По-оптимален вариант би бил да се извършва делението по модул на всяка стъпка от алгоритъма - това няма да промени крайния резултат (проверете). Вярно е, че така броят на извършените операции се увеличава, но за сметка на това вече не се налага да се работи с много големи числа, т.е. заетата памет значително намалява.
П.П.2. Единственият недостатък на така показания алгоритъм е, че при единичен бит се извършва една операция повече (square + multiply), отколкото при нулев (само square). Това прави алгоритъмът податлив на т.нар. "side channel attacks". Едно възможно решение е алгоритъмът на Монтгомери. Негова примерна реализация е следната:
static BigInteger montgomeryPow(BigInteger x, long n) {
BigInteger x1 = x;
BigInteger x2 = x.multiply(x);
// взима броя битове на числото (премахвайки нулевите в началото)
int n_bits_len = Long.SIZE - Long.numberOfLeadingZeros(n);
// обхождаме числото бит по бит, пропускайки най-старшия
for(int i = n_bits_len-2; i >= 0; i--){
// (n & (1 << i)) >> i връща i-ти бит на числото n
if (((n & (1 << i)) >> i) == 0) {
x2 = x1.multiply(x2);
x1 = x1.multiply(x1);
} else {
x1 = x1.multiply(x2);
x2 = x2.multiply(x2);
}
}
return x1;
}
Добави коментар