尽管已经有
一个答案使用
优先队列(PQ)作为
SkewBinomialHeap中的算法,但它可能不是适合此工作的正确PQ。增量埃拉托斯特尼筛(iEoS)需要一个PQ,在获取最小值和重新插入值方面具有出色性能,但不需要在添加新值方面达到最佳性能,因为iSoE仅将素数总数添加为新值直到范围的平方根(这是每个缩减操作发生的重新插入数量的一小部分)。 SkewBinomialHeap PQ实际上并没有比使用内置Map更多地提供
平衡二叉搜索树 - 所有O(log n)操作 - 除了它稍微改变了操作的加权,以符合SoE的要求。 然而,SkewBinaryHeap仍然需要许多每次缩减的O(log n)操作。
一个PQ的实现可以作为堆,更具体地说是二叉堆,甚至更具体地说是最小堆,基本上满足iSoE的要求,在获取最小值时具有O(1)的性能,在重新插入和添加新条目时具有O(log n)的性能,尽管实际性能只是O(log n)的一小部分,因为大多数重新插入发生在队列顶部附近,而大多数添加新值(这些不重要,因为它们很少发生)发生在队列末尾,这些操作是最有效的。此外,最小堆PQ可以在一次(通常是一小部分)O(log n)遍历中高效地实现删除最小值和插入函数。然后,与Map(实现为AVL树)不同,其中有一个O(log n)操作,通常由于我们需要的最小值位于树的最左侧最后一个叶子,而在MinHeap PQ中,我们通常在根处添加和删除最小值,并在一次遍历中平均向下几个级别插入。因此,MinHeap PQ可以每个筛选减少仅使用一小部分的O(log n)操作,而不是多个较大部分的O(log n)操作。
MinHeap PQ可以使用纯函数式代码实现(没有实现“removeMin”,因为iSoE不需要,但有一个“adjust”函数可用于分段),如下所示:
[<RequireQualifiedAccess>]
module MinHeap =
type MinHeapTreeEntry<'T> = class val k:uint32 val v:'T new(k,v) = { k=k;v=v } end
[<CompilationRepresentation(CompilationRepresentationFlags.UseNullAsTrueValue)>]
[<NoEquality; NoComparison>]
type MinHeapTree<'T> =
| HeapEmpty
| HeapOne of MinHeapTreeEntry<'T>
| HeapNode of MinHeapTreeEntry<'T> * MinHeapTree<'T> * MinHeapTree<'T> * uint32
let empty = HeapEmpty
let getMin pq = match pq with | HeapOne(kv) | HeapNode(kv,_,_,_) -> Some kv | _ -> None
let insert k v pq =
let kv = MinHeapTreeEntry(k,v)
let rec insert' kv msk pq =
match pq with
| HeapEmpty -> HeapOne kv
| HeapOne kv2 -> if k < kv2.k then HeapNode(kv,pq,HeapEmpty,2u)
else let nn = HeapOne kv in HeapNode(kv2,nn,HeapEmpty,2u)
| HeapNode(kv2,l,r,cnt) ->
let nc = cnt + 1u
let nmsk = if msk <> 0u then msk <<< 1
else let s = int32 (System.Math.Log (float nc) / System.Math.Log(2.0))
(nc <<< (32 - s)) ||| 1u //never ever zero again with the or'ed 1
if k <= kv2.k then if (nmsk &&& 0x80000000u) = 0u then HeapNode(kv,insert' kv2 nmsk l,r,nc)
else HeapNode(kv,l,insert' kv2 nmsk r,nc)
else if (nmsk &&& 0x80000000u) = 0u then HeapNode(kv2,insert' kv nmsk l,r,nc)
else HeapNode(kv2,l,insert' kv nmsk r,nc)
insert' kv 0u pq
let private reheapify kv k pq =
let rec reheapify' pq =
match pq with
| HeapEmpty -> HeapEmpty //should never be taken
| HeapOne kvn -> HeapOne kv
| HeapNode(kvn,l,r,cnt) ->
match r with
| HeapOne kvr when k > kvr.k ->
match l with //never HeapEmpty
| HeapOne kvl when k > kvl.k -> //both qualify, choose least
if kvl.k > kvr.k then HeapNode(kvr,l,HeapOne kv,cnt)
else HeapNode(kvl,HeapOne kv,r,cnt)
| HeapNode(kvl,_,_,_) when k > kvl.k -> //both qualify, choose least
if kvl.k > kvr.k then HeapNode(kvr,l,HeapOne kv,cnt)
else HeapNode(kvl,reheapify' l,r,cnt)
| _ -> HeapNode(kvr,l,HeapOne kv,cnt) //only right qualifies
| HeapNode(kvr,_,_,_) when k > kvr.k -> //need adjusting for left leaf or else left leaf
match l with //never HeapEmpty or HeapOne
| HeapNode(kvl,_,_,_) when k > kvl.k -> //both qualify, choose least
if kvl.k > kvr.k then HeapNode(kvr,l,reheapify' r,cnt)
else HeapNode(kvl,reheapify' l,r,cnt)
| _ -> HeapNode(kvr,l,reheapify' r,cnt) //only right qualifies
| _ -> match l with //r could be HeapEmpty but l never HeapEmpty
| HeapOne(kvl) when k > kvl.k -> HeapNode(kvl,HeapOne kv,r,cnt)
| HeapNode(kvl,_,_,_) when k > kvl.k -> HeapNode(kvl,reheapify' l,r,cnt)
| _ -> HeapNode(kv,l,r,cnt) //just replace the contents of pq node with sub leaves the same
reheapify' pq
let reinsertMinAs k v pq =
let kv = MinHeapTreeEntry(k,v)
reheapify kv k pq
let adjust f (pq:MinHeapTree<_>) = //adjust all the contents using the function, then rebuild by reheapify
let rec adjust' pq =
match pq with
| HeapEmpty -> pq
| HeapOne kv -> HeapOne(MinHeapTreeEntry(f kv.k kv.v))
| HeapNode (kv,l,r,cnt) -> let nkv = MinHeapTreeEntry(f kv.k kv.v)
reheapify nkv nkv.k (HeapNode(kv,adjust' l,adjust' r,cnt))
adjust' pq
使用上述模块,可以采用轮式分解优化和高效的共归流(CIS)编写iSoE,具体如下:
type CIS<'T> = class val v:'T val cont:unit->CIS<'T> new(v,cont) = { v=v;cont=cont } end
type cullstate = struct val p:uint32 val wi:int new(cnd,cndwi) = { p=cnd;wi=cndwi } end
let primesPQWSE() =
let WHLPRMS = [| 2u;3u;5u;7u |] in let FSTPRM = 11u in let WHLCRC = int (WHLPRMS |> Seq.fold
上述代码在一个i7-2700K (3.5GHz)作为64位代码下,约在0.077秒内计算出前100,000个质数,在0.977秒内计算出前1,000,000个质数,在大约14.33秒内计算出前10,000,000个质数,在大约221.87秒内计算出前100,000,000个质数。这段纯函数式代码略微比
Dustin Cambell的基于可变字典的代码更快,添加了常见的优化技巧如筛法分解、延迟加入基础质数和使用更高效的CID(
tryfsharp 和
ideone),但仍然是纯函数式代码,而他使用字典类的代码则不是。然而,对于大约两亿(约100 million个质数)的更大质数范围,使用基于哈希表的字典的代码将更快,因为字典操作没有O(log n)因素,这种收益超过了使用字典哈希表的计算复杂度。
上述程序具有进一步的特性,即分解轮被参数化,例如,可以通过将WHLPRMS设置为[|2u; 3u; 5u; 7u; 11u; 13u; 17u; 19u|]并将FSTPRM设置为23u来使用一个非常大的轮子,以获得大范围内约三分之二的运行时间,对于1000万个质数大约需要9.34秒,尽管请注意,在程序开始运行之前计算WHLPTRN需要几秒钟的时间,这是一个恒定的开销,无论质数范围如何。
比较分析:与纯函数式增量树折叠实现相比,该算法只稍微快一些,因为MinHeap树的平均使用高度比折叠树的深度少一倍,但由于它基于需要处理每个堆级别的右和左叶子以及任意一种方式的分支的二叉堆,而不是每个级别的树折叠中的单个比较,因此在遍历PQ树级别方面的效率损失等效于相应的常数因子损失。与其他基于PQ和Map的函数算法相比,改进通常是通过减少遍历各自树结构每个级别的O(log n)操作的常数因子来实现的。
小根堆通常是以可变数组的二叉堆形式实现的,这种形式基于追溯树模型,是由Michael Eytzinger发明的,已有400多年历史。虽然问题中说没有对非函数式可变代码感兴趣,但如果必须避免使用所有使用可变性的子代码,则无法使用列表或懒惰列表,因为它们在性能方面在"覆盖下"使用可变性。因此,假设以下可变版本的MinHeap PQ由库提供,并享受更大素数范围的另一个超过两倍的性能因素:
[<RequireQualifiedAccess>]
module MinHeap =
type MinHeapTreeEntry<'T> = class val k:uint32 val v:'T new(k,v) = { k=k;v=v } end
type MinHeapTree<'T> = ResizeArray<MinHeapTreeEntry<'T>>
let empty<'T> = MinHeapTree<MinHeapTreeEntry<'T>>()
let getMin (pq:MinHeapTree<_>) = if pq.Count > 0 then Some pq.[0] else None
let insert k v (pq:MinHeapTree<_>) =
if pq.Count = 0 then pq.Add(MinHeapTreeEntry(0xFFFFFFFFu,v)) //add an extra entry so there's always a right max node
let mutable nxtlvl = pq.Count in let mutable lvl = nxtlvl <<< 1 //1 past index of value added times 2
pq.Add(pq.[nxtlvl - 1]) //copy bottom entry then do bubble up while less than next level up
while ((lvl <- lvl >>> 1); nxtlvl <- nxtlvl >>> 1; nxtlvl <> 0) do
let t = pq.[nxtlvl - 1] in if t.k > k then pq.[lvl - 1] <- t else lvl <- lvl <<< 1; nxtlvl <- 0 //causes loop break
pq.[lvl - 1] <- MinHeapTreeEntry(k,v); pq
let reinsertMinAs k v (pq:MinHeapTree<_>) = //do minify down for value to insert
let mutable nxtlvl = 1 in let mutable lvl = nxtlvl in let cnt = pq.Count
while (nxtlvl <- nxtlvl <<< 1; nxtlvl < cnt) do
let lk = pq.[nxtlvl - 1].k in let rk = pq.[nxtlvl].k in let oldlvl = lvl
let k = if k > lk then lvl <- nxtlvl; lk else k in if k > rk then nxtlvl <- nxtlvl + 1; lvl <- nxtlvl
if lvl <> oldlvl then pq.[oldlvl - 1] <- pq.[lvl - 1] else nxtlvl <- cnt //causes loop break
pq.[lvl - 1] <- MinHeapTreeEntry(k,v); pq
let adjust f (pq:MinHeapTree<_>) = //adjust all the contents using the function, then re-heapify
if pq <> null then
let cnt = pq.Count
if cnt > 1 then
for i = 0 to cnt - 2 do //change contents using function
let e = pq.[i] in let k,v = e.k,e.v in pq.[i] <- MinHeapTreeEntry (f k v)
for i = cnt/2 downto 1 do //rebuild by reheapify
let kv = pq.[i - 1] in let k = kv.k
let mutable nxtlvl = i in let mutable lvl = nxtlvl
while (nxtlvl <- nxtlvl <<< 1; nxtlvl < cnt) do
let lk = pq.[nxtlvl - 1].k in let rk = pq.[nxtlvl].k in let oldlvl = lvl
let k = if k > lk then lvl <- nxtlvl; lk else k in if k > rk then nxtlvl <- nxtlvl + 1; lvl <- nxtlvl
if lvl <> oldlvl then pq.[oldlvl - 1] <- pq.[lvl - 1] else nxtlvl <- cnt //causes loop break
pq.[lvl - 1] <- kv
pq
极客提示:实际上,我原本期望可变版本会提供更好的性能比率,但由于嵌套的if-then-else代码结构和素数筛值的随机行为导致重新插入时出现了问题,这意味着CPU分支预测在大部分分支中失败,每个筛选减少需要重建指令预取缓存的CPU时钟周期增加了许多10倍。
这个算法中唯一固定的性能增益因素是分割和使用多任务处理,可以实现与 CPU 核心数量成比例的性能提升;然而,就目前而言,这是迄今为止最快的纯函数 SoE 算法,即使使用函数式 MinHeap 的纯函数形式也能击败简单的命令式实现,如
Jon Harrop's code 或
Johan Kullbom's Sieve of Atkin(后者在计时时出现错误,因为他只计算了
前 1000 万个质数而不是第 1000 万个质数),但如果使用更好的优化,那些算法将快约五倍。当我们添加更大的轮子分解的多线程时,函数式代码和命令式代码之间的比率约为五,因为命令式代码的计算复杂度增加得比函数式代码更快,而多线程有助于较慢的函数式代码,而不是较快的命令式代码,因为后者越来越接近枚举找到的质数所需的基本时间限制。
EDIT_ADD: 即使一个人可以选择继续使用MinHeap的纯函数版本,为了准备多线程的高效分割,稍微“破坏”了函数式代码的“纯度”,具体如下:1)传输复合筛选素数的表示最有效的方法是大小为段的打包位数组,2)虽然数组的大小已知,但使用数组理解以函数式方式初始化它并不高效,因为它在内部使用“ResizeArray”,需要每次添加x(我认为'x'对于当前实现来说是八)就需要复制自身,并且使用Array.init无法工作,因为许多特定索引处的值被跳过,3)因此,填充筛选的复合数组的最简单方法是将其零创建到正确的大小,然后运行初始化函数,该函数最多只能向每个可变数组索引写入一次。虽然这不是严格的“函数式”,但它接近于数组被初始化,然后再也没有修改过。
添加分割、多线程、可编程轮阶乘周长和许多性能调整的代码如下(除了一些新增的常量外,用于实现分割和多线程的额外调整代码大约占代码底部的一半,从“prmspg”函数开始):
type prmsCIS = class val pg:uint16 val bg:uint16 val pi:int val cont:unit->prmsCIS
new(pg,bg,pi,nxtprmf) = { pg=pg;bg=bg;pi=pi;cont=nxtprmf } end
type cullstate = struct val p:uint32 val wi:int new(cnd,cndwi) = { p=cnd;wi=cndwi } end
let primesPQOWSE() =
let WHLPRMS = [| 2u;3u;5u;7u;11u;13u;17u |] in let FSTPRM = 19u in let WHLCRC = int(WHLPRMS |> Seq.fold
请注意,MinHeap模块,包括函数和基于数组的模块,已添加了“adjust”函数,以允许在每个新段页开始时调整每个线程版本PQ的剪枝状态。还要注意,可以调整代码,使大部分计算使用32位范围完成,最终序列输出为uint64,计算时间成本很小,因此当前的理论范围是100万亿(10的14次方),如果愿意等待大约三到四个月来计算该范围,则可能性非常小。数字范围检查已被删除,因为不太可能有人使用此算法来计算到达甚至超过该范围。
使用纯函数式MinHeap和2、3、5、7轮因数分解,上述程序在0.062、0.629、10.53和195.62秒内计算出了前十万、一百万、一千万和一亿个质数。使用基于数组的MinHeap可以将其加速到0.097、0.276、3.48和51.60秒。通过将WHLPRMS更改为“[| 2u;3u;5u;7u;11u;13u;17u |]”并将FSTPRM更改为19u,使用2、3、5、7、11、13、17轮可以进一步加速到0.181、0.308、2.49和36.58秒(对于具有恒定开销的恒定因子改进)。这种最快的调整方式在约88.37秒内计算出32位数字范围内的203,280,221个质数。可以通过权衡较小范围版本的较慢时间与较大范围的较快时间来调整“BFSZ”常量,建议尝试值为“1<<<14”以获得更大的范围。该常量仅设置最小缓冲区大小,程序会自动调整超过该大小的缓冲区大小,以使所需页面范围的最大基本质数至少“打击”每个页面一次;这意味着不需要额外的“桶筛”。这个最后完全优化的版本可以在大约256.8秒和3617.4秒内计算出高达10亿和1000亿的质数(对于输出,使用“primesPQOWSE() |> Seq.takeWhile ((>=)100000000000UL) |> Seq.fold (fun s p -> s + 1UL) 0UL”进行测试)。这就是关于计算出万亿个质数需要大约半天、计算出高达万亿个质数需要一周以及计算出高达万亿个质数需要三到四个月的估计。
我认为使用增量SoE算法编写功能性或几乎功能性的代码运行速度比这快的可能性不大。从代码中可以看出,优化基本的增量算法使代码复杂度显著增加,使得它可能稍微比基于直接数组裁剪的等效优化代码更加复杂,并且那种代码能够以约十倍于此的速度运行且没有额外的性能指数意味着这个功能性增量代码具有日益增长的额外百分比开销。
那么除了从理论和知识角度来看,这个有用吗?可能不会。对于小于约一千万的质数范围,基本的非完全优化递增式筛法可能足够且编写相当简单,或者比最简单的命令式筛法使用更少的RAM内存。但是,它们比使用数组的更命令式代码要慢得多,因此对于超过该范围的范围,它们会“失去效力”。虽然在这里已经证明了通过优化可以加快代码速度,但它仍然比更命令式的纯基于数组的版本慢10倍以上,同时增加了与具有等效优化的代码至少同等复杂度的复杂度,即使在DotNet上使用F#编写的代码也比直接编译为本机代码的C++等语言慢四倍;如果真的想要研究大量的质数范围,人们可能会使用其他语言和技术,在那里
primesieve可以在不到四个小时的时间内计算出百万亿范围内的质数数量,而这个代码需要约三个月的时间。