F#函数式编程入门:埃拉托斯特尼筛法实战解析

1. 为什么选埃拉托斯特尼筛法作为F#入门切口

F#这门语言,我第一次在Visual Studio 2010 Beta1里见到它时,第一反应不是“又一门新语言”,而是“终于有人把函数式编程的骨架,和.NET生态的血肉,真正缝合在一起了”。它不像Haskell那样高冷得让人望而却步,也不像Scala那样在JVM上带着一丝妥协的痕迹。F#是那种你写完一段代码后,会下意识地停顿两秒,然后轻声说一句“嗯,这很干净”的语言。但干净不等于简单,初学者常被它的缩进、模式匹配、不可变性这些概念绊住脚。所以,我从不推荐一上来就讲monad或者computation expression——那不是入门,那是劝退。

我选埃拉托斯特尼筛法(Sieve of Eratosthenes)作为第一个实战案例,是经过反复掂量的。它足够小:核心逻辑十几行就能搞定;它又足够深:能自然带出F#最核心的几块基石——类型推断、不可变List与可变Array的协同、递归与尾递归、模式匹配、范围表达式。更重要的是,它有明确的“物理感”。你脑子里能清晰地浮现出一张数字表格,从2开始,把所有倍数一个个划掉,剩下的就是素数。这种具象化,是理解抽象语法最好的缓冲垫。

很多人学F#卡在第一步,不是因为语法难,而是因为思维没转过来。比如看到 let rec loop acc = function ,第一反应是“这function关键字放这儿干啥?C#里哪有这种写法?”——这恰恰是关键。F#里, function 不是个独立语句,它是 match 表达式的语法糖,是专门为“对一个值做模式匹配”这个高频动作设计的快捷键。它背后站着的是整个函数式编程的哲学:数据驱动流程,而不是流程驱动数据。你不是在写“先做A,再做B,最后做C”,而是在描述“当数据是空列表时,我返回什么;当数据是头+尾时,我又返回什么”。这种思维方式的切换,比记住 :: ; 的区别重要得多。

所以,这篇文章不叫“F#语法速查表”,它是一份带注释的思维导图。我们一行行拆解那段筛法代码,不是为了把它背下来,而是为了看清每一行代码背后,F#是如何用它特有的方式,去建模一个古老而优雅的数学算法的。你不需要有任何F#基础,但最好有一门主流语言(C#、Java或Python)的经验,这样我们才能用你熟悉的“锚点”,去理解F#里那些看似陌生的“新大陆”。

2. 核心设计思路与方案选型解析

2.1 为什么是“混合式”数据结构:Array + List?

这段筛法代码最精妙也最容易被忽略的一点,是它同时使用了两种截然不同的数据结构:一个 可变的Array 用于标记,一个 不可变的List 用于存储结果。这不是作者随手写的,而是F#函数式范式与现实世界性能需求之间一次教科书级的务实妥协。

我们来还原一下设计时的思考链:

  • 目标 :找出所有小于n的素数。
  • 算法本质 :需要一个“标记板”,能快速随机访问(O(1))并修改任意位置的值(标记为“已划掉”)。这天然指向 数组(Array) 。在F#中, Array.create (n+1) 0 创建了一个长度为n+1的整数数组,索引0到n,初始值全为0。后续用 container.[j] <- 1 进行原地修改,效率极高。
  • 但问题来了 :F#的核心信条之一是“默认不可变”。如果全程只用不可变数据结构,每次“标记一个数”,就得创建一个全新的数组副本,时间复杂度直接爆炸成O(n²),筛100万以内的素数可能要等上几分钟。这显然违背了算法的初衷。

