.NET 优化埃拉托色尼 F# 筛法

.NET Optimization of F# Sieve of Eratosthenes

我正在摆弄 F# 并使用 FSI REPL 我注意到我的初学者天真的埃拉托色尼筛法实现的两个略有不同的实现之间存在巨大的效率差异。第一个带有额外的 if:

let rec sieve max current pList =
    match current with
    | 2 -> sieve max (current + 1) (current::pList)
    | 3 -> sieve max (current + 2) (current::pList)
    | n when n < max ->
        if (n % 5 = 0) || (n % 3 = 0) then
            sieve max (current + 2) (current::pList)
        else if (pList |> (List.map (fun n -> current % n = 0)) |> List.contains true) then
            sieve max (current + 2) (pList)
        else
            sieve max (current + 2) (current::pList)
    | n when n >= max
        -> pList
    | _
        ->  printfn "Error: list length: %A, current: %A" (List.length pList) current
            [-1]

第二个没有:

let rec sieve max current pList =
    match current with
    | 2 -> sieve max (current + 1) (current::pList)
    | 3 -> sieve max (current + 2) (current::pList)
    | n when n < max ->
        if (pList |> (List.map (fun n -> current % n = 0)) |> List.contains true) then
            sieve max (current + 2) (pList)
        else
            sieve max (current + 2) (current::pList)
    | n when n >= max
        -> pList
    | _
        ->  printfn "Error: list length: %A, current: %A" (List.length pList) current
            [-1]

带有额外 if 分支的那个实际上 更慢 尽管它看起来应该更快。 然后,我使用以下命令对 REPL 中的两个实现进行计时:

#time sieve 200000 2 [] #time

并在我的机器上看到,带有额外 if 语句的实现大约需要 2 分钟,而没有 运行 的语句每次需要大约 1 分钟。 这怎么可能?通过添加一个处理 3 或 5 的倍数的 if 语句,它实际上比仅映射整个列表然后查找素数列表中是否有数字的约数要慢。 如何? F# 是否针对处理列表进行了优化?

额外的 if 执行额外的计算,但不会中断执行流程,它愉快地继续到第二个 if。所以实际上,你只是在你的函数中添加了一些无用的计算。难怪现在需要更长的时间!

你一定在想这样的事情:

if (a)
    return b;
if (x)
    return y;
else 
    return z;

好吧,这在 F# 中的工作方式与在 C# 或 Java 中的工作方式不同。 F# 没有 "early return"。没有"statements",一切都是表达,一切都有结果。

添加这种无用的计算实际上会产生警告。如果您注意警告,您应该会注意到“这个值正在被丢弃”之类的说法。编译器正试图通过指出一个无用的函数调用来帮助您。

要解决此问题,请将第二个 if 替换为 elif:

if (n % 5 = 0) || (n % 3 = 0) then
    sieve max (current + 2) (current::pList)
elif (pList |> (List.map (fun n -> current % n = 0)) |> List.contains true) then
    sieve max (current + 2) (pList)
else
    sieve max (current + 2) (current::pList)

这将使第二个分支仅在第一个分支失败时执行。

P.S。想想看,这样一个没有 elseif 甚至不应该编译,因为它的结果类型无法确定。我不确定那里发生了什么。

P.P.S。 List.map f |> List.contains true 更好地表示为 List.exists f。更短 性能更高。

第一个筛子中的额外 if 可能是为了快捷方式,实际上稍微改变了行为。它没有删除除以 3 和 5 的所有内容,而是实际添加了它。对比一下输出就很容易看出:

1st sieve: [19; 17; 15; 13; 11; 9; 7; 5; 3; 2]
2st sieve: [19; 17; 13; 11; 7; 5; 3; 2]

我假设你想要的是这样的:

if (n % 5 = 0) || (n % 3 = 0) then
    sieve max (current + 2) (pList)

但是,在这种情况下,它不会包括 5(很明显)。所以正确的代码是

if (n <> 5 && n % 5 = 0) || (n <> 3 && n % 3 = 0) then
    sieve max (current + 2) (pList)

检查上面代码的性能 - 应该没问题。

当然,列表不一定高效。我曾经创建了一个函数来创建一个布尔数组,其中每个素数都为真,每个 non-prime 为假:

let sieveArray limit =
    let a = Array.create limit true
    let rec setArray l h s x =
        if l <= h then
            a.[l] <- x
            setArray (l + s) h s x
    a.[0] <- false; a.[1] <- false
    for i = 0 to a.Length - 1 do
        if a.[i]
        then setArray (i + i) (a.Length - 1) i false
    a

要获得实际素数的列表,您只需映射索引,过滤结果数组:

sieveArray limit
|> Seq.mapi (fun i x -> i, x)
|> Seq.filter snd
|> Seq.map fst
|> Seq.toList