Як ця запам'ятована функція запам'ятовується?


114

За яким механізмом запам'ятовується ця вольова функція?

fib = (map fib' [0..] !!)                 
     where fib' 1 = 1                                                        
           fib' 2 = 1                                                        
           fib' n = fib (n-2) + fib (n-1)                    

І на пов'язаній ноті, чому ця версія не є?

fib n = (map fib' [0..] !! n)                                               
     where fib' 1 = 1                                                        
           fib' 2 = 1                                                        
           fib' n = fib (n-2) + fib (n-1)                    

13
Трохи не пов'язане між собою, fib 0не закінчується: ви, мабуть, хочете, щоб базовими справами fib'були fib' 0 = 0і є fib' 1 = 1.
хун

1
Зауважте, що перша версія може бути зроблена більш стислою: fibs = 1:1:zipWith (+) fibs (tail fibs)і fib = (fibs !!).
Бастіан

Відповіді:


95

Механізм оцінювання в Haskell є побічною потребою : коли потрібне значення, воно обчислюється і залишається готовим у випадку, якщо воно буде запропоновано знову. Якщо ми визначимо якийсь список, xs=[0..]а пізніше попросимо його 100-й елемент, xs!!99100-й слот у списку виходить "розробленим", тримаючи номер 99зараз, готовий до наступного доступу.

Саме це і застосовує той трюк, "перегляд списку". У звичайному подвійному рекурсивному визначенні Фібоначчі fib n = fib (n-1) + fib (n-2)сама функція викликається двічі зверху, викликаючи експоненційний вибух. Але з цим фокусом ми склали список проміжних результатів і переходимо "через список":

fib n = (xs!!(n-1)) + (xs!!(n-2)) where xs = 0:1:map fib [2..]

Трюк полягає в тому, щоб змусити цей список створити і змусити цей список не проходити (шляхом вивезення сміття) між дзвінками до fib. Найпростіший спосіб досягти цього - назвати цей список."Якщо ви його назвете, він залишиться".


Ваша перша версія визначає мономорфну ​​константу, а друга визначає поліморфну ​​функцію. Поліморфна функція не може використовувати один і той же внутрішній список для різних типів, які, можливо, знадобляться немає спільного доступу , тобто не запам'ятовування.

З першою версією компілятор великодушно працює з нами, виймаючи цю постійну субэкспресію ( map fib' [0..]) і робить її окремою спільною сукупністю, але робити це не має жодного зобов'язання. і є фактично випадки, коли ми цього не робимо хочемо, щоб це робилося для нас автоматично.

( правка :) Розгляньте ці переписи:

fib1 = f                     fib2 n = f n                 fib3 n = f n          
 where                        where                        where                
  f i = xs !! i                f i = xs !! i                f i = xs !! i       
  xs = map fib' [0..]          xs = map fib' [0..]          xs = map fib' [0..] 
  fib' 1 = 1                   fib' 1 = 1                   fib' 1 = 1          
  fib' 2 = 1                   fib' 2 = 1                   fib' 2 = 1          
  fib' i=fib1(i-2)+fib1(i-1)   fib' i=fib2(i-2)+fib2(i-1)   fib' i=f(i-2)+f(i-1)

Тож справжня історія, здається, стосується вкладених визначень обсягу. Немає зовнішньої області застосування з 1-м визначенням, а 3-я обережна, щоб не викликати зовнішню область fib3, але однорівневуf .

Кожен новий виклик , fib2здається, створює свої вкладені визначення заново , тому що будь-який з них може (теоретично) можна визначити по різному в залежності від значення n(спасибі Вітуса і Тихону для вказуючи , що вихід). З першим визначенням не nслід залежати, а з третім - залежність, але кожен окремий виклик на fib3виклики, в fякий обережно, слід лише визначити виклики з однорівневого діапазону, внутрішнього для цього конкретного виклику fib3, тому те самеxs отримує повторно використані (тобто спільні) для цього викликуfib3 .

Але ніщо не перешкоджає компілятору визнати, що внутрішні визначення будь-якої з наведених версій насправді не залежать від зовнішнього nприв’язки, щоб зрештою здійснити підняття лямбда , що призведе до повного запам'ятовування (за винятком поліморфних визначень). Насправді саме так відбувається з усіма трьома версіями, коли вони оголошені мономорфними типами та компілюються з прапором -O2. Завдяки деклараціям поліморфного типу, fib3демонструє місцевий обмін і fib2взагалі не ділиться.

Зрештою, залежно від використовуваного компілятора та використовуваних оптимізацій компілятора та того, як ви його тестуєте (завантаження файлів у GHCI, складений чи ні, з -O2 чи ні, або окремим), і чи отримує він мономорфний чи поліморфний тип поведінки повністю змінитись - незалежно від того, чи він демонструє локальний (за викликом) спільний доступ (тобто лінійний час на кожен дзвінок), запам'ятовування (тобто лінійний час на перший дзвінок та 0 час на наступні дзвінки з тим самим чи меншим аргументом) або взагалі немає спільного доступу ( експоненційний час).

Коротка відповідь - це справа компілятора. :)


4
Просто , щоб виправити невелику деталь: друга версія не отримує обміну , головним чином тому , що локальна функція fib'переопределяется для кожного nі , таким чином , fib'в fib 1fib'в fib 2, який також має на увазі списки різні. Навіть якщо ви фіксуєте тип мономорфним, він все одно проявляє таку поведінку.
Вітус

1
whereСтатті пропонують поділитися подібно до letвиразів, але вони, як правило, приховують такі проблеми, як ця. Переписавши це трохи виразніше
Вітус

1
Ще один цікавий момент щодо Вашого переписування: якщо Ви надаєте їм мономорфний тип (тобто Int -> Integer), то вони fib2запускаються в експоненціальний час, fib1і fib3обидва працюють в лінійний час, але fib1також запам'ятовуються - знову ж таки тому, що для fib3локальних визначень переосмислюються для кожного n.
Вітус

1
@misterbee Але справді було б непогано мати певну впевненість у компілятора; якийсь контроль над резиденцією пам'яті конкретного об'єкта. Іноді ми хочемо поділитися, іноді хочемо запобігти. Я уявляю / сподіваюся, що це стане можливим ...
Чи буде Несс

1
@ElizaBrandt, що я мав на увазі, що іноді ми хочемо перерахувати щось важке, щоб воно не зберігалося для нас у пам'яті - тобто вартість перерахунку нижче, ніж вартість величезного збереження пам'яті. Одним із прикладів є створення електроживлення: pwr (x:xs) = pwr xs ++ map (x:) pwr xs ; pwr [] = [[]]ми хочемо pwr xsрозраховуватись самостійно, вдвічі, так що це сміття може збиратися на льоту під час його виробництва та споживання.
Чи буде Несс

23

Я не зовсім впевнений, але ось здогадка про здогаду:

Компілятор припускає, що fib nможуть бути різними для різних, nі тому потрібно буде перераховувати список кожного разу. Зрештою, біти в whereоператорі можуть залежати n. Тобто в цьому випадку весь перелік чисел по суті є функцією n.

Версія без n може створити список один раз і перетворити його у функцію. Список не може залежати від значення nпереданих даних, і це легко перевірити. Список - це константа, на яку потім індексується. Звичайно, це константа, яку ліниво оцінюють, тому ваша програма не намагається отримати весь (нескінченний) список негайно. Оскільки це константа, її можна ділити між викликами функцій.

Він запам'ятовується зовсім, тому що рекурсивний виклик просто повинен шукати значення у списку. Оскільки fibверсія створює список один раз ліниво, він просто обчислює достатньо, щоб отримати відповідь, не роблячи зайвих обчислень. Тут "ледачий" означає, що кожен запис у списку є громом (неоцінений вираз). Коли ви робите оцінити перетворювач , вона стає значенням, тому доступ до нього в наступний раз це не повторити обчислення. Оскільки список можна ділити між дзвінками, усі попередні записи вже обчислюються часом, коли вам потрібна наступна.

Це по суті розумна і низька оренда форми динамічного програмування, заснованого на лінивій семантиці GHC. Я думаю, що стандарт вказує лише на те, що він повинен бути не суворим , тому сумісний компілятор потенційно міг би скомпілювати цей код, щоб не запам'ятати. Однак на практиці кожен розумний компілятор буде лінивим.

Для отримання додаткової інформації про те, чому взагалі працює другий випадок, читайте Розуміння рекурсивно визначеного списку (фібр з точки зору zipWith) .


ти мав на увазі " fib' nможе бути іншим по-іншому n", можливо?
Буде Несс

Я думаю, що я був не дуже зрозумілий: я мав на увазі те, що все всередині fib, в тому числі fib', може бути по-різному n. Я думаю, що оригінальний приклад трохи заплутаний, оскількиfib' також залежить від власного, nщо тініє іншого n.
Тіхон Єлвіс

20

По-перше, ghc-7.4.2, складений з -O2не запам’ятовуваною версією, не так вже й поганий, внутрішній список номерів Фібоначчі все ще зберігається для кожного виклику верхнього рівня до функції. Але це не може і розумно не запам'ятовується в різних дзвінках верхнього рівня. Однак для іншої версії список ділиться між дзвінками.

Це пов'язано з обмеженням мономорфізму.

Перша пов'язана простою прив'язкою візерунка (лише ім'я, аргументи немає), тому через обмеження мономорфізму воно повинно набути мономорфного типу. Виведений тип є

fib :: (Num n) => Int -> n

і таке обмеження за замовчуванням (за відсутності декларації за замовчуванням, яке інше говорить) Integer, встановлює тип, як

fib :: Int -> Integer

Таким чином, є лише один список (тип [Integer]) для запам'ятовування.

Другий визначається аргументом функції, таким чином він залишається поліморфним, і якби внутрішні списки запам'ятовувалися через виклики, потрібно було б запам'ятати один список для кожного типу в Num. Це не практично.

Скомпілюйте обидві версії з вимкненим обмеженням мономорфізму або з підписами однакового типу, і обидві проявляють однакову поведінку. (Це було невірно для старих версій компілятора, я не знаю, яка версія вперше це зробила.)


Чому запам’ятовувати список для кожного типу недоцільно? В принципі, чи може GHC створити словник (подібний до виклику функцій, обмежених класом типу), щоб містити частково обчислювані списки для кожного типу Num, зіткнутого під час виконання?
misterbee

1
@misterbee В принципі, це може, але якщо програма запускає fib 1000000багато типів, це з'їдає тонну пам'яті. Щоб цього уникнути, потрібен був би евристик, який перераховує викинути з кеша, коли він зростає занадто великим. І така стратегія запам'ятовування також застосовуватиметься до інших функцій чи значень, імовірно, тому компілятору доведеться мати справу з потенційно великою кількістю речей, які слід запам'ятати для потенційно багатьох типів. Я думаю, що можна було б реалізувати (часткове) поліморфне спогадування з досить хорошою евристикою, але я сумніваюся, що це було б вартим.
Даніель Фішер

5

Вам не потрібна функція запам'ятовування для Haskell. Тільки емпіративна мова програмування потребує цих функцій. Однак Хаскель є функціональним і ...

Отже, це приклад дуже швидкого алгоритму Фібоначчі:

fib = zipWith (+) (0:(1:fib)) (1:fib)

zipWith - функція зі стандартної прелюдії:

zipWith :: (a->b->c) -> [a]->[b]->[c]
zipWith op (n1:val1) (n2:val2) = (n1 + n2) : (zipWith op val1 val2)
zipWith _ _ _ = []

Тест:

print $ take 100 fib

Вихід:

[1,2,3,5,8,13,21,34,55,89,144,233,377,610,987,1597,2584,4181,6765,10946,17711,28657,46368,75025,121393,196418,317811,514229,832040,1346269,2178309,3524578,5702887,9227465,14930352,24157817,39088169,63245986,102334155,165580141,267914296,433494437,701408733,1134903170,1836311903,2971215073,4807526976,7778742049,12586269025,20365011074,32951280099,53316291173,86267571272,139583862445,225851433717,365435296162,591286729879,956722026041,1548008755920,2504730781961,4052739537881,6557470319842,10610209857723,17167680177565,27777890035288,44945570212853,72723460248141,117669030460994,190392490709135,308061521170129,498454011879264,806515533049393,1304969544928657,2111485077978050,3416454622906707,5527939700884757,8944394323791464,14472334024676221,23416728348467685,37889062373143906,61305790721611591,99194853094755497,160500643816367088,259695496911122585,420196140727489673,679891637638612258,1100087778366101931,1779979416004714189,2880067194370816120,4660046610375530309,7540113804746346429,12200160415121876738,19740274219868223167,31940434634990099905,51680708854858323072,83621143489848422977,135301852344706746049,218922995834555169026,354224848179261915075,573147844013817084101]

Час минув: 0.00018


Це рішення приголомшливо!
Ларрі
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.