于是,方案B登场: 拥抱可变性,但将其严格封装在局部作用域内 container 变量被定义在 GetAllPrimesBefore 函数内部,对外部完全不可见。外部调用者看到的,只是一个纯函数:输入一个整数n,输出一个素数列表。函数内部怎么折腾Array,是它的家务事。这完美践行了“纯函数接口,可变实现”的工程原则——既保证了调用的安全性和可预测性,又没有牺牲关键路径的性能。

  • 结果容器 :既然中间过程用了可变Array,那最终结果呢?F#强烈推荐用不可变List。原因在于,构建素数列表的过程是典型的“头插法”:每发现一个新素数,就把它加到当前结果列表的最前面( hd::acc )。List的 :: 操作符正是为此优化的,时间复杂度O(1)。而如果用可变List(比如 ResizeArray ),虽然也能用,但就失去了F#倡导的“数据流清晰、无副作用”的美感。最终, List.rev acc 将头插得到的逆序列表反转,得到升序结果,这一步是O(n),但只执行一次,代价完全可以接受。

提示:这种“可变Array + 不可变List”的组合,在F#中极其常见,尤其在需要高性能计算但又要求函数式接口的场景下。它不是对函数式原则的背叛,而是对其更深刻的理解:函数式编程的终极目标不是消灭可变性,而是 控制可变性的范围和影响

2.2 为什么必须用递归,且是尾递归?

代码中 loop 函数的定义前缀着 rec 关键字,这在F#中是声明递归函数的强制语法。但这只是表象,深层原因是F#对 尾递归优化(Tail Call Optimization, TCO) 的深度支持。

我们来看 loop 的两种可能写法:

非尾递归(危险!)

let rec badLoop acc lst =
    match lst with
    | [] -> List.rev acc
    | hd::tl ->
        if container.[hd] = 1 then
            badLoop acc tl // 这里是尾调用
        else
            // ... 标记倍数 ...
            let newAcc = hd::acc
            let result = badLoop newAcc tl // 这里不是尾调用!它后面还有其他操作
            result

尾递归(安全!)

let rec loop acc = function
    | [] -> List.rev acc
    | hd::tl ->
        if container.[hd] = 1 then
            loop acc tl // 尾调用:函数的最后一个动作就是调用自己
        else
            // ... 标记倍数 ...
            loop (hd::acc) tl // 尾调用:函数的最后一个动作就是调用自己

区别在哪?关键在于 函数返回前,是否还有其他计算需要完成 。在尾递归版本中, loop acc tl loop (hd::acc) tl 都是函数体的最后一个表达式,编译器可以将其优化为一个 goto 跳转,复用当前栈帧,从而避免栈溢出。而 badLoop 中, badLoop newAcc tl 调用之后,还需要将结果赋值给 result ,再返回 result ,这就无法优化,每次递归都压入一个新栈帧。对于筛100万以内的素数,递归深度轻松超过10万,非尾递归版本必然 StackOverflowException

F#编译器会严格检查尾递归,并在无法优化时给出警告。所以,当你看到 rec ,就要立刻想到:这个函数的设计,必须确保每一次递归调用都处于“尾位置”。这也是为什么代码中要用 accumulator (累加器 acc )来携带状态,而不是在递归调用后去“处理”返回值。这是一种思维训练:把“我要做什么”变成“我已经做了什么,接下来该做什么”。

2.3 为什么 function 是模式匹配的“快捷键”,而非多余语法?

let rec loop acc = function 这行代码,是初学者最大的困惑源。它看起来像是多此一举,为什么不写成 let rec loop acc lst = match lst with ... ?答案是: 它极大地提升了代码的简洁性、可读性和意图表达的纯粹性

我们来对比一下:

标准写法(啰嗦)

let rec loop acc lst =
    match lst with
    | [] -> List.rev acc
    | hd::tl -> 
        if container.[hd] = 1 then
            loop acc tl
        else
            // ... 标记 ...
            loop (hd::acc) tl

function 写法(清爽)

let rec loop acc = function
    | [] -> List.rev acc
    | hd::tl -> 
        if container.[hd] = 1 then
            loop acc tl
        else
            // ... 标记 ...
            loop (hd::acc) tl

