Làm cách nào để ghi nhớ hàm fibonacci này?


114

Chức năng fibonacci này được ghi nhớ theo cơ chế nào?

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

Và trên một lưu ý liên quan, tại sao không phải là phiên bản này?

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

13
Hơi không liên quan, fib 0không kết thúc: bạn có thể muốn các trường hợp cơ sở fib'fib' 0 = 0fib' 1 = 1.
huon

1
Lưu ý rằng phiên bản đầu tiên có thể ngắn gọn hơn: fibs = 1:1:zipWith (+) fibs (tail fibs)fib = (fibs !!).
Bastian

Câu trả lời:


95

Cơ chế đánh giá trong Haskell là theo yêu cầu : khi cần một giá trị, nó sẽ được tính toán và luôn sẵn sàng trong trường hợp nó được yêu cầu lại. Nếu chúng tôi xác định một số danh sách xs=[0..]và sau đó yêu cầu phần tử thứ 100 của nó, xs!!99thì vị trí thứ 100 trong danh sách sẽ được "bổ sung", giữ số 99ngay bây giờ, sẵn sàng cho lần truy cập tiếp theo.

Đó là những gì mà thủ thuật, "xem qua một danh sách", đang khai thác. Trong định nghĩa Fibonacci kép đệ quy thông thường fib n = fib (n-1) + fib (n-2), bản thân hàm được gọi, hai lần từ đỉnh, gây ra sự bùng nổ theo cấp số nhân. Nhưng với thủ thuật đó, chúng tôi thiết lập một danh sách cho các kết quả tạm thời và đi đến "danh sách":

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

Bí quyết là làm cho danh sách đó được tạo và làm cho danh sách đó không biến mất (bằng cách thu gom rác) giữa các lần gọi tới fib. Cách dễ nhất để đạt được điều này là đặt tên cho danh sách đó. "Nếu bạn đặt tên cho nó, nó sẽ ở lại."


Phiên bản đầu tiên của bạn xác định một hằng số đơn hình và phiên bản thứ hai định nghĩa một hàm đa hình. Một hàm đa hình không thể sử dụng cùng một danh sách nội bộ cho các kiểu khác nhau mà nó có thể cần để phân phát, vì vậy không có chia sẻ , tức là không có ghi nhớ.

Với phiên bản đầu tiên, trình biên dịch đang hào phóng với chúng tôi, loại bỏ biểu thức con không đổi đó ( map fib' [0..]) và biến nó thành một thực thể có thể chia sẻ riêng biệt, nhưng không có nghĩa vụ phải làm như vậy. và thực tế có những trường hợp chúng tôi không muốn nó tự động làm điều đó cho chúng tôi.

( sửa :) Hãy xem xét những lần viết lại này:

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)

Vì vậy, câu chuyện thực sự dường như là về các định nghĩa phạm vi lồng nhau. Không có phạm vi bên ngoài với định nghĩa thứ nhất và định nghĩa thứ 3 cẩn thận không gọi phạm vi bên ngoài fib3mà là cùng cấp f.

Mỗi gọi mới fib2dường như để tạo ra định nghĩa lồng nhau của nó một lần nữa bởi vì ai trong số họ có thể (về mặt lý thuyết) được định nghĩa khác nhau tùy thuộc vào giá trị của n(nhờ Vitus và Tikhon đã chỉ mà ra). Với defintion đầu tiên không có nphụ thuộc vào, và với sự thứ ba có một sự phụ thuộc, nhưng mỗi cuộc gọi riêng biệt để fib3các cuộc gọi vào fmà là cẩn thận để định nghĩa cuộc gọi chỉ từ phạm vi cùng cấp, nội bộ này gọi cụ thể của fib3, vì vậy cùng xsđược được sử dụng lại (tức là được chia sẻ) cho lời gọi đó fib3.

Nhưng không có gì ngăn cản trình biên dịch nhận ra rằng các định nghĩa nội bộ trong bất kỳ phiên bản nào ở trên thực tế là độc lập với nràng buộc bên ngoài , để thực hiện việc nâng lambda , dẫn đến việc ghi nhớ đầy đủ (ngoại trừ các định nghĩa đa hình). Trên thực tế, đó chính xác là những gì xảy ra với cả ba phiên bản khi được khai báo với các kiểu đơn hình và được biên dịch với cờ -O2. Với khai báo kiểu đa hình, hiển thị fib3chia sẻ cục bộ và fib2không chia sẻ gì cả.

