此代码取自“Haskell之路逻辑,数学和编程”一书。它实现了eratosthenes算法的筛选并解决了项目Euler问题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
实际上它比天真的主要测试运行得更慢。 有人可以解释这种行为吗?
我怀疑它与迭代标记函数中列表中的每个元素有关。
感谢。
答案 0 :(得分:11)
您正在使用此算法构建二次数的未评估的thunk。该算法严重依赖于懒惰,这也是它无法扩展的原因。
让我们来看看它是如何运作的,希望能让问题显而易见。简单地说,我们想要print
无限primes
的元素,即我们想要一个接一个地评估列表中的每个单元格。 primes
定义为:
primes :: [Integer]
primes = sieve [2..]
由于2不是0,因此sieve
的第二个定义适用,并且2被添加到素数列表中,列表的其余部分是未评估的thunk(我使用tail
代替对于n : xs
,sieve
中的模式匹配xs
,因此tail
实际上并未被调用,并且不会在下面的代码中添加任何开销; {{1实际上是唯一的thunked函数):
mark
现在我们想要第二个primes = 2 : sieve (mark (tail [2..]) 1 2)
元素。因此,我们遍历代码(为读者练习)并最终得到:
primes
同样的程序,我们想评估下一个素数......
primes = 2 : 3 : sieve (mark (tail (mark (tail [2..]) 1 2)) 1 3)
这开始看起来像LISP,但我离题了...开始看到问题了吗?对于primes = 2 : 3 : 5 : sieve (mark (tail (tail (mark (tail (mark (tail [2..]) 1 2)) 1 3))) 1 5)
列表中的每个元素,必须评估越来越多的primes
个应用程序堆栈。换句话说,对于列表中的每个元素,必须通过评估堆栈中的每个mark
应用程序来检查该元素是否由任何前面的素数标记。因此,对于mark
,Haskell运行时必须调用函数,导致调用堆栈的深度约为......我不知道,137900(n~=2000000
给出了下限)?这样的事情。这可能是导致减速的原因;也许vacuum
可以告诉你更多(我现在没有配备Haskell和GUI的计算机)。
Eratosthenes的筛子在C语言中工作的原因是:
let n = 2e6 in n / log n
之前标记整个数组,从而导致根本没有调用堆栈开销。答案 1 :(得分:8)
不仅是使得它非常慢的thunk,如果在有限位数组中用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
检查每个数字< n
,而不仅仅是n
的素数。最小的素数因素。
结果是该算法的复杂性为特纳筛的O(N ^ 2 / log N)对O((N / log N)^ 2)(和O(N * log(log N))为真正的Eratosthenes筛选。)
嵌套¹堆叠的thunks mentioned by dflemstr会加剧问题²,但即使没有这个,算法也会比Turner更差。我同时感到震惊和着迷。
¹“嵌套”可能不正确。虽然每个mark
thunks只能通过它上面的那个来访问,但它们不会引用封闭thunk范围内的任何内容。
²但是,在thunk的大小或深度上都没有任何二次方,而且thunk的表现相当不错。为了说明,我们假装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
thunk,强制mark 3 2
thunk,强制{{1} thunk,它强制mark 2 2
thunk并用0替换头部,并将尾部包裹在[8 .. ]
thunk中。这会冒泡到mark 2 1
,这会丢弃0,然后强制下一堆thunk。
因此,对于从sieve
到p_k + 1
(包括)的每个数字,p_(k+1)
中的模式匹配会强制sieve
形式k
thunk的堆栈/链1}}。其中每个都会从封闭的thunk(mark p r
获取最内层(y:ys)
)中获取[y ..]
并将尾部mark 2 r
包裹在新的thunk中,或者离开{{1} }未更改或将其替换为0,从而在到达ys
的列表尾部建立一个新的堆栈/ thunk链。
对于每个找到的素数,y
在顶部添加另一个sieve
thunk,所以最后,当找到大于2000000的第一个素数并且sieve
结束时,将会有148933 thunk的水平。
这里的堆叠不会影响复杂性,只会影响常数因素。在我们正在处理的情况下,一个懒惰生成的无限不可变列表,没有太多可以做的事情来减少将控制从一个thunk转移到下一个thunk所花费的时间。如果我们处理的是一个有限的可变列表或不是懒惰生成的数组,就像在C或Java这样的语言中那样,那么让每个mark p r
完成它的完整工作会更好(那将是一个在检查下一个数字之前,简单takeWhile (< 2000000)
循环的开销比函数调用/控制转移少,因此永远不会有多个标记处于活动状态且控制传递较少。
答案 2 :(得分:5)
好的,你肯定是对的,它比天真的实现慢。我从维基百科那里拿了这个,然后用GHCI将它与你的代码进行比较:
-- 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并被删除,但机制很清楚。此代码仅创建一组计数器,计数到每个素数,在正确的时刻发出下一个素数并将其传递到列表中。这相当于在初始实现中仅检查每个先前素数的除法,并且在thunk之间传递0或素数的额外开销。
这里可能还有一些我想念的细微之处。在Haskell中对Eratosthenes的Sieve进行了非常详细的处理以及各种优化here。
答案 3 :(得分:1)
简短回答:计数筛比Turner(又名“天真”)筛子慢,因为它通过顺序计数模拟直接RAM访问,这迫使它通过流 unieved < / em>标记阶段之间。这具有讽刺意味,因为计算使它成为Eratosthenes的“真正的”筛子,而不是特纳的试验筛子。实际上,就像特纳的筛子那样去除倍数会使计数陷入困境。
这两种算法都非常慢,因为它们从每个找到的素数而不是它的方块开始多次消除过早,从而创建了太多不需要的流处理阶段(无论是过滤还是标记) - {{它们中的1}},而不仅仅是O(n)
,用于生成价值最高~ 2*sqrt n/log n
的素数。在输入中看到 n
之前,不需要检查 7
的倍数。
This answer解释了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
每个这样的换能器可以实现为闭合框架(也称为“thunk”),或具有可变状态的发电机,这是不重要的。每个这样的生产者的输出直接作为输入进入其链中的后继者。这里没有没有被评估的价值,每个都被消费者强迫,以产生下一个产量。
所以,回答你的问题,
我怀疑它与迭代标记函数中列表中的每个元素有关。
是的,完全。他们都运行非推迟的计划。
通过推迟流标记的开始,可以改进代码:
[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
现在,在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
。但是,当我们可以按增量计算时,为什 (9
中的每个第3个奇数可以通过添加6
一次又一次找到。) 然后我们可以杂草而不是标记立即取出数字,让自己成为Eratosthenes的真正筛子(即使不是最有效的筛子):
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
以上的O(k^1.3)
运行,快速n-dirty测试被编译加载到GHCi中,产生高达10万到150k的质数,在约0.5时恶化到primes = sieve [2..] :: [Int]
where
sieve (x:xs) = x : sieve [y | y <- xs, rem y x /= 0]
mil primes。
那么实现了什么样的加速?比较OP代码和“维基百科”的特纳筛子,
8x
2k 的W / OP加速 15x
(即产生2000个素数)。但是在 4k 时,它是 O(k^1.9 .. 2.3)
加速。特纳筛子似乎在产生k = 1000 .. 6000
质数时约为O(k^2.3 .. 2.6)
经验复杂度,而20x
处的计数筛用于相同范围。
对于本答案中的两个版本,v1 / W在 4k 时更快 43x
, 5.2x
8k 。 v2 / v1在 20k 5.8x
, 6.5x
40k 且 O(k^1.2)
可以更快地产生80,000个素数。
(为了比较,Melissa O'Neill的优先级队列代码在k
经验复杂度下运行,在{{1}}质数产生。当然,它比这里的代码要好得多。
以下是Eratosthenes定义的筛子:
P = {3,5,...} \ U {{p p,p p + 2 * p ,. ..} | p在 P }
中Eratosthenes效率筛选的关键是直接生成素数的倍数,通过从每个素数计算增量(两次)素数值;它们的直接消除,可以通过值和地址的混合来实现,就像整数排序算法一样(只有可变数组才有可能)。它是否必须产生预先设定的素数或无限期地工作是无关紧要的,因为它总是可以按片段工作。