差别在哪里?

  • 参数数量 :标准写法显式声明了两个参数 acc lst ,而 function 写法只声明了一个参数 acc ,第二个参数(即待匹配的值)被隐式地绑定到了 function 关键字之后。这符合F#的“柯里化(Currying)”思想:一个接受多个参数的函数,本质上是一个接受第一个参数、返回一个新函数的函数。 loop acc 返回的是一个“等待一个List”的函数,而 function 就是这个新函数的主体。
  • 意图聚焦 function 让代码的焦点100%集中在“如何匹配和处理这个值”上,而不是分散在“声明参数”这个次要任务上。当你看到 | [] -> ... ,你的大脑立刻就知道:“哦,这是在处理空列表的情况”,没有任何干扰。
  • 一致性 :F#中大量内置函数(如 List.map , List.filter )都采用这种风格。 List.map (fun x -> x * 2) [1;2;3] ,这里的 (fun x -> x * 2) 就是一个单参数函数。 function 是这种风格的自然延伸。

所以, function 不是语法糖,它是F#函数式DNA的一部分,是让代码更“函数式”的一种本能。

3. 核心语法细节与实操要点精讲

3.1 类型推断:编译器如何成为你的“隐形搭档”

F#最令人上瘾的特性之一,就是它强大的类型推断能力。在 GetAllPrimesBefore 函数中,你找不到任何显式的类型标注(除了 container 的元素类型由 Array.create 的第二个参数 0 推断为 int ),但编译器却能100%准确地知道每个变量、每个参数、每个返回值的类型。

我们来一步步追踪这个“推理过程”:

  1. let GetAllPrimesBefore n = ...
    编译器看到函数体中出现了 n + 1 (第2行)和 [2 .. n] (第12行)。 + 运算符和范围表达式 .. 在F#中只对数值类型有效。它会尝试所有数值类型: int8 , int16 , int32 , int64 , float32 , float64 ... 最终,它发现 n + 1 [2 .. n] int (即 int32 )类型下是完全合法且最常用的,于是将 n 推断为 int

  2. let container = Array.create (n+1) 0
    Array.create 是一个泛型函数,签名是 'T -> int -> 'T[] 。它的第一个参数是数组的初始值,这里传入了 0 0 在F#中是一个 int 字面量。因此, 'T 被推断为 int ,整个 container 的类型就是 int[]

  3. let rec loop acc = function ...
    编译器看到 acc 被用在 hd::acc (第11行)中。 :: 操作符的签名是 'T -> 'T list -> 'T list ,它要求左边是一个 'T 类型的值,右边是一个 'T list hd 是从 lst (即 function 所匹配的List)中解构出来的,而 lst 的类型又由第12行的 [2 .. n] 决定。 [2 .. n] 是一个 int list ,所以 hd int acc 就必须是 int list loop 的返回值自然也是 int list

  4. 最终函数签名
    综合以上, GetAllPrimesBefore 的完整类型签名被推断为: int -> int list 。意思是“接受一个整数,返回一个整数列表”。

这个过程是全自动、无感知的。你写代码时,可以完全专注于逻辑,而编译器在后台默默为你构建起一道坚固的类型安全墙。只有当你写出明显矛盾的代码时(比如试图把字符串和整数相加),它才会报错。这种“信任编译器”的体验,是F#开发者幸福感的重要来源。

注意:类型推断虽强,但并非万能。在大型项目或接口边界,显式标注类型(如 let GetAllPrimesBefore (n: int) : int list = ... )是极佳的实践。它既是给编译器的明确指令,也是给未来阅读代码的你(或同事)的一份清晰文档。

3.2 List的不可变性与 :: 操作符的奥秘

F#中的 List 是纯正的、不可变的单链表。这意味着,一旦一个List被创建,它的内容就永远固定了。你不能修改其中任何一个元素,也不能在中间插入或删除一个元素。所有看似“修改”的操作,实际上都是在创建一个 全新的List

