Фибоначчи на собеседовании

Вычисление ряда Фибоначчи — это классическая алгоритмическая задача, потому её нередко дают на собеседованиях, когда хотят проверить, что кандидат в принципе хоть как-то умеет в алгоритмы. Предположим, вы тот самый кандидат. Вам дали задание: на языке JavaScript написать функцию fib(n), возвращающую энное число Фибоначчи. Считаем, что нулевое число Фибоначчи — это нуль. Проверка корректности аргумента не требуется. Какие у вас есть варианты?



1. Быть проще, и люди к вам потянутся.
Самое простое решение — это банальный цикл.

const fib = n => {
let prev = 0, next = 1;
while(n— && (next = prev + (prev = next)));
return prev;
}

Шутка. Разумеется, так писать не нужно — если, конечно, вы не собеседуетесь на должность штатного обфускатора.

const fib = n => {
let prev = 0, next = 1;
for(let i = 0; i < n; i++){
next = prev + next;
prev = next — prev;
}
return prev;
}

У вас кончились талончики на переменные? cypok
Ладно, ладно, для ещё большей читаемости напишем так:

const fib = n => {
let prev = 0, next = 1;
for(let i = 0; i < n; i++){
let temp = next;
next = prev + next;
prev = temp;
}
return prev;
}

Это — вариант классический, простой и элегантный. Но, возможно, вы хотите продемонстрировать своё знание ещё каких-то концепций? Например…

2. Чтобы понять рекурсию, надо понять рекурсию
Например, да, вы можете продемонстрировать, что умеете в рекурсию. Например, так:

const fib = n => {
if(n <= 1){
return n;
}else{
return fib(n — 1) + fib(n — 2);
}
}

Запомните этот вариант. Так делать не стоит. Не следует. Нельзя. Никогда. Это хуже, чем пинать щеночков, и сравнимо с небольшим холокостом.

Возможно, вы спросите, почему. В таком случае просто запустите этот код и попытайтесь посчитать, скажем, пятидесятое число Фибоначчи. Полагаю, вы ощутите некую задержку. Шутка. Полагаю, если вы запускаете этот код не на суперкомпьютере, то попросту не дождётесь результата. При том, что простой, не рекурсивный код из предыдущих примеров посчитает пятидесятый член последовательности Фибоначчи быстрее, чем вы успеете произнести слово «пятьдесят» или любой его слог.

Выражаясь грубым языком O-нотации, такое решение имеет временную сложность O(en). То есть — время выполнения этой функции растёт экспоненциально при увеличении n. То есть — когда n увеличивается на, время выполнения увеличивается в. Грубо говоря, если fib(45) вам пришлось ждать час, то fib(46) вы будете ждать два часа, fib(47) — 4 часа, и так далее. Я разжёвываю так подробно, чтобы каждый читатель, даже верстальщик, впервые попробовавший свои силы в написании скриптов, мог осознать ужас ситуации.

Это правильно, но слишком грубо. Можно получить более точную оценку числа вызов функции ~(1+sqrt(5)) fib(n) и красивое замечание «Для вычисления числа Фибонначи наивным рекуррентным методом понадобится вызовов функции в 3.2 раза больше чем само число Фибонначи». Taus И мы получаем ещё один метод его вычисления. Надо просто запустить наивный рекурректный метод, подсчитать количество вызовов функции и разделить на 3.2! Cerberuser
Если от вас на собеседовании потребуют рекурсивного решения этой задачи, скорее всего, это ловушка. «Правильная» рекурсия, работающая за линейное время, может выглядеть, например, так:

const fib2 = n => {
if(n == 0){
return [0, 1];
}else{
const [prev, next] = fib2(n — 1);
return [next, prev + next];
}
}

const fib = n => fib2(n)[0];

Подводя итог: несмотря на то, что числа Фибоначчи являются классическим, хрестоматийным примером рекурсии, в действительности это не самый удобный случай для применения рекурсии. Но можно попробовать блеснуть ещё какими-нибудь знаниями.

3. Мемная функция
Существует волшебный способ, превращающий чудовищно неэффективное решение из прошлого параграфа в потенциально очень быстрое (хотя и не лишённое проблем). Имя ему — мемоизация. А если говорить по-русски — мы просто запоминаем результаты предыдущих вызовов вместо того, чтобы вычислять их заново.

В принципе, мы можем даже ничего не менять внутри того решения — просто добавить функцию-обёртку memoize. Здесь я для наглядности использую её упрощённую версию для функции с единственным аргументом.

