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%准确地知道每个变量、每个参数、每个返回值的类型。
我们来一步步追踪这个“推理过程”:
-
let GetAllPrimesBefore n = ...
编译器看到函数体中出现了n + 1(第2行)和[2 .. n](第12行)。+运算符和范围表达式..在F#中只对数值类型有效。它会尝试所有数值类型:int8,int16,int32,int64,float32,float64... 最终,它发现n + 1和[2 .. n]在int(即int32)类型下是完全合法且最常用的,于是将n推断为int。 -
let container = Array.create (n+1) 0
Array.create是一个泛型函数,签名是'T -> int -> 'T[]。它的第一个参数是数组的初始值,这里传入了0。0在F#中是一个int字面量。因此,'T被推断为int,整个container的类型就是int[]。 -
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。 -
最终函数签名
综合以上,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 // 这行代码在递归调用之后
排查技巧 :
- 定位问题行 :编译器会精确指出哪一行的递归调用不是尾调用。
-
检查“最后一行”
:找到那个
let rec函数,然后检查每一个->(箭头)后面的表达式。确保在每一个分支中, 递归调用是该分支的最后一个表达式 ,其后面不能再有任何其他计算。 -
重构为尾递归
:引入一个额外的累加器(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
337

被折叠的 条评论
为什么被折叠?