:: (读作“cons”,源自Lisp,意为“construct”)是List构造的核心操作符。它的语法是 head :: tail ,其中 head 是一个元素, tail 是一个List。 :: 的作用,就是创建一个 新的List ,其第一个元素是 head ,其余部分(即 tail )完全复用原来的List。

让我们用一个具体例子来可视化这个过程:

let original = [2; 3; 5] // 内存中:2 -> 3 -> 5 -> []
let newList = 1 :: original // 内存中:1 -> (2 -> 3 -> 5 -> [])

注意, original 本身没有发生任何变化。 newList 是一个全新的节点,它指向了 original 的头部。这种“结构共享”是不可变数据结构高效的关键——创建新List的开销是O(1),因为它只分配了一个新节点,而复用了旧List的绝大部分内存。

回到筛法代码:

  • loop [] [2..n] :初始调用, acc 是空列表 [] lst [2;3;4;...;n]
  • hd 2 时,执行 loop (2::acc) tl ,即 loop ([2]) [3;4;...;n]
  • hd 3 时,执行 loop (3::[2]) tl ,即 loop ([3;2]) [4;5;...;n]
  • 以此类推, acc 始终是以“发现顺序”的逆序累积的。

最后, List.rev acc 将这个逆序列表翻转,得到正确的升序素数列表。 List.rev 本身也是一个高效的函数,它通过一次遍历,用头插法构建出新列表,时间复杂度O(n)。

实操心得:初学者常误以为 :: 是“追加”操作。请牢牢记住: :: 永远是“头插”。要在列表末尾添加元素,必须用 @ (连接操作符),但 @ 的时间复杂度是O(n),因为它需要复制整个左操作数。所以, 永远优先考虑头插+反转 ,这是F#中处理List的标准模式。

3.3 范围表达式 [start .. step .. end] for 循环的真相

F#中的 for 循环,乍看之下和C#里的 for (int i = 0; i < n; i++) 很像,但它的底层逻辑完全不同。F#的 for 循环,本质上是对一个 序列(Sequence) 的遍历。而 [start .. end] [start .. step .. end] ,就是创建这种序列的最简洁语法。

  • [2 .. 10] 等价于 [2; 3; 4; 5; 6; 7; 8; 9; 10] ,它创建了一个包含所有整数的列表。
  • [2 .. 2 .. 10] 等价于 [2; 4; 6; 8; 10] ,步长为2。

在筛法代码的第9行: for j in [hd .. hd .. n] do ,它创建了一个从 hd 开始,以 hd 为步长,直到不超过 n 的所有倍数的列表。例如,当 hd 3 n 120 时, [3 .. 3 .. 120] 就是 [3; 6; 9; 12; ...; 120]

这里有一个重要的性能细节: [start .. step .. end] 创建的是一个 列表(List) ,这意味着它会立即在内存中分配所有元素。对于大范围(比如筛1亿以内的素数),这会造成巨大的内存压力。

更优的替代方案是使用 seq { ... } (序列计算表达式)

// 内存友好:只在需要时生成下一个值
for j in seq { hd .. hd .. n } do
    container.[j] <- 1

seq { ... } 创建的是一个惰性求值的序列,它不会一次性把所有倍数都装进内存,而是在 for 循环每次迭代时,才计算出下一个 j 。这对于处理海量数据至关重要。

注意: for 循环在F#中 没有返回值 。它纯粹是一个副作用操作(在这里是修改 container 数组)。如果你需要一个基于范围的、有返回值的计算,应该使用 List.map List.filter 等高阶函数。例如, List.map (fun x -> x * 2) [1..5] 会返回 [2;4;6;8;10]

3.4 缩进与作用域:F#的“语法即结构”

F#和Python一样,使用 缩进(Indentation) 来定义代码块的结构,而不是大括号 {} begin / end 关键字。这是一个强大而严格的约定,它强制代码具有天然的可读性。