//я изменил название функции, потому что заказчику мы отдадим не её, а её обёрнутую версию
const oldFib = n => {
if(n <= 1){
return n;
}else{
return oldFib(n — 1) + oldFib(n — 2);
}
}

const memoize = f => {
const cache = {};
return arg => cache[arg] || (cache[arg] = f(arg));
}

const fib = memoize(oldFib);

Вуаля! Теперь функция fib имеет через замыкание доступ к объекту cache. Если её вызывают с аргументом, который ранее не встречался, вычисленное значение сохраняется в cache. При новых вызовах функции с тем же аргументом значение не придётся вычислять заново, оно будет просто взято из кэша. Основная проблема «плохой» старой функции fib была в том, что одни и те же значения в ней вычислялись заново несколько раз. Например, для вычисления fib(45) нужно было один раз вычислить f(44), два раза — f(43), три раза — f(42), пять раз — f(41), и так далее.

Занимательный фактПри использовании наивной рекурсии количества вычислений предыдущих чисел Фибоначчи сами являются числами Фибоначчи. Разве это не поразительно? На самом деле не очень. С числами Фибоначчи постоянно такое, в конце поста будет пример поинтереснее.

Так вот, теперь предыдущие значения будут вычисляться по одному разу, а при их повторном запросе — просто браться из кэша. Представляете, во сколько раз быстрее мы сможем теперь вычислить сорок пятое число Фибоначчи? Серьёзно, как вы думаете, во сколько?

На самом деле — чуть-чуть медленнее. Я сознательно допустил классическую ошибку, часто совершаемую при мемоизации рекурсивных функций. При вызове fib(45) «под капотом» вызывается oldFib(45), которая для своих нужд вызывает oldFib(44) и oldFib(43)… Вы чувствуете подвох? Здесь и далее идут уже вызовы обычной, не мемоизированной функции. Конечно, при повторном вызове fib(45) мы мгновенно получим результат из кэша — однако первый вызов ничуть не ускорился. Чтобы это поправить, придётся всё-таки влезть oldFib под днище с гаечным ключом:

const oldFib = n => {
if(n <= 1){
return n;
}else{
return fib(n — 1) + fib(n — 2);
}
}

const memoize = f => {
const cache = {};
return arg => cache[arg] || (cache[arg] = f(arg));
}

const fib = memoize(oldFib);

Замечательно! Теперь первый вызов fib(45) отработает со скоростью, сравнимой с версией с циклом. А дальнейшие вызовы вообще сработают за константное время… Оп! Опять обманул. Получение значения свойства объекта по ключу — это операция быстрая, но всё-таки O(1) только в среднем, в худшем случае она может деградировать до O(n). Чтобы стало совсем хорошо, в нашем случае мы можем сменить тип cache с объекта на массив.

Разумеется, не стоит также забывать, что мемоизация требует памяти. И пока мы уменьшаем сложность по времени, сложность по памяти растёт с O(1) до O(n).

Как ещё мы можем выпендриться? Например, продемонстрировав своё глубокое знание математики

4. Мистер Бине
Существует особая прекрасная наука о том, как рекуррентные соотношения превращать в явные формулы. Здесь мы не будем вдаваться в её детали. Скажем лишь, что для чисел Фибоначчи с помощью достаточно несложных рассуждений можно вывести следующую формулу, известную как формула Бине:

Однако довольно языка математики, запишем это на языке JavaScript:

const fib = n => {
const a = (1 + 5 ** 0.5) / 2;
const b = (1 — 5 ** 0.5) / 2;
return (a ** n — b ** n) / 5 ** 0.5;
}

Прогоним на первых нескольких числах. Замечательно, кажется, всё работает. Вот 13, вот 21, вот 34, вот… 54.99999999999999?

Да, разумеется, такой результат закономерен. Формула Бине точна математически, но компьютер оперирует дробями конечной точности, и при действиях над ними может накопиться ошибка, что и произошло в данном случае. Однако мы можем всё исправить. Зная, что вычитаемое в числителе всегда будет маленьким по модулю, мы можем упростить формулу до следующего состояния:

Здесь странные недоделанные квадратные скобки означают ближайшее целое число, то есть — округление. Перепишем наш код:

const fib = n => {
const a = (1 + 5 ** 0.5) / 2;
return Math.round(a ** n / 5 ** 0.5);
}

Да, так гораздо лучше. Мы сможем увидеть и 55, и 89, и даже моё любимое число Фибоначчи — 144 (которое я люблю за то, что оно равняется двенадцати в квадрате). Всё будет хорошо до числа за номером 76. Которое должно быть равно 3416454622906707, а наша функция вычислит 3416454622906706. Потому что проблема ограниченной точности дробных чисел никуда не делась, мы просто затолкали её поглубже и надеялись, что она не всплывёт. Как показывает данный пример — надеялись напрасно.

