จะปรับปรุงประสิทธิภาพด้วยการเขียนโปรแกรมฟังก์ชั่นได้อย่างไร?


20

ฉันเพิ่งจะได้รับการเรียนรู้ Haskell สำหรับคำแนะนำที่ดีมากและในทางปฏิบัติฉันต้องการที่จะแก้ปัญหาProject Euler ปัญหา 5ด้วยซึ่งระบุ:

จำนวนบวกที่เล็กที่สุดคืออะไรหารด้วยจำนวนทั้งหมดตั้งแต่ 1 ถึง 20 อย่างเท่าเทียมกัน?

ฉันตัดสินใจที่จะเขียนฟังก์ชั่นก่อนว่าจำนวนที่กำหนดนั้นสามารถหารด้วยตัวเลขเหล่านี้ได้หรือไม่:

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

จากนั้นฉันคำนวณขนาดเล็กที่สุดโดยใช้head:

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

และในที่สุดก็เขียนบรรทัดเพื่อแสดงผลลัพธ์:

main = putStrLn $ show $ sm

น่าเสียดายที่ใช้เวลาประมาณ 30 วินาทีจึงจะเสร็จ การทำสิ่งเดียวกันกับตัวเลข 1 ถึง 10 ให้ผลลัพธ์เกือบจะในทันที แต่แล้วผลลัพธ์ก็จะเล็กกว่าโซลูชันสำหรับ 1 ถึง 20

ฉันแก้ไขมันก่อนหน้านี้ใน C และผลลัพธ์สำหรับ 1 ถึง 20 ก็ถูกคำนวณเกือบจะทันที สิ่งนี้ทำให้ฉันเชื่อว่าฉันเข้าใจผิดว่าจะตีความปัญหานี้ให้กับ Haskell ได้อย่างไร ฉันดูวิธีแก้ไขปัญหาของผู้อื่นและพบสิ่งนี้:

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

ยุติธรรมเพียงพอนี่ใช้ฟังก์ชั่นในตัว แต่เหตุใดผลลัพธ์ที่ได้จึงช้าลงเมื่อทำด้วยตัวเอง? บทเรียนจากที่นั่นบอกวิธีใช้ Haskell แต่ฉันไม่เห็นความช่วยเหลือมากนักในการแปลงอัลกอริธึมให้เป็นรหัสเร็ว


6
ฉันควรชี้ให้เห็นว่าปัญหาของออยเลอร์ที่แก้ไขแล้วจำนวนมากมีไฟล์ PDF อยู่ถัดจากปัญหาที่อยู่ในการแก้ไขปัญหาคณิตศาสตร์ คุณอาจลองอ่านไฟล์ PDF นั้นและใช้อัลกอริทึมที่อธิบายไว้ในแต่ละภาษาจากนั้นโปรไฟล์นั้น

คำตอบ:


25

ก่อนอื่นคุณต้องแน่ใจว่าคุณมีไบนารีที่ปรับให้เหมาะสมก่อนที่จะคิดว่าภาษาเป็นปัญหา อ่านบทการรวบรวมข้อมูลและการเพิ่มประสิทธิภาพใน Real Wolrd Haskell เป็นที่น่าสังเกตว่าในกรณีส่วนใหญ่ลักษณะของภาษาในระดับสูงจะทำให้คุณมีประสิทธิภาพอย่างน้อย

อย่างไรก็ตามโปรดทราบว่าโซลูชันอื่นไม่ได้เร็วขึ้นเพราะใช้ฟังก์ชันในตัว แต่เพียงเพราะใช้อัลกอริธึมที่เร็วกว่ามาก : เพื่อค้นหาชุดตัวเลขจำนวนน้อยที่สุดที่คุณต้องการเพื่อหา GCD เพียงไม่กี่ตัว เปรียบเทียบกับวิธีการแก้ปัญหาของคุณซึ่งผ่านรอบทั้งหมดของตัวเลขตั้งแต่ 1 foldl lcm [1..20]ถึง ถ้าคุณลองกับ 30 ความแตกต่างระหว่าง runtimes จะยิ่งใหญ่กว่า