规则非常简单:

  • 同一级别的代码,必须有 完全相同的缩进量 (通常用4个空格或1个Tab)。
  • 下一级代码,必须比上一级 缩进更多
  • 编译器会根据缩进来判断 match if for 等语句的结束位置。

在筛法代码中,这个规则体现得淋漓尽致:

let GetAllPrimesBefore n = // 第1级
    let container = Array.create (n+1) 0 // 第2级:属于GetAllPrimesBefore函数体
    let rec loop acc = function // 第2级:同上
        | [] -> List.rev acc // 第3级:属于match分支
        | hd::tl -> // 第3级:同上
            if container.[hd] = 1 then // 第4级:属于| hd::tl分支
                loop acc tl // 第5级:属于if分支
            else // 第4级:同上
                for j in [hd .. hd .. n] do // 第5级:属于else分支
                    container.[j] <- 1 // 第6级:属于for循环体
                loop (hd::acc) tl // 第5级:属于else分支(注意:与for同级)
    loop [] [2..n] // 第2级:属于GetAllPrimesBefore函数体

这个缩进层次,清晰地勾勒出了整个程序的控制流和数据流。它不是一个可选项,而是一个强制性的语法要求。如果你的缩进错了,代码根本无法编译。这种“所见即所得”的结构,让团队协作和代码审查变得异常轻松——你一眼就能看出某段代码属于哪个作用域。

提示:在VS Code或Visual Studio中,F#插件会自动帮你管理缩进。但养成手动检查的习惯依然重要。一个常见的错误是,在 if / else 块中, else 分支的缩进量与 if 分支不一致,导致编译器认为 else 不属于这个 if ,从而报错。

4. 完整实操过程与代码逐行解析

4.1 环境准备与第一个Hello World

在动手写筛法之前,你需要一个能运行F#的环境。最简单的方式是安装 Visual Studio Community (免费),并在安装时勾选“.NET desktop development”工作负载,它会自动包含F#。或者,你也可以选择更轻量的**.NET SDK** + VS Code + Ionide-fsharp 插件。

安装完成后,打开终端(命令提示符或PowerShell),输入:

dotnet fsi

这会启动F#交互式环境(FSI),一个即时反馈的REPL(Read-Eval-Print Loop)。在这里,你可以逐行输入F#代码,立刻看到结果,是学习和调试的绝佳工具。

现在,输入你的第一个F#程序:

> printfn "Hello, F# World!"
Hello, F# World!
val it : unit = ()

printfn 是F#的打印函数, "Hello, F# World!" 是一个字符串字面量。 val it : unit = () 是FSI的反馈,告诉你上一条表达式的值是 unit 类型(相当于C#中的 void ),其值为 ()

4.2 逐行构建筛法:从零开始的代码之旅

现在,我们把筛法代码,一行行地输入到FSI中,并观察每一步发生了什么。请务必跟着做,这是理解F#思维的关键。

第1-2行:函数声明与容器初始化

> let GetAllPrimesBefore n =
-     let container = Array.create (n+1) 0
-     // ... 函数体暂略
-     ()
val GetAllPrimesBefore : n:int -> unit

输入到这里,FSI会告诉你 GetAllPrimesBefore 是一个函数,接受一个 int ,返回 unit 。注意,我们还没有定义完整的函数体,所以先用 () 占位。此时, container 变量还不存在,它只会在函数被调用时才被创建。

第3-11行:递归函数 loop 的定义

> let GetAllPrimesBefore n =
-     let container = Array.create (n+1) 0
-     let rec loop acc = function
-         | [] -> List.rev acc
-         | hd::tl ->
-             if container.[hd] = 1 then
-                 loop acc tl
-             else
-                 for j in [hd .. hd .. n] do
-                     container.[j] <- 1
-                 loop (hd::acc) tl
-     loop [] [2..n]
val GetAllPrimesBefore : n:int -> int list

