Почему эта реализация Фибоначчи чрезвычайно быстро? - PullRequest
0 голосов
/ 23 февраля 2019

Эта реализация Фибоначчи проста для понимания, но очень медленная:

fib 0 = 0
fib 1 = 1
fib n = fib (n-1) + fib (n-2)

Следующая реализация Фибоначчи трудна для понимания, но очень быстрая.Он вычисляет 100 000-е число Фибоначчи мгновенно на моем ноутбуке.

fib = fastFib 1 1

fastFib _ _ 0 = 0
fastFib _ _ 1 = 1
fastFib _ _ 2 = 1
fastFib a b 3 = a + b
fastFib a b c = fastFib (a + b) a (c - 1)

Что за чудо происходит здесь с последней реализацией и как она работает?

Ответы [ 4 ]

0 голосов
/ 24 февраля 2019

Это просто запутывание, чтобы скрыть тот факт, что введенный номер используется в качестве счетчика.Я надеюсь, что если бы вы увидели нечто подобное, вы бы поняли, почему:

fib2 n = fastFib2 0 1 0 n

fastFib2 current previous count 0 = 0
fastFib2 current previous count 1 = 1
fastFib2 current previous count n
  | count == n = current
  | otherwise  =
     fastFib2 (current + previous) current (count + 1) n

В приведенном выше коде мы сделали счетчик явным: когда он равен нашему вводу, n,возвращаем наш аккумулятор, current;в противном случае мы отслеживаем эту «прямую» рекурсию текущего и предыдущего чисел (« два предшествующих »), все, что необходимо для построения последовательности Фибоначчи.

КодВы поделились, делает то же самое.(c - 1) делает его похожим на более традиционное «обратное» повторение, когда он фактически запускает аккумулятор при первом вызове, а затем добавляет к нему.

0 голосов
/ 24 февраля 2019

Почему первая реализация медленная?

Что ж, она медленная, потому что каждый вызов fib может привести к двум (в среднем более 1,6) вызовам fib, так что для вычисления fib 5 вы вызываете fib 4 и fib 3, которые соответственно вызывают fib 3 и fib 2, fib 2 и fib 1, поэтому мы видим, что каждый вызов fib (n+1) приводит к чему-то вдвое большему количеству работыкак вызов fib n.

Одна вещь, которую мы могли бы заметить, состоит в том, что мы выполняем одно и то же много раз, например, выше мы работаем fib 3 дваждыЭто может занять много времени, если вам придется потренироваться, например, fib 100 дважды.

Как сделать выдумку быстрее?

Я думаю, что лучше начать с этого, чем пытаться прыгнуть прямо вfastFib.Если бы я попросил вас вычислить десятое число Фибоначчи вручную, я ожидаю, что вы не будете вычислять третье число десятки раз, применяя алгоритм.Возможно, вы помните, что у вас было до сих пор.Действительно, это можно сделать в Хаскеле.Просто напишите программу для генерации списка чисел Фибоначчи (лениво) и внесите в него индекс:

mediumFib = (\n -> seq !! n) where
  seq = 0:1:[mediumFib (i-1) + mediumFib (i-2)| i <- [2..]]

Это намного быстрее, но это плохо, потому что он использует много памяти для хранения списка Фибоначчичисла, и найти n-й элемент списка медленно, потому что вы должны следовать многим указателям.

Чтобы вычислить одно число Фибоначчи с нуля (то есть, не вычисляя его уже), требуется квадратичное время.

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

fib(n):
  if (n<2) return n
  preprevious = 0
  previous = 1
  i = 2
  while true:
    current = preprevious + previous
    if (i = n) return current
    preprevious, previous = previous, current

Это просто пошаговое выполнение отношения повторения:

f_n = f_(n-2) + f_(n-1)

Действительно, мы можем записать его и на Haskell:

fastFib n | n < 2     = n
          | otherwise = go 0 1 2 where
  go pp p i | i = n     = pp + p
            | otherwise = go p (pp + p) (i + 1)

Теперь это довольно быстро, и мы можем преобразовать это в функцию, которая у вас тоже есть.Вот шаги:

  1. Поменяйте местами порядок аргументов pp (предварительный) и p (предыдущий)
  2. Вместо того, чтобы считать i, начните с n и обратный отсчет.
  3. Извлечение go в функцию верхнего уровня, поскольку она больше не зависит от n.

Этот алгоритм должен делать только одну сумму на каждом шагетак что это линейное время, и это довольно быстро.Вычисление fib (n+1) - это лишь небольшая постоянная дополнительная работа, чем вычисление fib n.Сравните это с тем, что было примерно в 1,6 раза больше.

Есть ли быстрее fib?

Конечно, есть.Оказывается, есть умный способ выразить последовательность Фибоначчи.Мы считаем преобразование a,b -> a+b,a частным случаем семейства преобразований T_pq:

T_pq : a -> bq + aq + ap
       b -> bp + aq

В частности, это особый случай, когда p = 0 и q = 1.Теперь мы можем выполнить некоторую алгебру, если есть простой способ выразить применение T_pq дважды:

T_pq T_pq :
  a -> (bp + aq)q + (bq + aq + ap)(q + p)
  b -> (bp + aq)p + (bq + aq + ap)q
=
  a -> (b + a)(q^2 + 2pq) + a(q^2 + p^2)
  b -> b(q^2 + p^2) + a(q^2 + 2pq)
= T_(q^2 + p^2),(q^2 + 2pq)

