(Для лучшего восприятия прочтите исходный пост здесь.)
Число Фибоначчи определяется как:
F(n) = F(n-1) + F(n-2), где F(0)=0,F(1)=1 .
Когда мы узнали, что такое рекурсия, ее можно напрямую записать в следующий наиболее распространенный код:
////////////////////////////////////////////////////////////////////
// Recursive: O(2^n)
uint64_t fibonacci(unsigned int n)
{
return (n <= 1) ? n : fibonacci(n-1) + fibonacci(n-2);
}
Однако, если вы попытаетесь вычислить F(100), вам придется очень долго ждать результата, поскольку в нем так много перекрывающихся процессов. Например, если мы вычислим F(4), то будут дублированные вычисления (перекрывающиеся подструктуры) для F(2):
4 / \ / \ / \ 3 2 / \ / \ 2 1 1 0 / \ 1 0
Чем больше n, тем больше у нас пересекающихся процессов. В результате временная сложность составляет O(2^n).
ЗАПОМИНАНИЕ
Чтобы избежать этого, мы можем использовать кеш для сохранения всех результатов и проверки перед любым вычислением, поэтому все F(k), которые нам нужны, для k ∈ [0, n], будет вычислено только один раз. Следовательно, временная сложность может быть сокращена до O(n) с O(2^n).
////////////////////////////////////////////////////////////////////
// Recursive with memoization: O(n)
// F(k) = mem[k], F(0) = 0, F(1) = 1.
std::vector<uint64_t> mem = { 0, 1 };
uint64_t fibonacci(unsigned int n)
{
if (n + 1 > mem.size()) { // if n is not calculated yet
mem.push_back(fibonacci(n-1) + fibonacci(n-2));
}
return mem[n];
}
ДИНАМИЧЕСКОЕ ПРОГРАММИРОВАНИЕ
Приведенная выше реализация требует дополнительного места для сохранения результатов и затрат времени на выделение памяти. Если мы итеративно вычислим F(n) от F(0), F(1) до F(2), F(3), …,вF(n-1), F(n)или F(n), F(n+1), то мы можем использовать только две или три переменные, чтобы получить F(n):
////////////////////////////////////////////////////////////////////
// Dynamic programming: O(n)
uint64_t fibonacci(unsigned int n)
{
uint64_t a = 0, b = 1; // a = F(k), b = F(k+1), k = 0 now.
for (unsigned int k = 1 ; k <= n ; ++k) { // loop k from 1 to n.
std::swap(a, b); // a = F(k+1), b = F(k)
b += a; // b = F(k) + F(k+1) = F(k+2)
}
return a;
}
or
////////////////////////////////////////////////////////////////////
// Dynamic programming: O(n)
uint64_t fibonacci(unsigned int n)
{
uint64_t a = 0, b = 1, sum = 0; // a = F(0), b = F(1)
for (unsigned int i = 1 ; i < n ; ++i) { // run if n >= 2
sum = a + b; // sum = F(i+1)
a = b; // a = F(i)
b = sum; // b = F(i+1)
}
// Now, i = n, sum = F(n), a = F(n-1), b = F(n)
return (n < 2) ? n : sum;
}
Кроме того, они работают в режиме O(n) с меньшим потреблением памяти, чем метод memoization. Кроме того, они позволяют избежать накладных расходов памяти для записей активации в сегменте/пространстве стека для рекурсий. (Рекурсия будет вызывать себя несколько раз, поэтому она будет помещать несколько записей активации для одной и той же функции с разными аргументами в сегмент/пространство стека загрузки процесса. программа.)
ЗАКРЫТАЯ ФОРМА
На самом деле число Фибоначчи можно рассчитать по следующей формуле:
(Пожалуйста, прочитайте этот пост, чтобы узнать, как он получен.)
////////////////////////////////////////////////////////////////////
// closed-form: O(log(n))
uint64_t fibonacci(unsigned int n)
{
// double sqrt5 = sqrt((double)5);
double sqrt5 = 2.2360679775;
return (pow((1 + sqrt5) / 2, n) - pow((1 - sqrt5) / 2, n)) / sqrt5;
}
Его временная сложность зависит от того, как рассчитывается мощность n. Это можно сделать за O(log n) времени (мы объясним это ниже). Однако операции с плавающей запятой ограничивают вычисляемое число n и могут снижать производительность.
МАТРИЧНАЯ АЛГЕБРА
Числа Фибоначчи можно записать в следующую матрицу:
, поэтому его можно легко расширить по тому же правилу:
(Пожалуйста, прочитайте этот пост для дальнейшего обсуждения.)
То есть матрица Фибоначчи превращается в совершенную степень. Применение возведение в степень возведением в квадрат:
, мы могли бы реализовать приведенную выше идею, чтобы:
////////////////////////////////////////////////////////////////////
// Power by matrix exponentiation: O(log(n))
// Matrix A:
// <--- cols: n --->
// +- -+
// | A11, A12, ... A1n | ^
// | A21, A22, ... A2n | |
// | ... | rows: m
// | ... | |
// | Am1, Am2, ... Amn | v
// +- -+
class Matrix
{
public:
Matrix(unsigned int r, unsigned int c,
std::vector<std::vector<uint64_t>> d)
: rows(r)
, cols(c)
, data(d)
{
}
Matrix(unsigned int r, unsigned int c)
: rows(r)
, cols(c)
{
assert(rows && cols);
data.resize(rows);
for (unsigned int i = 0 ; i < rows ; ++i) {
data[i].resize(cols);
}
}
~Matrix()
{
}
uint64_t Read(unsigned int r, unsigned int c)
{
return data[r][c];
}
Matrix operator*(const Matrix& other)
{
assert(cols == other.rows); // Check if they can be multiplied.
Matrix z(rows, other.cols);
for (unsigned int i = 0 ; i < rows ; ++i) {
for (unsigned int j = 0 ; j < other.cols; ++j) {
for (unsigned int k = 0 ; k < cols; ++k) {
z.data[i][j] += data[i][k] * other.data[k][j];
}
}
}
return z;
}
// Calculate the power by fast doubling:
// k ^ n = (k^2) ^ (n/2) , if n is even
// or k * (k^2) ^ ((k-1)/2) , if n is odd
Matrix pow(unsigned int n)
{
Matrix k(*this); // Copy constructor.
Matrix r = Identity(rows);
while (n) {
if (/*n % 2*/n & 1) {
r = r * k;
}
k = k * k;
/*n /= 2*/n >>= 1;
}
return r;
}
private:
Matrix Identity(unsigned int size)
{
Matrix z(size, size);
for (unsigned int i = 0 ; i < size ; ++i) {
z.data[i][i] = 1;
}
return z;
}
unsigned int rows;
unsigned int cols;
std::vector<std::vector<uint64_t>> data;
};
// The Fibonacci matrix can be written into the following equation:
// +- -+ +- -+^n
// | F(n+1) F(n) | | 1 1 |
// | | = | |
// | F(n) F(n-1) | | 1 0 |
// +- -+ +- -+
uint64_t fibonacci(unsigned int n)
{
Matrix F { 2, 2, {
{ 1, 1 },
{ 1, 0 }
} };
// Using F.data[0][1] since n might be 0.
// (we need to power by n - 1 if we return F.data[0][0].)
F = F.pow(n);
return F.Read(0, 1);
}
Его временная сложность составляет O(log n) при делении пополам и пополам. Без операций с плавающей запятой n может быть больше, чем при использовании закрытой формы.
Чтобы сделать это быстрее, вы можете использовать собственный массив вместо std::vector
, но вам нужно самостоятельно управлять использованием памяти. Пожалуйста, прочитайте этот пост, чтобы узнать, как это сделать.
БЫСТРОЕ УДВОЕНИЕ
Следующие уравнения:
можно получить, применив 2n к приведенной выше матрице Фибоначчи:
Следовательно, мы могли бы вычислить F(n) следующим образом:
Как следствие, F(n) можно вычислить с помощью следующей программы: (Пожалуйста, прочитайте этот пост, чтобы узнать, как получен код.)
uint64_t fibonacci(unsigned int n)
{
// The position of the highest bit of n.
// So we need to loop `h` times to get the answer.
// Example: n = (Dec)50 = (Bin)00110010, then h = 6.
// ^ 6th bit from right side
unsigned int h = 0;
for (unsigned int i = n ; i ; ++h, i >>= 1);
uint64_t a = 0; // F(0) = 0
uint64_t b = 1; // F(1) = 1
// There is only one `1` in the bits of `mask`.
// The `1`'s position is same as
// the highest bit of n(mask = 2^(h-1) at first),
// and it will be shifted right iteratively to do
// `AND` operation with `n` to check `n / 2^j` is odd
// or even.
for (unsigned int mask = 1 << (h - 1) ; mask ; mask >>= 1) {
// Let j = h-i (looping from i = 1 to i = h),
// n_j = floor(n / 2^j) = n >> j (n_j = n when j = 0),
// k = floor(n_j / 2), then a = F(k), b = F(k+1) now.
uint64_t c = a * (2 * b - a); // F(2k)=F(k) * [2*F(k+1)–F(k)]
uint64_t d = a * a + b * b; // F(2k+1) = F(k)^2 + F(k+1)^2
if (mask & n) { // n_j is odd: k = (n_j-1)/2 => n_j = 2k + 1
a = d; // F(n_j) = F(2k + 1)
b = c + d; // F(n_j + 1) = F(2k + 2) = F(2k) + F(2k + 1)
} else { // n_j is even: k = n_j/2 => n_j = 2k
a = c; // F(n_j) = F(2k)
b = d; // F(n_j + 1) = F(2k + 1)
}
}
return a;
}
Его временная сложность также составляет O(log n) при уменьшении вдвое. В отличие от подхода матричной алгебры, нет необходимости использовать какой-либо массив или вектор для содержания дублированного F(k) в матрице, поэтому это будет быстрее.
ПРЕДСТАВЛЕНИЕ
Приведенные выше результаты представляют собой время в миллисекундах для вычисления F(n). Получение результатов при использовании рекурсивного подхода займет слишком много времени, поэтому мы его пропускаем. Подход с закрытой формой также игнорируется, так как операции с плавающей запятой работают только при n≤97 в реализации выше.
ВЫВОД
Хотя производительность зависит от платформы, она по-прежнему указывает на то, что:
- Подход быстрого удвоения всегда является самым быстрым способом, и его производительность намного выше, чем у других.
- Подход динамического программирования работает быстрее, чем метод матричной алгебры, когда nn мало (n≤13000 здесь), но медленнее, когда n большой.
- Поэтому, если вы почти уверены, что у вас небольшое n, и узкое место вашего алгоритма не зависит от расчета Фибоначчи, тогда динамическое программирование приемлем и его проще реализовать.
Этот пост — конец моего пути к расчету Фибоначчи. Надеюсь тебе понравилось. Весь приведенный выше код загружается в суть здесь. Пожалуйста, клонируйте их, чтобы поиграть с ними.
Я скоро начну новое путешествие по другим интересным темам. Быть в курсе!