Làm thế nào để nâng cao hiệu quả với lập trình chức năng?


20

Gần đây tôi đã trải qua Hướng dẫn tìm hiểu về bạn rất tốt và vì thực tế tôi muốn giải quyết vấn đề Project Euler 5 với nó, trong đó chỉ rõ:

Số dương nhỏ nhất chia hết cho tất cả các số từ 1 đến 20 là bao nhiêu?

Trước tiên tôi quyết định viết một hàm xác định xem một số đã cho có chia hết cho các số này không:

divisable x = all (\y -> x `mod` y == 0)[1..20]

Sau đó, tôi tính toán nhỏ nhất bằng cách sử dụng head:

sm = head [x | x <- [1..], divisable x]

Và cuối cùng đã viết dòng để hiển thị kết quả:

main = putStrLn $ show $ sm

Thật không may, điều này mất khoảng 30 giây để hoàn thành. Làm điều tương tự với các số từ 1 đến 10 mang lại kết quả gần như ngay lập tức, nhưng sau đó, kết quả lại nhỏ hơn nhiều so với giải pháp cho 1 đến 20.

Tôi đã giải quyết nó sớm hơn trong C và kết quả từ 1 đến 20 cũng được tính gần như ngay lập tức. Điều này khiến tôi tin rằng tôi đang hiểu sai cách giải thích vấn đề này cho Haskell. Tôi đã xem qua các giải pháp của người khác và thấy điều này:

main = putStrLn $ show $ foldl1 lcm [1..20]

Đủ công bằng, điều này sử dụng một chức năng tích hợp, nhưng tại sao kết quả cuối cùng lại chậm hơn rất nhiều khi tự làm điều đó? Các hướng dẫn ngoài kia cho bạn biết cách sử dụng Haskell, nhưng tôi không thấy nhiều trợ giúp với việc chuyển đổi thuật toán thành mã nhanh.


6
Tôi nên chỉ ra rằng nhiều vấn đề Euler đã giải quyết có pdf bên cạnh chúng để giải quyết vấn đề toán học. Bạn có thể thử đọc pdf đó và thực hiện thuật toán được mô tả trong từng ngôn ngữ và sau đó lập hồ sơ đó.

Câu trả lời:


25

Trước tiên, bạn cần chắc chắn rằng bạn có một nhị phân được tối ưu hóa, trước khi nghĩ rằng ngôn ngữ là vấn đề. Đọc chương Profiling và tối ưu hóa trong Real Wolrd Haskell. Điều đáng chú ý là trong hầu hết các trường hợp, bản chất cấp cao của ngôn ngữ khiến bạn mất ít nhất một phần hiệu suất.

Tuy nhiên, lưu ý rằng giải pháp khác không nhanh hơn vì nó sử dụng hàm tích hợp, mà đơn giản là vì nó sử dụng thuật toán nhanh hơn nhiều : để tìm bội số chung nhỏ nhất của một tập hợp số bạn chỉ cần tìm một vài GCD. So sánh điều này với giải pháp của bạn, mà chu kỳ thông qua tất cả các số từ 1 đến foldl lcm [1..20]. Nếu bạn thử với 30, sự khác biệt giữa thời gian chạy sẽ còn lớn hơn.

Hãy xem sự phức tạp: thuật toán của bạn có O(ans*N)thời gian chạy, đâu anslà câu trả lời và Nlà con số mà bạn đang kiểm tra tính phân chia (20 trong trường hợp của bạn). Tuy nhiên ,
thuật toán khác thực thi Nlần và GCD có độ phức tạp . Do đó thuật toán thứ hai có độ phức tạp . Bạn có thể đánh giá cho mình cái nào nhanh hơn.lcmlcm(a,b) = a*b/gcd(a,b)O(log(max(a,b)))O(N*log(ans))

Vì vậy, để tóm tắt:
Vấn đề của bạn là thuật toán của bạn, không phải ngôn ngữ.

Lưu ý rằng có những ngôn ngữ chuyên ngành vừa có chức năng vừa tập trung vào các chương trình nặng về toán học, như Mathematica, đối với các bài toán tập trung vào toán học có lẽ nhanh hơn hầu hết mọi thứ khác. Nó có một thư viện các chức năng rất được tối ưu hóa, và nó hỗ trợ mô hình chức năng (phải thừa nhận rằng nó cũng hỗ trợ lập trình bắt buộc).


3
Gần đây tôi đã gặp vấn đề về hiệu năng với chương trình Haskell và sau đó tôi nhận ra mình đã biên dịch với việc tối ưu hóa bị tắt. Chuyển đổi tối ưu hóa hiệu suất tăng khoảng 10 lần. Vì vậy, cùng một chương trình được viết bằng C vẫn nhanh hơn, nhưng Haskell không chậm hơn nhiều (chậm hơn khoảng 2, 3 lần, mà tôi nghĩ là hiệu suất tốt, cũng xem xét tôi đã không cố gắng cải thiện mã Haskell nữa). Tóm lại: hồ sơ và tối ưu hóa là một gợi ý tốt. +1
Giorgio