Так что теперь давайте напишем простую функцию для вычисления T_pq^n (a,b) и fib n

tPow p q a b n | n = 1 = (b*q + a*q + a*p, b*p + a*q)
               | otherwise = let (a', b') = tPow p q a b 1 in tPow p q a' b' (n-1)

fib 0 = 0
fib 1 = 1
fib n = fst $ tPow 0 1 1 0 (n-1)

И теперь мы можем использовать наше отношение, чтобы сделать tPow быстрее:

tPow p q a b n | n = 1 = (b*q + a*q + a*p, b*p + a*q)
               | odd n = let (a', b') = tPow p q a b 1 in tPow p q a' b' (n-1)
               | even n = tPow (q*q + p*p) (q*q + 2*p*q) a b (n `div` 2)

Почему это быстрее?Что ж, это быстрее, потому что тогда вычисление fib (2*n) - это всего лишь небольшая постоянная дополнительная работа, чем вычисление fib n, тогда как раньше это было вдвое больше работы, а до этого было в четыре раза больше работы, а до этого это было квадратом суммы.работы.На самом деле количество шагов - это что-то вроде количества бит n в двоичном формате плюс число 1 с в двоичном представлении n.Для вычисления fib 1024 требуется всего около 10 шагов, тогда как предыдущий алгоритм занимал около 1000. Вычисление миллиардного числа Фибоначчи занимает всего 30 шагов, что намного меньше миллиарда.

0 голосов
/ 24 февраля 2019

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

fib1 n = slow n id
  where
    slow 0 k = k 0
    slow 1 k = k 1
    slow n k = slow (n - 1) (\a ->
               slow (n - 2) (\b ->
               k (a + b)))

fib2 n = fast n 0 1
  where
    fast 0 a _ = a
    fast n a b = fast (n - 1) b (a + b)

Влияние на крошечные числа, такие как n = 10, незначительно -

fib1 10
-- 55
-- (0.01 secs, 138,264 bytes)

fib2 10
-- 55
-- (0.01 secs, 71,440 bytes)

Но даже около n = 20мы замечаем огромный спад в производительности fib1 -

fib1 20
-- 6765
-- (0.70 secs, 8,787,320 bytes)

fib2 20
-- 6765
-- (0.01 secs, 76,192 bytes)

При n = 30 воздействие смехотворно.Обе программы все еще достигают одного и того же результата, так что это хорошо, но fib1 занимает более 30 секунд.fib2 по-прежнему занимает доли секунды -

fib1 30
-- 832040
-- (32.91 secs, 1,072,371,488 bytes) LOL so bad

fib2 30
-- 832040 (0.09 secs, 80,944 bytes)

Причина этого в том, что первая программа, fib1, делает два рекурсивных вызова.Процесс для этой функции использует экспоненциальное время и пространство по мере роста n.При n = 30 медленная программа будет совершать 1 073 741 824 (2 30 ) рекурсивных вызовов.Быстрая программа будет повторяться только 30 раз.

При n = 1000 у нас возникает серьезная проблема с fib1.Исходя из производительности fib1 30, мы полагаем, что для завершения 2 1000 рекурсивных вызовов потребуется 1.041082353242204e286 лет .Между тем, fib2 1000 обрабатывает 1000 рекурсий без усилий -

fib2 1000
-- 43466557686937456435688527675040625802564660517371780402481729089536555417949051890403879840079255169295922593080322634775209689623239873322471161642996440906533187938298969649928516003704476137795166849228875
-- (0.13 secs, 661,016 bytes)

Исходное переписывание вашей первой программы может быть затруднено с добавленным параметром k.Использование Cont позволяет нам увидеть четкую последовательность шагов в знакомой нотации Хаскелла do -

import Control.Monad.Cont

fib1 n = runCont (slow n) id
  where
    slow 0 = return 0
    slow 1 = return 1
    slow n = do
      a <- slow (n - 1)
      b <- slow (n - 2)
      return (a + b)
0 голосов
/ 23 февраля 2019

Магия - это отражение, уменьшение, объяснение вычислительного процесса, описываемого рекурсивной формулой:

fib 0 = 0    -- NB!
fib 1 = 1
fib n = fib (n-1) + fib (n-2)
      --  n1          n2
      = let {n1 = fib (n-1) ; n2 = fib (n-2)} 
        in n1 + n2
      = let {n1 = fib (n-2) + fib (n-3) ; n2 = fib (n-2)} 
      --            n2          n3
        in n1 + n2
      = let {n1 = n2+n3 ; n2 = fib (n-2) ; n3 = fib (n-3)} 
        in n1 + n2
      = let {n1 = n2+n3 ; n2 = fib (n-3) + fib (n-4) ; n3 = fib (n-3)} 
      --                         n3          n4
        in n1 + n2
      = let {n1 = n2+n3 ; n2 = n3+n4 ; n3 = fib (n-3) ; n4 = fib (n-4)} 
        in n1 + n2
      = let {n1 = n2+n3 ; n2 = n3+n4 ; n3 = n4+n5 ; n4 = fib (n-4) ; n5 = fib (n-5)} 
        in n1 + n2
      = .......

, доведение до конца (ей) случая, затем перелистывание стрелки времени (или просто читая его справа налево) и кодируя явно то, что неявно происходит внутри let как часть имитируемой операции "стек вызовов" рекурсии.

Наиболее важно заменить равные на равные, то есть ссылочную прозрачность - используя n2 вместо каждый вид fib (n-2) и т. Д.

...