ดูความซับซ้อน: อัลกอริทึมของคุณมีO(ans*N)รันไทม์ซึ่งansเป็นคำตอบและNเป็นตัวเลขที่คุณกำลังตรวจสอบการหาร (20 ในกรณีของคุณ)
ขั้นตอนวิธีการอื่น ๆ ที่ดำเนินการNครั้งlcmแต่lcm(a,b) = a*b/gcd(a,b)และ GCD O(log(max(a,b)))มีความซับซ้อน O(N*log(ans))ดังนั้นขั้นตอนวิธีการที่สองมีความซับซ้อน คุณสามารถตัดสินด้วยตัวคุณเองซึ่งเร็วกว่า

ดังนั้นเพื่อสรุป:
ปัญหาของคุณคืออัลกอริธึมไม่ใช่ภาษา

โปรดทราบว่ามีภาษาเฉพาะที่มีทั้งฟังก์ชั่นและมุ่งเน้นไปที่โปรแกรมคณิตศาสตร์หนักเช่น Mathematica ซึ่งสำหรับปัญหาเชิงคณิตศาสตร์อาจจะเร็วกว่าเกือบทุกอย่าง มันมีไลบรารี่ของฟังก์ชั่นที่ได้รับการปรับให้เหมาะสมที่สุดและมันรองรับกระบวนทัศน์การทำงาน


3
ฉันเพิ่งมีปัญหาประสิทธิภาพกับโปรแกรม Haskell แล้วฉันก็รู้ว่าได้คอมไพล์ด้วยการปรับให้เหมาะสมปิดอยู่ การปรับการปรับให้เหมาะสมกับประสิทธิภาพที่เพิ่มขึ้นประมาณ 10 ครั้ง ดังนั้นโปรแกรมเดียวกันที่เขียนใน C ก็ยังเร็วกว่า แต่ Haskell ก็ไม่ช้ากว่านี้มาก (ประมาณ 2, 3 ครั้งช้ากว่าซึ่งฉันคิดว่าเป็นผลงานที่ดีและยังพิจารณาว่าฉันไม่ได้พยายามปรับปรุงรหัส Haskell อีกต่อไป) Bottom line: การทำโปรไฟล์และการเพิ่มประสิทธิภาพเป็นคำแนะนำที่ดี +1
Giorgio

3
โดยสุจริตคิดว่าคุณสามารถลบสองย่อหน้าแรกพวกเขาไม่ได้ตอบคำถามและอาจไม่ถูกต้อง (แน่นอนพวกเขาเล่นได้อย่างรวดเร็วและหลวมกับคำศัพท์ภาษาไม่สามารถมีความเร็ว)
jk

1
คุณกำลังให้คำตอบที่ขัดแย้ง ในอีกด้านหนึ่งคุณยืนยัน OP "ไม่เข้าใจผิดอะไร" และความเชื่องช้ามีอยู่ใน Haskell ในอีกทางหนึ่งคุณแสดงให้เห็นว่าการเลือกอัลกอริทึมไม่สำคัญ! คำตอบของคุณน่าจะดีกว่าถ้าข้ามสองย่อหน้าแรกซึ่งขัดแย้งกับคำตอบที่เหลือ
Andres F.

2
การรับคำติชมจาก Andres F. และ jk ฉันตัดสินใจลดสองย่อหน้าแรกเป็นประโยคสองสามประโยค ขอบคุณสำหรับความคิดเห็น
K.Steff

5

ความคิดแรกของฉันคือตัวเลขที่หารด้วยจำนวนเฉพาะทั้งหมด <= 20 จะหารด้วยจำนวนทั้งหมดที่น้อยกว่า 20 ดังนั้นคุณต้องพิจารณาตัวเลขที่เป็นทวีคูณของ 2 * 3 * 5 * 7 * 11 * 13 * 17 * 19 . วิธีการแก้ปัญหาดังกล่าวตรวจสอบ 1 / 9,699,690 จำนวนมากที่สุดเท่าที่วิธีการบังคับกำลังดุร้าย แต่วิธีแก้ปัญหาอย่างรวดเร็ว Haskell ของคุณทำได้ดีกว่านั้น

ถ้าฉันเข้าใจวิธีการแก้ปัญหา "fast Haskell" มันจะใช้ foldl1 เพื่อใช้ฟังก์ชัน lcm (ตัวคูณร่วมน้อย) กับรายการตัวเลขตั้งแต่ 1 ถึง 20 ดังนั้นมันจะใช้ lcm 1 2, ให้ผลลัพธ์ 2 จากนั้น lcm 2 3 ให้ผล 6 จากนั้น lcm 6 4 ให้ผล 12 และอื่น ๆ ด้วยวิธีนี้ฟังก์ชัน lcm จะถูกเรียกใช้เพียง 19 ครั้งเพื่อให้คำตอบของคุณ ในสัญลักษณ์ Big O นั่นคือการดำเนินการ O (n-1) เพื่อให้ได้โซลูชัน