На самом деле мы можем сделать ещё кое-что, чтобы спасти этот метод. Но об этом ниже. А пока — шутки в сторону. Поговорим о методе суровом, хардкорном и брутальном.

5. Следуй за белым кроликом.
Говорят, если у вас есть проблема и вам пришла в голову идея, что можно решить её с помощью регулярных выражений, то теперь у вас две проблемы. Матрицы — это регулярные выражения наоборот. Многие проблемы, если их переформулировать на языке матриц, решаются просто сами собой.

Что касается чисел Фибоначчи, для них на матричном языке можно записать вот такое очевидное тождество:

То есть если взять пару подряд идущих чисел Фибоначчи и умножить их на такую вот незамысловатую матрицу, мы получим следующую пару. А отсюда логично следует вывод: если мы возьмём пару из нулевого и первого числа Фибоначчи, то есть нуля и единицы, и умножим их на эту матрицу в энной степени, мы получим пару из энного и эн плюс первого числа Фибоначчи. То есть, говоря по-человечески:

Можно это ещё немного упростить, отказавшись от векторов. На самом деле все необходимые значения содержатся в самой матрице:

Замечательно, не правда ли? Осталось понять, нафига попу гармонь, если он не филармонь. В смысле — зачем такие сложности на ровном месте. А ответ прост — быстрое возведение в степень.

Сколько нужно выполнить элементарных умножений, чтобы вычислить, скажем, 210? Нормальный человек скажет, что девять. Дважды два — четыре. Дважды четыре — восемь. Дважды восемь — шестнадцать. И так далее. Хитрый человек скажет, что четыре.

Программист скажет. что он это число помнит наизусть, и ничего умножать не нужно. Однако вопросы мемоизации мы рассмотрели выше.

Так вот, быстрое возведение в степень применимо и к матрицам, и таким образом позволяет уменьшить асимптотическую временную сложность нашей функции с O(n) до O(log n). И это очень круто — если, конечно, нам действительно так важна эта сложность. Давайте напишем код:

//здесь я использую таинства деструктуризации, чтобы записать матричное умножение почти как в учебнике алгебры
const mul = (
[
[a1, a2],
[a3, a4]
],
[
[b1, b2],
[b3, b4]
]) =>
[
[a1 * b1 + a2 * b3, a1 * b2 + a2 * b4],
[a3 * b1 + a4 * b3, a3 * b2 + a4 * b4]
];

const matrix = [
[0, 1],
[1, 1]
];

//единичная матрица, не айдишник
const id = [
[1, 0],
[0, 1]
]

const fib = n => {
let result = id;
const bits = n.toString(2);
//да простят мне такой колхоз любители битовой магии
for(const bit of bits){
result = mul(result, result);
if(bit == «1»){
result = mul(result, matrix);
}
}
return result[1][0];
}
Вот мы и получили самый быстрый алгоритм на Диком Западе. И его, в отличие от большинства предыдущих, можно неиронично продемонстрировать на собеседовании. А в каких-нибудь математико-ёмких местах именно его от вас и будут ждать.

P. S.
Я обещал ремарку относительно того, как же нам спасти метод, основанный на формуле Бине. Ответ кроется вот в этой моей статье. Там я для нужд народного хозяйства написал специальный класс корень-из-пяти-рациональных чисел, которые могут без потери точности хранить результаты арифметических действий над целыми числами и корнем из пяти. Можно взять этот класс, дополнить его методом округления и использовать для поиска чисел Фибоначчи по формуле Бине. А затем впрыснуть закись азота, применив быстрое возведение в степень.

И что самое интересное: если внимательно посмотреть, какие числа будут получаться в процессе, какие будут выполняться операции, то станет понятно, что этот метод — это то же самое матричное умножение, только под другим фасадом. Разница лишь в том, храним мы числа в двумерных массивах или в полях объекта специального класса.

На этом всё. Если вы считаете, что я упустил ещё какие-то интересные способы найти никому не нужные числа, обязательно напишите об этом в комментариях.

Есть ещё такой способ как fast doubling. Работает как и матричное умножение за O(log), но с меньшей константой в асимптотике (и на практике). Если кратко, то там используется две формулы, опираясь на которые можно быстро рекурсивно откатываться к вдвое меньшим индексам:

F2n = Fn * (2*Fn+1 – Fn)
F2n+1 = Fn2 + Fn+12

Реализация, кстати, получается довольно компактная.

Сравнение скорости работы разных методов

just_maksim

Оставить комментарий