Дълги числа
Long Numbers
Какво са "Дълги числа"? Кога би ни се наложило да ги ползваме? Ефективни имплементации.
Сблъсквали ли сте се с проблема, когато 32-битовите числа не са ви достатъчни за да съхраните отговора за дадена задача или подзадача? Тогава трябва да ползвате 64-битов тип или да държите отговора под модул. Какво става, обаче, ако дори 64-битовите типове не са достатъчни?
Примерен проблем
Ели се чуди кое е 1000-ното число на Фибоначи. Напомняме, че числата на Финбоначи са дефинирани като:- F0 = 0;
- F1 = 1;
- Fi = Fi-2 + Fi-1, за i ≥ 2.
Какъв е проблемът?
? | 1000-ното число на Фибоначи е: 43466557686937456435688527675040625802564660517371780402481729089536555417949051890403879840079255169295922593080322634775209689623239873322471161642996440906533187938298969649928516003704476137795166849228875 |
O(N2)
време (ако приемем, че събирането на две числа с дължина L става за O(L)
).Проблемът тук е, че отговорът ще надхвърля многократно възможностите на 64-, а дори и 128-битов тип. Наистина, 64-битовият тип поддържа до около 19 десетични цифри.
Дълги числа
? | Много от по-модерните езици (например Java и Python) включват имплементации на дълги числа, които са добре тествани и имплементирани (демек верни и бързи). За съжаление, C++ не е един от тези езици. |
Основна идея
Добре, как можем да направим събиране на две 20-цифрени числа? Оказва се, че относително просто - можем да разбием всяко от числата в масив, който пази във всяка своя клетка по една цифра от число. Така получаваме два масива с по 20 елемента, във всеки от които има числа между 0 и 9, включително. Резултатът от събирането ще има най-много 21 цифри, тоест може да е масив с 21 клетки от същия тип.Самото събиране протича по следния начин. Започвайки от най-младшите цифри (тоест първо единици, после десетици, после стотици и т.н.), събираме всяка клетка със съответната ѝ от другия масив, като сумата им запазваме в клетката на масива за резултата. Понякога ще се случи тази сума да е по-голяма или равна на 10, в който случай имаме "едно наум" и добавяме единица към следващата клетка от резултата (съседната по-старша цифра).
В най-прост смисъл тези масиви с цифри наричаме "дълги числа".
Други аритметични операции
До тук разгледахме само събиране (впрочем, то често ще е единствената операция, която ще ви трябва). Какво можем да кажем за останалите възможни аритметични операции? Както се оказва, те не са много различни. Можем да имплементираме на компютър "алгоритмите" за събиране, изваждане, умножение, и деление, на които са ни учили в училище.С малки подробности. Например, при изваждане може да се получи отрицателно число. Как да отбележим, че даден масив от цифри е "отрицателен"? Можем да решим този проблем тривиално, като освен масива пазим и допълнителна променлива, която ни показва дали числото е отрицателно или положително.
Освен това, по всяко време трябва да знаем точно колко цифри има дългото число. Например, хубаво имаме две 20-цифрени числа, но след изваждане, например, резултатът може да е с под 20 цифри; или след умножение да е с около 40. Затова ни трябва и променлива, която показва колко цифрено е числото.
Така вече данните, които ни трябват за горната задача биха били нещо от сорта на:
struct Long {
bool positive;
int digits[1024], numDigits;
};
struct Long {
bool positive;
vector <int> digits;
};
База на числата
Забелязахте ли, че пазим само по една десетична цифра във всяка клетка на масива? Ако масивът е отint
-ове (което е стандартно, тъй като операциите с int
са най-бързи), то всяка клетка поддържа числа до над 2 милиарда (или 4 милиарда, ако ползваме unsigned
). А ние ползваме до 10. Какво разхищение!Добре, а защо не преминем към друга база (бройна система)? Например 100-тична? Така всяка цифра ще е число между 0 и 99, включително, операциите се извършват отново без проблем, а съкратихме броя цифри на половина! А с това забързахме и операциите поне двойно.
Развивайки идеята нататък, можем да видим, че една от удобните бройни системи, които можем да ползваме, е едномилиардна (тоест число между 0 и 999,999,999 във всяка клетка). Тя е удобна, тъй като е лесно да се премине в десетична от нея, сборът на две клетки не прехвърля максимума на 32-битовия тип, а сборът на произведението на две клетки не прехвърля максимума на 64-битовия тип. Така ползваме девет пъти по-малко памет и операциите са ни поне девет пъти по-бързи от стандартното. За съжаление, имплементацията е малко по-сложна (трябва да внимаваме за overflow при умножение; печатането на числото е съвсем малко по-трудно).
Трикове
Тук даваме някакви трикове, с които можете да жертвате бързодействие в полза на леснота на имплементация.Обратен ред на цифрите
Оказва се, че е по-удобно за имплементация, ако пазим цифрите на числото в "обратен" ред - тоест в нулевата клетка на масива пазим най-младшата цифра (цифрата на единиците, при база 10), в клетката с индекс едно пазим втората най-младша цифра (цифрата на десетиците, при база 10) и т.н. Това е удобно, тъй като обикновено извършваме операциите именно в този ред (помислете си как правите операциите на лист). Допълнително, това е удобно, тъй като като прибавяме нова цифра към числото (след събиране или умножение, например) то тази цифра е най-старша на числото. Ако я пазихме на първа позиция в масива, щеше да се наложи да преместим всички други цифри, за да я вмъкнем. Вместо това, по този начин добавянето на нова цифра ни е константно.Дълги и Къси числа
Можем да разбием числата на два типа: "дълги" и "къси". За "късо" число ще считаме число, което е по-малко от базата, която сме избрали. Демек ако сме избрали база 10, това число ще е между 0 и 9, включително. Ако сме избрали база 1,000,000,000, това число ще е между 0 и 999,999,999, включително.За да спечелим бързодействие може да разглеждаме различни случаи ако имаме операция на дълго с дълго и на дълго с късо число. Имплементацията на всички операции, в които (поне) едното от числата е късо, е далеч по-лесна и бърза.
Лесно деление
Делението на дълго с дълго число е най-сложната за имплементиране основна операция (тоест събиране, изваждане, умножение, и деление). Забележете, че ако делим дълго число на някакво сравнително малко ("късо" число, например такова, което заема една цифра от базата, която сме избрали), това не е толкова трудно - единственото трудно е да делим дълго на друго дълго число. Като пример се замислете как бихте имплементирали деление на 2 (не е много сложно).Ако ни се наложи да имплементираме деление на дълго с дълго, но не държим да е кой-знае колко бързо, можем вместо да го имплементираме, да ползваме двоично търсене. Двоичното ще правим по отговора, а сметките вътре изискват единствено деление на късото число 2 и умножение на дълго с дълго (което е значително по-лесно за имплементиране).
Двоична база
Казахме, че можем да ползваме база, различна от 10, за да забързаме операциите и да спестим известно количество памет. Все пак, базата, която избрахме, беше степен на 10 - тъй като сме свикнали с операциите в тази база и е сравнително очевидно (за човек) какво се случва с цифрите.За компютрите, обаче, такъв тип бази не са много удобни. Например през цялото време имаме деление и намиране на остатък по тази база. Защо, тогава, не ползваме база, която е точна степен на 2 (вместо на 10)? Така делението и намирането на остатъка можем да извършваме само с побитови операции, които са много по-бързи! Допълнително, можем лесно да имплементираме "побитови" операции в дълго число и да направим дългите си числа почти еквивалентни на стандартните целочислени типове.
Проблемът тук е, че входните числа често са ни зададени в 10-тична бройна система. Така при "създаване" на дългото число трябва да го превърнем в бройната система, която сме избрали - което не е толкова тривиално, ако тя не е степен на 10. Обратното също е вярно - най-често ще искаме да изпечатаме резултата в 10-тична бройна система, затова ще ни трябва и обратното превръщане от (степен на) двоична към 10-тична.
Имплементация
Тук ще покажем базовата имплементация на основните операции (сравнение, събиране, изваждане, умножение и деление). При нея не сме ползвали никой от триковете за забързване на операциите - тя е чисто с "научни" цели - тоест да покаже каква е основната идея и как можем лесно да имплементираме тези операции.След няколко имплементации от моя страна (това ми е трета цялостна) по време на кодерския ми живот, стигнах до извода, че е далеч по-лесно, ако числата бяха само положителни. В противен случай е много лесно да се обърка някой знак, или да се получи (например) отрицателна нула.
Оказва се, че е по-лесно ако се раздели същинската операция от нагласянето на знака. Двете логики са относително независими и можем да имплементираме в отделни функции. В първата от тях ще се справяме с операцията, ако числата бяха без знак (вземаме по абсолютна стойност както самите входни числа, така и резултата от операцията). Във втората ще извикаме първата (за да намерим все пак какви цифри има резултата) и ще донагласим знака, спрямо знаците на двете входни числа.
Както ще видите (особено ако вече сте пробвали да пишете дълги числа), така нещата стават далеч по-чисто.
Последно, преди да започнем със самата имплементация - повечето операции са бинарни - тоест приемат два аргумента. Обикновено между тях има и знак (например '<', '+', '-', '*', '/'). Така можем да кръстим числата "ляво" и "дясно", в зависимост от коя страна на знака стоят. В кода по-нататък ще ги именуваме lvalue (left-hand side value) и rvalue (right-hand side value).
База
Ще ползваме стандартната база 10, с която сме добре свикнали.Данни
Както и по-горе, ще дефинираме тип "дълго число", който пази знак и цифрите на числото. Ще ползваме STL-скиvector
за цифрите, като така няма да се налага сами да менажираме паметта си.
struct Long {
bool positive;
vector <int> digits;
};
Инициализация
Трябва да имаме начин, по който да "създаваме" дълго число (с потенциално много цифри). Един често ползван и удобен начин за това е да го създаваме отstring
. За удобство, можем да направим конструктор, който приема стринг като аргумент и създава дълго число от този стринг. Алтернативно, ще направим и конструктор по подразбиране, който инициализиара числото с нула. Така структурата, която ще ползваме, става:
struct Long {
bool positive;
vector <int> digits;
Long() {
positive = true;
digits.push_back(0);
}
Long (string value) {
positive = true;
int endAt = 0;
if (value[0] == '-' || value[0] == '+') {
positive = value[0] == '+';
endAt++;
}
for (int i = (int)value.size() - 1; i >= endAt; i--)
digits.push_back(value[i] - '0');
if ((int)digits.size() == 1 && digits[0] == 0)
positive = true;
}
};
Печатане
Очевидно е хубаво да имаме някакъв лесен начин да печатаме числото. Една възможност е да имаме функция, която го превръща обратно вstring
, който можем да изпечатаме по много начини (както с cout
, така и с printf
).
string toString(Long value) {
string ret;
if (!value.positive)
ret += '-';
for (int i = (int)value.digits.size() - 1; i >= 0; i--)
ret += value.digits[i] + '0';
return ret;
}
Нормализация
Ще направим един допълнителен метод, който нормализира числото - тоест премахва водещи нули, и го прави положително, ако е нула (за да нямаме отрицателна нула, която може да се получи при някои от операциите).Long fix(Long num) {
while ((int)num.digits.size() > 1 && num.digits.back() == 0)
num.digits.pop_back();
if ((int)num.digits.size() == 1 && num.digits[0] == 0)
num.positive = true;
return num;
}
Сравнение по абсолютна стойност
Сравнението има следната семантика: функцията ще връща -1, ако лявото е по-малко, 0 ако са равни и +1 ако лявото е по-голямо.// Returns -1 if lvalue is less, 0 if equal, or +1 if lvalue is greater.
int absoluteCompare(Long lvalue, Long rvalue) {
if (lvalue.digits.size() != rvalue.digits.size())
return lvalue.digits.size() < rvalue.digits.size() ? -1 : 1;
for (int i = (int)lvalue.digits.size() - 1; i >= 0; i--)
if (lvalue.digits[i] != rvalue.digits[i])
return lvalue.digits[i] < rvalue.digits[i] ? -1 : 1;
return 0;
}
Събиране по абсолютна стойност
Long absoluteAdd(Long lvalue, Long rvalue) {
// Guarantee |lvalue| >= |rvalue|
if (absoluteCompare(lvalue, rvalue) < 0)
swap(lvalue, rvalue);
int carry = 0;
for (int i = 0; i < (int)lvalue.digits.size(); i++) {
if (i < (int)rvalue.digits.size())
carry += rvalue.digits[i];
lvalue.digits[i] += carry;
carry = 0;
if (lvalue.digits[i] >= 10) {
lvalue.digits[i] -= 10;
carry = 1;
}
}
if (carry)
lvalue.digits.push_back(carry);
return fix(lvalue);
}
Изваждане по абсолютна стойност
Long absoluteSubtract(Long lvalue, Long rvalue) {
// Guarantee |lvalue| >= |rvalue|
if (absoluteCompare(lvalue, rvalue) < 0)
swap(lvalue, rvalue);
int carry = 0;
for (int i = 0; i < (int)lvalue.digits.size(); i++) {
if (i < (int)rvalue.digits.size())
carry += rvalue.digits[i];
lvalue.digits[i] -= carry;
carry = 0;
if (lvalue.digits[i] < 0) {
lvalue.digits[i] += 10;
carry = 1;
}
}
return fix(lvalue);
}
Умножение по абсолютна стойност
Long absoluteMultiply(Long lvalue, Long rvalue) {
Long ret;
for (int i = 0; i < (int)lvalue.digits.size(); i++) {
Long add;
add.digits.resize(i, 0);
int carry = 0;
for (int c = 0; c < (int)rvalue.digits.size(); c++) {
carry += lvalue.digits[i] * rvalue.digits[c];
add.digits.push_back(carry % 10);
carry /= 10;
}
if (carry)
add.digits.push_back(carry);
ret = absoluteAdd(ret, add);
}
return fix(ret);
}
Деление по абсолютна стойност
Long absoluteDivide(Long lvalue, Long rvalue) {
Long ret;
Long left, right = lvalue;
while (absoluteCompare(left, right) <= 0) {
Long mid = absoluteAdd(left, right);
int carry = 0;
for (int i = (int)mid.digits.size() - 1; i >= 0; i--) {
carry = carry * 10 + mid.digits[i];
mid.digits[i] = carry / 2;
carry %= 2;
}
mid = fix(mid);
if (absoluteCompare(absoluteMultiply(mid, rvalue), lvalue) <= 0) {
ret = mid;
left = absoluteAdd(mid, Long("1"));
}
else {
right = absoluteSubtract(mid, Long("1"));
}
}
return fix(ret);
}
Операции със знак
Остана да направим функции, които се справят със знака при различните операции. Както ще видите, те не са толкова трудни - само трябва да внимаваме да не получим отрицателна нула. Затова викаме функциятаfix()
преди да върнем всеки резултат (макар и на някои места това да не е нужно).
// Returns -1 if lvalue is less, 0 if equal, or +1 if lvalue is greater.
int compare(Long lvalue, Long rvalue) {
if (lvalue.positive != rvalue.positive)
return !lvalue.positive ? -1 : 1;
return absoluteCompare(lvalue, rvalue) * (lvalue.positive ? +1 : -1);
}
Long add(Long lvalue, Long rvalue) {
Long ret;
if (lvalue.positive == rvalue.positive) {
ret = absoluteAdd(lvalue, rvalue);
ret.positive = lvalue.positive;
}
else {
ret = absoluteSubtract(lvalue, rvalue);
ret.positive = absoluteCompare(lvalue, rvalue) < 0 ?
rvalue.positive : lvalue.positive;
}
return fix(ret);
}
Long subtract(Long lvalue, Long rvalue) {
Long ret;
if (lvalue.positive != rvalue.positive) {
ret = absoluteAdd(lvalue, rvalue);
ret.positive = lvalue.positive;
}
else {
if (absoluteCompare(lvalue, rvalue) == -1) {
ret = absoluteSubtract(rvalue, lvalue);
ret.positive = !rvalue.positive;
}
else {
ret = absoluteSubtract(lvalue, rvalue);
ret.positive = lvalue.positive;
}
}
return fix(ret);
}
Long multiply(Long lvalue, Long rvalue) {
Long ret = absoluteMultiply(lvalue, rvalue);
ret.positive = (lvalue.positive == rvalue.positive);
return fix(ret);
}
Long divide(Long lvalue, Long rvalue) {
Long ret = absoluteDivide(lvalue, rvalue);
ret.positive = (lvalue.positive == rvalue.positive);
return fix(ret);
}
Ефективна имплементация
Предоставяме и що-годе ефективна имплементация, която ползва истинско деление и някои от начините за оптимизиране на бързодействието (база 1,000,000 и отделни имплементации на операциите дълго-с-дълго и дълго-с-късо). Имплементацията е предоставена като клас, който е сравнително удобен/лесен за ползване.Имплементация и тестове: Long.h | LongTester.cpp
Примерни задачи
Можете да пробвате да решите задачата Crypto, където се налага да правим сметките ползвайки дълги числа.Допълнителни материали
За да предложите корекция, селектирайте думата или текста, който искате да бъде променен,
натиснете Enter и изпратете Вашето предложение.
натиснете Enter и изпратете Вашето предложение.
Страницата е посетена 9256 пъти.