3
Thành thật nghĩ rằng bạn có thể loại bỏ hai đoạn đầu tiên, họ không thực sự trả lời câu hỏi và có lẽ không chính xác (họ chắc chắn chơi nhanh và lỏng lẻo với thuật ngữ, ngôn ngữ không thể có tốc độ)
jk.

1
Bạn đang đưa ra một câu trả lời mâu thuẫn. Một mặt, bạn khẳng định OP "không hiểu lầm gì cả" và rằng sự chậm chạp vốn có ở Haskell. Mặt khác, bạn cho thấy sự lựa chọn của thuật toán có vấn đề! Câu trả lời của bạn sẽ tốt hơn nhiều nếu nó bỏ qua hai đoạn đầu tiên, điều này hơi mâu thuẫn với phần còn lại của câu trả lời.
Andres F.

2
Lấy thông tin phản hồi từ Andres F. và jk. Tôi đã quyết định giảm hai đoạn đầu tiên xuống một vài câu. Cảm ơn các ý kiến
K.Steff

5

Suy nghĩ đầu tiên của tôi là chỉ các số chia hết cho tất cả các số nguyên tố <= 20 sẽ chia hết cho tất cả các số nhỏ hơn 20. Vì vậy, bạn chỉ cần xem xét các số là bội số của 2 * 3 * 5 * 7 * 11 * 13 * 17 * 19 . Một giải pháp như vậy kiểm tra 1 / 9.699.690 số lượng nhiều như cách tiếp cận vũ phu. Nhưng giải pháp Haskell nhanh của bạn làm tốt hơn thế.

Nếu tôi hiểu giải pháp "Haskell nhanh", nó sử dụng Foldl1 để áp dụng hàm lcm (bội số chung nhỏ nhất) cho danh sách các số từ 1 đến 20. Vì vậy, nó sẽ áp dụng lcm 1 2, mang lại 2. Sau đó lcm 2 3 mang lại 6 Sau đó lcm 6 4 mang lại 12, và cứ thế. Theo cách này, hàm lcm chỉ được gọi 19 lần để mang lại câu trả lời của bạn. Trong ký hiệu Big O, đó là các hoạt động O (n-1) để đi đến một giải pháp.

Giải pháp Haskell chậm của bạn đi qua các số từ 1-20 cho mỗi số từ 1 đến giải pháp của bạn. Nếu chúng ta gọi giải pháp s, thì giải pháp Haskell chậm thực hiện các hoạt động O (s * n). Chúng tôi đã biết rằng s là hơn 9 triệu, vì vậy có lẽ giải thích sự chậm chạp. Ngay cả khi tất cả các phím tắt và nhận được trung bình một nửa trong danh sách các số 1-20, thì đó vẫn chỉ là O (s * n / 2).

Gọi điện headkhông cứu bạn khỏi việc thực hiện các tính toán này, chúng phải được thực hiện để tính toán giải pháp đầu tiên.

Cảm ơn, đây là một câu hỏi thú vị. Nó thực sự kéo dài kiến ​​thức Haskell của tôi. Tôi hoàn toàn không thể trả lời nếu tôi không nghiên cứu thuật toán vào mùa thu năm ngoái.


Trên thực tế, cách tiếp cận bạn đang thực hiện với 2 * 3 * 5 * 7 * 11 * 13 * 17 * 19 có lẽ ít nhất là nhanh như giải pháp dựa trên lcm. Những gì bạn đặc biệt cần là 2 ^ 4 * 3 ^ 2 * 5 * 7 * 11 * 13 * 17 * 19. Bởi vì 2 ^ 4 là sức mạnh lớn nhất của 2 nhỏ hơn hoặc bằng 20, và 3 ^ 2 là sức mạnh lớn nhất 3 nhỏ hơn hoặc bằng 20, v.v.
dấu chấm phẩy

@semicolon Mặc dù chắc chắn nhanh hơn các lựa chọn thay thế khác được thảo luận, phương pháp này cũng yêu cầu một danh sách các số nguyên tố được tính toán trước, nhỏ hơn tham số đầu vào. Nếu chúng ta xác định rằng trong thời gian chạy (và quan trọng hơn là trong dấu chân bộ nhớ), cách tiếp cận này không may trở nên ít hấp dẫn hơn
K.Steff

@ K.Steff Bạn đang đùa tôi à ... bạn phải tính toán các số nguyên tố cho đến 19 ... chỉ mất một phần rất nhỏ của một giây. Tuyên bố của bạn hoàn toàn có ý nghĩa KHÔNG, tổng thời gian chạy theo cách tiếp cận của tôi là cực kỳ nhỏ ngay cả với thế hệ chính. Tôi kích hoạt hồ sơ và cách tiếp cận của tôi (trong Haskell) có total time = 0.00 secs (0 ticks @ 1000 us, 1 processor)total alloc = 51,504 bytes. Thời gian chạy là một tỷ lệ đủ không đáng kể của một giây để thậm chí không đăng ký trên trình hồ sơ.
dấu chấm phẩy