这一次,我们输入了完整的函数体。FSI的反馈变成了 int -> int list ,说明类型推断成功了! container 现在是一个 int[] loop 是一个 int list -> int list -> int list 的函数(接受 acc 和一个 int list ,返回 int list )。

第12-13行:函数调用与验证

> let primesBefore120 = GetAllPrimesBefore 120
val primesBefore120 : int list = [2; 3; 5; 7; 11; 13; 17; 19; 23; 29; 31; 37; 41; 43; 47; 53; 59; 61; 67; 71; 73; 79; 83; 89; 97; 101; 103; 107; 109; 113]

大功告成! primesBefore120 现在是一个包含了所有小于120的素数的列表。你可以用 List.length primesBefore120 查看它有多少个元素(30个),或者用 List.head primesBefore120 获取第一个元素(2)。

4.3 关键步骤的现场记录与参数计算

让我们深入到算法的核心循环,看看 loop 函数是如何工作的。我们可以用一个更小的 n 来观察,比如 n=10

Step 0: 初始化

  • n = 10
  • container = [|0;0;0;0;0;0;0;0;0;0;0|] (索引0到10)
  • loop [] [2;3;4;5;6;7;8;9;10]

Step 1: 处理 hd=2

  • container.[2] 是0,未被标记,所以2是素数。
  • 执行 for j in [2..2..10] ,即 j 取值 2,4,6,8,10
  • container 变为 [|0;0;1;0;1;0;1;0;1;0;1|] (索引2,4,6,8,10被设为1)。
  • loop 被递归调用为 loop [2] [3;4;5;6;7;8;9;10]

Step 2: 处理 hd=3

  • container.[3] 是0,未被标记,所以3是素数。
  • 执行 for j in [3..3..10] ,即 j 取值 3,6,9
  • container 变为 [|0;0;1;1;1;0;1;0;1;1;1|] (索引3,6,9被设为1)。
  • loop 被递归调用为 loop [3;2] [4;5;6;7;8;9;10]

Step 3: 处理 hd=4

  • container.[4] 是1,已被标记(是2的倍数),所以跳过。
  • loop 被递归调用为 loop [3;2] [5;6;7;8;9;10]

Step 4: 处理 hd=5

  • container.[5] 是0,未被标记,所以5是素数。
  • 执行 for j in [5..5..10] ,即 j 取值 5,10
  • container 变为 [|0;0;1;1;1;1;1;0;1;1;1|] (索引5,10被设为1)。
  • loop 被递归调用为 loop [5;3;2] [6;7;8;9;10]

Step 5: 后续处理

  • hd=6 : 已标记,跳过。
  • hd=7 : 未标记,是素数,标记 7,14... 但14>10,所以只标记7。
  • hd=8,9,10 : 全部已标记。

最终结果

  • acc 在最后是 [10;7;5;3;2] (注意,这是头插的逆序)。
  • List.rev acc 得到 [2;3;5;7] ,即所有小于10的素数。

这个手算过程清晰地展示了算法的每一步,也印证了代码的正确性。F#的不可变List和可变Array的组合,让这个过程既清晰又高效。

5. 常见问题与排查技巧实录

5.1 “The value or constructor ‘xxx’ is not defined” —— 作用域与声明顺序之谜

这是新手遇到的第一个高频错误。例如,如果你把代码顺序写反了:

// ❌ 错误:loop在container定义之前就被引用了
let rec loop acc = function
    | [] -> List.rev acc
    | hd::tl ->
        if container.[hd] = 1 then // Error! container尚未定义
            loop acc tl
        else
            ...
let container = Array.create (n+1) 0 // container定义在后面

原因 :F#是 自上而下(top-down) 解析代码的。一个值( let 绑定)只能在它被声明 之后 的代码中被使用。 loop 函数体中引用了 container ,但 container 的声明在 loop 之后,所以编译器找不到它。