โซลูชัน Slow-Haskell ของคุณต้องผ่านหมายเลข 1-20 สำหรับทุกหมายเลขตั้งแต่ 1 ถึงโซลูชันของคุณ หากเราเรียกใช้โซลูชัน s ดังนั้นโซลูชันช้าแฮสเคลล์จะดำเนินการกับ O (s * n) เรารู้อยู่แล้วว่ามีมากกว่า 9 ล้านคนดังนั้นนั่นอาจอธิบายถึงความเชื่องช้า แม้ว่าทางลัดทั้งหมดและรับค่าเฉลี่ยครึ่งทางผ่านรายการหมายเลข 1-20 นั่นยังคงเป็นเพียง O (s * n / 2)

การโทรheadไม่ได้ช่วยให้คุณประหยัดจากการคำนวณพวกเขาจะต้องทำเพื่อคำนวณวิธีแรก

ขอบคุณนี่เป็นคำถามที่น่าสนใจ มันยืดความรู้ของ Haskell ออกไปจริงๆ ฉันไม่สามารถตอบได้เลยหากฉันไม่ได้ศึกษาอัลกอริทึมเมื่อฤดูใบไม้ร่วงที่ผ่านมา


ที่จริงแล้ววิธีการที่คุณได้รับด้วย 2 * 3 * 5 * 7 * 11 * 13 * 17 * 19 อาจเป็นวิธีที่รวดเร็วอย่างน้อยที่สุดเท่าที่โซลูชันที่ใช้ lcm สิ่งที่คุณต้องการโดยเฉพาะคือ 2 ^ 4 * 3 ^ 2 * 5 * 7 * 11 * 13 * 17 * 19 เพราะ 2 ^ 4 เป็นพลังที่ยิ่งใหญ่ที่สุดของ 2 น้อยกว่าหรือเท่ากับ 20 และ 3 ^ 2 เป็นพลังที่ยิ่งใหญ่ที่สุด ของ 3 น้อยกว่าหรือเท่ากับ 20 และอื่น ๆ
อัฒภาค

@semicolon ในขณะที่เร็วกว่าตัวเลือกอื่น ๆ ที่กล่าวถึงวิธีนี้ยังต้องใช้รายการ primes ที่คำนวณล่วงหน้าซึ่งมีขนาดเล็กกว่าพารามิเตอร์อินพุต หากเราคำนึงถึงสิ่งนั้นในรันไทม์ (และที่สำคัญกว่านั้นในหน่วยความจำรอยเท้า) วิธีการนี้จะกลายเป็นสิ่งที่น่าดึงดูดน้อยลง
K.Steff

@ K.Steff คุณล้อเล่นฉัน ... คุณต้องคำนวณคอมพิวเตอร์ให้ได้จนถึง 19 ... ซึ่งใช้เวลาเพียงเสี้ยววินาที คำสั่งของคุณทำให้รู้สึกเป็นศูนย์อย่างแน่นอนรันไทม์รวมของวิธีการของฉันมีขนาดเล็กอย่างไม่น่าเชื่อแม้จะมีรุ่นที่สำคัญ ผมทำงาน profiling และวิธีการของฉัน (ใน Haskell) ได้และtotal time = 0.00 secs (0 ticks @ 1000 us, 1 processor) total alloc = 51,504 bytesรันไทม์เป็นสัดส่วนที่น้อยมากพอที่จะไม่ลงทะเบียนบน profiler
เซมิโคลอน

@semicolon ฉันควรจะมีคุณสมบัติความคิดเห็นของฉันขอโทษเกี่ยวกับเรื่องนี้ คำสั่งของฉันเกี่ยวข้องกับราคาที่ซ่อนอยู่ของการคำนวณช่วงเวลาทั้งหมดถึง N - Eratosthenes naïveคือ O (N * log (N) * log (log (N))) การดำเนินการและหน่วยความจำ O (N) ซึ่งหมายความว่านี่เป็นครั้งแรก ส่วนประกอบของอัลกอริธึมที่จะหมดหน่วยความจำหรือเวลาถ้า N นั้นใหญ่จริง ๆ มันไม่ได้ดีไปกว่านี้กับตะแกรงของ Atkin ดังนั้นฉันจึงสรุปว่าอัลกอริทึมจะน่าสนใจน้อยกว่าfoldl lcm [1..N]ซึ่งจำเป็นต้องมีค่าคงที่จำนวนมาก
K.Steff

