Почему этот тест такой медленный?
этот код был взят из книги "Haskell Road to Logic, Math and Programming". Он реализует сито алгоритма Эратосфена и решает задачу проекта Эйлера 10.
sieve :: [Integer] -> [Integer]
sieve (0 : xs) = sieve xs
sieve (n : xs) = n : sieve (mark xs 1 n)
where
mark :: [Integer] -> Integer -> Integer -> [Integer]
mark (y:ys) k m | k == m = 0 : (mark ys 1 m)
| otherwise = y : (mark ys (k+1) m)
primes :: [Integer]
primes = sieve [2..]
-- Project Euler #10
main = print $ sum $ takeWhile (< 2000000) primes
на самом деле он работает еще медленнее, чем наивный простой тест. Может ли кто-нибудь объяснить это поведение?
Я подозреваю, что это связано с итерацией каждого элемента в списке в функции mark.
спасибо.
4 ответов
вы создаете квадратичное число недооцененных ударов, используя этот алгоритм. Алгоритм настолько сильно полагается на лень, что это также является причиной того, что он не масштабируется.
давайте рассмотрим, как это работает, что, надеюсь, должно сделать проблему очевидной. Для simplicitly, скажем, что мы хотим print
элементы primes
ad infinitum, т. е. мы хотим оценить каждую ячейку в списке одну за другой. primes
определяется as:
primes :: [Integer]
primes = sieve [2..]
поскольку 2 не равно 0, второе определение sieve
применяется, и 2 добавляется в список простых чисел, а остальная часть списка является не оцененным thunk (я использую tail
вместо шаблона матч n : xs
на sieve
на xs
, так что tail
фактически не вызывается и не добавляет никаких накладных расходов в коде ниже;mark
на самом деле единственная функция thunked):
primes = 2 : sieve (mark (tail [2..]) 1 2)
теперь мы хотим второго primes
элемент. Итак, мы проходим через код (упражнение для читателя) и в итоге:
primes = 2 : 3 : sieve (mark (tail (mark (tail [2..]) 1 2)) 1 3)
снова та же процедура, мы хотим оценить следующее простое число...
primes = 2 : 3 : 5 : sieve (mark (tail (tail (mark (tail (mark (tail [2..]) 1 2)) 1 3))) 1 5)
это начинает выглядеть как шепелявость, но я отвлекаюсь... Начинаешь видеть проблему? Для каждого элемента primes
список, все более большой стук стеков mark
приложения должны быть оценены. Другими словами, для каждого элемента в списке должна быть проверка, отмечен ли этот элемент каким-либо из предыдущих простые числа, оценивая каждый mark
приложения в стеке. Итак, for n~=2000000
, среда выполнения Haskell должна вызывать функции, приводящие к стеку вызовов с глубиной около ... Не знаю, 137900 (let n = 2e6 in n / log n
дает нижнюю границу)? Что-то вроде того. Это, вероятно, то, что вызывает замедление; может быть vacuum
могу рассказать вам больше (у меня сейчас нет компьютера с Haskell и GUI).
причина, по которой сито Эратосфена работает на таких языках, как C это:
- вы не используете бесконечный список.
- из-за (1) Вы можете пометить весь массив, прежде чем продолжить следующий
n
, в результате чего нет накладных расходов стека вызовов вообще.
это не только удары, которые делают его ужасно медленным, этот алгоритм также будет очень медленным, если он будет реализован в C на конечном битовом массиве.
sieve :: [Integer] -> [Integer]
sieve (0 : xs) = sieve xs
sieve (n : xs) = n : sieve (mark xs 1 n)
where
mark :: [Integer] -> Integer -> Integer -> [Integer]
mark (y:ys) k m | k == m = 0 : (mark ys 1 m)
| otherwise = y : (mark ys (k+1) m)
для каждого p
этот алгоритм проверяет все номера от p+1
до предела, являются ли они кратными p
. Он делает это не путем деления, как это делает сито Тернера, а путем сравнения счетчика с простым. Теперь сравнение двух чисел намного быстрее, чем деление, но цена, заплаченная за это, заключается в том, что каждый номер n
теперь проверяется для каждого prime < n
, а не только для простых чисел до n
наименьший простой фактор.
в результате сложность этого алгоритма равна O (N^2 / log N) против O ((N/log N)^2 ) для сита Тернера(и O (N*log (log N)) для реального сита Эратосфена).
на вложение1 укладка thunks упоминается dflemstr усугубляет problem2, но даже без этого алгоритм будет хуже, чем у Тернера. Я потрясена и очарована одновременно.
1 "вложенность" может быть неправильным словом. Хотя каждый из mark
thunks доступен только через один над ним, они не ссылаются ни на что из области охвата thunk.
2 нет ничего квадратного ни в размере, ни в глубине ударов, хотя, и удары довольно хорошо себя ведут. Для иллюстрации, давайте притворимся mark
были определены с обратным аргумент порядок. Тогда, когда 7 оказывается простым, ситуация
sieve (mark 5 2 (mark 3 1 (mark 2 1 [7 .. ])))
~> sieve (mark 5 2 (mark 3 1 (7 : mark 2 2 [8 .. ])))
~> sieve (mark 5 2 (7 : mark 3 2 (mark 2 2 [8 .. ])))
~> sieve (7 : mark 5 3 (mark 3 2 (mark 2 2 [8 .. ])))
~> 7 : sieve (mark 7 1 (mark 5 3 (mark 3 2 (mark 2 2 [8 .. ]))))
и следующий шаблон-матч по sieve
сил mark 7 1
thunk, который заставляет mark 5 3
тук, который заставляет mark 3 2
тук, который заставляет mark 2 2
thunk, который заставляет [8 .. ]
thunk и заменяет голову на 0, и обертывает хвост в mark 2 1
преобразователь. Это пузыри до sieve
, который отбрасывает 0, а затем заставляет следующий стек ударов.
так для каждого номера от p_k + 1
to p_(k+1)
(включительно), рисунок-матч sieve
заставляет стек / цепь k
удары формы mark p r
. Каждый из них принимает (y:ys)
получено из прилагаемого thunk ([y ..]
для сокровенных mark 2 r
) и заворачивает хвост ys
в новый стук, либо уходя y
без изменений или заменить его на 0, тем самым создавая новый стек/цепочку ударов в том, что будет хвост списка, достигающего sieve
.
для каждого найденного премьер, sieve
добавляет еще один mark p r
thunk сверху, так что в конце, когда первое простое число больше 2000000 найдено и takeWhile (< 2000000)
финиши, будут 148933 уровня глухарей.
укладка thunks здесь не влияет на сложность, она просто влияет на постоянные факторы. В ситуации, с которой мы имеем дело, лениво сгенерированный бесконечный неизменяемый список, мало что можно сделать, чтобы сократить время, затрачиваемое на передачу контроля от одного thunk к другому. Если бы мы имели дело с конечным изменяемым списком или массивом, который не генерируется лениво, как это было бы на языке C или Java, было бы намного лучше позволить каждому mark p
завершить свою полную работу (это было бы просто for
цикл с меньшими накладными расходами, чем вызов функции / Передача управления), прежде чем рассматривать следующий номер, поэтому никогда не будет более одного активного маркера и меньше контроля.
хорошо, вы определенно правы, это медленнее, чем наивная реализация. Я взял это из Википедии и сравнил его с вашим кодом с Г таким образом:
-- from Wikipedia
sieveW [] = []
sieveW (x:xs) = x : sieveW remaining
where
remaining = [y | y <- xs, y `mod` x /= 0]
-- your code
sieve :: [Integer] -> [Integer]
sieve (0 : xs) = sieve xs
sieve (n : xs) = n : sieve (mark xs 1 n)
where
mark :: [Integer] -> Integer -> Integer -> [Integer]
mark (y:ys) k m | k == m = 0 : (mark ys 1 m)
| otherwise = y : (mark ys (k+1) m)
Бег дает
[1 of 1] Compiling Main ( prime.hs, interpreted )
Ok, modules loaded: Main.
*Main> :set +s
*Main> sum $ take 2000 (sieveW [2..])
16274627
(1.54 secs, 351594604 bytes)
*Main> sum $ take 2000 (sieve [2..])
16274627
(12.33 secs, 2903337856 bytes)
чтобы попытаться понять, что происходит и как именно mark
код работает, я попытался расширить код вручную:
sieve [2..]
= sieve 2 : [3..]
= 2 : sieve (mark [3..] 1 2)
= 2 : sieve (3 : (mark [4..] 2 2))
= 2 : 3 : sieve (mark (mark [4..] 2 2) 1 3)
= 2 : 3 : sieve (mark (0 : (mark [5..] 1 2)) 1 3)
= 2 : 3 : sieve (0 : (mark (mark [5..] 1 2) 1 3))
= 2 : 3 : sieve (mark (mark [5..] 1 2) 1 3)
= 2 : 3 : sieve (mark (5 : (mark [6..] 2 2)) 1 3)
= 2 : 3 : sieve (5 : mark (mark [6..] 2 2) 2 3)
= 2 : 3 : 5 : sieve (mark (mark (mark [6..] 2 2) 2 3) 1 5)
= 2 : 3 : 5 : sieve (mark (mark (0 : (mark [7..] 1 2)) 2 3) 1 5)
= 2 : 3 : 5 : sieve (mark (0 : (mark (mark [7..] 1 2) 3 3)) 1 5)
= 2 : 3 : 5 : sieve (0 : (mark (mark (mark [7..] 1 2) 3 3)) 2 5))
= 2 : 3 : 5 : sieve (mark (mark (mark [7..] 1 2) 3 3)) 2 5)
= 2 : 3 : 5 : sieve (mark (mark (7 : (mark [8..] 2 2)) 3 3)) 2 5)
Я думаю, что я, возможно, сделал небольшую ошибку в конце там, так как 7 выглядит, как он собирается превратиться в 0 и исключено, но механизм понятен. Этот код просто создает набор счетчиков, подсчитывающих до каждого простого числа, испуская следующее простое число в нужный момент и передавая его по списку. Это эквивалентно простой проверке деления на каждое предыдущее простое число, как в наивной реализации, с дополнительными накладными расходами на передачу нулей или простых чисел между ударами.
здесь может быть еще какая-то тонкость, которой мне не хватает. Очень детальная обработка сетки Эратосфен в Хаскелле вместе с различными оптимизациями здесь.
короткий ответ: счетное сито медленнее, чем у Тернера (a.к. а. "наивное") сито, потому что оно эмулирует прямой доступ к ОЗУ с последовательным подсчетом, что заставляет его проходить по потокам unsieved между этапами маркировки. Это иронично, потому что инвентаризация делает "подлинной" сито Эратосфена, в отличие от сита судебного отдела Тернера. Фактически извлекающ многократные цепи, как сетка Тернера делает, испортил бы счет.
оба алгоритма чрезвычайно медленные, потому что они запускают работу исключения кратных рано от каждого найденного простого вместо его квадрата, таким образом, создавая слишком много ненужных этапов обработки потока (будь то фильтрация или маркировка) - O(n)
из них, а не просто ~ 2*sqrt n/log n
, в производстве простых до n
в стоимость. Нет проверки для кратных 7
требуется до 49
рассматривается в вход.
ответ объясняет, как sieve
можно рассматривать как построение конвейера потоковой обработки "преобразователей" за собой, так как он работает:
[2..] ==> sieve --> 2
[3..] ==> mark 1 2 ==> sieve --> 3
[4..] ==> mark 2 2 ==> mark 1 3 ==> sieve
[5..] ==> mark 1 2 ==> mark 2 3 ==> sieve --> 5
[6..] ==> mark 2 2 ==> mark 3 3 ==> mark 1 5 ==> sieve
[7..] ==> mark 1 2 ==> mark 1 3 ==> mark 2 5 ==> sieve --> 7
[8..] ==> mark 2 2 ==> mark 2 3 ==> mark 3 5 ==> mark 1 7 ==> sieve
[9..] ==> mark 1 2 ==> mark 3 3 ==> mark 4 5 ==> mark 2 7 ==> sieve
[10..]==> mark 2 2 ==> mark 1 3 ==> mark 5 5 ==> mark 3 7 ==> sieve
[11..]==> mark 1 2 ==> mark 2 3 ==> mark 1 5 ==> mark 4 7 ==> sieve --> 11
сито токаря использует nomult p = filter ((/=0).(`rem`p))
на месте mark _ p
записи, но в остальном выглядит так же:
[2..] ==> sieveT --> 2
[3..] ==> nomult 2 ==> sieveT --> 3
[4..] ==> nomult 2 ==> nomult 3 ==> sieveT
[5..] ==> nomult 2 ==> nomult 3 ==> sieveT --> 5
[6..] ==> nomult 2 ==> nomult 3 ==> nomult 5 ==> sieveT
[7..] ==> nomult 2 ==> nomult 3 ==> nomult 5 ==> sieveT --> 7
[8..] ==> nomult 2 ==> nomult 3 ==> nomult 5 ==> nomult 7 ==> sieveT
каждый такой датчик можно снабдить как рамка закрытия (a.к. a. "thunk") или генератор с изменяемым состоянием, что не имеет значения. Выход каждого из таких производитель идет непосредственно в качестве вклада в его преемника в цепочке. Здесь нет недооцененных громил, каждый из которых вынужден своим потребителем производить свою следующую продукцию.
Итак, чтобы ответить на ваш вопрос,
я подозреваю, что это связано с итерацией каждого элемента в списке в функции mark.
да точно. в противном случае они оба запускают не отложенные схемы.
код может быть таким образом улучшено путем откладывать начало поток-маркировки:
primes = 2:3:filter (>0) (sieve [5,7..] (tail primes) 9)
sieve (x:xs) ps@ ~(p:t) q
| x < q = x:sieve xs ps q
| x==q = sieve (mark xs 1 p) t (head t^2)
where
mark (y:ys) k p
| k == p = 0 : (mark ys 1 p) -- mark each p-th number in supply
| otherwise = y : (mark ys (k+1) p)
теперь он работает чуть выше O(k^1.5)
, эмпирически, в k
простые числа производят. Но зачем считать единицами, если можно считать приращением. (каждое 3-е нечетное число из 9
можно найти, добавив 6
, снова и снова.) и тогда вместо того, чтобы отмечать, мы можем отсеять числа сразу, получив себе добросовестное сито Эратосфена (даже если не самое эффективное один):
primes = 2:3:sieve [5,7..] (tail primes) 9
sieve (x:xs) ps@ ~(p:t) q
| x < q = x:sieve xs ps q
| x==q = sieve (weedOut xs (q+2*p) (2*p)) t (head t^2)
where
weedOut i@(y:ys) m s
| y < m = y:weedOut ys m s
| y==m = weedOut ys (m+s) s
| y > m = weedOut i (m+s) s
это работает на выше O(k^1.2)
на k
произведенные простые числа, быстро-N-грязное тестирование скомпилировано-загружено в GHCi, производя до 100k-150k простых чисел, ухудшающихся до O(k^1.3)
около 0,5 мил простых чисел.
Итак, какие ускорения достигаются этим? Сравнение кода OP с ситом Тернера "Википедии",
primes = sieve [2..] :: [Int]
where
sieve (x:xs) = x : sieve [y | y <- xs, rem y x /= 0]
было 8x
ускорение W / OP в 2k (т. е. 2000 расцветы.) Но 4К это 15x
ускорение. Сито Тернера, кажется, работает примерно O(k^1.9 .. 2.3)
эмпирическая сложность в производстве k = 1000 .. 6000
простые числа и счетное сито в O(k^2.3 .. 2.6)
для того же диапазона.
для двух версий здесь, в этом ответе, v1 / W был дополнительным 20x
быстрее 4К и 43x
at 8k. v2 / v1 был 5.2x
at 20k, 5.8x
at 40k и 6.5x
быстрее производить 80,000 числа.
(для сравнения, код приоритетной очереди мелиссы О'Нил работает примерно O(k^1.2)
эмпирическая сложности, в k
простые числа производят. Он масштабируется намного лучше, чем код здесь, конечно).
вот сито определения Эратосфена:
P = {3,5,...} \ U { {pp, pp+2*p, ...} | Р P }
ключ к сетке эффективности Эратосфена -прямая генерация кратных простых чисел, считая с шагом (дважды) значения prime от каждого prime; и их прямой ликвидации, что стало возможным благодаря слиянию значения и адреса, как в алгоритмах целочисленной сортировки (возможно только с изменяемыми массивами). Это несущественно должен ли он производить заданное число простых чисел или работать бесконечно, потому что он всегда может работать по сегментам.