解决方案 :调整声明顺序,确保所有依赖项都在被引用之前定义。在函数内部,通常的顺序是:先定义所有需要的变量( container ),再定义内部函数( loop ),最后是函数的主逻辑( loop [] [2..n] )。

实操心得:在大型函数中,我习惯用 // --- Setup --- // --- Core Logic --- 这样的注释来分隔不同职责的代码块,强迫自己理清依赖关系。

5.2 “This expression was expected to have type ‘unit’ but here has type ‘int list’” —— for 循环的“静默”本质

这个错误常常出现在你试图把 for 循环当作一个有返回值的表达式来用时:

// ❌ 错误:for循环返回unit,不能赋值给primes
let primes = for j in [2..10] do
    printfn "%d" j
// Error! The value 'primes' is being assigned the result of a 'for' loop, which is 'unit'.

原因 for 循环在F#中是一个 语句(statement) ,它的唯一目的是产生副作用(如打印、修改数组),它本身没有“值”。它的类型是 unit 。你不能把它赋值给一个变量,也不能把它作为另一个函数的参数。

解决方案 :如果你需要一个基于范围的、有返回值的列表,请使用 List.map List.filter List.collect 等函数。

// ✅ 正确:map返回一个新列表
let doubled = List.map (fun x -> x * 2) [1;2;3] // [2;4;6]

// ✅ 正确:filter返回一个满足条件的新列表
let evens = List.filter (fun x -> x % 2 = 0) [1;2;3;4;5] // [2;4]

5.3 “The recursive function ‘loop’ does not have a tail call” —— 尾递归优化失败警告

当你看到这个编译器警告时,意味着你的递归调用不是尾调用,F#无法对其进行优化,存在栈溢出风险。

典型错误模式

// ❌ 错误:递归调用后还有其他操作
let rec badLoop acc lst =
    match lst with
    | [] -> List.rev acc
    | hd::tl ->
        if container.[hd] = 1 then
            badLoop acc tl // 这是尾调用
        else
            // ... 标记倍数 ...
            let newAcc = hd::acc
            let result = badLoop newAcc tl // 这不是尾调用!
            result // 这行代码在递归调用之后

排查技巧

  1. 定位问题行 :编译器会精确指出哪一行的递归调用不是尾调用。
  2. 检查“最后一行” :找到那个 let rec 函数,然后检查每一个 -> (箭头)后面的表达式。确保在每一个分支中, 递归调用是该分支的最后一个表达式 ,其后面不能再有任何其他计算。
  3. 重构为尾递归 :引入一个额外的累加器(accumulator)参数,把所有需要在递归调用后做的计算,提前到递归调用之前完成,并通过累加器传递下去。这正是我们筛法代码中 acc 参数的作用。

5.4 性能陷阱: [start .. end] vs seq { start .. end }

当你用筛法处理大数(比如 n=1000000 )时,可能会发现程序启动缓慢,甚至内存爆满。罪魁祸首很可能就是 [hd .. hd .. n]

问题分析

  • [2 .. 1000000] 会创建一个包含一百万个整数的列表,占用大量内存。
  • 在筛法中, hd 从2开始,每次都会创建一个越来越大的列表( [2..1000000] , [3..1000000] , [5..1000000] ...),内存消耗呈指数级增长。

解决方案 :用惰性序列 seq 替代列表 []

// ❌ 内存杀手
for j in [hd .. hd .. n] do
    container.[j] <- 1

// ✅ 内存友好
for j in seq { hd .. hd .. n } do
    container.[j] <- 1

seq { ... } 创建的是一个 IEnumerable<int> ,它只在 for 循环每次迭代时,才计算出下一个 j 的值,内存占用恒定为O(1)。

实操心得:在F#中,养成一个习惯:**凡是范围很大、且你只打算遍历一次的场景,优先使用 seq ;只有当你需要多次遍历、或者需要随机访问(如 List.item 100 )时,才使用 List

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值