@ K.Steff ฉันเพิ่งทดสอบอัลกอริทึมทั้งสอง สำหรับขั้นตอนวิธีการตามฉันนายก Profiler ให้ฉัน (= n 100,000) และtotal time = 0.04 secs total alloc = 108,327,328 bytesสำหรับขั้นตอนวิธีการตาม LCM อื่น ๆ Profiler ให้ฉัน: และtotal time = 0.67 secs total alloc = 1,975,550,160 bytesสำหรับ n = 1,000,000 ฉันได้สำหรับนายกตาม: total time = 1.21 secsและtotal alloc = 8,846,768,456 bytesและ LCM ตาม: และtotal time = 61.12 secs total alloc = 200,846,380,808 bytesดังนั้นในคำอื่น ๆ คุณผิดฐานที่ดีนั้นดีกว่ามาก
เซมิโคลอน

1

ตอนแรกฉันไม่ได้วางแผนที่จะเขียนคำตอบ lcmแต่ผมก็บอกว่าจะหลังจากที่ผู้อื่นทำเรียกร้องแปลกที่เพียงแค่คูณจำนวนเฉพาะคู่แรกที่ได้รับมากขึ้นคอมพิวเตอร์มีราคาแพงแล้วใช้ซ้ำแล้วซ้ำอีก ดังนั้นนี่คืออัลกอริธึมสองตัวและเกณฑ์มาตรฐาน

อัลกอริทึมของฉัน:

อัลกอริธึมการสร้างที่ยอดเยี่ยมทำให้ฉันมีช่วงเวลาที่ไม่สิ้นสุด

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

ตอนนี้ใช้รายการเฉพาะเพื่อคำนวณผลลัพธ์สำหรับบางรายการN:

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

ตอนนี้ขั้นตอนวิธีการตาม LCM อื่น ๆ ซึ่งเป็นที่ยอมรับค่อนข้างรัดกุมส่วนใหญ่เพราะผมดำเนินการรุ่นที่สำคัญจากรอยขีดข่วน (และไม่ได้ใช้อัลกอริทึมรายการที่กระชับเข้าใจซุปเปอร์เนื่องจากผลงานไม่ดี) ในขณะที่ถูกนำเข้ามาเพียงแค่จากlcmPrelude

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

ตอนนี้สำหรับการวัดประสิทธิภาพโค้ดที่ฉันใช้สำหรับแต่ละตัวนั้นง่าย: ( -prof -fprof-auto -O2จากนั้น+RTS -p)

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

สำหรับ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

สำหรับ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

สำหรับ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

ฉันคิดว่าผลลัพธ์พูดได้ด้วยตัวเอง

Profiler บ่งชี้ว่ารุ่นที่ใช้เวลาน้อยกว่าและน้อยกว่าเปอร์เซ็นต์ของเวลาทำงานเมื่อnเพิ่มขึ้น ดังนั้นมันจึงไม่ใช่คอขวดดังนั้นเราสามารถเพิกเฉยได้ตอนนี้

ซึ่งหมายความว่าเราเป็นจริงๆเปรียบเทียบโทรlcmที่หนึ่งอาร์กิวเมนต์ไปจาก 1 ถึงnและอื่น ๆ ไปทางเรขาคณิตจาก 1 ansถึง การโทร*ด้วยสถานการณ์เดียวกันและผลประโยชน์ที่เพิ่มขึ้นของการข้ามหมายเลขที่ไม่สำคัญทั้งหมด (asymptotically ฟรีเนื่องจากลักษณะที่มีราคาแพงกว่า*)

และเป็นที่ทราบกันดีว่า*เร็วกว่าlcmที่lcmต้องการแอปพลิเคชั่นซ้ำ ๆ ของmodและmodช้าลงด้วย asymptotically ( O(n^2)vs ~O(n^1.5))

ดังนั้นผลลัพธ์ข้างต้นและการวิเคราะห์อัลกอริธึมสั้นควรทำให้ชัดเจนว่าอัลกอริทึมใดที่เร็วกว่า

โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.