@semicolon Tôi nên có đủ điều kiện nhận xét của tôi, xin lỗi về nó. Tuyên bố của tôi liên quan đến giá ẩn của việc tính toán tất cả các số nguyên tố lên đến N - Eratosthenes ngây thơ là các hoạt động O (N * log (N) * log (log (N))) và bộ nhớ O (N) có nghĩa là đây là lần đầu tiên thành phần của thuật toán sẽ hết bộ nhớ hoặc thời gian nếu N thực sự lớn. Nó không trở nên tốt hơn nhiều với rây Atkin, vì vậy tôi đã kết luận thuật toán sẽ kém hấp dẫn hơn foldl lcm [1..N], cần một số lượng lớn các liên kết.
K.Steff

@ K.Steff Vâng, tôi vừa thử nghiệm cả hai thuật toán. Đối với thuật toán dựa trên nguyên tố của tôi, trình lược tả đã cho tôi (với n = 100.000): total time = 0.04 secstotal alloc = 108,327,328 bytes. Đối với thuật toán dựa trên lcm khác, trình lược tả đã cho tôi: total time = 0.67 secstotal alloc = 1,975,550,160 bytes. Với n = 1.000.000 tôi nhận được dựa trên số nguyên tố: total time = 1.21 secstotal alloc = 8,846,768,456 bytes, và dựa trên lcm: total time = 61.12 secstotal alloc = 200,846,380,808 bytes. Vì vậy, nói cách khác, bạn đã sai, dựa trên nguyên tố tốt hơn nhiều.
dấu chấm phẩy

1

Ban đầu tôi không định viết một câu trả lời. Nhưng tôi đã được thông báo sau khi một người dùng khác đưa ra tuyên bố kỳ lạ rằng chỉ cần nhân các số nguyên tố cặp đầu tiên sẽ tốn kém hơn về mặt tính toán sau đó liên tục áp dụng lcm. Vì vậy, đây là hai thuật toán và một số điểm chuẩn:

Thuật toán của tôi:

Thuật toán tạo tướng, cho tôi một danh sách vô hạn các số nguyên tố.

isPrime :: Int -> Bool
isPrime 1 = False
isPrime n = all ((/= 0) . mod n) (takeWhile ((<= n) . (^ 2)) primes)

toPrime :: Int -> Int
toPrime n 
    | isPrime n = n 
    | otherwise = toPrime (n + 1)

primes :: [Int]
primes = 2 : map (toPrime . (+ 1)) primes

Bây giờ sử dụng danh sách nguyên tố đó để tính kết quả cho một số N:

solvePrime :: Integer -> Integer
solvePrime n = foldl' (*) 1 $ takeWhile (<= n) (fromIntegral <$> primes)

Bây giờ thuật toán dựa trên lcm khác, được thừa nhận là khá ngắn gọn, chủ yếu là do tôi đã triển khai thế hệ chính từ đầu (và không sử dụng thuật toán hiểu danh sách siêu súc tích do hiệu suất kém) trong khi lcmchỉ được nhập từ Prelude.

solveLcm :: Integer -> Integer
solveLcm n = foldl' (flip lcm) 1 [2 .. n]
-- Much slower without `flip` on `lcm`

Bây giờ đối với điểm chuẩn, mã tôi sử dụng cho mỗi mã rất đơn giản: ( -prof -fprof-auto -O2sau đó +RTS -p)

main :: IO ()
main = print $ solvePrime n
-- OR
main = print $ solveLcm n

Đối với n = 100,000, solvePrime:

total time = 0.04 secs
total alloc = 108,327,328 bytes

vs solveLcm:

total time = 0.12 secs
total alloc = 117,842,152 bytes

Đối với n = 1,000,000, solvePrime:

total time = 1.21 secs
total alloc = 8,846,768,456 bytes

vs solveLcm:

total time = 9.10 secs
total alloc = 8,963,508,416 bytes

Đối với n = 3,000,000, solvePrime:

total time = 8.99 secs
total alloc = 74,790,070,088 bytes

vs solveLcm:

total time = 86.42 secs
total alloc = 75,145,302,416 bytes

Tôi nghĩ rằng kết quả nói cho chính họ.

Trình lược tả biểu thị thế hệ nguyên tố chiếm tỷ lệ phần trăm nhỏ hơn và nhỏ hơn của thời gian chạy khi ntăng. Vì vậy, nó không phải là nút cổ chai, vì vậy chúng ta có thể bỏ qua nó ngay bây giờ.

Điều này có nghĩa là chúng tôi thực sự so sánh việc gọi lcmnơi một đối số đi từ 1 đến nvà đối số khác đi theo hình học từ 1 đến ans. Để gọi *với tình huống tương tự và lợi ích gia tăng của việc bỏ qua mọi số không phải là số nguyên tố (không có triệu chứng miễn phí, do tính chất đắt hơn của *).

Và nó được biết đến *là nhanh hơn lcm, như lcmyêu cầu các ứng dụng lặp đi lặp lại mod, và modchậm hơn bất thường ( O(n^2)vs ~O(n^1.5)).

Vì vậy, các kết quả trên và phân tích thuật toán ngắn gọn sẽ làm cho nó rất rõ ràng thuật toán nào nhanh hơn.

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.