Cuối cùng, tùy thuộc vào trình biên dịch và tối ưu hóa trình biên dịch được sử dụng và cách bạn kiểm tra nó (tải tệp trong GHCI, được biên dịch hay không, có -O2 hay không, hoặc độc lập) và liệu nó nhận được kiểu đơn hình hay đa hình mà hành vi có thể thay đổi hoàn toàn - cho dù nó thể hiện chia sẻ cục bộ (mỗi cuộc gọi) (tức là thời gian tuyến tính trên mỗi cuộc gọi), ghi nhớ (tức là thời gian tuyến tính trong cuộc gọi đầu tiên và 0 thời gian trong các cuộc gọi tiếp theo với cùng một đối số hoặc nhỏ hơn), hay hoàn toàn không chia sẻ ( thời gian theo cấp số nhân).

Câu trả lời ngắn gọn là, đó là một thứ của trình biên dịch. :)


4
Chỉ cần sửa một chi tiết nhỏ: phiên bản thứ hai không nhận được bất kỳ chia sẻ nào chủ yếu là do hàm cục bộ fib'được xác định lại cho mọi nvà do đó fib'trong fib 1fib'in fib 2, điều này cũng ngụ ý các danh sách khác nhau. Ngay cả khi bạn sửa kiểu thành đơn hình, nó vẫn thể hiện hành vi này.
Vitus

1
wherecác mệnh đề giới thiệu sự chia sẻ giống như các letbiểu thức, nhưng chúng có xu hướng ẩn các vấn đề như câu này. Viết lại nó một chút một cách rõ ràng hơn, bạn có được điều này: hpaste.org/71406
Vitus

1
Một điểm thú vị khác về cách viết lại của bạn: nếu bạn cung cấp cho chúng kiểu đơn hình (tức là Int -> Integer), sau đó fib2chạy theo thời gian hàm mũ fib1fib3cả hai đều chạy theo thời gian tuyến tính nhưng fib1cũng được ghi nhớ - một lần nữa vì đối với fib3các định nghĩa cục bộ được xác định lại cho mọi n.
Vitus

1
@misterbee Nhưng thực sự sẽ rất tuyệt nếu có một số loại đảm bảo từ trình biên dịch; một số loại kiểm soát đối với vùng cư trú bộ nhớ của một thực thể cụ thể. Đôi khi chúng ta muốn chia sẻ, đôi khi chúng ta muốn ngăn cản nó. Tôi tưởng tượng / hy vọng nó sẽ có thể ...
Will Ness

1
@ElizaBrandt ý của tôi là đôi khi chúng ta muốn tính toán lại một thứ gì đó nặng để nó không được lưu lại cho chúng ta trong bộ nhớ - tức là chi phí tính toán lại thấp hơn chi phí lưu giữ bộ nhớ khổng lồ. một ví dụ là việc tạo tập hợp quyền hạn: trong pwr (x:xs) = pwr xs ++ map (x:) pwr xs ; pwr [] = [[]]chúng tôi muốn pwr xsđược tính toán một cách độc lập, hai lần, vì vậy nó có thể được thu gom ngay khi nó đang được sản xuất và tiêu thụ.
Will Ness

23

Tôi không hoàn toàn chắc chắn, nhưng đây là một phỏng đoán có học thức:

Trình biên dịch giả định rằng fib ncó thể khác với một khác nvà do đó sẽ cần phải tính toán lại danh sách mỗi lần. Rốt cuộc, các bit bên trong wherecâu lệnh có thể phụ thuộc vào n. Nghĩa là, trong trường hợp này, toàn bộ danh sách các số về cơ bản là một hàm của n.

Phiên bản không n có có thể tạo danh sách một lần và gói nó trong một hàm. Danh sách không thể phụ thuộc vào giá trị nđược chuyển vào và điều này rất dễ xác minh. Danh sách là một hằng số sau đó được lập chỉ mục vào. Tất nhiên, nó là một hằng số được đánh giá một cách lười biếng, vì vậy chương trình của bạn không cố gắng lấy toàn bộ danh sách (vô hạn) ngay lập tức. Vì nó là một hằng số, nó có thể được chia sẻ trên các lệnh gọi hàm.

Nó được ghi nhớ hoàn toàn vì cuộc gọi đệ quy chỉ phải tìm kiếm một giá trị trong danh sách. Vì fibphiên bản tạo danh sách một lần một cách lười biếng, nó chỉ tính toán đủ để nhận được câu trả lời mà không thực hiện phép tính thừa. Ở đây, "lazy" có nghĩa là mỗi mục nhập trong danh sách là một cú đánh (một biểu thức không được đánh giá). Khi bạn làm đánh giá thunk, nó trở thành một giá trị, vì vậy việc tiếp cận nó lần sau không không lặp lại các tính toán. Vì danh sách có thể được chia sẻ giữa các cuộc gọi, tất cả các mục nhập trước đó đã được tính toán cho thời gian bạn cần đến mục tiếp theo.

Về cơ bản, nó là một dạng lập trình động thông minh và có chi phí thấp dựa trên ngữ nghĩa lười biếng của GHC. Tôi nghĩ rằng tiêu chuẩn chỉ xác định rằng nó phải không nghiêm ngặt , vì vậy một trình biên dịch tuân thủ có thể có khả năng biên dịch mã này để không ghi nhớ. Tuy nhiên, trong thực tế, mọi trình biên dịch hợp lý sẽ trở nên lười biếng.

Để biết thêm thông tin về lý do tại sao trường hợp thứ hai hoạt động, hãy đọc Tìm hiểu danh sách được xác định đệ quy (fibs theo zipWith) .


ý của bạn là " fib' ncó thể khác trên một cái khác n"?
Will Ness

Tôi nghĩ rằng tôi đã không rõ ràng lắm: ý tôi là mọi thứ bên trong fib, bao gồm cả fib', đều có thể khác nhau n. Tôi nghĩ rằng ví dụ ban đầu là một chút khó hiểu vì fib'cũng phụ thuộc vào chính nnó mà đổ bóng cho cái kia n.
Tikhon Jelvis

20

Đầu tiên, với ghc-7.4.2, được biên dịch với -O2, phiên bản không ghi nhớ không quá tệ, danh sách nội bộ các số Fibonacci vẫn được ghi nhớ cho mỗi lệnh gọi cấp cao nhất đến hàm. Nhưng nó không, và không thể hợp lý, được ghi nhớ qua các cuộc gọi cấp cao nhất khác nhau. Tuy nhiên, đối với phiên bản khác, danh sách được chia sẻ giữa các cuộc gọi.

Đó là do hạn chế đơn hình.

Đầu tiên được ràng buộc bởi một liên kết mẫu đơn giản (chỉ có tên, không có đối số), do đó theo giới hạn đơn hình, nó phải nhận được một kiểu đơn hình. Loại suy ra là

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

và một ràng buộc như vậy được đặt mặc định (trong trường hợp không có khai báo mặc định nói cách khác) Integer, sửa kiểu là

fib :: Int -> Integer

Vì vậy, chỉ có một danh sách (loại [Integer]) để ghi nhớ.

Danh sách thứ hai được xác định với một đối số hàm, do đó nó vẫn đa hình và nếu các danh sách bên trong được ghi nhớ qua các cuộc gọi, một danh sách sẽ phải được ghi nhớ cho mỗi kiểu trong Num. Điều đó không thực tế.

Biên dịch cả hai phiên bản với hạn chế đơn hình bị vô hiệu hóa hoặc với các chữ ký kiểu giống hệt nhau và cả hai đều thể hiện hành vi giống hệt nhau. (Điều đó không đúng với các phiên bản trình biên dịch cũ hơn, tôi không biết phiên bản nào đã làm điều đó đầu tiên.)


Tại sao việc ghi nhớ một danh sách cho từng loại là không thực tế? Về nguyên tắc, GHC có thể tạo một từ điển (giống như cách gọi các hàm ràng buộc lớp kiểu) để chứa danh sách được tính toán một phần cho mỗi lần gặp kiểu Num trong thời gian chạy không?
misterbee

1
@misterbee Về nguyên tắc, nó có thể, nhưng nếu chương trình gọi fib 1000000nhiều kiểu, điều đó sẽ ngốn rất nhiều bộ nhớ. Để tránh điều đó, người ta sẽ cần một heuristic liệt kê danh sách để loại bỏ bộ nhớ cache khi nó phát triển quá lớn. Và chiến lược ghi nhớ như vậy có lẽ cũng sẽ áp dụng cho các hàm hoặc giá trị khác, vì vậy trình biên dịch sẽ phải xử lý một số lượng lớn những thứ có thể xảy ra để ghi nhớ cho nhiều kiểu tiềm năng. Tôi nghĩ rằng có thể thực hiện phép ghi nhớ đa hình (một phần) với phương pháp heuristic hợp lý tốt, nhưng tôi nghi ngờ nó sẽ đáng giá.
Daniel Fischer

5

Bạn không cần chức năng ghi nhớ cho Haskell. Chỉ ngôn ngữ lập trình thực nghiệm mới cần các chức năng đó. Tuy nhiên, Haskel là chức năng lang ...

Vì vậy, đây là ví dụ về thuật toán Fibonacci rất nhanh:

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

zipWith là chức năng từ Prelude tiêu chuẩn:

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

Kiểm tra:

print $ take 100 fib

Đầu ra:

[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]

Thời gian trôi qua: 0,00018s


Giải pháp này là tuyệt vời!
Larry
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.